暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

SpringBoot多数据源动态切换

啃饼思录 2022-05-30
1682

本文章来自微信公众号"啃饼思录",欢迎关注。

写在前面

在前一篇文章中我们介绍了如何在SpringBoot中整合Jdbc Template
Mybatis
Spring Data JPA
的多数据源配置,但是很明显这些思路都是设置多个Dao层,然后手动选择使用的实例,在实际工作中可能会有需要动态切换数据源的情况,因此本篇来学习如何利用AOP来实现多数据源的动态切换功能。

知识回顾

AbstractRoutingDataSource
是Spring2.0.1版本引入的一个抽象类,它提供了多数据源的支持能力。AbstractRoutingDataSource
抽象类定义了抽象的determineCurrentLookupKey
方法,子类只需实现此方法,进而动态确定要使用的数据源。

查看一下这个AbstractRoutingDataSource
抽象类的源码,如下所示:

public abstract class AbstractRoutingDataSource extends AbstractDataSource implements InitializingBean {
    @Nullable
    private Map<Object, Object> targetDataSources;
    @Nullable
    private Object defaultTargetDataSource;
    private boolean lenientFallback = true;
    private DataSourceLookup dataSourceLookup = new JndiDataSourceLookup();
    @Nullable
    private Map<Object, DataSource> resolvedDataSources;
    @Nullable
    private DataSource resolvedDefaultDataSource;
    
    ......

    protected DataSource determineTargetDataSource() {
        Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
        Object lookupKey = this.determineCurrentLookupKey();
        DataSource 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 + "]");
        } else {
            return dataSource;
        }
    }

    @Nullable
    protected abstract Object determineCurrentLookupKey();
}

可以看到determineTargetDataSource
方法的逻辑是先判断resolvedDataSources
属性是否不为空,之后再调用determineCurrentLookupKey
方法来获取数据源名称key,并从resolvedDataSources
属性中得到对应的DataSource对象。如果找不到DataSource对象或者数据源名称key不存在则使用resolvedDefaultDataSource

说白了就是开发者提前准备好多个数据源,然后将其存入一个Map中,Map的Key是对应数据源的名称,而Value则是对应的数据源。接着将Map设置到AbstractRoutingDataSource
对象的resolvedDataSources
属性中,然后当执行数据库操作的时候就通过一个Key来从Map中获取对应的数据源实例,并执行对应的数据库操作。

实战

项目初始化

第一步,新建一个名为dynamic-multiple-ds
的SpringBoot项目,选择spring web
mybatis framework
mysql driver
依赖:

第二步,在POM文件中新增Druid和AOP依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.2.8</version>
</dependency>

修改配置文件

第三步,修改application.properties
配置文件信息,不过由于笔者一般喜欢使用YAML格式的写法,因此application.yml
配置文件其实使用更为频繁,因此这里也使用application.yml
配置文件。但是后期我们需要动态获取里面的内容,因此可以使用SpringBoot提供的profile机制来加载里面的信息。定义一个名为application-multiple.yml
的配置文件,并在里面新增如下配置信息:

# 数据源配置
spring:
  datasource:
    # 数据源类型
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    # 自定义数据源
    my-ds:
      # 主数据源,默认为master
      master:
        url: jdbc:mysql://127.0.0.1:3306/my-ds1?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=Asia/Shanghai
        username: root
        password: envy123
      # 从数据源,slave
      slave:
        url: jdbc:mysql://127.0.0.1:3306/my-ds2?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=Asia/Shanghai
        username: root
        password: envy123
    # 初始连接数
    initial-size: 5
    # 最小连接池数量
    min-idle: 10
    # 最大连接池数量
    max-active: 20
    # 获取连接等待超时的时间
    max-wait: 60000
    # 检测间隔时间,检测需要关闭的空闲连接,单位毫秒
    time-between-eviction-runs-millis: 60000
    # 一个连接在连接池中最小的生存时间,单位毫秒
    min-evictable-idle-time-millis: 300000
    # 一个连接在连接池中最大的生存时间,单位毫秒
    max-evictable-idle-time-millis: 900000
    # 配置检测连接是否有效
    validation-query: SELECT 1 FROM DUAL
    # 如果为true(默认为false),当应用向连接池申请连接时,连接池会判断这条连接是否是可用的
    test-on-borrow: false
    # 连接返回检测
    test-on-return: false
    # 失效连接检测
    test-while-idle: true
    druid:
      web-stat-filter:
        enabled: true
      stat-view-servlet:
        enabled: true
        # 设置白名单,缺省为所有
        allow:
        url-pattern: /druid/*
        # 登录用户名及密码
        login-username: melody
        login-password: melody
      filter:
        # 开启统计功能
        stat:
          enabled: true
          # 开启慢查询功能
          log-slow-sql: true
          slow-sql-millis: 1000
          # 合并多SQL
          merge-sql: true
        # 开启防火墙功能
        wall:
          enabled: true
          config:
            # 允许多语句同时执行
            multi-statement-allow: true

这里我们提供了两个数据源,一主一备,其实这里就是同一个连接中的不同数据库罢了。同时我们还对Druid连接池工具进行了详细配置,启用了SQL监控和SQL防火墙等功能。

然后我们需要在application.yml
配置文件中启动上述配置文件信息:

spring:
  profiles:
    active: multiple

加载数据源

第四步,新建一个名为MultipleDSConfiguration
类,显然该类是一个配置类,用于将之前定义的application-multiple.yml
配置文件内容加载到该类中:

@Component
@ConfigurationProperties(prefix = "spring.datasource")
public class MultipleDSConfiguration {
    private Map<String,Map<String,String>> myDS;
    private int initialSize;
    private int minIdle;
    private int maxActive;
    private int maxWait;
    private int timeBetweenEvictionRunsMillis;
    private int minEvictableIdleTimeMillis;
    private int maxEvictableIdleTimeMillis;
    private String validationQuery;
    private boolean testOnBorrow;
    private boolean testOnReturn;
    private boolean testWhileIdle;

    public DruidDataSource dataSource(DruidDataSource druidDataSource){
        // 初始连接数
        druidDataSource.setInitialSize(initialSize);
        // 最小连接池数量
        druidDataSource.setMinIdle(minIdle);
        // 最大连接池数量
        druidDataSource.setMaxActive(maxActive);
        // 获取连接等待超时的时间
        druidDataSource.setMaxWait(maxWait);
        // 检测间隔时间,检测需要关闭的空闲连接,单位毫秒
        druidDataSource.setTimeBetweenEvictionRunsMillis(timeBetweenEvictionRunsMillis);
        // 一个连接在连接池中最小的生存时间,单位毫秒
        druidDataSource.setMinEvictableIdleTimeMillis(minEvictableIdleTimeMillis);
        // 一个连接在连接池中最大的生存时间,单位毫秒
        druidDataSource.setMaxEvictableIdleTimeMillis(maxEvictableIdleTimeMillis);
        // 配置检测连接是否有效
        druidDataSource.setValidationQuery(validationQuery);
        // 如果为true(默认为false),当应用向连接池申请连接时,连接池会判断这条连接是否是可用的
        druidDataSource.setTestOnBorrow(testOnBorrow);
        // 连接返回检测
        druidDataSource.setTestOnReturn(testOnReturn);
        // 失效连接检测
        druidDataSource.setTestWhileIdle(testWhileIdle);
        return druidDataSource;
    }

    //getter和setter方法
}

myDS这个就是我们所定义的Map,我们将定义的多个数据源写入到该Map中。此处还定义了一个名为dataSource的方法,该方法传入一个DruidDataSource对象,其实就是给这个DruidDataSource对象设置属性。

第五步,加载数据源。有了数据源及配置文件后,接下来我们开始根据配置文件来加载数据源。定义一个名为MultipleDataSourceProvider
的接口类,里面定义默认的数据源名称(master)以及加载数据源的抽象方法:

public interface MultipleDataSourceProvider {
    String DEFAULT_DATASOURCE = "master";

    Map<String, DataSource> loadDataSource();
}

接着我们定义一个该接口的实现类YmlMultipleDataSourceProvider
,因此它需要重写其中的loadDataSource()
方法:

@Configuration
public class YmlMultipleDataSourceProvider implements MultipleDataSourceProvider{
    @Autowired
    private MultipleDSConfiguration multipleDSConfiguration;

    @Override
    public Map<String, DataSource> loadDataSource() {
        Map<String, Map<String, String>> myDS = multipleDSConfiguration.getMyDS();
        Map<String,DataSource> map = new HashMap<>(myDS.size());
        try{
            for (String key: myDS.keySet()){
                DruidDataSource druidDataSource = (DruidDataSource)DruidDataSourceFactory.createDataSource(myDS.get(key));
                map.put(key,multipleDSConfiguration.dataSource(druidDataSource));
            }
        }catch (Exception e){
            e.printStackTrace();
        }
        return map;
    }
}

首先我们将之前的MultipleDSConfiguration对象注入进来,然后调用该对象的getMyDS()
方法得到我们在application-multiple.ym
l配置文件中设置的如下信息:

# 数据源配置
spring:
  datasource:
    my-ds:
      # 主数据源,默认为master
      master:
        url: jdbc:mysql://127.0.0.1:3306/my-ds1?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=Asia/Shanghai
        username: root
        password: envy123
      # 从数据源,slave
      slave:
        url: jdbc:mysql://127.0.0.1:3306/my-ds2?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=Asia/Shanghai
        username: root
        password: envy123

接着调用DruidDataSourceFactory.createDataSource()
方法传入一个map,这个map中就url、username和password这三个属性,并构建为一个DruidDataSource对象,之后我们调用MultipleDSConfiguration对象的dataSource()
方法将之前对连接设置的各种参数对此数据源进行属性设置,最后以key为数据源名称,value为对应数据源将其存入到前面所述的Map中。

切换数据源

第五步,切换数据源。对于当前数据库操作应当使用哪个数据源有多种实现方式,需要说明的是当前数据库操作对数据源所做的修改不应该影响到其他的数据库操作,因此可以使用ThreadLocal来实现。将当前数据库操作所使用的数据源存入到ThreadLocal中,这样只有当前线程才能获取到该数据,保证了多线程并发情况下数据的安全性。

首先定义一个用于操作ThreadLocal的类DynamicMultipleDataSourceContextHolder
,它主要用于往ThreadLocal中存入、获取和清除数据,注意我们其实是将数据源的名称存入ThreadLocal里面:

public class DynamicMultipleDataSourceContextHolder {
    public static final Logger log = LoggerFactory.getLogger(DynamicMultipleDataSourceContextHolder.class);

    private String dataSourceName;

    private static final ThreadLocal<String> CURRENT_DATASOURCE_NAME = new ThreadLocal<>();

    public static void setDataSourceName(String dataSourceName){
        log.info("切换到{}数据源",dataSourceName);
        CURRENT_DATASOURCE_NAME.set(dataSourceName);
    }

    public static String getDataSourceName(){
        return CURRENT_DATASOURCE_NAME.get();
    }

    public static void clearDataSourceName(){
        CURRENT_DATASOURCE_NAME.remove();
    }
}

第六步,标记数据源。不过现在还有一个问题,就是我们怎么知道当前使用的是哪个数据源呢?因此需要有一个标识来标记当前使用的数据源。最简单的方式就是使用注解来做标记,因此可以定义一个名为MyDataSource
的注解,用于标记当前使用的数据源名称:

/**
 * 标记使用数据源的名称
 */
@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface MyDataSource {

    String dataSourceName() default MultipleDataSourceProvider.DEFAULT_DATASOURCE;

    @AliasFor("dataSourceName")
    String value() default MultipleDataSourceProvider.DEFAULT_DATASOURCE;
}

可以看到我们允许这个注解添加在方法、类、接口(包括注解类型) 或enum上,因为只加载方法表示当前方法使用该数据源,而加载类上则表示该类中的所有方法都使用该数据源。还有用户在使用这个注解的时候需要指定一个数据源名称,不指定的话默认为master。

第七步,解析自定义注解。前面我们自定义了注解来标记所使用的数据源,那么接下来就是通过AOP来解析该自定义注解。新建一个名为MyDataSourceAspect
的类,这是一个切面类:

@Order(1)
@Aspect
@Component
public class MyDataSourceAspect {

    @Pointcut("@annotation(com.melody.dynamicmultipleds.annotation.MyDataSource)"
    +"||@within(com.melody.dynamicmultipleds.annotation.MyDataSource)")
    public void myDS(){};

    @Around("myDS()")
    public Object around(ProceedingJoinPoint point)throws Throwable {
        MethodSignature signature = (MethodSignature)point.getSignature();
        MyDataSource myDataSource = AnnotationUtils.findAnnotation(signature.getMethod(), MyDataSource.class);
        if(Objects.nonNull(myDataSource)){
            DynamicMultipleDataSourceContextHolder.setDataSourceName(myDataSource.dataSourceName());
        }
        try{
            return point.proceed();
        } finally {
            // 清空数据源
            DynamicMultipleDataSourceContextHolder.clearDataSourceName();
        }
    }
}

简单解释一下上述代码的含义:(1)myDS方法定义了切入点,这里拦截所有被前面自定义的MyDataSource注解所修饰的方法或者类、接口(包括注解类型) 或enum;(2)此处使用环绕通知,表示方法执行前后都进行通知。先根据切入点得到所有的方法签名,然后使用AnnotationUtils.findAnnotation()
方法传入方法名和注解名,找到包含该注解的方法,然后得到得到该注解。注意该注解可能来自方法上,也可能来自类、接口(包括注解类型) 或enum上,不过方法上的优先级高于类上的优先级。之后判断此注解是否存在,如果存在则将该注解中的数据源名称,设置到当前线程的ThreadLocal中;如果注解不存在则直接进行方法的调用,不用设置数据源,而是使用默认的master数据源。最后当方法调用完成后,我们需要将数据源从ThreadLocal中移除。

动态使用数据源

第七步,动态使用数据源。在完成上述操作后,接下来就是如何让Spring知道我们使用的是哪个具体数据源,因此就需要继承AbstractRoutingDataSource
抽象类并重写其中的determineTargetDataSource()
方法。

定义一个名为DynamicMultipleDataSource
的类,来实现上述操作:

/**
 * 动态使用数据源
 */
public class DynamicMultipleDataSource extends AbstractRoutingDataSource {
    //实际数据源提供者
    YmlMultipleDataSourceProvider ymlMultipleDataSourceProvider;

    public DynamicMultipleDataSource(YmlMultipleDataSourceProvider provider){
        this.ymlMultipleDataSourceProvider = provider;
        Map<Object, Object> targetDataSources = new HashMap<>(provider.loadDataSource());
        super.setTargetDataSources(targetDataSources);
        super.setDefaultTargetDataSource(provider.loadDataSource().get(MultipleDataSourceProvider.DEFAULT_DATASOURCE));
        super.afterPropertiesSet();
    }

    @Override
    protected Object determineCurrentLookupKey() {
        String dataSourceName = DynamicMultipleDataSourceContextHolder.getDataSourceName();
        return dataSourceName;
    }
}

简单解释一下上述代码的含义:(1)重写determineCurrentLookupKey
方法逻辑,该方法用于根据数据源的名称来决定调用的数据源,如前面定义的master或者slave数据源名称,得到这个数据源名称后就可以从Map中得到对应的数据源实例。(2)给DynamicMultipleDataSource
类提供了一个有参的构造方法,该方法传入一个YmlMultipleDataSourceProvider
对象,这个是实际数据源的提供者,即所有的数据源都可以从中获取到。(3)在前面分析AbstractRoutingDataSource
抽象类源码的时候,可以看到里面有几个非空的参数:

public abstract class AbstractRoutingDataSource extends AbstractDataSource implements InitializingBean {
    @Nullable
    private Map<Object, Object> targetDataSources;
    @Nullable
    private Object defaultTargetDataSource;
    @Nullable
    private Map<Object, DataSource> resolvedDataSources;
    @Nullable
    private DataSource resolvedDefaultDataSource;

    ......
}

targetDataSources
表示所有的数据源,这个我们可以调用YmlMultipleDataSourceProvider.loadDataSource()
方法来获取;defaultTargetDataSource则是默认的数据源,这个可以传入master这一数据源名称从前面所说的所有数据源中获取得到,这个master数据源是默认的,也是必须设置的数据源;最后就是调用afterPropertiesSet()
来对前面所说的参数进行赋值和校验:

    public void afterPropertiesSet() {
        if (this.targetDataSources == null) {
            throw new IllegalArgumentException("Property 'targetDataSources' is required");
        } else {
            this.resolvedDataSources = new HashMap(this.targetDataSources.size());
            this.targetDataSources.forEach((key, value) -> {
                Object lookupKey = this.resolveSpecifiedLookupKey(key);
                DataSource dataSource = this.resolveSpecifiedDataSource(value);
                this.resolvedDataSources.put(lookupKey, dataSource);
            });
            if (this.defaultTargetDataSource != null) {
                this.resolvedDefaultDataSource = this.resolveSpecifiedDataSource(this.defaultTargetDataSource);
            }

        }
    }

第八步,注解Bean。接下来我们就是将前面定义的DynamicMultipleDataSource
对象注入到Spring容器中:

@Configuration
public class DynamicMultipleDataSourceConfiguration {
    @Autowired
    private YmlMultipleDataSourceProvider provider;

    @Bean
    public DynamicMultipleDataSource dynamicMultipleDataSource(){
        return new DynamicMultipleDataSource(provider);
    }
}

进行测试

第九步,创建对应数据表。在数据库my-ds1和my-ds2中依次创建book数据表,并给前者数据库的数据表中插入1条数据,后者插入2条数据:

CREATE TABLE `book` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL,
  `price` int(11) DEFAULT NULL,
  `description` varchar(500) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

第十步,创建mapper层。这里简单起见没有使用XML文件,直接通过注解方式书写SQL语句:

@Mapper
public interface BookMapper {
    @Select("select count(*) from book")
    Integer number();
}

第十一步,创建service层。这里我们定义了两个方法,分别调用master和slave这两个数据源:

@Service
public class BookService {
    @Autowired
    private BookMapper bookMapper;

    @MyDataSource("master")
    public Integer master(){
        return bookMapper.number();
    }

    @MyDataSource("slave")
    public Integer slave(){
        return bookMapper.number();
    }
}

第十二步,创建controller层。这里我们定义了一个方法,用于输出各个数据源中表的记录条数:

@RestController
public class BookController {
    public static final Logger log = LoggerFactory.getLogger(BookController.class);
    @Autowired
    private BookService bookService;

    @GetMapping("/book")
    public List<Integer> books(){
        List<Integer> list = new ArrayList<>();
        log.info("master db numbers is {}",bookService.master());
        list.add(bookService.master());
        log.info("slave db numbers is {}",bookService.slave());
        list.add(bookService.slave());
        return list;
    }
}

第十三步,启动项目进行测试。打开浏览器,访问http://localhost:8080/book
链接,可以看到它显示一个列表[1,2]
,而IDEA控制台输出如下信息:

这样我们就实现了多数据源的动态切换这一功能,但是美中不足的是无法通过界面来实时控制,其实只需将注解的值作为参数来传入就可以实现,关于一点会在后面的文章中进行介绍和学习。

【参考文章】 手把手教你玩多数据源动态切换! ,感谢大佬的指导与解惑。







我就知道你“在看”
最后修改时间:2022-07-04 15:57:05
文章转载自啃饼思录,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论