全程解析,MyBatis在SpringBoot中的動態多資料來源配置

左拉左拉發表於2020-12-07

在分庫的業務場景和跨資料庫例項獲取資訊之類的場景中,我們會遇到處理多個資料來源訪問的問題,通常情況下可以採用中介軟體,如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();
}

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

相關文章