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数据源这是不符合我们预期的,要解决这个问题,也就是走动态代理,我们要:

  1. 开启exposeProxy=true的配置,将类内部引用也走AOP代理

在启动类上标注

@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)		// 允许类内获取当前实例的代理
public class MultiSourceMyBatisApplication {

    public static void main(String[] args) {
        SpringApplication.run(MultiSourceMyBatisApplication.class, args);
    }

}
  1. 获取代理对象,通过代理对象调用
    @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);
    }

至此全篇完。

posted @ 2024-10-24 10:07  CharyGao  阅读(11)  评论(0编辑  收藏  举报