解决多租户,多数据源的问题
本demo的技术栈,SpringBoot,SpringCloud Alibaba(dubbo, nacos),hikaricp,druid,mysql
项目地址:https://gitee.com/xuwenjingrencai/dynamic-datasource-spring-boot-starter
1、动态数据源原理
利用aop做的动态数据源的切换,每个线程绑定一个数据源
2、动态数据源基础类
DynamicDatasource
public class DynamicDatasource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DynamicDatasourceHandler.getDatasource();
}
@Override
protected DataSource determineTargetDataSource() {
Object o = determineCurrentLookupKey();
if (Objects.nonNull(o) && DynamicDatasourceUtil.DATASOURCES.containsKey(o)) {
return DynamicDatasourceUtil.DATASOURCES.get(o);
}
throw new RuntimeException("数据源不存在");
}
}
DynamicDatasourceHandler
@Slf4j
public class DynamicDatasourceHandler {
private static ThreadLocal<String> threadDatasource = new InheritableThreadLocal<>();
public static void setDatasource(String dataSource) {
threadDatasource.set(dataSource);
}
public static String getDatasource() {
return threadDatasource.get();
}
public static void remove() {
threadDatasource.remove();
}
public static void close(DataSource dataSource) {
Class<? extends DataSource> clazz = dataSource.getClass();
try {
Method close = clazz.getDeclaredMethod("close");
close.invoke(clazz);
log.debug("该数据源已关闭");
} catch (Exception e) {
log.warn("动态数据源关闭错误, 数据源名称 [{}]", dataSource, e);
}
}
}
DynamicDatasourceUtil ,对连接池做了简单适配,代码有点长
@Slf4j
public class DynamicDatasourceUtil {
public static final String MASTER = "master";
public static final Map<String, DataSource> DATASOURCES = new ConcurrentHashMap<>();
public static void createDatasource(DynamicDatasourceProperty property) {
if (DynamicDatasourceUtil.DATASOURCES.containsKey(property.getKey())) {
// return DATASOURCES.get(property.getKey());
return;
}
DataSource dataSource = null;
if (Objects.nonNull(property.getType()) && ("com.zaxxer.hikari.HikariDataSource".equals(property.getType()) || "hikari".equals(property.getType()))) {
dataSource = createHikariDataSource(property);
} else if (Objects.nonNull(property.getType()) && ("com.alibaba.druid.pool.DruidDataSource".equals(property.getType()) || "druid".equals(property.getType()))) {
dataSource = createDruidDataSource(property);
} else {
dataSource = createDefaultDataSource(property);
}
log.info("创建数据源: key: {}, type: {}", property.getKey(), property.getType());
DynamicDatasourceUtil.DATASOURCES.putIfAbsent(property.getKey(), dataSource);
}
private static DataSource createDruidDataSource(DynamicDatasourceProperty property) {
DruidDataSource dataSource = new DruidDataSource();
dataSource.setDriverClassName(property.getDriverClassName());
dataSource.setUrl(property.getUrl());
dataSource.setUsername(property.getUsername());
dataSource.setPassword(property.getPassword());
if (Objects.nonNull(property.getMaxPoolSize())) {
dataSource.setMaxActive(property.getMaxPoolSize());
}
if (Objects.nonNull(property.getInitSize())) {
dataSource.setInitialSize(property.getInitSize());
}
if (Objects.nonNull(property.getMinIdle())) {
dataSource.setMinIdle(property.getMinIdle());
}
if (Objects.nonNull(property.getPoolName())) {
dataSource.setName(property.getPoolName());
}
if (Objects.nonNull(property.getMaxWait())) {
dataSource.setMaxWait(property.getMaxWait());
}
if (Objects.nonNull(property.getValidationQuery())) {
dataSource.setValidationQuery(property.getValidationQuery());
}
return dataSource;
}
private static DataSource createDefaultDataSource(DynamicDatasourceProperty property) {
DriverManagerDataSource dataSource = new DriverManagerDataSource();
dataSource.setDriverClassName(property.getDriverClassName());
dataSource.setUrl(property.getUrl());
dataSource.setUsername(property.getUsername());
dataSource.setPassword(property.getPassword());
return dataSource;
}
private static HikariDataSource createHikariDataSource(DynamicDatasourceProperty property) {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setDriverClassName(property.getDriverClassName());
dataSource.setJdbcUrl(property.getUrl());
dataSource.setUsername(property.getUsername());
dataSource.setPassword(property.getPassword());
if (Objects.nonNull(property.getMaxPoolSize())) {
dataSource.setMaximumPoolSize(property.getMaxPoolSize());
}
if (Objects.nonNull(property.getMinIdle())) {
dataSource.setMinimumIdle(property.getMinIdle());
}
if (Objects.nonNull(property.getPoolName())) {
dataSource.setPoolName(property.getPoolName());
}
if (Objects.nonNull(property.getIdleTimeout())) {
dataSource.setIdleTimeout(property.getIdleTimeout());
}
if (Objects.nonNull(property.getMaxLifetime())) {
dataSource.setMaxLifetime(property.getMaxLifetime());
}
if (Objects.nonNull(property.getConnectionTimeout())) {
dataSource.setConnectionTimeout(property.getConnectionTimeout());
}
return dataSource;
}
}
DynamicDatasourceProperty ,配置类
@Data
public class DynamicDatasourceProperty {
private String key;
private String type;
private String url;
private String driverClassName;
private String username;
private String password;
//可选参数
private Integer maxPoolSize;
private Integer minIdle;
private String poolName;
private Long idleTimeout;
private Long maxLifetime;
private Long connectionTimeout;
//druid
private Integer initSize;
private Long maxWait;
private String validationQuery;
}
DataSourceConfig ,注册动态数据源,这里为了和springboot的固定数据源做兼容,用上了@ConditionalOnProperty,当属性为true的时候,切换至动态数据源,否则走springboot的固定数据源
@Data
@Component
@ConditionalOnProperty(name = "spring.dynamicdatasource.enabled", havingValue = "true")
@ConfigurationProperties(prefix = "spring.dynamicdatasource")
public class DataSourceConfig implements InitializingBean {
private List<DynamicDatasourceProperty> ds = new ArrayList<>();
private Map<String, DynamicDatasourceProperty> dsMap;
@Override
public void afterPropertiesSet() throws Exception {
dsMap = ds.stream().collect(Collectors.toConcurrentMap(DynamicDatasourceProperty::getKey, x->x));
}
@Primary
@Bean
public DataSource dataSource() {
DynamicDatasourceProperty master = dsMap.get(DynamicDatasourceUtil.MASTER);
DynamicDatasourceUtil.createDatasource(master);
DynamicDatasource dynamicDatasource = new DynamicDatasource();
dynamicDatasource.setDefaultTargetDataSource(DynamicDatasourceUtil.DATASOURCES.get(DynamicDatasourceUtil.MASTER));
dynamicDatasource.setTargetDataSources(new HashMap<>());
return dynamicDatasource;
}
}
3、切面
切面注解,DynamicDatasourceAop ,为什么这么写,下面会讲解
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
@Transactional
public @interface DynamicDatasourceAop {
@AliasFor(annotation = Transactional.class)
String value() default "";
@AliasFor(annotation = Transactional.class)
String transactionManager() default "";
@AliasFor(annotation = Transactional.class)
Propagation propagation() default Propagation.REQUIRED;
@AliasFor(annotation = Transactional.class)
Isolation isolation() default Isolation.DEFAULT;
@AliasFor(annotation = Transactional.class)
int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;
@AliasFor(annotation = Transactional.class)
boolean readOnly() default false;
@AliasFor(annotation = Transactional.class)
Class<? extends Throwable>[] rollbackFor() default {};
@AliasFor(annotation = Transactional.class)
String[] rollbackForClassName() default {};
@AliasFor(annotation = Transactional.class)
Class<? extends Throwable>[] noRollbackFor() default {};
@AliasFor(annotation = Transactional.class)
String[] noRollbackForClassName() default {};
}
切面类,DynamicDatasourceAspect
@Configuration
@Slf4j
@Aspect
@Order(value = Ordered.HIGHEST_PRECEDENCE + 10)
@RequiredArgsConstructor
@ConditionalOnProperty(name = "spring.dynamicdatasource.enabled", havingValue = "true")
public class DynamicDatasourceAspect {
@Autowired
private DataSourceConfig dataSourceConfig;
@Before("@annotation(DynamicDatasourceAop)")
public void before(JoinPoint joinpoint) {
BaseParam baseParam = null;
try {
Object[] args = joinpoint.getArgs();
for (Object o : args) {
if (o instanceof BaseParam) {
baseParam = (BaseParam) o;
}
}
if (baseParam == null) {
throw new RuntimeException("没有数据源");
}
if (DynamicDatasourceUtil.DATASOURCES.containsKey(baseParam.getDataKey())) {
DynamicDatasourceHandler.setDatasource(baseParam.getDataKey());
} else {
if (dataSourceConfig.getDsMap().containsKey(baseParam.getDataKey())) {
DynamicDatasourceUtil.createDatasource(dataSourceConfig.getDsMap().get(baseParam.getDataKey()));
DynamicDatasourceHandler.setDatasource(baseParam.getDataKey());
} else {
throw new RuntimeException("数据源不存在");
}
}
} catch (Throwable e) {
log.error(e.getMessage(), e);
}
}
@After("@annotation(DynamicDatasourceAop)")
public void after(JoinPoint joinPoint) {
DynamicDatasourceHandler.remove();
}
}
4、配置
配置文件,不同的profile加载不同的nacos配置
#application.yml
server:
port: 0
dubbo:
application:
name: cloud-dubbo-provider
scan:
base-packages: com.xuwj.provider.dubboService
protocols:
dubbo:
id: dubbo
name: dubbo
port: -1
#bootstrap.yml
spring:
profiles:
# active: dev
# active: test
active: dynamicshort
# active: dynamiclong
application:
name: cloud-dubbo-provider
cloud:
nacos:
discovery:
enabled: true
register-enabled: true
server-addr: 127.0.0.1:8848
ephemeral: false
config:
enabled: true
server-addr: 127.0.0.1:8848
file-extension: yml
profile: dev
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/test?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=UTC
username: root
password: root
druid:
initial-size: 8
max-active: 8
min-idle: 8
max-wait: 1000
validation-query: 'select 1'
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
auto-mapping-behavior: full
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
mapper-locations: classpath*:mapper/**/*Mapper.xml
global-config:
db-config:
logic-not-delete-value: 1
logic-delete-value: 0
profile: test
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/testv?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=UTC
username: root
password: root
druid:
initial-size: 8
max-active: 8
min-idle: 8
max-wait: 1000
validation-query: 'select 1'
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
auto-mapping-behavior: full
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
mapper-locations: classpath*:mapper/**/*Mapper.xml
global-config:
db-config:
logic-not-delete-value: 1
logic-delete-value: 0
profile: dynamicshot
spring:
# datasource:
# # type: com.alibaba.druid.pool.DruidDataSource
# driverClassName: com.mysql.cj.jdbc.Driver
# url: jdbc:mysql://127.0.0.1:3306/test?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=UTC
# username: root
# password: root
dynamicdatasource:
enabled: true
ds:
- key: master
type: hikari
driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/test?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=UTC
username: root
password: root
- key: test1
type: hikari
driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/test1?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=UTC
username: root
password: root
- key: test2
# type: hikari
driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/test2?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=UTC
username: root
password: root
- key: testv1
type: hikari
driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/testv1?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=UTC
username: root
password: root
- key: testv2
# type: druid
driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/testv2?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=UTC
username: root
password: root
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
auto-mapping-behavior: full
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
mapper-locations: classpath*:mapper/**/*Mapper.xml
global-config:
db-config:
logic-not-delete-value: 1
logic-delete-value: 0
5、总结
本案例可以支持根据配置文件的配置,走springboot的固定数据源或者动态数据源,动态数据源中根据key匹配不同的数据源,根据type开启对应的连接池,动态数据源是懒加载的方式,用到了,再去配置中寻找数据源的配置,初始化数据源。
如果仔细看,可以发现,本案例已经将事务和动态数据源的切面的注解合二为一了,具体的心路历程:
1、做动态数据源,切面注解 @DynamicDatasourceAop 中是空的,实现了动态切换数据源
2、支持事务,发现事务注解 @Transactional 只能放在 切面注解标的方法的内层方法上面,如果放在一起,会先执行事务,发现没有数据源,报错
3、将两个注解放在一起,思考事务和切面都是走的aop,应该可以规定一个顺序,此时发现开启事务的注解 @EnableTransactionManagement 里面的 int order() default Ordered.LOWEST_PRECEDENCE; 是最低优先级,于是在切面类上标注 @Order(value = Ordered.HIGHEST_PRECEDENCE),报错,
java.lang.IllegalStateException: No MethodInvocation found: Check that an AOP invocation is in progress and that the ExposeInvocationInterceptor is upfront in the interceptor chain. Specifically, note that advices with order HIGHEST_PRECEDENCE will execute before ExposeInvocationInterceptor! In addition, ExposeInvocationInterceptor and ExposeInvocationInterceptor.currentInvocation() must be invoked from the same thread.
,寻找原因,是order的问题,于是修改order:@Order(value = Ordered.HIGHEST_PRECEDENCE + 10),正常启动,事务和动态数据源兼容
4、合并注解,参考RestController的注解获得现在的注解