SpringBoot3动态切换数据源

背景

随着公司业务战略的发展,相关的软件服务也逐步的向多元化转变,之前是单纯的拿项目,赚人工钱,现在开始向产品化\服务化转变。最近雷袭又接到一项新的挑战:了解SAAS模型,考虑怎么将公司的产品转换成多租户架构。

经过一番百度,雷袭对多租户架构总算有了一番了解,以下是整理的笔记。

多租户架构是一种软件架构,用于实现多用户环境下,使用相同的系统或程序组件时,能保证用户之间数据的隔离性。简单说就是使用共用的数据中心,通过单一系统架构与服务提供多数客户端相同甚至可定制化的服务,并且保障客户的数据隔离。一个支持多租户架构的系统需要在设计上对它的数据和配置进行虚拟分区,从而使系统的每个租户或组织都能够使用一个单独的系统实例,每个租户都可以根据自己的需求对租用的系统实例进行个性化配置。

多租户技术的实现重点在于不同租户间应用程序环境的隔离以及数据的隔离,使得不同租户间应用程序不会相互干扰。应用程序可通过进程隔离或者多种运维工具实现,数据存储上的隔离方案则是有三种:

1、独立数据库:优点是独立性最高,缺点是数据库较多,购置和维护成本高。

2、共享数据库,隔离数据架构:同一个数据库实例内多个用户/schema来对应多个租户,优点是单实例可以支持更多租户,缺点是数据恢复比较困难。

3、共享数据库,共享数据结构:物理分表,表分区,或者在表中通过字段区分,优点是成本最低,实现难度低,缺点是数据隔离程度低。

第三种其实雷袭已经试过了,之前的博客里就提到了表分区,分表的实现方式,这里不多缀述,今天雷袭想试试前面两种,因此不得不解决的一个问题:如何实现同一个项目中,数据源的动态切换?

代码实践

雷袭在网上查阅了很多资料,最终找到了两种合适的方式实现,一种是通过AOP来实现,另一种是通过Filter实现,以下是实现的方式说明。

一、通过切面实现

1、准备工作,创建数据库模式,添加测试数据:

sql 复制代码
--先创建三个用户,设置密码
SAAS_MASTER   leixi123
SAAS_DEV   leixi123
SAAS_UAT   leixi123

--再用sysdba给用户授权
grant dba to SAAS_MASTER;
grant resource to SAAS_MASTER;
grant dba to SAAS_DEV;
grant resource to SAAS_DEV;
grant dba to SAAS_UAT;
grant resource to SAAS_UAT;


CREATE TABLE SAAS_MASTER."sys_db_info"
(
    "id" VARCHAR2(32) NOT NULL,
    "url" VARCHAR2(255) NOT NULL,
    "username" VARCHAR2(255) NOT NULL,
    "password" VARCHAR2(255) NOT NULL,
    "driver_class_name" VARCHAR2(255) NOT NULL,
    "db_name" VARCHAR2(255) NOT NULL,
    "db_key" VARCHAR2(255) NOT NULL,
    "status" INT DEFAULT '0' NOT NULL,
    "remark" VARCHAR2(255) DEFAULT NULL,
     PRIMARY KEY("id")) ;

COMMENT ON TABLE SAAS_MASTER."sys_db_info" IS '数据库配置信息表';
COMMENT ON COLUMN SAAS_MASTER."sys_db_info"."db_key" IS '数据库key,即保存Map中的key(保证唯一,并且和DataSourceType中的枚举项保持一致,包括大小写)';
COMMENT ON COLUMN SAAS_MASTER."sys_db_info"."db_name" IS '数据库名称';
COMMENT ON COLUMN SAAS_MASTER."sys_db_info"."driver_class_name" IS '数据库驱动';
COMMENT ON COLUMN SAAS_MASTER."sys_db_info"."id" IS '主键ID';
COMMENT ON COLUMN SAAS_MASTER."sys_db_info"."password" IS '密码';
COMMENT ON COLUMN SAAS_MASTER."sys_db_info"."remark" IS '备注说明';
COMMENT ON COLUMN SAAS_MASTER."sys_db_info"."status" IS '是否停用';
COMMENT ON COLUMN SAAS_MASTER."sys_db_info"."url" IS '数据库URL';
COMMENT ON COLUMN SAAS_MASTER."sys_db_info"."username" IS '用户名';

--添加数据源信息
insert into SAAS_MASTER."sys_db_info" ("id","url","username","password","driver_class_name","db_name","db_key","status","remark")
values ('1', 'jdbc:dm://127.0.0.1:5236/SAAS_DEV', 'SAAS_DEV', 'leixi123', 'dm.jdbc.driver.DmDriver', 'DEV', 'DEV', 0, '连接DEV数据库');
insert into SAAS_MASTER."sys_db_info" ("id","url","username","password","driver_class_name","db_name","db_key","status","remark")
values ('2', 'jdbc:dm://127.0.0.1:5236/SAAS_UAT', 'SAAS_UAT', 'leixi123', 'dm.jdbc.driver.DmDriver', 'UAT', 'UAT', 0, '连接UAT数据库');

--添加测试数据库
CREATE TABLE SAAS_MASTER.leixi_test (
  id VARCHAR2(32) NOT NULL,
  name VARCHAR2(255) NOT NULL,
  PRIMARY KEY (id)
) ;

CREATE TABLE SAAS_DEV.leixi_test (
    id VARCHAR2(32) NOT NULL,
    name VARCHAR2(255) NOT NULL,
    PRIMARY KEY (id)
) ;

CREATE TABLE SAAS_UAT.leixi_test (
     id VARCHAR2(32) NOT NULL,
     name VARCHAR2(255) NOT NULL,
     PRIMARY KEY (id)
) ;


insert into SAAS_MASTER.leixi_test(id, name) values('', '这里是leixi_test 的MASTER库数据');
insert into SAAS_DEV.leixi_test(id, name) values('1', '这里是leixi_test 的DEV库数据');
insert into SAAS_UAT.leixi_test(id, name) values('1', '这里是leixi_test 的UAT数据');

2、创建一个springboot项目,项目环境为JDK17,以下是相关配置和代码:

pom.xml

XML 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <packaging>jar</packaging>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.2</version> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.leixi.hub.saasdb</groupId>
    <artifactId>leixi-saas-db</artifactId>
    <version>1.0-SNAPSHOT</version>
    <name>leixi-saas-db</name>
    <description>用于动态切换数据源</description>
    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

        <hutool.version>5.8.15</hutool.version>
        <mysql.version>8.0.28</mysql.version>
        <druid.version>1.2.16</druid.version>
        <mybatis-plus.version>3.5.3.1</mybatis-plus.version>
        <lombok-mapstruct-binding.version>0.2.0</lombok-mapstruct-binding.version>
    </properties>
    <dependencies>
        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <!--编译测试环境,不打包在lib-->
            <scope>provided</scope>
        </dependency>
        <!-- 允许使用Lombok的Java Bean类中使用MapStruct注解 (Lombok 1.18.20+) -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok-mapstruct-binding</artifactId>
            <version>${lombok-mapstruct-binding.version}</version>
            <scope>provided</scope>
        </dependency>

        <!--    hutool工具包    -->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>${hutool.version}</version>
        </dependency>

        <!-- web支持 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

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


        <!-- DM驱动 -->
        <dependency>
            <groupId>com.dameng</groupId>
            <artifactId>DmJdbcDriver18</artifactId>
            <version>8.1.1.193</version>
        </dependency>

        <!--    阿里druid工具包    -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>${druid.version}</version>
        </dependency>

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>2.0.40</version>
        </dependency>

        <!-- mybatis-plus -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
            <version>3.5.5</version>
        </dependency>


    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-archetype-plugin</artifactId>
                <version>3.0.0</version>
            </plugin>
        </plugins>
    </build>

</project>

application.yml

XML 复制代码
server:
  port: 19200
  servlet:
    context-path: /leixi

spring:
  jackson:
    ## 默认序列化时间格式
    date-format: yyyy-MM-dd HH:mm:ss
    ## 默认序列化时区
    time-zone: GMT+8
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: dm.jdbc.driver.DmDriver
    url: jdbc:dm://127.0.0.1:5236/SAAS_MASTER
    username: SAAS_MASTER
    password: leixi123
    druid:
      slave: false
      initial-size: 15
      min-idle: 15
      max-active: 200
      max-wait: 60000
      time-between-eviction-runs-millis: 60000
      min-evictable-idle-time-millis: 300000
      validation-query: ""
      test-while-idle: true
      test-on-borrow: false
      test-on-return: false
      pool-prepared-statements: false
      connection-properties: false
  task:
    execution:
      thread-pool:
        core-size: 10
        max-size: 20
        queue-capacity: 100
mybatis-plus:
  mapper-locations: classpath:/mapper/**/*Mapper.xml
  global-config:
    db-config:
      #主键类型  0:"数据库ID自增",1:"该类型为未设置主键类型", 2:"用户输入ID",3:"全局唯一ID (数字类型唯一ID)", 4:"全局唯一ID UUID";
      id-type: assign_uuid
      # 默认数据库表下划线命名
      table-underline: true
  configuration:
    # 返回类型为Map,显示null对应的字段
    call-setters-on-nulls: true
    map-underscore-to-camel-case: true #开启驼峰和下划线互转
    # 这个配置会将执行的sql打印出来,在开发或测试的时候可以用
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
leixi:
  saas:
    data_source_key: data_source_key
    load_source_form_db: true

以下是设置数据源的核心代码,其原理为:在项目启动时先通过LoadDataSourceRunner从数据库中查询相关的数据连接,存储在内存中,对Controller中的方法添加@DataSource注解,执行方法时,通过注解中的静态枚举切换对应的数据源,对指定的数据库进行操作。

java 复制代码
package com.leixi.hub.saasdb.config;

import com.alibaba.druid.pool.DruidDataSource;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import com.leixi.hub.saasdb.entity.SysDbInfo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

import javax.sql.DataSource;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.List;
import java.util.Map;
import java.util.Objects;


/**
 * 实现动态数据源,根据AbstractRoutingDataSource路由到不同数据源中
 *
 * @author 雷袭月启
 * @since 2024/12/5 19:39
 */
@Slf4j
public class DynamicDataSource extends AbstractRoutingDataSource {

    // 数据源列表,多数据源情况下,具体使用哪一个数据源,由此获取
    private final Map<Object, Object> targetDataSourceMap;

    /**
     * 构造方法,设置默认数据源和目标多数据源
     *
     * @param defaultDataSource 默认主数据源,只能有一个
     * @param targetDataSources 从数据源,可以是多个
     */
    public DynamicDataSource(DataSource defaultDataSource, Map<Object, Object> targetDataSources) {
        super.setDefaultTargetDataSource(defaultDataSource);
        super.setTargetDataSources(targetDataSources);
        this.targetDataSourceMap = targetDataSources;
    }

    /**
     * 动态数据源的切换(核心)
     * 决定使用哪个数据源
     *
     * @return Object
     */
    @Override
    protected Object determineCurrentLookupKey() {
        return DynamicDataSourceContextHolder.getDataSource();
    }

    /**
     * 添加数据源信息
     *
     * @param dataSources 数据源实体集合
     */
    public void createDataSource(List<SysDbInfo> dataSources) {
        try {
            if (CollectionUtils.isNotEmpty(dataSources)) {
                for (SysDbInfo ds : dataSources) {
                    //校验数据库是否可以连接
                    Class.forName(ds.getDriverClassName());
                    DriverManager.getConnection(ds.getUrl(), ds.getUsername(), ds.getPassword());
                    //定义数据源
                    DruidDataSource dataSource = new DruidDataSource();
                    BeanUtils.copyProperties(ds, dataSource);
                    //申请连接时执行validationQuery检测连接是否有效,这里建议配置为TRUE,防止取到的连接不可用
                    dataSource.setTestOnBorrow(true);
                    //建议配置为true,不影响性能,并且保证安全性。
                    //申请连接的时候检测,如果空闲时间大于timeBetweenEvictionRunsMillis,执行validationQuery检测连接是否有效。
                    dataSource.setTestWhileIdle(true);
                    //用来检测连接是否有效的sql,要求是一个查询语句。
                    dataSource.setValidationQuery("select 1 ");
                    dataSource.init();
                    // 将数据源放入Map中,key为数据源名称,要和DataSourceType中的枚举项对应,包括大小写,并且保证唯一
                    this.targetDataSourceMap.put(ds.getDbKey(), dataSource);
                }
                // 更新数据源配置列表,这里主要是从数据源
                super.setTargetDataSources(this.targetDataSourceMap);
                // 将TargetDataSources中的连接信息放入resolvedDataSources管理
                super.afterPropertiesSet();
            }
        } catch (ClassNotFoundException | SQLException e) {
            log.error("---解析数据源出错---:{}", e.getMessage());
        }
    }

    /**
     * 校验数据源是否存在
     *
     * @param key 数据源保存的key
     * @return 返回结果,true:存在,false:不存在
     */
    public boolean existsDataSource(String key) {
        return Objects.nonNull(this.targetDataSourceMap) && Objects.nonNull(this.targetDataSourceMap.get(key));
    }
}
java 复制代码
package com.leixi.hub.saasdb.config;
import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceBuilder;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

/**
 * 设置数据源
 *
 * @author 雷袭月启
 * @since 2024/12/5 19:39
 */
@Configuration
public class DataSourceConfig {

    private static final String MASTER_SOURCE_KEY = "MASTER";

    /**
     * 配置主数据源,默认使用该数据源,并且主数据源只能配置一个
     *
     * @return DataSource
     * @description 该数据源是在application配置文件master中所配置的
     */
    @Bean
    @ConfigurationProperties("spring.datasource")
    public DataSource masterDataSource() {
        return DruidDataSourceBuilder.create().build();
    }

    /**
     * 配置动态数据源的核心配置项
     *
     * @return DynamicDataSource
     */
    @Primary
    @Bean(name = "dynamicDataSource")
    public DynamicDataSource createDynamicDataSource() {
        Map<Object, Object> dataSourceMap = new HashMap<>();
        // 默认的数据源(主数据源)
        DataSource defaultDataSource = masterDataSource();
        // 配置主数据源,默认使用该数据源,并且主数据源只能配置一个
        dataSourceMap.put(MASTER_SOURCE_KEY, defaultDataSource);
        // 配置动态数据源,默认使用主数据源,如果有从数据源配,则使用从数据库中读取源,并加载到dataSourceMap中
        return new DynamicDataSource(defaultDataSource, dataSourceMap);
    }
}
java 复制代码
package com.leixi.hub.saasdb.config;
/**
 * 动态数据源类型
 *
 * @author 雷袭月启
 * @since 2024/12/5 19:39
 */
public enum DataSourceType {
    // 注意:枚举项要和 DataSourceConfig 中的 createDynamicDataSource()方法dataSourceMap的key保持一致
    /**
     * 主库
     */
    MASTER,
    /**
     * 从库
     */
    UAT,
}
java 复制代码
package com.leixi.hub.saasdb.config;

import lombok.extern.slf4j.Slf4j;

/**
 * 创建一个类用于实现ThreadLocal,主要是通过get,set,remove方法来获取、设置、删除当前线程对应的数据源。
 *
 * @author 雷袭月启
 * @since 2024/12/5 19:39
 */
@Slf4j
public class DynamicDataSourceContextHolder {
    //此类提供线程局部变量。这些变量不同于它们的正常对应关系是每个线程访问一个线程(通过get、set方法),有自己的独立初始化变量的副本。
    private static final ThreadLocal<String> DATASOURCE_HOLDER = new ThreadLocal<>();

    /**
     * 设置数据源
     *
     * @param dataSourceName 数据源名称
     */
    public static void setDataSource(String dataSourceName) {
        log.info("切换数据源到:{}", dataSourceName);
        DATASOURCE_HOLDER.set(dataSourceName);
    }

    /**
     * 获取当前线程的数据源
     *
     * @return 数据源名称
     */
    public static String getDataSource() {
        return DATASOURCE_HOLDER.get();
    }

    /**
     * 删除当前数据源
     */
    public static void removeDataSource() {
        log.info("删除当前数据源:{}", getDataSource());
        DATASOURCE_HOLDER.remove();
    }
}
java 复制代码
package com.leixi.hub.saasdb.config;

import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.leixi.hub.saasdb.dao.SysDbInfoMapper;
import com.leixi.hub.saasdb.entity.SysDbInfo;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;

import java.util.ArrayList;
import java.util.List;


/**
 *  CommandLineRunner 项目启动时执行
 *
 * @author 雷袭月启
 * @since 2024/12/5 19:39
 */
@Slf4j
@Component
public class LoadDataSourceRunner implements CommandLineRunner {
    /**
     * 是否启用从库多数据源配置
     */
    @Value("${leixi.saas.load_source_form_db:false}")
    private boolean enabled;
    @Resource
    private DynamicDataSource dynamicDataSource;
    @Resource
    private SysDbInfoMapper dbInfoMapper;

    /**
     * 项目启动时加载数据源
     */
    @Override
    public void run(String... args) {
        if (!enabled) return;
        refreshDataSource();
    }

    /**
     * 刷新数据源
     */
    public void refreshDataSource() {
        List<SysDbInfo> dbInfos = dbInfoMapper.selectList(new LambdaQueryWrapper<SysDbInfo>().eq(SysDbInfo::getStatus, 0));
        if (CollectionUtils.isEmpty(dbInfos)) return;
        List<SysDbInfo> ds = new ArrayList<>();
        log.info("====开始加载数据源====");
        for (SysDbInfo info : dbInfos) {
            if (StrUtil.isAllNotBlank(
                    info.getUrl(), // 数据库连接地址
                    info.getDriverClassName(), // 数据库驱动
                    info.getUsername(), // 数据库用户名
                    info.getPassword(), // 数据库密码
                    info.getDbKey() // 数据源key
            )) {
                ds.add(info);
                log.info("加载到数据源 ---> dbName:{}、dbKey:{}、remark:{}", info.getDbName(), info.getDbKey(), info.getRemark());
            }
        }
        dynamicDataSource.createDataSource(ds);
        log.info("====数据源加载完成====");
    }
}
java 复制代码
package com.leixi.hub.saasdb.config;

import java.lang.annotation.*;


/**
 * 自定义多数据源切换注解
 * <p>
 * 优先级:先方法,后类,如果方法覆盖了类上的数据源类型,以方法的为准,否则以类上的为准
 * @author 雷袭月启
 * @since 2024/12/5 19:39
 */

@Inherited
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface DataSource {
    /**
     * 切换数据源名称(默认是主数据源test01)
     */
    public DataSourceType value() default DataSourceType.MASTER;
}
java 复制代码
package com.leixi.hub.saasdb.config;

import io.micrometer.common.util.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.aspectj.lang.reflect.MethodSignature;
import java.util.Objects;


/**
 * 多数据源切换
 * @author 雷袭月启
 * @since 2024/12/5 19:39
 */
@Aspect
@Order(1)
@Component
public class DataSourceAspect {
    // 配置织入点,为DataSource 注解
    @Pointcut("@annotation(com.leixi.hub.saasdb.config.DataSource)"
            + "|| @within(com.leixi.hub.saasdb.config.DataSource)")
    public void dsPointCut() {
    }

    /**
     * * 环绕通知
     *
     * @param point 切入点
     * @return Object
     * @throws Throwable 异常
     */
    @Around("dsPointCut()")
    public Object around(ProceedingJoinPoint point) throws Throwable {
        DataSource dataSource = getDataSource(point);
        if (Objects.nonNull(dataSource) && StringUtils.isNotEmpty(dataSource.value().name())) {
            // 将用户自定义配置的数据源添加到线程局部变量中
            DynamicDataSourceContextHolder.setDataSource(dataSource.value().name());
        }
        try {
            return point.proceed();
        } finally {
            // 在执行完方法之后,销毁数据源
            DynamicDataSourceContextHolder.removeDataSource();
        }
    }

    /**
     * 获取需要切换的数据源
     * 注意:顺序为:方法>类,方法上加了注解后类上的将不会生效
     * 注意:当类上配置后,方法上没有该注解,那么当前类中的所有方法都将使用类上配置的数据源
     */
    public DataSource getDataSource(ProceedingJoinPoint point) {
        MethodSignature signature = (MethodSignature) point.getSignature();
        // 从方法上获取注解
        DataSource dataSource = AnnotationUtils.findAnnotation(signature.getMethod(), DataSource.class);
        // 方法上不存在时,再从类上匹配
        return Objects.nonNull(dataSource) ? dataSource : AnnotationUtils.findAnnotation(signature.getDeclaringType(), DataSource.class);
    }
}

接下来是测试的一些实体类,Controller方法:

java 复制代码
package com.leixi.hub.saasdb.entity;

import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
import lombok.experimental.Accessors;

import java.io.Serial;
import java.io.Serializable;

/**
 *
 * @author 雷袭月启
 * @since 2024/12/5 19:39
 */
@Data
@ToString
@EqualsAndHashCode
@NoArgsConstructor
@Accessors(chain = true)
@TableName(value = "sys_db_info")
public class SysDbInfo implements Serializable {
    @Serial
    @TableField(exist = false)
    private static final long serialVersionUID = 8115921127536664152L;
    /**
     * 数据库地址
     */
    private String url;
    /**
     * 数据库用户名
     */
    private String username;
    /**
     * 密码
     */
    private String password;
    /**
     * 数据库驱动
     */
    private String driverClassName;
    /**
     * 数据库key,即保存Map中的key(保证唯一)
     * 定义一个key用于作为DynamicDataSource中Map中的key。
     * 这里的key需要和DataSourceType中的枚举项保持一致
     */
    private String dbKey;
    /**
     * 数据库名称
     */
    private String dbName;
    /**
     * 是否停用:0-正常,1-停用
     */
    private Integer status;
    /**
     * 备注
     */
    private String remark;
}
java 复制代码
package com.leixi.hub.saasdb.dao;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.leixi.hub.saasdb.entity.SysDbInfo;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface SysDbInfoMapper extends BaseMapper<SysDbInfo> {}
java 复制代码
package com.leixi.hub.saasdb.dao;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import java.util.List;
import java.util.Map;

/**
 *
 * @author 雷袭月启
 * @since 2024/12/5 19:39
 */
@Mapper
public interface CommonMapper extends BaseMapper {

    List<Map<String, Object>> getDataBySql(@Param("sql") String sql);

    void updateDataBySql(@Param("sql") String sql);

}
java 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.leixi.hub.saasdb.dao.CommonMapper">

    <select id="getDataBySql" resultType="java.util.Map">
        ${sql}
    </select>

    <update id="updateDataBySql">
        ${sql}
    </update>
</mapper>
java 复制代码
package com.leixi.hub.saasdb.controller;

import com.leixi.hub.saasdb.config.DataSource;
import com.leixi.hub.saasdb.config.DataSourceType;
import com.leixi.hub.saasdb.dao.CommonMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

/**
 *
 * @author 雷袭月启
 * @since 2024/12/5 19:39
 */
@RestController
public class DemoController {

    @GetMapping("/demo")
    public Object demo() {
        return "Hello World";
    }

    @Autowired
    private CommonMapper commonMapper;


    @GetMapping("/getDataBySqlFromMaster")
    @DataSource(DataSourceType.MASTER)
    public Object getDataBySqlFromMaster(@RequestParam(value = "sql") String sql) {
        return commonMapper.getDataBySql(sql);
    }

    @GetMapping("/getDataBySqlFromUat")
    @DataSource(DataSourceType.UAT)
    public Object getDataBySqlFromSlave(@RequestParam(value = "sql") String sql) {
        return commonMapper.getDataBySql(sql);
    }

    @GetMapping("/getDataBySql")
    public Object getDataBySql(@RequestParam(value = "sql") String sql) {
        return commonMapper.getDataBySql(sql);
    }


}

3、启动项目,通过Postman测试,结果和预期一致:

二、通过Filter实现

上述的方法虽然有效,但多少有些固化了,为何?一:只有添加了注解的类或方法才能动态切换数据源,需要对已有代码进行修改,那就多少会有漏改,少改的位置,二来,可选的数据源在枚举或代码中写死了,假设在数据库里新增了一个数据源,则程序中必须要做相应的调整,可扩展性不高,综合考虑后,我决定再用过滤器的方式试试。

过滤器的原理其实和AOP相似,只是在Header中添加一个数据库的Key,在过滤器中根据这个Key来指定数据源,实现代码如下:

java 复制代码
package com.leixi.hub.saasdb.filter;

import com.leixi.hub.saasdb.config.DynamicDataSourceContextHolder;
import io.micrometer.common.util.StringUtils;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.core.annotation.Order;

import java.io.IOException;

/**
 *
 * @author 雷袭月启
 * @since 2024/12/5 19:39
 */
@Order(1)
public class DataSourceChangeFilter implements Filter {

    private String dataSourceKey;

    public DataSourceChangeFilter(String dataSourceKey) {
        this.dataSourceKey = dataSourceKey;
    }


    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String dataSource = httpRequest.getHeader(dataSourceKey);
        if (StringUtils.isNotEmpty(dataSource)) {
            DynamicDataSourceContextHolder.setDataSource(dataSource);
            chain.doFilter(request, response);
            destroy();
        } else {
            chain.doFilter(request, response);
        }
    }

    @Override
    public void destroy() {
        DynamicDataSourceContextHolder.removeDataSource();
    }

}
java 复制代码
package com.leixi.hub.saasdb.filter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 *
 * @author 雷袭月启
 * @since 2024/12/5 19:39
 */
@Configuration
public class FilterConfig {

    @Value("${leixi.saas.data_source_key:data_source_key}")
    private String dataSourceKey;

    @Bean
    public FilterRegistrationBean<DataSourceChangeFilter> licenseValidationFilterRegistration() {
        FilterRegistrationBean<DataSourceChangeFilter> registration = new FilterRegistrationBean<>();
        registration.setFilter(new DataSourceChangeFilter(dataSourceKey));
        registration.addUrlPatterns("/*"); // 应用于所有URL /* 应用于登陆 /login
        return registration;
    }
}

测试过程如下:

而不传Header时,默认查询的是Master库:

三、推广使用

当前这个项目是已经实现了多数据源的动态切换,那么如果想让其他项目也支持,应该怎么办呢?咱可以把这个项目打成一个jar包,然后让其他项目引入依赖即可,改动如下:

1、删除Application.java文件。

2、在pom中用以下打包语法进行打包。

java 复制代码
    <!--可以打成供其他包依赖的包-->
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-archetype-plugin</artifactId>
                <version>3.0.0</version>
            </plugin>

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.11.0</version>
                <configuration>
                    <source>17</source>
                    <target>17</target>
                    <encoding>UTF-8</encoding>
                </configuration>
            </plugin>
        </plugins>
        <resources>
            <resource>
                <directory>src/main/resources/config</directory>
                <filtering>true</filtering>
                <excludes>
                    <exclude>*</exclude>
                </excludes>
            </resource>
        </resources>
    </build>

3、打包完成后可以在target中看到对应的jar文件,也可以在其他项目中引用该文件,如下:

后记与致谢

以上就是我今天的全部分享了, Demo比较简单,手法也相对稚嫩,希望不会贻笑大方,也希望新手看到这个Demo能有所启发。这次实践也并非一蹴而就的,离不开大佬们的支持和点拨,雷袭在网上找了很多资料,以下这篇博客是最有价值的,可以说雷袭完全是照抄了他的成果,这里附上原文链接,拜谢大佬!

SpringBoot3多数据源动态切换-陌路

相关推荐
bohu839 天前
openfeign-一些配置
springboot3·日志·openfeign·拦截器·超时时间
synda@hzy15 天前
MONI后台管理系统-swagger3(springdoc-openapi)集成
springboot3·接口文档·swagger3
AH_HH2 个月前
Spring Boot 3.0废弃了JavaEE,改用了Jakarta EE
springboot3·javaee·jakarta ee
胡耀超2 个月前
多租户架构的全景分析(是什么?基本概念、实现策略、资源管理和隔离、数据安全与隔离、性能优化、扩展性与升级、案例研究)
性能优化·架构·1024程序员节·多租户
大霸王龙3 个月前
智云人才推荐与管理系统
大数据·python·django·推荐算法·多租户·课题
向上的车轮3 个月前
ASP.NET Zero 多租户介绍
后端·asp.net·saas·多租户
Moshow郑锴3 个月前
SpringBoot3脚手架
springboot3·fastjson2·mybatisplus3
萧曵 丶5 个月前
Spring Boot 3 新特性
java·spring boot·后端·springboot3
萧曵 丶5 个月前
SpringBoot3 响应式编程
springboot·springboot3·响应式编程