MO_or掰泡饃式教學實現多資料來源

MO_or發表於2022-04-07

一、引言

本文主要介紹一種優雅、安全、易用,支援事務管理的Spring Boot整合多資料來源的方式,本文中不針對多資料來源是什麼、為什麼用、什麼時候用做介紹,小夥伴可根據自身情況酌情采納

溫馨提示:
基於以下知識有一定應用與實踐後,能更好地理解本篇文章

  • Lambda、ThreadLocal、棧、佇列、自定義註解
  • IoC、AOP、Druid、Maven、Spring Boot

由於本文主要講解程式碼的具體實現,程式碼與註釋較多,若感到閱讀體驗不佳,可配合開原始碼,使用程式碼編輯器進行閱讀
多資料來源Gitee地址
對應專案模組為hei-dynamic-datasource

二、大致思路

  1. 通過配置類與yml配置檔案先裝配好預設資料來源與多資料來源
  2. 再通過自定義註解與AOP,找到目標類或方法,並指定其使用的資料來源Key值
  3. 最後通過繼承AbstractRoutingDataSource類,返回經AOP處理後的資料來源Key值,從第一步裝配好的資料來源中找到對應配置並應用

三、測試用例

在類或方法上加上@DataSource("value")就可以指定不同資料來源

@Service
// 方法上的註解比類上註解優先順序更高
@DataSource("slave2")
public class DynamicDataSourceTestService {
    @Autowired
    private SysUserDao sysUserDao;

    @Transactional
    public void updateUser(Long id){
        SysUserEntity user = new SysUserEntity();
        user.setUserId(id);
        user.setMobile("13500000002");
        sysUserDao.updateById(user);
    }

    @Transactional
    @DataSource("slave1")
    public void updateUserBySlave1(Long id){
        SysUserEntity user = new SysUserEntity();
        user.setUserId(id);
        user.setMobile("13500000001");
        sysUserDao.updateById(user);
    }

    @DataSource("slave2")
    @Transactional
    public void updateUserBySlave2(Long id){
        SysUserEntity user = new SysUserEntity();
        user.setUserId(id);
        user.setMobile("13500000003");
        sysUserDao.updateById(user);

        // 測試事務
        int i = 1/0;
    }
}

@RunWith(SpringRunner.class)
@SpringBootTest
public class DynamicDataSourceTest {
    @Autowired
    private DynamicDataSourceTestService dynamicDataSourceTestService;

    @Test
    public void test(){
        Long id = 1L;

        dynamicDataSourceTestService.updateUser(id);
        dynamicDataSourceTestService.updateUserBySlave1(id);
        dynamicDataSourceTestService.updateUserBySlave2(id);
    }

}

四、專案結構

多資料來源專案結構

五、程式碼示例及解析

5.1、maven相關依賴

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

5.2、yml配置

dynamic:
  datasource:
    slave1:
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://localhost:3306/hei?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
      username: root
      password: 123456
    slave2:
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://localhost:3306/hei?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
      username: root
      password: 123456

5.3、自定義註解(DataSource)

// 定義作用範圍為(方法、介面、類、列舉、註解)
@Target({ElementType.METHOD, ElementType.TYPE})
// 保證執行時能被JVM或使用反射的程式碼使用
@Retention(RetentionPolicy.RUNTIME)
// 生成Javadoc時讓使用了@DataSource這個註解的地方輸出@DataSource這個註解或不同內容
@Documented
// 類繼承中讓子類繼承父類@DataSource註解
@Inherited
public @interface DataSource {
    // @DataSource註解裡傳的參,這裡主要傳配置檔案中不同資料來源的標識,如@DataSource("slave1")
    String value() default "";
}

5.4、切面類(DataSourceAspect)

// 宣告、定義切面類
@Aspect
@Component
/**
 * 讓該bean的執行順序優先順序最高,並不能控制載入入IoC的順序
 * 如果一個方法被多個 @Around 增強,那就可以使用該註解指定順序
 */
@Order(Ordered.HIGHEST_PRECEDENCE)
public class DataSourceAspect {
    protected Logger logger = LoggerFactory.getLogger(getClass());

    // 指明通知在使用@DataSource註解標註下才觸發
    @Pointcut("@annotation(io.renren.commons.dynamic.datasource.annotation.DataSource) " +
            "|| @within(io.renren.commons.dynamic.datasource.annotation.DataSource)")
    public void dataSourcePointCut() {

    }

    // 對通知方法的具體實現並採用環繞通知設定方法與切面的執行順序,即在方法執行前和後觸發
    @Around("dataSourcePointCut()")
    /**
     * ProceedingJoinPoint繼承了JoinPoint,相較於JoinPoint暴露了proceed方法,該類僅配合實現around通知
     * JoinPoint類,用來獲取代理類和被代理類的資訊
     * 呼叫proceed方法,表示繼續執行目標方法(即加了@DataSource註解的方法)
     */
    public Object around(ProceedingJoinPoint point) throws Throwable {

        // 通過反射獲得被代理類(目標物件)
        Class targetClass = point.getTarget().getClass();
        System.out.println("targetClass:" + targetClass);
        /**
         * 獲得被代理類(目標物件)的方法簽名
         * signature加簽是一種簡單、 低成本、保障資料安全的方式
         */
        MethodSignature signature = (MethodSignature) point.getSignature();
        /**
         * 獲得被代理類(目標物件)的方法
         * 這裡獲得方法也可以通過反射和getTarget(),但步驟更多更復雜
         */
        Method method = signature.getMethod();
        System.out.println("method:" + method);

        // 獲得被代理類(目標物件)的註解物件
        DataSource targetDataSource = (DataSource) targetClass.getAnnotation(DataSource.class);
        System.out.println("targetDataSource:" + targetDataSource);
        // 獲得被代理類(目標物件)的方法的註解物件
        DataSource methodDataSource = method.getAnnotation(DataSource.class);
        System.out.println("methodDataSource:" + methodDataSource);
        // 判斷被代理類(目標物件)的註解物件或者被代理類(目標物件)的方法的註解物件不為空
        if (targetDataSource != null || methodDataSource != null) {
            String value;
            // 優先用被代理類(目標物件)的方法的註解物件的值進行後續賦值
            if (methodDataSource != null) {
                value = methodDataSource.value();
            } else {
                value = targetDataSource.value();
            }

            /**
             * DynamicContextHolder是自己實現的棧資料結構
             * 將註解物件的值入棧
             */
            DynamicContextHolder.push(value);
            logger.debug("set datasource is {}", value);
        }

        try {
            // 繼續執行被代理類(目標物件)的方法
            return point.proceed();
        } finally {
            // 清空棧中資料
            DynamicContextHolder.poll();
            logger.debug("clean datasource");
        }
    }
}

5.5、多資料來源上下文操作支援類(DynamicContextHolder)

public class DynamicContextHolder {
    /**
     * Lambda構造 本地執行緒變數
     * 用於避免多次建立資料庫連線或者多執行緒使用同一個資料庫連線
     * 減少資料庫連線建立關閉對程式執行效率的影響與伺服器壓力
     *
     * 這裡使用陣列佇列實現棧資料結構
     * 主要為了解決呼叫鏈執行順序問題,比如這裡有ABC三個方法,它們分別使用的是不同資料來源
     * A呼叫B,B呼叫C,這裡資料來源切換時就要保證,C方法資料來源要最先被清除,再是B,最後A,即後進先出LIFO
     */
    private static final ThreadLocal<Deque<String>> CONTEXT_HOLDER = ThreadLocal.withInitial(ArrayDeque::new);

    /**
     * 獲得當前執行緒資料來源
     *
     * @return 資料來源名稱
     */
    public static String peek() {
        return CONTEXT_HOLDER.get().peek();
    }

    /**
     * 設定當前執行緒資料來源
     *
     * @param dataSource 資料來源名稱
     */
    public static void push(String dataSource) {
        CONTEXT_HOLDER.get().push(dataSource);
    }

    /**
     * 清空當前執行緒資料來源
     */
    public static void poll() {
        Deque<String> deque = CONTEXT_HOLDER.get();
        deque.poll();
        if (deque.isEmpty()) {
            CONTEXT_HOLDER.remove();
        }
    }

}

5.6、多資料來源類(DynamicDataSource)

public class DynamicDataSource extends AbstractRoutingDataSource {

    /**
     * 返回當前上下文環境的資料來源key
     * 後續會根據這個key去找到對應的資料來源屬性
     */
    @Override
    protected Object determineCurrentLookupKey() {
        return DynamicContextHolder.peek();
    }

}

5.7、多資料來源配置類(DynamicDataSourceConfig)

/**
 * 通過@EnableConfigurationProperties(DynamicDataSourceProperties.class)
 * 將DynamicDataSourceProperties.class注入到Spring容器中 
 */
@Configuration
@EnableConfigurationProperties(DynamicDataSourceProperties.class)
public class DynamicDataSourceConfig {
    // 這裡properties已經包含了yml配置中所對應的多資料來源的屬性了
    @Autowired
    private DynamicDataSourceProperties properties;

    /**
     * 通過@ConfigurationProperties與@Bean,將yml配置檔案關於druid中的屬性配置,轉化成bean,並將bean注入到容器中
     * 這裡作用是通過autowire作為引數應用到下面的dynamicDataSource()方法中
     */
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.druid")
    public DataSourceProperties dataSourceProperties() {
        return new DataSourceProperties();
    }

    /**
     * 通過@Bean告知Spring容器,該方法會返回DynamicDataSource物件
     * 通過dynamicDataSource()配置多資料來源選擇邏輯,主要配置目標資料來源和預設資料來源
     */
    @Bean
    public DynamicDataSource dynamicDataSource(DataSourceProperties dataSourceProperties) {
        // 例項化自己實現的多資料來源,其中實現了獲取當前執行緒資料來源名稱的方法
        DynamicDataSource dynamicDataSource = new DynamicDataSource();
        // 設定多資料來源屬性
        dynamicDataSource.setTargetDataSources(getDynamicDataSource());

        // 工廠方法建立Druid資料來源
        DruidDataSource defaultDataSource = DynamicDataSourceFactory.buildDruidDataSource(dataSourceProperties);
        // 設定預設資料來源屬性
        dynamicDataSource.setDefaultTargetDataSource(defaultDataSource);

        return dynamicDataSource;
    }

    private Map<Object, Object> getDynamicDataSource(){
        Map<String, DataSourceProperties> dataSourcePropertiesMap = properties.getDatasource();
        Map<Object, Object> targetDataSources = new HashMap<>(dataSourcePropertiesMap.size());
        dataSourcePropertiesMap.forEach((k, v) -> {
            DruidDataSource druidDataSource = DynamicDataSourceFactory.buildDruidDataSource(v);
            targetDataSources.put(k, druidDataSource);
        });

        return targetDataSources;
    }

}

5.8、多資料來源工廠類(DynamicDataSourceFactory)

// 這裡訪問許可權是包私有
class DynamicDataSourceFactory {

    static DruidDataSource buildDruidDataSource(DataSourceProperties properties) {
        DruidDataSource druidDataSource = new DruidDataSource();
        druidDataSource.setDriverClassName(properties.getDriverClassName());
        druidDataSource.setUrl(properties.getUrl());
        druidDataSource.setUsername(properties.getUsername());
        druidDataSource.setPassword(properties.getPassword());

        druidDataSource.setInitialSize(properties.getInitialSize());
        druidDataSource.setMaxActive(properties.getMaxActive());
        druidDataSource.setMinIdle(properties.getMinIdle());
        druidDataSource.setMaxWait(properties.getMaxWait());
        druidDataSource.setTimeBetweenEvictionRunsMillis(properties.getTimeBetweenEvictionRunsMillis());
        druidDataSource.setMinEvictableIdleTimeMillis(properties.getMinEvictableIdleTimeMillis());
        druidDataSource.setMaxEvictableIdleTimeMillis(properties.getMaxEvictableIdleTimeMillis());
        druidDataSource.setValidationQuery(properties.getValidationQuery());
        druidDataSource.setValidationQueryTimeout(properties.getValidationQueryTimeout());
        druidDataSource.setTestOnBorrow(properties.isTestOnBorrow());
        druidDataSource.setTestOnReturn(properties.isTestOnReturn());
        druidDataSource.setPoolPreparedStatements(properties.isPoolPreparedStatements());
        druidDataSource.setMaxOpenPreparedStatements(properties.getMaxOpenPreparedStatements());
        druidDataSource.setSharePreparedStatements(properties.isSharePreparedStatements());

        try {
            druidDataSource.setFilters(properties.getFilters());
            druidDataSource.init();
        } catch (SQLException e) {
            e.printStackTrace();
        }
        return druidDataSource;
    }
}

5.9、資料來源屬性類(DataSourceProperties)

public class DataSourceProperties {
    /**
     * 可動態配置的資料庫連線屬性
     */
    private String driverClassName;
    private String url;
    private String username;
    private String password;

    /**
     * Druid預設引數
     */
    private int initialSize = 2;
    private int maxActive = 10;
    private int minIdle = -1;
    private long maxWait = 60 * 1000L;
    private long timeBetweenEvictionRunsMillis = 60 * 1000L;
    private long minEvictableIdleTimeMillis = 1000L * 60L * 30L;
    private long maxEvictableIdleTimeMillis = 1000L * 60L * 60L * 7;
    private String validationQuery = "select 1";
    private int validationQueryTimeout = -1;
    private boolean testOnBorrow = false;
    private boolean testOnReturn = false;
    private boolean testWhileIdle = true;
    private boolean poolPreparedStatements = false;
    private int maxOpenPreparedStatements = -1;
    private boolean sharePreparedStatements = false;
    private String filters = "stat,wall";

    public String getDriverClassName() {
        return driverClassName;
    }

    public void setDriverClassName(String driverClassName) {
        this.driverClassName = driverClassName;
    }

    public String getUrl() {
        return url;
    }

    public void setUrl(String url) {
        this.url = url;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public int getInitialSize() {
        return initialSize;
    }

    public void setInitialSize(int initialSize) {
        this.initialSize = initialSize;
    }

    public int getMaxActive() {
        return maxActive;
    }

    public void setMaxActive(int maxActive) {
        this.maxActive = maxActive;
    }

    public int getMinIdle() {
        return minIdle;
    }

    public void setMinIdle(int minIdle) {
        this.minIdle = minIdle;
    }

    public long getMaxWait() {
        return maxWait;
    }

    public void setMaxWait(long maxWait) {
        this.maxWait = maxWait;
    }

    public long getTimeBetweenEvictionRunsMillis() {
        return timeBetweenEvictionRunsMillis;
    }

    public void setTimeBetweenEvictionRunsMillis(long timeBetweenEvictionRunsMillis) {
        this.timeBetweenEvictionRunsMillis = timeBetweenEvictionRunsMillis;
    }

    public long getMinEvictableIdleTimeMillis() {
        return minEvictableIdleTimeMillis;
    }

    public void setMinEvictableIdleTimeMillis(long minEvictableIdleTimeMillis) {
        this.minEvictableIdleTimeMillis = minEvictableIdleTimeMillis;
    }

    public long getMaxEvictableIdleTimeMillis() {
        return maxEvictableIdleTimeMillis;
    }

    public void setMaxEvictableIdleTimeMillis(long maxEvictableIdleTimeMillis) {
        this.maxEvictableIdleTimeMillis = maxEvictableIdleTimeMillis;
    }

    public String getValidationQuery() {
        return validationQuery;
    }

    public void setValidationQuery(String validationQuery) {
        this.validationQuery = validationQuery;
    }

    public int getValidationQueryTimeout() {
        return validationQueryTimeout;
    }

    public void setValidationQueryTimeout(int validationQueryTimeout) {
        this.validationQueryTimeout = validationQueryTimeout;
    }

    public boolean isTestOnBorrow() {
        return testOnBorrow;
    }

    public void setTestOnBorrow(boolean testOnBorrow) {
        this.testOnBorrow = testOnBorrow;
    }

    public boolean isTestOnReturn() {
        return testOnReturn;
    }

    public void setTestOnReturn(boolean testOnReturn) {
        this.testOnReturn = testOnReturn;
    }

    public boolean isTestWhileIdle() {
        return testWhileIdle;
    }

    public void setTestWhileIdle(boolean testWhileIdle) {
        this.testWhileIdle = testWhileIdle;
    }

    public boolean isPoolPreparedStatements() {
        return poolPreparedStatements;
    }

    public void setPoolPreparedStatements(boolean poolPreparedStatements) {
        this.poolPreparedStatements = poolPreparedStatements;
    }

    public int getMaxOpenPreparedStatements() {
        return maxOpenPreparedStatements;
    }

    public void setMaxOpenPreparedStatements(int maxOpenPreparedStatements) {
        this.maxOpenPreparedStatements = maxOpenPreparedStatements;
    }

    public boolean isSharePreparedStatements() {
        return sharePreparedStatements;
    }

    public void setSharePreparedStatements(boolean sharePreparedStatements) {
        this.sharePreparedStatements = sharePreparedStatements;
    }

    public String getFilters() {
        return filters;
    }

    public void setFilters(String filters) {
        this.filters = filters;
    }
}

5.10、多資料來源屬性類(DynamicDataSourceProperties)

/**
 * 通過@ConfigurationProperties指定讀取yml的字首關鍵字
 * 配合setDatasource(),即讀取dynamic.datasource下的配置,將配置屬性轉化成bean
 * 容器執行順序是,在bean被例項化後,會呼叫後置處理,遞迴的查詢屬性,通過反射注入值
 *
 * 由於該類只在DynamicDataSourceConfig類中使用,沒有其它地方用到,所以沒有使用@Component
 * 而是在DynamicDataSourceConfig類中用@EnableConfigurationProperties定義為bean
 */
@ConfigurationProperties(prefix = "dynamic")
public class DynamicDataSourceProperties {
    private Map<String, DataSourceProperties> datasource = new LinkedHashMap<>();

    public Map<String, DataSourceProperties> getDatasource() {
        return datasource;
    }

    public void setDatasource(Map<String, DataSourceProperties> datasource) {
        this.datasource = datasource;
    }
}

六、最後

以上程式碼均已提交到開源專案中,對應專案模組為hei-dynamic-datasource
有需要的小夥伴可點選下方連結,clone程式碼到本地
多資料來源Gitee地址

相關文章