Spring是如何支援多資料來源的

Java課代表發表於2022-06-06
大家好,我是課代表。
歡迎關注我的公眾號:Java課代表

上篇介紹了資料來源基礎,並實現了基於兩套DataSource,兩套mybatis配置的多資料來源,從基礎知識層面闡述了多資料來源的實現思路。不瞭解的同學請戳→同學,你的多資料來源事務失效了!

正如文末回顧所講,這種方式的多資料來源對程式碼侵入性很強,每個元件都要寫兩套,不適合大規模線上實踐。

對於多資料來源需求,Spring早在 2007 年就注意到並且給出瞭解決方案,原文見:dynamic-datasource-routing

Spring提供了一個AbstractRoutingDataSource類,用來實現對多個DataSource的按需路由,本文介紹的就是基於此方式實現的多資料來源實踐。

一、什麼是AbstractRoutingDataSource

先看類上的註釋:

Abstract {@link javax.sql.DataSource} implementation that routes {@link #getConnection()}
calls to one of various target DataSources based on a lookup key. The latter is usually
(but not necessarily) determined through some thread-bound transaction context.

課代表翻譯:這是一個抽象類,可以通過一個lookup key,把對getConnection()方法的呼叫,路由到目標DataSource。後者(指lookup key)通常是由和執行緒繫結的上下文決定的。

這段註釋可謂字字珠璣,沒有一句廢話。下文結合主要程式碼解釋其含義。

public abstract class AbstractRoutingDataSource extends AbstractDataSource implements InitializingBean {

    //目標 DataSource Map,可以裝很多個 DataSource
    @Nullable
    private Map<Object, Object> targetDataSources;
    
    @Nullable
    private Map<Object, DataSource> resolvedDataSources;

    //Bean初始化時,將 targetDataSources 遍歷並解析後放入 resolvedDataSources
    @Override
    public void afterPropertiesSet() {
        if (this.targetDataSources == null) {
            throw new IllegalArgumentException("Property 'targetDataSources' is required");
        }
        this.resolvedDataSources = CollectionUtils.newHashMap(this.targetDataSources.size());
        this.targetDataSources.forEach((key, value) -> {
            Object lookupKey = resolveSpecifiedLookupKey(key);
            DataSource dataSource = resolveSpecifiedDataSource(value);
            this.resolvedDataSources.put(lookupKey, dataSource);
        });
        if (this.defaultTargetDataSource != null) {
            this.resolvedDefaultDataSource = resolveSpecifiedDataSource(this.defaultTargetDataSource);
        }
    }
    
    @Override
    public Connection getConnection() throws SQLException {
        return determineTargetDataSource().getConnection();
    }

    /**
     * Retrieve the current target DataSource. Determines the
     * {@link #determineCurrentLookupKey() current lookup key}, performs
     * a lookup in the {@link #setTargetDataSources targetDataSources} map,
     * falls back to the specified
     * {@link #setDefaultTargetDataSource default target DataSource} if necessary.
     * @see #determineCurrentLookupKey()
     */
     //根據 #determineCurrentLookupKey()返回的lookup key 去解析好的資料來源 Map 裡取相應的資料來源
    protected DataSource determineTargetDataSource() {
        Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
        // 當前 lookupKey 的值由使用者自己實現↓
        Object lookupKey = determineCurrentLookupKey();
        DataSource dataSource = this.resolvedDataSources.get(lookupKey);
        if (dataSource == null && (this.lenientFallback || lookupKey == null)) {
            dataSource = this.resolvedDefaultDataSource;
        }
        if (dataSource == null) {
            throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
        }
        return dataSource;
    }
    
    /**
     * Determine the current lookup key. This will typically be
     * implemented to check a thread-bound transaction context.
     * <p>Allows for arbitrary keys. The returned key needs
     * to match the stored lookup key type, as resolved by the
     * {@link #resolveSpecifiedLookupKey} method.
     */
    // 該方法用來決定lookup key,通常用執行緒繫結的上下文來實現
    @Nullable
    protected abstract Object determineCurrentLookupKey();
    
    // 省略其餘程式碼...

}

首先看類圖

AbstractRoutingDataSource-uml

是個DataSource,並且實現了InitializingBean,說明有Bean的初始化操作。

其次看例項變數

private Map<Object, Object> targetDataSources;private Map<Object, DataSource> resolvedDataSources;其實是一回事,後者是經過對前者的解析得來的,本質就是用來儲存多個 DataSource例項的 Map

最後看核心方法

使用DataSource,本質就是呼叫其getConnection()方法獲得連線,從而進行資料庫操作。

AbstractRoutingDataSource#getConnection()方法首先呼叫determineTargetDataSource(),決定使用哪個目標資料來源,並使用該資料來源的getConnection()連線資料庫:

@Override
public Connection getConnection() throws SQLException {
   return determineTargetDataSource().getConnection();
}
protected DataSource determineTargetDataSource() {
   Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
   // 這裡使用的 lookupKey 就能決定返回的資料來源是哪個
   Object lookupKey = determineCurrentLookupKey();
   DataSource dataSource = this.resolvedDataSources.get(lookupKey);
   if (dataSource == null && (this.lenientFallback || lookupKey == null)) {
      dataSource = this.resolvedDefaultDataSource;
   }
   if (dataSource == null) {
      throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
   }
   return dataSource;
}

所以重點就是determineCurrentLookupKey()方法,該方法是抽象方法,由使用者自己實現,通過改變其返回值,控制返回不同的資料來源。用表格表示如下:

lookupKeyDataSource
firstfirstDataSource
secondsecondDataSource

如何實現這個方法呢?結合Spring在註釋裡給的提示:

後者(指lookup key)通常是由和執行緒繫結的上下文決定的。

應該能聯想到ThreadLocal了吧!ThreadLocal可以維護一個與當前執行緒繫結的變數,充當這個執行緒的上下文。

二、實現

設計yaml檔案外部化配置多個資料來源

spring:
  datasource:
    first:
      driver-class-name: org.h2.Driver
      jdbc-url: jdbc:h2:mem:db1
      username: sa
      password:
    second:
      driver-class-name: org.h2.Driver
      jdbc-url: jdbc:h2:mem:db2
      username: sa
      password:

建立lookupKey的上下文持有類:

/**
 * 資料來源 key 上下文
 * 通過控制 ThreadLocal變數 LOOKUP_KEY_HOLDER 的值用於控制資料來源切換
 * @see RoutingDataSource
 * @author :Java課代表
 */
public class RoutingDataSourceContext {

    private static final ThreadLocal<String> LOOKUP_KEY_HOLDER = new ThreadLocal<>();

    public static void setRoutingKey(String routingKey) {
        LOOKUP_KEY_HOLDER.set(routingKey);
    }

    public static String getRoutingKey() {
        String key = LOOKUP_KEY_HOLDER.get();
        // 預設返回 key 為 first 的資料來源
        return key == null ? "first" : key;
    }

    public static void reset() {
        LOOKUP_KEY_HOLDER.remove();
    }
}

實現AbstractRoutingDataSource

/**
 * 支援動態切換的資料來源
 * 通過重寫 determineCurrentLookupKey 實現資料來源切換
 * @author :Java課代表
 */
public class RoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return RoutingDataSourceContext.getRoutingKey();
    }

}

給我們的RoutingDataSource初始化上多個資料來源:

/**
 * 資料來源配置
 * 把多個資料來源,裝配到一個 RoutingDataSource 裡
 * @author :Java課代表
 */
@Configuration
public class RoutingDataSourcesConfig {

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.first")
    public DataSource firstDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.second")
    public DataSource secondDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Primary
    @Bean
    public RoutingDataSource routingDataSource() {
        RoutingDataSource routingDataSource = new RoutingDataSource();
        routingDataSource.setDefaultTargetDataSource(firstDataSource());
        Map<Object, Object> dataSourceMap = new HashMap<>();
        dataSourceMap.put("first", firstDataSource());
        dataSourceMap.put("second", secondDataSource());
        routingDataSource.setTargetDataSources(dataSourceMap);
        return routingDataSource;
    }

}

演示一下手工切換的程式碼:

public void init() {
    // 手工切換為資料來源 first,初始化表
    RoutingDataSourceContext.setRoutingKey("first");
    createTableUser();
    RoutingDataSourceContext.reset();

    // 手工切換為資料來源 second,初始化表
    RoutingDataSourceContext.setRoutingKey("second");
    createTableUser();
    RoutingDataSourceContext.reset();

}

這樣就實現了最基本的多資料來源切換了。

不難發現,切換工作很明顯可以抽成一個切面,我們可以優化一下,利用註解標明切點,哪裡需要切哪裡。

三、引入AOP

自定義註解

/**
 * @author :Java課代表
 */
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface WithDataSource {
    String value() default "";
}

建立切面

@Aspect
@Component
// 指定優先順序高於@Transactional的預設優先順序
// 從而保證先切換資料來源再進行事務操作
@Order(Ordered.LOWEST_PRECEDENCE - 1)
public class DataSourceAspect {

    @Around("@annotation(withDataSource)")
    public Object switchDataSource(ProceedingJoinPoint pjp, WithDataSource withDataSource) throws Throwable {

        // 1.獲取 @WithDataSource 註解中指定的資料來源
        String routingKey = withDataSource.value();
        // 2.設定資料來源上下文
        RoutingDataSourceContext.setRoutingKey(routingKey);
        // 3.使用設定好的資料來源處理業務
        try {
            return pjp.proceed();
        } finally {
            // 4.清空資料來源上下文
            RoutingDataSourceContext.reset();
        }
    }
}

有了註解和切面,使用起來就方便多了:

// 註解標明使用"second"資料來源
@WithDataSource("second")
public List<User> getAllUsersFromSecond() {
    List<User> users = userService.selectAll();
    return users;
}

關於切面有兩個細節需要注意:

  1. 需要指定優先順序高於宣告式事務

    原因:宣告式事務事務的本質也是 AOP,其只對開啟時使用的資料來源生效,所以一定要在切換到指定資料來源之後再開啟,宣告式事務預設的優先順序是最低階,這裡只需要設定自定義的資料來源切面的優先順序比它高即可。

  2. 業務執行完之後一定要清空上下文

    原因:假設方法 A 使用@WithDataSource("second")指定走"second"資料來源,緊跟著方法 B 不寫註解,期望走預設的first資料來源。但由於方法A放入上下文的lookupKey此時還是"second"並未刪除,所以導致方法 B 執行的資料來源與期望不符。

四、回顧

至此,基於AbstractRoutingDataSource+AOP的多資料來源就實現好了。

在配置DataSource 這個Bean的時候,用的是自定義的RoutingDataSource,並且標記為 @Primary。這樣就可以讓mybatis-spring-boot-starter使用RoutingDataSource幫我們自動配置好mybatis,比搞兩套DataSource+兩套Mybatis配置的方案簡單多了。

文中相關程式碼已上傳課代表的github

特別說明:

樣例中為了減少程式碼層級,讓展示更直觀,在 controller 層寫了事務註解,實際開發中可別這麼幹,controller 層的任務是繫結、校驗引數,封裝返回結果,儘量不要在裡面寫業務!

五、優化

對於一般的多資料來源使用場景,本文方案已足夠覆蓋,可以實現靈活切換。

但還是存在如下不足:

  • 每個應用使用時都要新增相關類,大量重複程式碼
  • 修改或新增功能時,所有相關應用都得改
  • 功能不夠強悍,沒有高階功能,比如讀寫分離場景下的讀多個從庫負載均衡

其實把這些程式碼封裝到一個starter裡面,高階功能慢慢擴充套件就可以。

好在開源世界早就有現成工具可用了,開發mybatis-plus的"baomidou"團隊在其生態中開源了一個多資料來源框架 Dynamic-Datasource,底層原理就是AbstractRoutingDataSource,增加了更多強悍的擴充套件功能,下篇介紹其使用。

相關文章