MyBatis多数据源配置与使用,基于ThreadLocal+AOP
MyBatis多数据源配置与使用
前言:MyBatis默认情况下只能在application配置文件中配置单数据源,但有一些开发场景可能有多数据源的需求,这需要做一些额外的配置。
查了一下Mybatis多数据源的解决方案,主要有两种方式:
其一
利用MyBatis的@MapperScan注解,该注解除了标注扫描路径外,还能给扫描到的mapper文件的dao操作指定sqlSessionFactoryRef属性指定使用的SqlSessionFactory,此时我们就可以构建不同源的SqlSessionFactory,从而实现不同的mapper文件对应不同的数据源操作。
这种方式简单易懂,创建对应的SqlSessionFactory即可,缺点是需要为每个数据源维护对应的mapper文件。这里不详细描述这种方式。
其二
第二种方式是利用springboot自身的AbstractRoutingDataSource,AbstractRoutingDataSource是一个抽象类,其中维护了一个Map属性,该Map是用于存储多个数据源,通过不同的key获取对应的数据源。另外提供determineCurrentLookupKey抽象方法,供给用户自定义获取键的方式。例如我们两个数据库,db1和db2,当我们想用db1时,只需要让determineCurrentLookupKey方法获取到db1的key就行,db2同理。下面说下详细编码过程:
1. 引依赖
无需额外依赖,springboot,mybatis,mysql驱动即可,注意的是如果springboot版本过高,则可能需要升级其中的mybatis-spring版本,否则报错
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- springboot版本过高,需要升级其中的mybatis-spring版本,否则报错 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.2</version>
<exclusions>
<exclusion>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>3.0.3</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.19</version>
</dependency>
</dependencies>
2. 配置文件
配置文件中定义数据源的信息,需要注意的是,在单数据源中,连接数据库参数时,使用的key是url,但在多数据源中,默认使用的是jdbc-url。(实际上我们也可以随便定义,但需要我们自己读取配置封装DataSource,后面会讲到)
spring:
application:
name: MultiSourceMyBatis
# datasource配置文件如下
datasource:
# 数据源1
db1:
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://127.0.0.1/inote?userUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
# 数据源2
db2:
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://111.111.111.111/inote?userUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
3. 编写测试代码
测试代码的部分省略,就是controller,service,dao常规流程
4. 自定义DynamicDataSource类
创建DynamicDataSource类,继承AbstractRoutingDataSource类,实现determineCurrentLookupKey抽象方法,determineCurrentLookupKey方法就是如何获取DataSource的key的方法。通过不同的key获取对应的数据源。该方法的具体实现我们暂时留白,下面会再做修改
public class DynamicDataSource extends AbstractRoutingDataSource {
/**
* 获取数据源key的方式,要使用哪个数据源,是通过数据源key选择的,这个key是数据源map中的key
*/
@Override
protected Object determineCurrentLookupKey() {
return "db1";
}
}
5. DataSourceConfig配置类
DataSourceConfig这个类的主要作用是将我们自定义DynamicDataSource类的实例对象交由spring bean管理,由容器装配与调用。而在这之前,我们还需要给DynamicDataSource设置DataSource的map(也就是将多个DataSource添加到DynamicDataSource中)。
@Configuration
public class DataSourceConfig {
@Autowired
Environment environment; // 用于读取application.yml文件配置
/**
* 构建两个数据库源,交由spring管理,但其实直接创建也无妨,注意保证创建相同配置的DataSource只有一个就行
*/
@Bean
public DataSource db1(){
HikariDataSource dataSource = new HikariDataSource();
dataSource.setDriverClassName(environment.getProperty("spring.datasource.db1.driver-class-name"));
dataSource.setJdbcUrl(environment.getProperty("spring.datasource.db1.jdbc-url"));
dataSource.setUsername(environment.getProperty("spring.datasource.db1.username"));
dataSource.setPassword(environment.getProperty("spring.datasource.db1.password"));
return dataSource;
}
@Bean
public DataSource db2(){
HikariDataSource dataSource = new HikariDataSource();
dataSource.setDriverClassName(environment.getProperty("spring.datasource.db2.driver-class-name"));
dataSource.setJdbcUrl(environment.getProperty("spring.datasource.db2.jdbc-url"));
dataSource.setUsername(environment.getProperty("spring.datasource.db2.username"));
dataSource.setPassword(environment.getProperty("spring.datasource.db2.password"));
return dataSource;
}
// /**
// * 实际上创建DataSource的方式可以用以下代码替代,但是需要注意的是配置文件中的数据库连接参数要改为jdbc-url
// */
// @ConfigurationProperties(prefix = "spring.datasource.db1")
// @Bean
// public DataSource db1(){
// return DataSourceBuilder.create().build();
// }
/**
* 创建DynamicDataSource,并将db1,db2添加进去。
*/
@Bean("dynamicDataSource")
@Primary // 该注解表示如果有多个相同bean,首选这个
public DataSource dynamicDataSource(@Qualifier("db1") DataSource db1,@Qualifier("db2") DataSource db2){
DynamicDataSource dynamicDataSource = new DynamicDataSource();
//默认数据源,如果determineCurrentLookupKey方法获取到的key不在列表中,则走默认的datasource
dynamicDataSource.setDefaultTargetDataSource(db1);
Map<Object,Object> map = new HashMap<>();
map.put("db1",db1);
map.put("db2",db2);
dynamicDataSource.setTargetDataSources(map);
return dynamicDataSource;
}
}
至此,配置就完成了,此时我们可以通过上面的determineCurrentLookupKey方法指定我们想使用的数据源。
这时候就会有人问了,这也没完成啊,determineCurrentLookupKey方法中写死了数据库的key,怎么做到数据库切换?
刚才说了,determineCurrentLookupKey方法留白了,关键就是怎么动态切换要使用的数据库的key,就的改写determineCurrentLookupKey方法。下面就展开说说。
6. AOP与ThreadLocal结合
我们想实现多数据源,目的肯定是希望不同用户,或者不同操作同时进行时能够使用不同的数据库,而不是同一时刻只有一个数据源起作用,因而多线程下,相同操作对不同资源进行访问,首先想到的是ThreadLocal。如果在用户请求进来后,我们为其配置对应数据库源的key,然后在determineCurrentLookupKey中通过ThreadLocal获取到key,OK,万事大吉。
但……,我们给一个线程创建同一个数据源,我们需要怎么去创建,创建的时机是怎样的?基于编码习惯,我们肯定希望的是通过注解的方式做方法增强。
“对啊,AOP,ThreadLocal+AOP,在service层方法执行前捕获方法,然后通过ThreadLocal设置数据源,后续就能使用该数据源源进行sql操作了,你真聪明”。
7. 引入AOP依赖
<!-- aop依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
8. DataSourceContextHolder
创建一个线程上下文工具类DataSourceContextHolder,该类主要作用是给线程创建ThreadLocal,然后实现ThreadLocal的getter,setter以及清除工作。
public class DataSourceContextHolder {
private static ThreadLocal<String> dataSourceKey = new ThreadLocal<>();
public static void setDataSourceKey(String key){
dataSourceKey.set(key);
}
public static String getDataSourceKey(){
return dataSourceKey.get();
}
public static void clear(){
dataSourceKey.remove();
}
}
9. 自定义注解@UseDB
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface UseDB {
/**
* 要使用的数据源的key
*/
String value();
}
10. 创建切面类UseDBAspect
在代理方法执行前设置数据库源,方法执行后移除数据库源
@Aspect
@Component
public class UseDBAspect {
/**
* 定义切面
*/
@Pointcut(value = "@annotation(com.example.multisourcemybatis.announce.UseDB)")
private void getAnnounce(){}
/**
* 环绕通知
* @param joinPoint 切点,就是被注解的目标方法
*/
@Around("getAnnounce()")
public Object logPostMapping(ProceedingJoinPoint joinPoint) throws Throwable {
// 获取自定义注解中的value值
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
UseDB annotation = signature.getMethod().getAnnotation(UseDB.class);
String dataSourceKey = annotation.value();
// 将dataSource的key设置到ThreadLocal
DataSourceContextHolder.setDataSourceKey(dataSourceKey);
// 执行目标方法,也就是service方法
Object result = joinPoint.proceed();
// 执行方法后,记得清除ThreadLocal,避免内存泄漏
DataSourceContextHolder.clear();
// 返回方法返回值
return result;
}
}
11. 修改DynamicDataSource
补充DynamicDataSource的determineCurrentLookupKey方法,也就是如何获得key的方法,改为从ThreadLocal中获取即可
public class DynamicDataSource extends AbstractRoutingDataSource {
/**
* 获取数据源key的方式,要使用哪个数据源,是通过数据源key选择的,这个key是数据源map中的key
*/
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSourceKey();
}
}
12. 简单测试一下
service方法
@UseDB("db1")
public void addInDB1(UserInfo userInfo) {
String stringId = SnowFlakeUtils.getStringId();
userInfo.setId(stringId);
userInfoMapper.insert(userInfo);
}
@UseDB("db2")
public void addInDB2(UserInfo userInfo) {
String stringId = SnowFlakeUtils.getStringId();
userInfo.setId(stringId);
userInfoMapper.insert(userInfo);
}
controller方法
@PostMapping("add")
public Result add(UserInfo userInfo) throws Exception {
userInfoService.addInDB1(userInfo);
userInfoService.addInDB2(userInfo);
return ResultUtils.success();
}
测试结果:
两个数据库分别插入一条数据,符合预期
13. 未完
“你这例子确实实现了通过注解方式实现数据源的切换,但是好像有点问题,你测试的例子是从controller中分别执行两个service方法(被自定义注解@UseDB标注的方法),但在实际开发中,我不确保总是从controller中调用,万一我在一个service中调用另一个service,而且在调用完另一个service后还需要进行数据库操作,这样的话就出问题了,在调用内层service的时候,我的ThreadLocal值已经被覆盖,并且内层service执行完后还进行了清除ThreadLocal,也就是说外层service设置的数据源已经没了,等到后面再执行dao操作时,会走默认的数据源,而不是@UseDB标注的数据源。这……是bug啊”
是的,理想状态下我们认为一个service不调用另一个service,但如果确实调用了,就可能出现bug,但也不是不能解决,那我们就针对性修改下吧
14. 结合栈的使用
我们要实现的效果是,外层方法使用外层数据源,内层方法使用内层方法数据源,如果还有内层的内层方法,使用内层的内层的数据源。然后方法执行完后一步一步弹出,但不影响相对外层的数据源。
有没有很熟悉,这就是栈啊,先进后出,我们使用栈来存储数据源的key,当调用内层方法后pop掉就行了,这样外层方法依旧能获取到外层的数据源key。
15. 修改DataSourceContextHolder
只修改DataSourceContextHolder,修改setter,getter以及clear方法,适配stack。
public class DataSourceContextHolder {
private static ThreadLocal<Stack<String>> dataSourceKey = new ThreadLocal<>();
/**
* 将DataSource的key添加到ThreadLocal的Stack中,效果等同直接交给ThreadLocal
* @param key DataSource的key
*/
public static void setDataSourceKey(String key){
// 判断stack是否为空,在初始状态下stack == null
if (dataSourceKey.get()==null){
dataSourceKey.set(new Stack<String>());
}
// 将DataSource的key添加到stack中
dataSourceKey.get().push(key);
}
/**
* 获取ThreadLocal中Stack最后添加进的key,效果等同获取当前DataSource的key
* @return DataSource的key
*/
public static String getDataSourceKey(){
// 注意,我们获取DataSource时不能采用pop方法,因为我们不能保证一个方法中只有一个数据库操作,
// 如果直接pop,则会导致同一个方法后续数据库操作使用错误的数据源
return dataSourceKey.get().peek();
}
/**
* 将DataSource的key删除,但是不一定删除ThreadLocal,只有最后一个key配Stack踢出后才删除ThreadLocal
*/
public static void clear(){
dataSourceKey.get().pop();
// 如果此时栈中没有数据了,则将ThreadLocal清除
if (dataSourceKey.get().empty()) {
dataSourceKey.remove();
}
}
/**
* 额外再写个方法,无论如何都清除ThreadLocal,避免异常问题,没有将栈全部踢出,导致ThreadLocal内存泄漏
* 建议在servlet拦截器中调用清除,afterCompletion中调用。
*/
public static void clearWhatever(){
dataSourceKey.remove();
}
}
16. 最后小坑
这个不是上面代码的坑,而是AOP实现代理时,类的内部调用默认不走代理方法,也就是说,上面service的addInDB1和addInDB2方法,如果在addInDB1中直接调用或通过this调用addInDB2,如下
@UseDB("db1")
public void addInDB1(UserInfo userInfo) {
String stringId = SnowFlakeUtils.getStringId();
userInfo.setId(stringId);
userInfoMapper.insert(userInfo);
// 直接调用addInDB2
this.addInDB2(userInfo);
}
@UseDB("db2")
public void addInDB2(UserInfo userInfo) {
String stringId = SnowFlakeUtils.getStringId();
userInfo.setId(stringId);
userInfoMapper.insert(userInfo);
}
上述代码中this.addInDB2(userInfo);
默认不走AOP动态代理,也就会导致addDB2方法用的依然是db1数据源这是不符合我们预期的,要解决这个问题,也就是走动态代理,我们要:
- 开启exposeProxy=true的配置,将类内部引用也走AOP代理
在启动类上标注
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true) // 允许类内获取当前实例的代理
public class MultiSourceMyBatisApplication {
public static void main(String[] args) {
SpringApplication.run(MultiSourceMyBatisApplication.class, args);
}
}
- 获取代理对象,通过代理对象调用
@UseDB("db1")
public void addInDB1(UserInfo userInfo) {
String stringId = SnowFlakeUtils.getStringId();
userInfo.setId(stringId);
userInfoMapper.insert(userInfo);
// 通过AopContext获取当前实例的代理对象
UserInfoService userInfoService = (UserInfoService) AopContext.currentProxy();
userInfoService.addInDB2(userInfo);
}
@UseDB("db2")
public void addInDB2(UserInfo userInfo) {
String stringId = SnowFlakeUtils.getStringId();
userInfo.setId(stringId);
userInfoMapper.insert(userInfo);
}
至此全篇完。