全程解析,MyBatis在SpringBoot中的動態多數據源配置

  • 2020 年 12 月 7 日
  • 筆記

在分庫的業務場景和跨數據庫實例獲取信息之類的場景中,我們會遇到處理多個數據源訪問的問題,通常情況下可以採用中間件,如cobar, tddl, mycat等。

但取決於業務需求,有時我們需要直接通過MyBatis和SpringData來完成這個任務。即使沒有,理解MyBatis多數據源配置的過程也有助於理解其他分庫分表操作的原理

背景依賴如下:

<dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
    </dependency>
    <dependency>
      <groupId>org.mybatis.spring.boot</groupId>
      <artifactId>mybatis-spring-boot-starter</artifactId>
    </dependency>
</dependencies>

要進行多數據源的配置,首先需要了解MyBatis是如何將XML中的Sql語句執行的,是哪些類提供了數據庫的連接,又是哪些類提供了配置參數。

首先來看MyBatis的SQL執行過程:

MyBatisSQL執行過程

不難看出,與數據源相關的處理是在第4、5步中完成的。第四步獲取到的SqlSessionFactory為第五步的SqlSession提供了連接工廠,也就是說我們只需要對第四步進行處理,替換掉原生的DefaultSqlSessionFactory即可

接下來,在SpringBoot框架下,我們可以通過常用的FactoryBean<T>來嘗試獲取SqlSessionFactory

通過查找FactoryBeanSqlSessionFactory的交集,不難找到SqlSessionFactoryBean,這個類中包含大量與數據庫連接配置相關的字段

SqlSessionFactoryBean

並且因為它實現了FactoryBean<T>,可以通過getObject()方法來獲得一個SqlSessionFactory的實例。

通過分析SqlSessionFactoryBean的參數,對於多數據源的處理,基本的可以分為兩種思路:

  1. 不同數據源使用的SQL語句不同(一般見於跨業務實例數據訪問),通過不同的SqlSessionFactory管理不同包中的mapper來實現。
  2. 不同數據源使用的SQL語句相同(一般見於分表場景),通過在語句執行前動態替換線程所使用的數據源來完成。

對於第一種情況,處理方式非常簡單,通過配置多個SqlSessionFactory,為每一個配置不同的MapperLocations來管理。本文不細講這種情況。

對於第二種情況,相對複雜一些,我們接下來一步一步分析。

SqlSessionFactory進行數據庫連接的核心是通過DataSource完成的,因此需要獲取一個可以調整規則的非固化DataSource

通過對javax.sql.DataSource接口進行分析,可以發現AbstractDataSource是絕大部分Spring數據源的父類,與此不同的是我們的連接池數據源(如HikariDataSource和驅動數據源(如MySqlDataSource),由於我們使用SpringBoot框架進行IOC託管,並且通過mybatis-spring-boot-starter進行mybatis接入,因此我們進一步調研AbstractDataSource

經過簡單的父子關係跟蹤,我們發現Spring提供了一個動態配置數據源的抽象類AbstractRoutingDataSource,我們只需要對這個類進行routing部分的實現即可完成需求.

P.S. Spring全都想到了,tql…

這個抽象類需要重寫的方法是protected abstract Object determineCurrentLookupKey()返回值雖然是Object類型,但意思實際上是允許我們自定義key而避免IllegalArgumentException等相關的問題。因此我們先去看一下這個key對應的map是一個什麼結構。

省去無關代碼後,AbstractRoutingDataSource對dataSource的map相關操作實際上基於下面的這個部分

/**
 * 數據源Map key即determineCurrentLookupKey()方法返回的Object,value即為動態切換到的目標dataSource
 */
@Nullable
private Map<Object, Object> targetDataSources;

/**
 * 當determineCurrentLookupKey()返回的結果無法獲取到一個可用的DataSource時,採用的默認數據源
 */
@Nullable
private Object defaultTargetDataSource;
	

換言之,在AbstractRoutingDataSource中實際上維護了多個DataSource,我們只需要將自定義的key獲取方法寫入determineCurrentLookupKey(),並將數據源map和默認數據源set進這兩個變量中即可。

重寫AbstractRoutingDataSource,提供determineCurrentLookupKey()方法的實現

這個部分為了將數據源的切換與DynamicDataSource隔離,我選擇通過編寫一個DataSourceSwitcher來作為數據源選擇的中介。眾所周知,MyBatis的事務和sql執行都是基於SqlSessionHolder進行的線程隔離,其內部是基於ThreadLocal完成的。這個方法很好的解決了單例對象復用時的線程安全問題。因此參考這種形式,switcher應該提供基於ThreadLocal的DataSource選擇機制。

// DataSourceSwitcher.java
@Component
public class DataSourceSwitcher {
    private static final ThreadLocal<Integer> DATA_SOURCE = new ThreadLocal<>();

    public int chooseDefaultDataSource() {
        DATA_SOURCE.set(0);
        return 0;
    }

    public void chooseDataSource(int index) {
        DATA_SOURCE.set(index);
    }

    public static Integer getDataSource() {
        return DATA_SOURCE.get();
    }

    public void clear() {
        DATA_SOURCE.remove();
    }
}

而我們重寫的AbstractRoutingDataSource則應接入為

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

/**
 * <p>動態數據源</p>
 *
 * @author zora
 * @since 2020.09.15
 */
public class DynamicDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return DataSourceSwitcher.getDataSource();
    }
}

到目前為止,動態數據源的切換部分我們已經完成,接下來需要進行數據源的提供。

AbstractRoutingDataSource中的兩個setter提供對應的內容

最簡單的當然是new幾個DataSource,但是大部分環境中,我們是通過連接池進行數據庫連接,而不是每次去創建新的連接對象。而連接池與數據庫的交互需要有最基本的4個參數。

首先創建DatabaseSetting類作為數據模版。

@Data
public class DatabaseSetting {
    /**
     * 用戶名
     */
    private String username;
    /**
     * 密碼
     */
    private String password;
    /**
     * 連接url
     */
    private String url;
    /**
     * driver
     */
    private String driver;
}

然後,本文以主流的HikariPool作為示例,首先創建一個獲取hikari配置的映射器。

import com.zaxxer.hikari.HikariConfig;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections.CollectionUtils;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

import javax.annotation.PostConstruct;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;

/**
 * <h3>cloud-userPlayTime</h3>
 * <h4>com.metaapp.cloud.userplaytime.config.db</h4>
 * <p>動態數據源yml配置映射</p>
 *
 * @author zora
 * @since 2020.09.15
 */
@Slf4j
@Configuration
@ConfigurationProperties(prefix = "spring.datasource")
public class DynamicDataSourceValueMapper {
    @Setter
    @Getter
    private List<DatabaseSetting> dynamic;
    @Setter
    @Getter
    private HikariConfig hikari;

    @PostConstruct
    private void statePrint() {
        dynamic = dynamic.stream().sorted(Comparator.comparingInt(DatabaseSetting::getId)).collect(Collectors.toList());
        StringBuilder builder = new StringBuilder();
        builder.append("【");
        if (CollectionUtils.isEmpty(dynamic)) {
            builder.append("配置失敗,數據源為空");
            builder.append('】');
            log.error("多數據源{}", builder.toString());
        } else {
            for (DatabaseSetting databaseSetting : dynamic) {
                builder.append('{').append("UserName=").append(databaseSetting.getUrl()).append(", ").append("Url=").append(databaseSetting.getUrl()).append("} ,");
            }
            builder.deleteCharAt(builder.lastIndexOf(","));
            builder.append('】');
            log.info("多數據源配置獲取完畢,配置信息為{}", builder.toString());
        }

    }
}

接下來,通過DynamicDataSourceValueMapper提供的元數據,開始創建對應的多個數據源.

@Autowired
private DynamicDataSourceValueMapper dynamicDataSourceValueMapper;

/**
 * 基於元數據創建多個HikariDataSource
 *
 * @return 對應到AbstractRoutingDataSource中Map的數據集
 */
@Bean(name = "dynamicDataSourceList")
public List<DataSource> getDataSourceList() {
    List<DatabaseSetting> settingList = dynamicDataSourceValueMapper.getDynamic();
    HikariConfig hikariPoolConfig = dynamicDataSourceValueMapper.getHikari();
    List<DataSource> dataSourceList = new ArrayList<>(settingList.size());
    for (DatabaseSetting databaseSetting : settingList) {
        HikariConfig currentHikariConfig = new HikariConfig();
        hikariPoolConfig.copyStateTo(currentHikariConfig);
        currentHikariConfig.setDataSource(DataSourceBuilder.create()
                .driverClassName(databaseSetting.getDriver())
                .url(databaseSetting.getUrl())
                .password(databaseSetting.getPassword())
                .username(databaseSetting.getUsername())
                .build());
        dataSourceList.add(new HikariDataSource(currentHikariConfig));
    }
    return dataSourceList;
}

/**
 * 創建真正的"動態切換"數據源
 *
 * @param dataSourceList 上面方法提供的HikariDataSource
 * @return 實際使用的DynamicDataSource
 */
@Bean(name = "dynamicDataSource")
public DynamicDataSource getDynamicDataSource(@Qualifier(value = "dynamicDataSourceList") List<DataSource> dataSourceList) {
  Map<Object, Object> targetDataSource = new HashMap<>(dataSourceList.size());
  for (int i = 0; i < dataSourceList.size(); i++) {
    DataSource dataSource = dataSourceList.get(i);
    targetDataSource.put(i, dataSource);
  }
  DynamicDataSource dataSource = new DynamicDataSource();
  dataSource.setTargetDataSources(targetDataSource);
  dataSource.setDefaultTargetDataSource(dataSourceList.get(0));
  return dataSource;
}

至此,動態數據源的切換部分已經完成。在需要進行數據源切換的時候,注入DataSourceSwitcher並調用chooseDataSource(int index)方法即可。可以根據具體場景,採用aop等其他形式進行增強。

結合到MyBatis中,需要更新SqlSessionFactory以提供對應的SqlSession

因為我們是基於MyBatis來做數據映射,因此我們在重寫數據源的過程中,需要保證mybatis與我們的數據源能夠正常關聯。因此,我們需要重新提供sqlSessionFactory給容器。

@Bean(name = "MybatisConfiguration")
@ConfigurationProperties("mybatis.configuration")
public org.apache.ibatis.session.Configuration mybatisConfiguration() {
    return new org.apache.ibatis.session.Configuration();
}

@Bean(name = "SqlSessionFactory")
public SqlSessionFactory dynamicSqlSessionFactory(@Qualifier("dynamicDataSource") DataSource dynamicDataSource, @Qualifier("MybatisConfiguration") org.apache.ibatis.session.Configuration configuration)
        throws Exception {
    SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
    bean.setDataSource(dynamicDataSource);
    bean.setConfiguration(configuration);
    // 調整MapperLocation指定到實際的mapper路徑即可。
    bean.setMapperLocations(
            new PathMatchingResourcePatternResolver().getResources("classpath*:com/zora/demo/mapper/mapping/*.xml"));
    return bean.getObject();
}

如果我的文章對您有所幫助,希望能夠點右下角👍支持一下,不勝感謝🥺