Spring动态数据源与运行时动态添加数据源

1、多数据源与动态数据源

当项目不只是要用到一个数据库的时候就需要使用到多个数据源了,这种场景很多,比如要查找的数据不在同一个数据库库中,或者是要做数据库读写分离。应对上面的问题主要有两种解决方法。

第一种:在Spring中注入多个DataSource,然后根据不同的DataSource生成不同的SqlSessionFactory,把要操作不同数据库的mapper接口放在不同的包下,并且使用MyBatis的@MapperScan注解,使用不同的SqlSessionFactory扫描不同包下的mapper接口。

例如:

第一步:向Spring中注入多个数据源
@Bean("db1-DataSource")
@Primary//注意这个@Primary注解是必须的,必须要有一个数据源加上这个注解,否则SpringBoot无法启动
@ConfigurationProperties(prefix = "datasource.db1")
fun db1DataSource(): DataSource {return DataSourceBuilder.create().build()
}@Bean("db2-DataSource")
@ConfigurationProperties(prefix = "datasource.db2")
fun db2DataSource(): DataSource {return DataSourceBuilder.create().build()
}第二步:生成多个SqlSessionFactory并注入Spring
@Bean("db1-SqlSessionFactory")
fun db1SqlSessionFactory(@Qualifier("db1-DataSource") ds: DataSource): SqlSessionFactory {val fb = SqlSessionFactoryBean()fb.setDataSource(ds)fb.setMapperLocations("你的mapper的xml文件位置")return fb.getObject()
}@Bean("db2-SqlSessionFactory")
fun db2SqlSessionFactory(@Qualifier("db2-DataSource") ds: DataSource): SqlSessionFactory {val fb = SqlSessionFactoryBean()fb.setDataSource(ds)fb.setMapperLocations("你的mapper的xml文件位置")return fb.getObject()
}第三步:@MapperScan使用不同的SqlSessionFactory扫描不同包下的mapper接口生成代理对象,假设要操作db1数据库的mapper接口放在app.mapper.db1包下,要操作db2数据库的mapper接口放在app.mapper.db2包下@MapperScan(value = ["app.mapper.db1"], sqlSessionFactoryRef = "db1-SqlSessionFactory")@MapperScan(value = ["app.mapper.db2"], sqlSessionFactoryRef = "db2-SqlSessionFactory")这样不同包下的mapper接口就可以操作不同的数据库了。

接下来详细介绍第二种方法。

第二种:使用Spring提供的动态数据源AbstractRoutingDataSource

AbstractRoutingDataSource是一个抽象类,他实现了DataSource接口,内部可以存放多个DataSource,可以在需要的时候返回不同的DataSource。

接下来解析AbstractRoutingDataSource的部分源码:

//AbstractRoutingDataSource的内部使用了一个map存放多个数据源,key是数据源的唯一名字(可以任意命名,但是要保证唯一),value是对应的DataSourceprivate Map targetDataSources;
//提供一个默认使用的数据源
private Object defaultTargetDataSource;
//这个是我们要实现的一个抽象方法,返回值是DataSource的唯一名字,表示使用该名字对应的DataSource
protected abstract Object determineCurrentLookupKey();
//这个是决定使用哪个数据源的方法,根据determineCurrentLookupKey的返回值来决定
protected DataSource determineTargetDataSource() {Object lookupKey = determineCurrentLookupKey();DataSource dataSource = this.resolvedDataSources.get(lookupKey);if (dataSource == null && (this.lenientFallback || lookupKey == null)) {dataSource = this.resolvedDefaultDataSource;}if (dataSource == null) {throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");}return dataSource;
}
//可以设置存放多个数据源的map
public void setTargetDataSources(Map targetDataSources) {this.targetDataSources = targetDataSources;
}//设置默认的数据源
public void setDefaultTargetDataSource(Object defaultTargetDataSource) {this.defaultTargetDataSource = defaultTargetDataSource;
}

我们要做的就是继承这个AbstractRoutingDataSource抽象类,并且实现determineCurrentLookupKey() 这个方法,通过返回值来动态改变需要使用的数据源。

现在来假设一种应用场景,我们需要拦截标注了@Transactional注解的方法,如果这个方法是以"add"、"update"、 "delete",作为前缀,那么就使用db1-DataSource,如果是以 "get" 做为前缀,则使用db2-DataSource。这相当于进行了一个简单的读写分离。

也就是说我们需要在标注了@Transactional注解的事务方法执行之前,根据方法签名去动态改变使用的DataSource。

这里可以编写一个切面去拦截该要执行的事务方法,然后在切面当中去判断执行的方法名,将获取的结果信息保存在一个ThreradLocal当中,这样就可以在AbstractRoutingDataSource的determineCurrentLookupKey方法中从ThreadLocal中获取这个信息并且返回对应的数据源的名字。使用ThreadLocal的主要原因是因为事务方法总是并发执行的,为了防止相互的干扰。

具体的示例代码如下:

我编写了一个DynamicDataSource去继承了AbstractRoutingDataSource,并且实现了determineCurrentLookupKey方法。

class DynamicDataSource : AbstractRoutingDataSource() {override fun determineCurrentLookupKey(): Any {//这个MultipleDataSourceConfig是配置了多个数据源、sqlSessionFactory等的一个配置类val key = MultipleDataSourceConfig.threadLocal.get()println(" DynamicDataSource 当前使用的数据库为 : ${key} ")if (key == null) {println("error dataSource key ")return "db1"//这个是默认数据源的key}return key}
}

这个是配置类。

EnableTransactionManagement
@Configuration
open class MultipleDataSourceConfig {companion object {val threadLocal: ThreadLocal = ThreadLocal()}@Bean("db1-DataSource")@Primaryopen fun masterDataSource(): DataSource? {根据配置信息返回一个数据源}@Bean("db2-DataSource")open fun masterDataSource(): DataSource? {根据配置信息返回一个数据源}@Bean("dataSource")open fun dataSource(@Qualifier("db1-DataSource") db1: DataSource, @Qualifier("db2-DataSource") db2: DataSource): DynamicDataSource {val map = 把db1与db2放入一个map当中,key是db1和db2,value是对应的数据源return DynamicDataSource().apply {this.setTargetDataSources(map)this.setDefaultTargetDataSource(db1)}}@Bean("sqlSessionFactory")open fun sqlSessionFactory(@Qualifier("dataSource") ds: DataSource): SqlSessionFactory {val bean = SqlSessionFactoryBean()bean.setDataSource(ds)return bean.getObject()}@Beanopen fun transactionManager(@Qualifier("dataSource") ds: DataSource): DataSourceTransactionManager {return DataSourceTransactionManager(ds)}
}

这个是拦截事务方法的切面。

@Component
@Aspect
@Order(1)//这个注解的作用稍后会进行讲解
class SelectDataSourceAspect {@Before("@annotation(org.springframework.transaction.annotation.Transactional)")fun before(jp: JoinPoint) {println(" SelectDataSourceAspect 拦截的方法名为 ${jp.signature.name} ")try {println(" SelectDataSourceAspect 执行${jp.signature.name}方法 : 事务已经开启 -> " + TransactionInterceptor.currentTransactionStatus().toString())//println(TransactionInterceptor.currentTransactionStatus().toString() + " " + txm)} catch (e: Exception) {println(" SelectDataSourceAspect 执行${jp.signature.name}方法 : 事务没有开启 ")}if (如果 jp.signature.name 是以add、update、delete作为前缀) {MultipleDataSourceConfig.threadLocal.set("db1")} else {MultipleDataSourceConfig.threadLocal.set("db2")}}}

到这里好像看起来没问题,但是有一个重大的bug。

Spring当中有一个事务切面来帮我们处理事务方法,他主要是在事务方法执行之前从数据源中拿取connection,设置开启事务,如果成功执行则提交,抛出异常则回滚(具体的源码在Spring的事务管理器DataSourceTransactionManager中)。

而我们的切面是要在事务方法执行之前进行使用数据源判断的,也就是是说这两个切面是有执行的先后顺序的。

例如:假设开始的时候Spring的事务切面先执行,他从数据源中拿取connection,因为ThreadLocal当中没有值,所以拿取到的connection是默认数据源db1的,在他拿取connection之后我们才改变了使用的数据源,这个显然是错误的。

那么如何改变切面的执行顺序呢?

查阅官方文档有这样的一段描述:

点我跳转:Spring关于切面执行顺序的描述

大致内容就是可以给切面加上@Order注解,@Order注解内部有一个int类型的值表示优先级,该值越小则切面越优先被执行。

因此给我们配置的切面加上@Order(1)注解,就可以保证我们的优先执行了。

这就是第二种方法的全部内容以及一些注意事项。

说明:

1、并不是一定要使用AOP来判断使用的数据源,你可以在filter、controller、controller的拦截器等等 当中进行判断。

2、示例当中通过方法名来判断使用的数据源显然不是很优美,你可以编写一个自己的注解,例如@Read、@Write,然后在AOP中获取Method,利用反射拿出注解内容再进行判断,这样的话代码也会好看很多。注意:只有环绕的通知(@Around)才能拿到Method,像前置通知@Before只能拿到方法签名。

2、运行时动态添加数据源

针对上面使用的spring动态数据源,我有一个突发奇想,能不能在项目运行的时候添加数据源呢?也就是在不需要停止web应用的情况的通过网络传输一些数据源的配置信息给web应用,然后web应用就可以使用这个新加数据源操作对应的数据库。

我们前面提到的AbstractRoutingDataSource中使用了[Map targetDataSources]来保存多个数据源,考虑在运行的时候往这个map里面put新的数据源就可以。

在具体的实现上,发送数据源配置信息我决定使用apollo,在apollo上添加的配置会同步到web应用中,当然你可以使用其他的方法,比如通过controller接受数据源配置或者通过dubbo这样的rpc框架、或者是写个listener监听端口使用原生的socket进行信息传输等等,喜欢那种就用那种。我使用apollo主要因为,我的项目的配置信息全部放在apollo上进行了统一管理。

使用一个ConcureentHashMap来存放这些数据源,把它注册进Spring的容器当中,并且通过setTargetDataSources方法set进AbstractRoutingDataSource中。使用ConcureentHashMap而不使用HashMap的主要是因为增加数据源操作和事务切面获取数据源操作之间可能会有并发执行的情况,为了线程安全考虑。

代码示例如下:

向Spring中注入一个ConcureentHashMap

@Bean("slaves")
open fun runtimeDataSource(): ConcurrentHashMap {val map = ConcurrentHashMap()val cfg = ConfigService.getAppConfig()val set = cfg.propertyNamesfor (key in set) {//配置的前缀是db.slave.,这里是获取已经配置了的数据源if (key.startsWith("db.slave.")) {val value = cfg.getProperty(key, "")//编写一个getDataSource方法解析自定义的配置,kv中key表示数据源的名字,value表示数据源。val kv = getDataSource(key, value)if (kv != null) {map.put(kv.key, kv.value)}}}//这里是监听随时通过网络发送过来的数据源配置cfg.addChangeListener(ConfigChangeListener { changeEvent ->//使用同步方法的主要原因是,把更新后的数据及时刷新回主存当中。//因为执行synchronized之前,需要的数据会从主存中获取而不是缓存中获取,执行完成之后会及时的刷新回主存。(周志明的深入理解jvm中有提到)//而且也是防止一次性添加多个数据源并发的时候发生一些错误。synchronized(this) {for (key in changeEvent.changedKeys()) {val change = changeEvent.getChange(key)if (change.changeType.equals(PropertyChangeType.ADDED) && change.propertyName.startsWith("db.slave.")) {//编写一个getDataSource方法解析自定义的配置,kv中key表示数据源的名字,value表示数据源。val kv = getDataSource(change.propertyName, change.newValue)if (kv != null) {//把数据源put进行map当中map.put(kv.key, kv.value)}}}}})return map
}

在我们的切面当中注入这个map。

@Component
@Aspect
@Order(1)
class SelectDataSourceAspect {@Autowired@Qualifier("slaves")lateinit var dbs: ConcurrentHashMap@Before("@annotation(org.springframework.transaction.annotation.Transactional)")fun before(jp: JoinPoint) {println(" SelectDataSourceAspect 拦截的方法名为 ${jp.signature.name} ")try {println(" SelectDataSourceAspect 执行${jp.signature.name}方法 : 事务已经开启 -> " + TransactionInterceptor.currentTransactionStatus().toString())} catch (e: Exception) {println(" SelectDataSourceAspect 执行${jp.signature.name}方法 : 事务没有开启 ")}val methodName = jp.signature.nameif (methodName 是一个add、updat、delete方法 || dbs.size == 1) {MultipleDataSourceConfig.threadLocal.set("db1")} else {val li = mutableListOf()for (k in dbs.keys) {if (!"db1".equals(k.toString())) {li.add(k.toString())}}//如果是读取方法则随便从其他的数据源当中返回一个。//读写分离,写的话必须走主库,读的话随便挑一个从库。//当然可以编写更有效率的路由选择算法来替换这个随机选择算法。val idx = Math.abs(random.nextInt()) % li.sizeMultipleDataSourceConfig.threadLocal.set(li[idx])}}}

DynamicDataSource中的代码保持不变,只需要从ThreadLocal中获取只然后返回就可以了。

但是!测试的发现并不会选择到动态添加的数据源,只有提前配置好的数据源才可以,但是DynamicDataSource中已经返回了正确的数据源的key。

打开AbstractRoutingDataSource源码查看选择数据源的过程:

//可以看到,获取connection是从一个determineTargetDataSource返回的数据源中拿的
@Override
public Connection getConnection() throws SQLException {return determineTargetDataSource().getConnection();
}//这个determineTargetDataSource是根据determineCurrentLookupKey返回的key来获取数据源
//但是!他是从resolvedDataSources中获取的,那么这个resolvedDataSources又是从哪里来的呢?
protected DataSource determineTargetDataSource() {Object lookupKey = determineCurrentLookupKey();DataSource dataSource = this.resolvedDataSources.get(lookupKey);if (dataSource == null && (this.lenientFallback || lookupKey == null)) {dataSource = this.resolvedDefaultDataSource;}if (dataSource == null) {throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");}return dataSource;
}//原来resolvedDataSources只是从targetDataSources中copy了一份过去而已
@Override
public void afterPropertiesSet() {if (this.targetDataSources == null) {throw new IllegalArgumentException("Property 'targetDataSources' is required");}this.resolvedDataSources = new HashMap(this.targetDataSources.size());for (Map.Entry entry : this.targetDataSources.entrySet()) {Object lookupKey = resolveSpecifiedLookupKey(entry.getKey());DataSource dataSource = resolveSpecifiedDataSource(entry.getValue());this.resolvedDataSources.put(lookupKey, dataSource);}if (this.defaultTargetDataSource != null) {this.resolvedDefaultDataSource = resolveSpecifiedDataSource(this.defaultTargetDataSource);}
}

知道原因之后就可以进行修改了,干脆在DynamicDataSource把determineTargetDataSource方法也重写了,然后从注入的map当中选择数据源,就不用通过resolvedDataSources获取了。

修改后的DynamicDataSource代码如下:

class DynamicDataSource : AbstractRoutingDataSource() {@Autowired@Qualifier("slaves")lateinit var dbs: ConcurrentHashMapoverride fun determineCurrentLookupKey(): Any {val key = MultipleDataSourceConfig.threadLocal.get()if (key == null) {println("error dataSource key ")return "db1"}return key}//重写这个方法,其实这个在父类中是protect,这是一个很明显的提示override fun determineTargetDataSource(): DataSource? {val lookupKey = determineCurrentLookupKey()var dataSource = dbs[lookupKey]if (dataSource is DataSource) {return dataSource}return null}}

到此整个过程就分享完毕了,我进行了一些场景的测试都得到了正确的结果。

3、工程的源代码

代码托管在github上面:工程源码


本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!

相关文章

立即
投稿

微信公众账号

微信扫一扫加关注

返回
顶部