【Spring boot】实现运行时数据源添加和切换

Spring Boot 实现运行时的数据源添加和切换

在spring boot项目中,有时候会存在多个数据库,可能项目使用了多个数据库,也可能为了实现读写分离连接到了多个数据库。

一般情况下,比如mybatis-plus时内置的数据源切换组件的。我们在编写代码的时候可以使用@DS注解在方法级别上切换数据库。也可以使用mybatis-mate组件实现注解切换或者通过java代码切换。

示例 实现 切换 数据源

@Service
@DS("slave")
public class UserServiceImpl implements UserService {

  @Autowired
  private JdbcTemplate jdbcTemplate;

  public List selectAll() {
    return  jdbcTemplate.queryForList("select * from user");
  }

  @Override
  @DS("slave_1")
  public List selectByCondition() {
    return  jdbcTemplate.queryForList("select * from user where age >10");
  }
}

多数据源的配置

spring:
  datasource:
    dynamic:
      primary: master #设置默认的数据源或者数据源组,默认值即为master
      datasource:
        master:
          url: jdbc:mysql://xx.xx.xx.xx:3306/dynamic
          username: root
          password: 123456
          driver-class-name: com.mysql.jdbc.Driver # 3.2.0开始支持SPI可省略此配置
        slave_1:
          url: jdbc:mysql://xx.xx.xx.xx:3307/dynamic
          username: root
          password: 123456
          driver-class-name: com.mysql.jdbc.Driver

但是这种方案有一个缺陷,所有的数据源都是通过Application.yaml配置进去的。在低代码开发领域,用户创建的每个应用都会在后台创建一个独立的数据库,此时低代码开发平台在创建数据库后需要连接这个数据库执行下一步的初始化操作,用户可可以在低代码平台上直接操作应用数据库内的表和数据。这样就要求我们能够实现程序再运行期间内创建新的数据库,并切换到这些新的数据库执行一些查询。

技术分析

进一步整理需求后,我们实现数据库切换需要实现如下要求:

  1. 能在运行时添加数据源并维护好这些数据源
  2. 支持多种数据库类型
  3. 能在代码中切换数据源,且目标数据源由用户操作决定
  4. 不能影响平台数据库的操作

初步实现动态数据源

在实现动态数据源之前,我们需要先了解数据源的一些基本概念。所谓的数据源指的是spring boot程序和数据库之间的连接通道,一般由数据库的驱动实现与数据库的通讯和控制。为了提高数据库的执行效率,我们一般再数据库连接之上套一层数据库连接池(默认使用HikariDataSource),实现数据库连接的复用。

在java程序中,数据库连接使用DataSource这个接口类表示。DataSource接口类声明了getConnection方法,用于获得一个数据库连接实例。

  /**
   * <p>Attempts to establish a connection with the data source that
   * this {@code DataSource} object represents.
   *
   * @return  a connection to the data source
   * @throws SQLException if a database access error occurs
   * @throws java.sql.SQLTimeoutException  when the driver has determined that the
   * timeout value specified by the {@code setLoginTimeout} method
   * has been exceeded and has at least tried to cancel the
   * current database connection attempt
   */
  getConnection() throws SQLException;

有 Connection,我们就可以实现最原始的数据库操作了。

        Connection connection = null;
        ResultSet resultSet = connection.createStatement().executeQuery("select *  from User");
        while (resultSet.next()) {
            // 手动遍历返回的每一行数据  这里的name是查询出来的字段名
            System.out.println(resultSet.getString("name"));
        }

DataSource是一个接口类,是不能实例化的,也不包含实现代码,那么springboot下DataSource的实现类是哪个? 又是怎么自动创建的?

在DataSourceConfiguration类中存在一个静态内部类Hikari,内部定义了一个dataSource方法组装系统默认的DataSource。

    /**
     * Hikari DataSource configuration.
     */
    @Configuration(proxyBeanMethods = false)
    @ConditionalOnClass(HikariDataSource.class)
    @ConditionalOnMissingBean(DataSource.class)
    @ConditionalOnProperty(name = "spring.datasource.type", havingValue = "com.zaxxer.hikari.HikariDataSource",
            matchIfMissing = true)
    static class Hikari {

        @Bean
        @ConfigurationProperties(prefix = "spring.datasource.hikari")
        HikariDataSource dataSource(DataSourceProperties properties) {
            HikariDataSource dataSource = createDataSource(properties, HikariDataSource.class);
            if (StringUtils.hasText(properties.getName())) {
                dataSource.setPoolName(properties.getName());
            }
            return dataSource;
        }

    }

这个类上有好几个注解,
proxyBeanMethods=false会CGLIB代理,每次调用dataSource都会返回一个新的HikariDataSource,而不会通过缓存机制只执行一次。

@ConditionalOnClass(HikariDataSource.class)表示只有你引入了hikari包才会解析这个类,

@ConditionalOnMissingBean表示你没主动创建DataSource才会调用(默认,备胎)

@ConditionalOnProperty注解表示开启属性条件,暂不清楚这个属性在哪里配置的默认值。

dataSource方法注入了DataSourceProperties类,这个属性类对应了spring.datasource的属性配置,也就是程序的数据库配置。

@ConfigurationProperties(prefix = "spring.datasource")
public class DataSourceProperties implements BeanClassLoaderAware, InitializingBean {
  ...
}

拿到了用户的数据库配置后,调用了createDataSource方法创建了数据源。

protected static <T> T createDataSource(DataSourceProperties properties, Class<? extends DataSource> type) {
        return (T) properties.initializeDataSourceBuilder().type(type).build();
    }

initializeDataSourceBuilder方法的实现如下:

public DataSourceBuilder<?> initializeDataSourceBuilder() {
        return DataSourceBuilder.create(getClassLoader()).type(getType()).driverClassName(determineDriverClassName())
                .url(determineUrl()).username(determineUsername()).password(determinePassword());
    }

我们可以看到最终通过DataSourceBuilder.create方法实现了DataSource的实例创建,使用了url,用户名密码,数据库连接池的实现类,数据源驱动等参数。整个构建过程很明显使用建造者模式,DataSourceBuilder类是无法直接new出来的(私有构造函数),必须通过create方法创建,create方法又必须传递一个classloader,这个classloader是为了加载数据库连接池的实现类的。具体的可以学习下springboot jar打包格式下的类加载机制。

通过上面的流程的学习,我们知道了DataSouce的基本用法以及创建方式,如果想手动创建一个,编写如下代码即可。

DataSourceBuilder.create().type(HikariDataSource.class).username(userName)
                .password(password).url(host).driverClassName(driverClassName).build()

其中HikariDataSource.class可以改成别的东西,比如使用达梦的数据库则可以使用DmdbDataSource.class

现在我们进一步改在,在前面已经确定我们需要支持多种类型的数据库,且支持数据库类型是已知,每一种数据库的驱动类是固定的,对应的URL的格式也是固定的。所以,我们需要创建一个枚举类,枚举类内包含了数据库连接,数据库驱动类,URL参数模板。

public enum DataSourceType {
    // 不同的数据库类型配置
    MYSQL("MYSQL", "com.mysql.cj.jdbc.Driver", "jdbc:mysql://[ip]:[port]/[Schema]?characterEncoding=utf8&serverTimezone=GMT%2B8&allowMultiQueries=true&useSSL=false&useOldAliasMetadataBehavior=true&nullCatalogMeansCurrent=true"),
    SQLSERVER("SQLSERVER", "com.microsoft.sqlserver.jdbc.SQLServerDriver", "jdbc:sqlserver://[ip]:[port];DatabaseName=[Schema];Encrypt=false;sslProtocol=TLSv1.2;"),
    ORACLE("ORACLE", "oracle.jdbc.OracleDriver", "jdbc:oracle:thin:@//[ip]:[port]/[serviceName]"),
    DM7("DM7", "dm.jdbc.driver.DmDriver", "jdbc:dm://[ip]:[port]/[Schema]?schema=[Schema]&zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=utf-8")

    // 驱动包
    private final String driver;
    // 数据库类型
    private final String name;
    // url模板
    private final String urlTemplate;

    DataSourceType(String name, String driver, String urlTemplate) {
        this.driver = driver;
        this.name = name;
        this.urlTemplate = urlTemplate;
    }

对于枚举类的理解,其实可以把枚举类理解成存在固定实例的特殊类,每一个枚举值都是这个枚举类的实例,所以我们判断枚举值的时候使用的是等于而不是equals。每一个枚举值的声明都可以看作对构造函数的调用。

我们在代码里数据库类型都可以使用枚举类表示,但是前段传递的都是字符串,所以还需要一个字符串转枚举值的方法。

 public static DataSourceType ofType(@Nullable String type) {
        if (type == null) {
            log.warn("ofType 传递数据库类型为null 返回mysql类型");
            return MYSQL;
        }
        switch (type) {
            case "MYSQL":
                return MYSQL;
            case "SQLSERVER":
                return SQLSERVER;
            case "ORACLE":
                return ORACLE;
            case "DM":
            case "DM7":
                return DM7;
            default:
                throw new IllegalArgumentException("未知的数据库类型:" + type);
        }
    }

有了枚举类型,然后我们需要创建一个Entity类,用于存数据库配置信息,以及一个build datasource方法。

@Data
@Slf4j
public class DataSourceEntity {
    private String driverClassName ;
    private boolean isBuild = false;
    private Long id;
    private String host;
    private String dataBaseName;
    private String userName;
    private String password;
    private String ip;
    private String port;
    private String urlTemplate;
    private String dataBaseType ;
    // 这个key是数据源的唯一标识,防止同一个数据库创建了多个DataSource 具体逻辑可以可以实现。
    public String buildKey() {
        return server + "_" + dataBaseName + "_" + Md5Utils.X.computeHash(getHost() + this.dataBaseName + userName + password + port);
    }
    // 组装url参数
    public String getHost() {
        if (this.host == null) {
            switch (dataBaseName) {
                // 除了应用数据库的连接之外,还有个特殊的不指向数据库的连接,用于创建应用数据库的,此时数据库还没创建。
                case "sqlserver_root":
                    host = urlTemplate.replace(";DatabaseName=[Schema]", "").replace("[ip]", ip)
                        .replace("[port]", port);
                    break;
                case "mysql_root":
                    host = urlTemplate.replace("[Schema]", "").replace("[ip]", ip)
                        .replace("[port]", port);
                    break;
                case "SYSDBA":
                    host = urlTemplate.replace("[Schema]", dataBaseName).replace("[ip]", ip)
                        .replace("[port]", port);
                    break;
                case "ORACLEDB":
                    host = urlTemplate.replace("[serviceName]", dataBaseName).replace("[ip]", ip)
                        .replace("[port]", port);
                    break;
                default:
                    if (DataSourceType.ofType(dataBaseType) == DataSourceType.ORACLE) {
                        host = urlTemplate.replace("[serviceName]", oracleServiceName).replace("[ip]", ip)
                            .replace("[port]", port);
                    } else {
                        host = urlTemplate.replace("[Schema]", dataBaseName).replace("[ip]", ip)
                            .replace("[port]", port);
                    }
                    break;
            }
        }
        return this.host;
    }
    // 创建数据源
    public DataSource build() {

        String host = getHost();

        DataSource db;
        if ("DM".equals(server)) {
            db = DataSourceBuilder.create().type(DmdbDataSource.class).username(userName)
                .password(password).url(host).driverClassName(driverClassName).build();
        } else {
            db = DataSourceBuilder.create().type(HikariDataSource.class).username(userName)
                .password(password).url(host).driverClassName(driverClassName).build();
        }
        return db;
    }
}

现在我们能给予数据库配置信息创建出DataSource了。下一步就是实现数据库切换给你了。在Spring boot框架中已经存在了一个动态数据源的实现类 AbstractRoutingDataSource的实现原理也不复杂,AbstractRoutingDataSource中存在一个Map用于存储所有数据库的信息,key是数据源的别名,. 不过这是个抽象类,部分内容还需要我们实现。

AbstractRoutingDataSource的实现原理也不复杂,AbstractRoutingDataSource实现了DataSource接口,所以本身就是个数据源。内部由一个Map用于存储所有数据库的信息,key是数据源的别名,value就是对应的DataSource。然后还存在一个变量defaultTargetDataSource,没有指定数据源的情况下默认使用这个数据源。
AbstractRoutingDataSource的getConnection的实现如下,determineTargetDataSource是个抽象方法,需要我们自己实现。

@Override
    public Connection getConnection() throws SQLException {
        return determineTargetDataSource().getConnection();
    }

所以所谓的动态数据源的实现逻辑如下

1.自己声明一个DataSource作为主DataSource
2.这个DataSource包含了所有可用的数据源
3.通过实现determineTargetDataSource方法指定切换到哪个数据源

注意这种实现在事务理无效的,之前被自己坑过,事务内的操作肯定是在同一个Connection内的。

@Slf4j
public class DynamicDataSource extends AbstractRoutingDataSource {
    // 用于存储可被使用的数据源 HashMap类型带商榷
    private final Map<String, DataSource> dataSourceMap = new  HashMap<>();
    // 数据源切换是基于线程实现的
    private final FastThreadLocal<String> currentDbKey = new FastThreadLocal<>();
    // 默认数据源
    private final DataSource defaultDataSource;
    // 针对不同的类型的数据库的连接配置 平台针对不同类型的数据库搭建了相应的数据库实例
    // 如果连接是用户应用的数据库 则会使用
    private final Map<String, ModuleDatabaseConfig> config;

    public DynamicDataSource(DataSource defaultDataSource,
                             Map<String, ModuleDatabaseConfig> moduleDatabaseConfig) {
        this.defaultDataSource = defaultDataSource;
        this.config = moduleDatabaseConfig;
        this.moduleAccountsClient = moduleAccountsClient;
    }

    // 实现了抽象类的方法 用于获取待切换到的数据库别名
    @Override
    protected String determineCurrentLookupKey() {
        return currentDbKey.get();
    }

    // 干掉父类中afterPropertiesSet的实现 因为我的方案不需要那些逻辑,AbstractRoutingDataSource实际上都可以不继承。
    @Override
    public void afterPropertiesSet() {
    }

    /**
     * 获取数据源
     */
    @NotNull
    @Override
    public DataSource determineTargetDataSource() {
        String key = determineCurrentLookupKey();
        log.debug("当前数据源为" + (key == null ? "默认数据库" : key));
        DataSource dataSource = dataSourceMap.get(key);
        if (dataSource == null) {
            dataSource = defaultDataSource;
        }
        return dataSource;
    }

    // 添加一个数据源,并切换到
    public synchronized void setCurrentKey(DataSourceEntity dataSource) {
        if (!dataSourceMap.containsKey(dataSource.buildKey())) {
            addDataSource(dataSource);
        }
        currentDbKey.set(dataSource.buildKey());
    }
    // 获取指定数据库类型的基础配置 (ip 端口号 账号 密码之类的)
    public ModuleDatabaseConfig getDataBaseConfig(DataSourceType type) {
        ModuleDatabaseConfig moduleDatabaseConfig = config.get(type.getName());
        moduleDatabaseConfig.setDriverClassName(type.getDriver());
        return moduleDatabaseConfig;
    }
    // 新增一个数据源 (用户应用类型的 使用平台提供的数据库)
    public String addDataSource(String dataBaseName, DataSourceType type) {
        ModuleDatabaseConfig moduleDatabaseConfig = getDataBaseConfig(type);
        DataSourceEntity entity = new DataSourceEntity(dataBaseName, moduleDatabaseConfig);
        String buildKey = entity.buildKey();
        if (!dataSourceMap.containsKey(buildKey)) {
            dataSourceMap.put(buildKey, entity.build());
        }
        return buildKey;
    }

    /**
     * 针对外部数据源的数据源切换
     */
    public synchronized void setCurrentKeyForExt(String key) {
        log.debug("当前数据源为" + (key == null ? "默认数据库" : key));
        if (!dataSourceMap.containsKey(key)) {
            throw new IllegalArgumentException("数据库对应的key不存在" + key);
        }
        currentDbKey.set(key);
    }

    /**
     * 手动切换数据源后务必finally中调用本方法切换回默认数据库!
     */
    public void reset() {
        currentDbKey.remove();
        log.debug("切换到默认数据库");
    }

    // 添加一个数据源 (自定义类型 手动指定ip 账号密码)
    public String addDataSource(DataSourceEntity entity) {
        log.debug("添加动态数据源" + entity.getDataBaseName());

        DataSourceType dataSourceType = DataSourceType.ofType(entity.getDataBaseType());

        if (StringUtils.isEmpty(entity.getUrlTemplate())) {
            String urlTemplate;
            if (dataSourceType == DataSourceType.ORACLE) {
                urlTemplate = dataSourceType.getUrlTemplate().replace("[ip]", Objects.requireNonNull(entity.getHost())).replace("[serviceName]", Objects.requireNonNull(entity.getOracleServiceName()));
            } else {
                urlTemplate = dataSourceType.getUrlTemplate().replace("[ip]", Objects.requireNonNull(entity.getHost()));
            }
            entity.setUrlTemplate(urlTemplate);
            // 置空host DataSourceEntity 转化host
            entity.setIp(entity.getHost());
            entity.setHost(null);
        }

        entity.setDriverClassName(dataSourceType.getDriver());

        String buildKey = entity.buildKey();
        if (!dataSourceMap.containsKey(buildKey)) {
            dataSourceMap.put(buildKey, entity.build());
        }
        return buildKey;
    }

    @Override
    public Connection getConnection() throws SQLException {
        Connection connection = super.getConnection();
        String currentKey = determineCurrentLookupKey();
        //  key 的结构定义 server + "_" + dataBaseName + "_"
        // OSRDB 为数据库的名字 传递这个名字表示不指定schema
        if (currentKey != null && currentKey.startsWith("DM_")) {
            String schema = currentKey.substring(currentKey.indexOf("_") + 1, currentKey.lastIndexOf("_")).toUpperCase();
            System.out.println("当前数据库为达梦设定schema为" + schema);

            connection.setSchema("\"" + schema + "\"");

        }
        return connection;
    }
}

参考文章

mybatis-plus 多数据源插件
https://baomidou.com/pages/a61e1b/#dynamic-datasource
mybatis-plus 数据源切换原理
https://blog.csdn.net/ZGL_cyy/article/details/129034151