动态多数据源的配置与思考

image.png

鉴于公司业务要求,项目中需要配有动态多数据源,故有了此篇文章。

本文主要讲解两种动态多数据源的方式:

  1. Spring框架提供的AbstractRoutingDataSource

  2. Mybatis提供的MapperFactoryBean,也是它自身的实现方式。

这两种实现方式各有优劣,文章最后会有总结。

AbstractRoutingDataSource

image.png

如何实现(白话,不涉及源码)

AbstractRoutingDataSource的思路其实比较简单,也就是每次做数据库查询的时候都通过对应的key(用户提供)查找出对应的数据源然后进行sql查询。提供对应的key路由到对应的数据源,这就形成了动态多数据源切换。

代码配置

DynamicDataSource

配置数据源的基本信息,并且模拟数据源信息加入到多数据源配置中。 这里要非常清楚,动态多数据源配置其实也是一个DataSource数据源,只不过里面装有多个数据源的信息而已。

/**
 * 动态多数据源配置
 *
 * @author yaoj
 * @since 2022/6/21
 */
@Slf4j
@Component("dynamicDataSource")
public class DynamicDataSourceCopy extends AbstractRoutingDataSource {

    // 连接池属性
    @Value("${spring.datasource.type}")
    private String type;
    @Value("${spring.datasource.driverClassName}")
    private String driverClassName;
    @Value("${spring.datasource.initialSize}")
    private String initialSize;
    @Value("${spring.datasource.minIdle}")
    private String minIdle;
    @Value("${spring.datasource.maxActive}")
    private String maxActive;
    @Value("${spring.datasource.maxWait}")
    private String maxWait;
    @Value("${spring.datasource.timeBetweenEvictionRunsMillis}")
    private String timeBetweenEvictionRunsMillis;
    @Value("${spring.datasource.minEvictableIdleTimeMillis}")
    private String minEvictableIdleTimeMillis;
    @Value("${spring.datasource.validationQuery}")
    private String validationQuery;
    @Value("${spring.datasource.testWhileIdle}")
    private String testWhileIdle;
    @Value("${spring.datasource.testOnBorrow}")
    private String testOnBorrow;
    @Value("${spring.datasource.testOnReturn}")
    private String testOnReturn;
    @Value("${spring.datasource.filters}")
    private String filters;

    @Override
    protected String determineCurrentLookupKey() {
        return getDataSource();
    }

    @Override
    public void afterPropertiesSet() {
        registerDynamicDataSources();
    }


    /**
     * 从数据库读取租户的DB配置,并动态注入Spring容器
     */
    private void registerDynamicDataSources() {
        // 模拟租户的DB配置 //可以从数据库读取 也可以配置文件配置
        List<DynamicDataSourceEntity> list = new ArrayList<>();
        DynamicDataSourceEntity db1 = new DynamicDataSourceEntity();
        DynamicDataSourceEntity db2 = new DynamicDataSourceEntity();
        list.add(db1);
        list.add(db2);
        db1.setDsName("db1");
        db2.setDsName("db2");
        db1.setDsKey("db1");
        db2.setDsKey("db2");
        db1.setPassword("yaojie");
        db2.setPassword("yaojie");
        db1.setUsername("root");
        db2.setUsername("root");
        db1.setUrl("jdbc:mysql://localhost:3307/db1");
        db2.setUrl("jdbc:mysql://localhost:3307/db2");
        if (!CollectionUtils.isEmpty(list)) {
            addDataSourceBeans(list);
        }
    }

    /**
     * 根据DataSource创建bean
     */
    private void addDataSourceBeans(List<DynamicDataSourceEntity> entities) {
        Map<Object, Object> targetDataSources = new LinkedHashMap<>();
        for (DynamicDataSourceEntity entity : entities) {
            String beanKey = entity.getDsKey();
            targetDataSources.put(beanKey, getBean(entity, beanKey));
        }
        //将创建的map对象set到targetDataSources
        setTargetDataSources(targetDataSources);
        //将targetDataSources放入resolvedDataSources
        super.afterPropertiesSet();
    }

    /**
     * 组装数据源spring bean
     */
    private AtomikosDataSourceBean getBean(DynamicDataSourceEntity entity, String beanKey) {
        return buildDataSource(
                beanKey,
                DynamicDataSourceUtil.getJDBCUrl(entity.getUrl()),
                entity.getUsername(),
                entity.getPassword(),
                driverClassName,
                filters,
                maxActive,
                initialSize,
                maxWait,
                minIdle,
                timeBetweenEvictionRunsMillis,
                minEvictableIdleTimeMillis,
                validationQuery,
                testWhileIdle,
                testOnBorrow,
                testOnReturn,
                type
        );
    }

    /**
     * 构建数据源
     */
    public static AtomikosDataSourceBean buildDataSource(
            String dataSourceName,
            String url,
            String username,
            String password,
            String driverClassName,
            String filters,
            String maxActive,
            String initialSize,
            String maxWait,
            String minIdle,
            String timeBetweenEvictionRunsMillis,
            String minEvictableIdleTimeMillis,
            String validationQuery,
            String testWhileIdle,
            String testOnBorrow,
            String testOnReturn,
            String xaDataSourceClassNam) {
        Properties prop = new Properties();
        prop.put("url", url);
        prop.put("username", username);
        prop.put("password", password);
        prop.put("driverClassName", driverClassName);
        prop.put("filters", filters);
        prop.put("maxActive", maxActive);
        prop.put("initialSize", initialSize);
        prop.put("maxWait", maxWait);
        prop.put("minIdle", minIdle);
        prop.put("timeBetweenEvictionRunsMillis", timeBetweenEvictionRunsMillis);
        prop.put("minEvictableIdleTimeMillis", minEvictableIdleTimeMillis);
        prop.put("validationQuery", validationQuery);
        prop.put("testWhileIdle", testWhileIdle);
        prop.put("testOnBorrow", testOnBorrow);
        prop.put("testOnReturn", testOnReturn);

        AtomikosDataSourceBean ds = new AtomikosDataSourceBean();
        ds.setXaDataSourceClassName(xaDataSourceClassNam);
        ds.setPoolSize(5);
        ds.setXaProperties(prop);
        ds.setUniqueResourceName(dataSourceName);
        ds.setTestQuery("select 1");
        return ds;
    }
}
复制代码

DynamicSqlSessionTemplateConfig

手动创建并且配置sqlSessionFactorySqlSessionTemplate

因为可能项目中还有其他的数据源配置,所以自行创建配置

这里变形成了多个数据源对应一份mapper文件的操作

/**
 * 动态多数据源配置
 *
 * @author yaoj
 * @since 2022/6/21
 */
@Slf4j
@Configuration
@MapperScan(value = "com.spring.web.dao.test2", sqlSessionTemplateRef = "dynamicSqlSessionTemplate")
public class DynamicSqlSessionTemplateConfig {

    @Value("${mybatis.mapper-locations-test2}")
    private String mapperLocations;

    /**
     * 自定义sqlSessionFactory配置(因为没有用到MybatisAutoConfiguration自动配置类,需要手动配置)
     */
    @Bean("dynamicSqlSessionFactory")
    public SqlSessionFactory dynamicSqlSessionFactory(@Qualifier("dynamicDataSource") DataSource dataSource)
            throws Exception {
        SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
        bean.setDataSource(dataSource);
        //如果重写了 SqlSessionFactory 需要在初始化的时候手动将 mapper 地址 set到 factory 中,否则会报错:
        //org.apache.ibatis.binding.BindingException: Invalid bound statement (not found)
        bean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources(mapperLocations));
        bean.setVfs(SpringBootVFS.class);
        // bean.setPlugins(new MybatisInterceptor());
        return bean.getObject();
    }

    /**
     * SqlSessionTemplate 是 SqlSession接口的实现类,是spring-mybatis中的,实现了SqlSession线程安全
     */
    @Bean("dynamicSqlSessionTemplate")
    public SqlSessionTemplate dynamicSqlSessionTemplate(@Qualifier("dynamicSqlSessionFactory") SqlSessionFactory sqlSessionFactory) {
        return new SqlSessionTemplate(sqlSessionFactory);
    }
}
复制代码

DynamicDataSourceUtil

这个就是利用ThreadLocal存储用户当前所使用的数据源信息

切换数据源便是利用此工具类

/**
 * 动态数据源辅助工具类
 *
 * @author yaoj
 * @since 2022/6/21
 */
@Slf4j
public class DynamicDataSourceUtil {

    // 对当前线程的操作-线程安全的
    private static final ThreadLocal<String> contextHolder = new ThreadLocal<>();

    // 调用此方法,切换数据源
    public static void setDataSource(String dataSource) {
        contextHolder.set(dataSource);
        log.info("已切换到数据源:{}", dataSource);
    }

    // 获取数据源
    public static String getDataSource() {
        return contextHolder.get();
    }

    // 删除数据源
    public static void clearDataSource() {
        contextHolder.remove();
        //互换primary的数据源
        log.info("已切换到主数据源");
    }


    private static final String JDBC_URL_ARGS = "?useUnicode=true&characterEncoding=UTF-8&useSSL=false";

    /**
     * 拼接完整的JDBC URL
     */
    public static String getJDBCUrl(String baseUrl) {
        if (!StringUtils.hasText(baseUrl)) {
            return null;
        }
        return baseUrl + JDBC_URL_ARGS;
    }


}
复制代码

DynamicDataSourceEntity

/**
 * 数据源基本配置实体
 *
 * @author yaoj
 * @since 2022/6/21
 */
@Data
public class DynamicDataSourceEntity {
    String dsKey;
    String dsName;
    String url;
    String username;
    String password;
}
复制代码

yml配置文件

spring:
  # 数据源
  datasource:
    base:
      url: jdbc:mysql://localhost:3307/test
      username: root
      password: yaojie
    type: com.alibaba.druid.pool.xa.DruidXADataSource
    driverClassName: com.mysql.cj.jdbc.Driver
    initialSize: 1
    minIdle: 3
    maxActive: 20
    maxWait: 60000
    timeBetweenEvictionRunsMillis: 60000
    minEvictableIdleTimeMillis: 300000
    validationQuery: SELECT 'x' FROM DUAL
    testWhileIdle: true
    testOnBorrow: false
    testOnReturn: false
    filters: stat

# Mybatis
mybatis:
  mapper-locations-test1: classpath:mapper/test1/*.xml
  mapper-locations-test2: classpath:mapper/test2/*.xml
  configuration:
    # 开启驼峰命名方式 user_name 自动转成 java 实体类中的 userName
    map-underscore-to-camel-case: true
复制代码

源码部分讲解

查询数据库我们可以直接定位到SimpleExecutordoQuery方法。

query.png

conn.png

data.png

abas.png

image.png

至此AbstractRoutingDataSource源码基本就讲得差不多了,思路很简单。

MapperFactoryBean

如何实现(白话,不涉及源码)

@MapperScan扫包封装对应的dao层接口到MapperFactoryBean,我们项目中自动注入的dao层接口实际是MapperFactoryBean.getObject()的对象。(下面会分析)

注:如果对factoryBean不熟悉可以阅读我之前的对factoryBean分析的文章。

mapper.png

说到底其实就是我们自己去生成MapperFactoryBean对象并将它注入spring容器。然后通过对应的beanName获取容器中的dao层对象。

mybatis大致实现

首先必须要想到@MapperScan注解(入口),就好比springboot主启动类的@SpringBootApplication注解。

追随@import注解到MapperScannerRegistrar类中,读取registerBeanDefinitions方法,发现把BeanDefinition信息注入到了spring容器之中。

此时需要你对spring本身有一定理解,在spring容器中对BeanDefinition信息操作的后置处理器为BeanFactoryPostProcessor接口。它的实现类中MapperScannerConfigurer即为mybatis操作BeanDefinition的后置处理接口。

image.png

post.png

定位到MapperScannerConfigurerpostProcessBeanDefinitionRegistry方法。

register.png

定位到ClassPathMapperScanner的doScan方法。

scan.png

beand.png

至于Spring如何创建MapperFactoryBean对象的,可以自行debug源码DefaultListableBeanFactory类的doCreateBean方法进行跟踪。

至此mybatis大致的一个流程已经很明确了,细节还有很多。

自己如何在容器中添加MapperFactoryBean对象

既然mybatis是这么实现的,我们自然也是这么实现。

首先分析MapperFactoryBean类,实例化这个类需要什么。

mapperfactory.png

template.png

首先我们必须清楚,我们通过DataSource对象创建SqlSessionFactory,通过SqlSessionFactory对象创建SqlSessionTemplate,SqlSessionTemplate这个对象即为操作数据库的对象。

而创建MapperFactoryBean对象则需要SqlSessionTemplate和对应的dao层接口。

总体思路

由于业务原因,这边以多数据源对应一份dao层文件为例。所以自动注入变得不现实,也必须提供对应的工具类来从spring容器直接获取dao层对象操作对应数据库。(更加灵活)

/**
 * 动态创建 SqlSessionTemplate;
 *
 * @author yaoj
 * @since 2022/6/21
 */
@Slf4j
@Component
public class DemoSqlSessionTemplateProcessor implements InitializingBean {

    /**
     * Mybatis mapper 地址
     */
    public static final String mapperLocations = "classpath:mapper/test1/*.xml";
    public static final String packagePath = "com.spring.web.dao.test1";
    // 数据源接口集合
    private static final Set<Class<?>> interfaces = new LinkedHashSet<>();

    @Override
    public void afterPropertiesSet() {
        // 扫描接口信息 扫包代码完全可以参照spring源码
        SqlSessionTemplateUtils.scanInterface(packagePath, interfaces);
        // 加载 Bean
        process();
    }

    /**
     * 动态添加 SqlSessionTemplate
     */
    public void process() {
        // 模拟租户的DB配置 //可以从数据库读取 也可以配置文件配置
        List<DynamicDataSourceEntity> list = new ArrayList<>();
        DynamicDataSourceEntity db1 = new DynamicDataSourceEntity();
        DynamicDataSourceEntity db2 = new DynamicDataSourceEntity();
        list.add(db1);
        list.add(db2);
        db1.setDsName("db1");
        db2.setDsName("db2");
        db1.setPassword("yaojie");
        db2.setPassword("yaojie");
        db1.setUsername("root");
        db2.setUsername("root");
        db1.setUrl("jdbc:mysql://localhost:3307/db1");
        db2.setUrl("jdbc:mysql://localhost:3307/db2");
      
        // 处理 Bean 注册
        dataSourceList = SqlSessionTemplateUtils.registeredBean(list, mapperLocations,interfaces);
    }
}
复制代码

准备dao层接口数据

/**
 * 扫描接口信息 直接参照spring源码
 */
public static void scanInterface(String packagePath, Set<Class<?>> interfaces) {
    try {
        assert packagePath != null;
        for (String p : packagePath.split(GlobalConstant.COMMA)) {
            String packageSearchPath = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX +
                    ClassUtils.convertClassNameToResourcePath(environment.resolveRequiredPlaceholders(p)) + "/**/*.class";
            Resource[] resources = new PathMatchingResourcePatternResolver().getResources(packageSearchPath);
            CachingMetadataReaderFactory cachingMetadataReaderFactory = new CachingMetadataReaderFactory();
            for (Resource resource : resources) {
                if (resource.isReadable()) {
                    try {
                        MetadataReader metadataReader = cachingMetadataReaderFactory.getMetadataReader(resource);
                        ClassMetadata classMetadata = metadataReader.getClassMetadata();
                        interfaces.add(Class.forName(classMetadata.getClassName()));
                    } catch (Throwable ex) {
                        throw new BeanDefinitionStoreException("Failed to read candidate component class: " + resource, ex);
                    }
                }
            }
        }
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}
复制代码

注册bean到spring容器(核心方法)

public static void registeredBean(Collection<DynamicDataSourceEntity> dataSources, String mapperLocations, Set<Class<?>> interfaces) {

    String dataSourceSuffix = "DataSource";
    String sqlSessionFactorySuffix = "SqlSessionFactory";
    String sqlSessionTemplateSuffix = "SqlSessionTemplate";

    if (CollectionUtils.isEmpty(dataSources)) {
        return;
    }
    for (DynamicDataSourceEntity dataSource : dataSources) {
        String dsName = dataSource.getDsName();
        //测试数据库连接 若连接失败 则直接抛出异常
        testDataSource(dataSource);
        log.info("开始装配【{}】相关组件", dsName);
        //准备dataSource的信息
        DataSource dataSourceBean = processDataSource(dataSource, dsName);
        //准备SqlSessionFactory的信息
        SqlSessionFactory sqlSessionFactory = processSqlSessionFactory(dataSourceBean, dsName, mapperLocations);
        //准备SqlSessionTemplate的信息
        SqlSessionTemplate sqlSessionTemplate = processSqlSessionTemplate(sqlSessionFactory);
        // 注册Bean
        getBeanFactory().registerSingleton(dsName + dataSourceSuffix, dataSourceBean);
        getBeanFactory().registerSingleton(dsName + sqlSessionFactorySuffix, sqlSessionFactory);
        getBeanFactory().registerSingleton(dsName + sqlSessionTemplateSuffix, sqlSessionTemplate);
        //关键:注册MapperFactoryBean对象到spring
        registerMapper(sqlSessionTemplate, dsName, interfaces);
        log.info("【{}】相关组件,装配完成", systemNameEn);
    }
}
复制代码

测试数据库是否正常

/**
 * 测试数据源是否连接正常
 *
 * @param dataSource
 */
private static void testDataSource(DynamicDataSourceEntity dataSource) {
    try {
        // 数据源连接超时时间
        int CONNECTION_TIMEOUT = 10;
        DriverManager.setLoginTimeout(CONNECTION_TIMEOUT);
        DriverManager.getConnection(dataSource.getUrl(),
                dataSource.getUsername(), dataSource.getPassword());
        log.info("【{}】数据库连接成功", dataSource.getDsName());
    } catch (Exception e) {
        log.error("【{}】数据库连接失败", dataSource.getDatabaseName());
        throw new RuntimeException(String.format("【%s】 数据库连接失败", dataSource.getDsName()));

    }
}
复制代码

准备DataSource

/**
 * 创建数据源
 *
 * @param dataSourceDto 数据源信息
 * @param dsName  数据库名称
 * @return 数据源信息
 */
private static DataSource processDataSource(DynamicDataSourceEntity dataSourceDto, String dsName) {
    //Properties中为一些基本配置
    Properties prop = new Properties();
    prop.put("url", dataSourceDto.getUrl());
    prop.put("username", dataSourceDto.getUsername());
    prop.put("password", dataSourceDto.getPassword());
    prop.put("driverClassName","com.mysql.cj.jdbc.Driver");
    AtomikosDataSourceBean ds = new AtomikosDataSourceBean();
    ds.setXaDataSourceClassName("com.alibaba.druid.pool.xa.DruidXADataSource");
    ds.setMinPoolSize(5);
    ds.setMaxPoolSize(20);
    ds.setBorrowConnectionTimeout(60);
    ds.setXaProperties(prop);
    ds.setUniqueResourceName(dsName + "DataSource");
    ds.setTestQuery("select 1");
    return ds;
}
复制代码

准备SqlSessionFactory

/**
 * 创建 SqlSessionFactory
 *
 * @param dataSource   数据源信息
 * @param dsName 数据库名称
 * @return SqlSessionFactory
 */
private static SqlSessionFactory processSqlSessionFactory(DynamicDataSourceEntity dataSource, String dsName, String mapperLocations) {
    try {
        SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
        bean.setDataSource(dataSource);
        Resource[] resources = resolveMapperLocations(mapperLocations);
        bean.setMapperLocations(resources);
        bean.setVfs(SpringBootVFS.class);
        bean.afterPropertiesSet();
        return bean.getObject();
    } catch (Exception e) {
        e.printStackTrace();
        throw new RuntimeException(String.format("Error create SqlSessionFactoryBean, by %s", systemNameEn));
    }
}
复制代码
/**
 * 解析 Mapper 地址
 *
 * @param mapperLocations 资源路径
 * @return Resource
 */
public static Resource[] resolveMapperLocations(String mapperLocations) {
    String[] locations = Optional.of(mapperLocations.split(GlobalConstant.COMMA)).orElse(new String[0]);
    return Stream.of(locations).flatMap(location -> Stream.of(new PathMatchingResourcePatternResolver().getResources(location))).toArray(Resource[]::new);
}
复制代码

准备SqlSessionTemplate


/**
 * 创建 SqlSessionTemplate
 *
 * @param sqlSessionFactory SqlSessionFactory
 * @return SqlSessionTemplate
 */
private static SqlSessionTemplate processSqlSessionTemplate(SqlSessionFactory sqlSessionFactory) {
    return new SqlSessionTemplate(sqlSessionFactory);
}
复制代码

生成MapperFactoryBean并注入到Spring

/**
 * 注册Mapper
 */
private static void registerMapper(SqlSessionTemplate sqlSessionTemplate, String dsName, Set<Class<?>> interfaces) {
    if (CollectionUtils.isEmpty(interfaces)) {
        return;
    }
    // 注册Mapper
    for (Class<?> anInterface : interfaces) {
        MapperFactoryBean mapperFactoryBean = new MapperFactoryBean();
        mapperFactoryBean.setMapperInterface(anInterface);
        mapperFactoryBean.setSqlSessionTemplate(sqlSessionTemplate);
        //Bean名称为数据库名称加上对应的dao层的名称
        //后续获取dao也是通过spring容器直接获取
        String beanName = dsName + anInterface.getSimpleName();
        getBeanFactory().registerSingleton(beanName, mapperFactoryBean);
    }
}
复制代码

提供获取dao的工具类

public class DaoUtils {

    /**
     * 获取某一Dao
     *
     * @param dsName       数据库名称
     * @param clazz        类字节码文件
     * @param <T>          参数与返回值类型
     * @return dao
     */
    public static <T> T getDao(String dsName, Class<T> clazz) {
        //与上方呼应
        String name = dsName + clazz.getSimpleName();
        return SpringUtils.getBean(name);
    }
}
复制代码

至此MapperFactoryBean方式的动态数据源到此也大致清楚了。

孰优孰劣

这两种多数据源的配置到底孰优孰劣?其实归根结底最后都要根据业务来做判断。

好处:

AbstractRoutingDataSource配置简单原理易懂,依然可以使用自动注入。且操作时可以动态的切换数据源配置。

MapperFactoryBean使用时更加灵活,可根据自己的需求获取相应的数据源,也可以根据自己的需求动态的删除,修改,新增数据源信息。

劣势:

AbstractRoutingDataSource存在各种事务问题,此处不展开。动态新增,删除,修改数据源码的时候需要对容器中的多数据源配置进行相关操作。

MapperFactoryBean配置复杂,且原理需要对于源码有相应的了解,使用时要遵循相应规范,不能自动注入。(或者说不适合自动注入)

说实话本人对他们的优劣也不是非常了解,毕竟应用场景只有那一两个项目,拙荆见肘。

多数据源的配置方式有很多,本文介绍了2种配置方式,都有着他们特定的应用场景。

非常感谢能阅读到此的jy,如文中有错误请尽管指正,非常感谢!

注:文中大多数代码可直接使用。

猜你喜欢

转载自juejin.im/post/7111600075217829901