1. 程式人生 > >Spring程式碼中動態切換資料來源

Spring程式碼中動態切換資料來源

最近專案中遇到一個場景,需要能夠在一個方法中操作多個具有相同表結構資料庫(你可以理解為一個表中的資料被水平拆分到多個庫中,查詢時需要遍歷這多個庫)。經過筆者幾天的研究,最終解決了問題,並且寫了一個demo共享到我的github

關注筆者部落格的小夥伴一定知道之前的這篇文章點選開啟連結這篇部落格中的解決方案僅僅適用讀寫分離的場景。就是說,當你在開發的時候已經確定了使用寫庫一讀庫的形式。筆者今天要寫的這篇文章具有普適性,適合所有需要在Spring工程中動態切換資料來源的場景,而且本文中的解決方案對工程的程式碼基本沒有侵入性。下面就來說下該方案的實現原理:

在Spring-Mybatis中,有這樣一個類

AbstractRoutingDataSource根據名字可以猜到,這是一個框架提供的用於動態選擇資料來源的類。這個類有兩個重要的引數,分別叫

defaultTargetDataSource和targetDataSources。一般的工程都是一個數據源,所以不太接觸到這個類。在作者之前的部落格自動切換多個數據源中,可以看到這個類的xml配置如下:

    <bean id="myoneDataSource" class="org.apache.commons.dbcp2.BasicDataSource" destroy-method="close">  
        <property name="driverClassName" value="${jdbc.myone.driver}"/>  
        <property name="url" value="${jdbc.myone.url}"/>  
        <property name="username" value="${jdbc.myone.username}"/>  
        <property name="password" value="${jdbc.myone.password}"/>  
    </bean>  
    <bean id="mytwoDataSource" class="org.apache.commons.dbcp2.BasicDataSource" destroy-method="close">  
        <property name="driverClassName" value="${jdbc.mytwo.driver}"/>  
        <property name="url" value="${jdbc.mytwo.url}"/>  
        <property name="username" value="${jdbc.mytwo.username}"/>  
        <property name="password" value="${jdbc.mytwo.password}"/>  
    </bean>  
  
    <bean id="multipleDataSource" class="dal.datasourceswitch.MultipleDataSource">  
        <property name="defaultTargetDataSource" ref="myoneDataSource"/> <!--預設主庫-->  
        <property name="targetDataSources">  
            <map>  
                <entry key="myone" value-ref="myoneDataSource"/>            <!--輔助aop完成自動資料庫切換-->  
                <entry key="mytwo" value-ref="mytwoDataSource"/>  
            </map>  
        </property>  
    </bean> 


上面的配置檔案對這兩個引數的描述已經很清楚了,但這是多個數據源已經確定的場景。我們這篇部落格中的場景是多個數據源的資訊存在於資料庫中,可能資料庫中的資料來源資訊會動態的增加或者減少。這樣的話,就不能像上面這樣配置了。那怎麼辦呢?

我們僅僅需要設定預設的資料來源,即defaultDataSource引數,至於targetDataSources引數我們需要在程式碼中動態的設定。來看下具體的xml配置:

<bean id="defaultDataSource" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close"
          p:driverClassName="${db_driver}"
          p:url="${db_url}"
          p:username="${db_user}"
          p:password="${db_pass}"
          p:validationQuery="select 1"
          p:testOnBorrow="true"/>

    <!--動態資料來源相關-->
    <bean id="dynamicDataSource" class="org.xyz.test.service.datasourceswitch.impl.DynamicDataSource">
        <property name="targetDataSources">
            <map key-type="java.lang.String">
                <entry key="defaultDataSource" value-ref="defaultDataSource"/>
            </map>
        </property>
        <property name="defaultTargetDataSource" ref="defaultDataSource"/>
    </bean>


從上面的配置檔案中可以看到,我們僅僅配置了預設的資料來源defaultDataSource。至於其他的資料來源targetDataSources,我們沒有配置,需要在程式碼中動態的建立。關於配置就講清楚啦!但我們注意到,支援動態資料來源的不應該是AbstractRoutingDataSource類嗎?怎麼上面的配置中是DynamicDataSource類。沒錯,這個是我們自定義的繼承自AbstractRoutingDataSource類的類,也只最重要的類,來看下:(理解這個類,你需要熟練掌握JAVA反射,以及ThreadLocal變數,和Spring的注入機制。別退縮,大家都是這樣一步步學過來的!)(下面僅僅是看下全貌,程式碼的下面會有詳細的說明)

final class DynamicDataSource extends AbstractRoutingDataSource implements ApplicationContextAware{

    private static final String DATA_SOURCES_NAME = "targetDataSources";

    private ApplicationContext applicationContext;

    @Override
    protected Object determineCurrentLookupKey() {
        DataSourceBeanBuilder dataSourceBeanBuilder = DataSourceHolder.getDataSource();
        System.out.println("----determineCurrentLookupKey---"+dataSourceBeanBuilder);
        if (dataSourceBeanBuilder == null) {
            return null;
        }
        DataSourceBean dataSourceBean = new DataSourceBean(dataSourceBeanBuilder);
        //檢視當前容器中是否存在
        try {
            Map<Object,Object> map=getTargetDataSources();
            synchronized (this) {
                if (!map.keySet().contains(dataSourceBean.getBeanName())) {
                    map.put(dataSourceBean.getBeanName(), createDataSource(dataSourceBean));
                    super.afterPropertiesSet();//通知spring有bean更新
                }
            }
            return dataSourceBean.getBeanName();
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new SystemException(ErrorEnum.MULTI_DATASOURCE_SWITCH_EXCEPTION);
        }
    }


    private Object createDataSource(DataSourceBean dataSourceBean) throws IllegalAccessException {
        //在spring容器中建立並且宣告bean
        ConfigurableApplicationContext context = (ConfigurableApplicationContext) applicationContext;
        DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) context.getBeanFactory();
        BeanDefinitionBuilder beanDefinitionBuilder = BeanDefinitionBuilder.genericBeanDefinition(BasicDataSource.class);
        //將dataSourceBean中的屬性值賦給目標bean
        Map<String, Object> properties = getPropertyKeyValues(DataSourceBean.class, dataSourceBean);
        for (Map.Entry<String, Object> entry : properties.entrySet()) {
            beanDefinitionBuilder.addPropertyValue((String) entry.getKey(), entry.getValue());
        }
        beanFactory.registerBeanDefinition(dataSourceBean.getBeanName(), beanDefinitionBuilder.getBeanDefinition());
        return applicationContext.getBean(dataSourceBean.getBeanName());
    }

    private Map<Object, Object> getTargetDataSources() throws NoSuchFieldException, IllegalAccessException {
        Field field = AbstractRoutingDataSource.class.getDeclaredField(DATA_SOURCES_NAME);
        field.setAccessible(true);
        return (Map<Object, Object>) field.get(this);
    }


    private <T> Map<String, Object> getPropertyKeyValues(Class<T> clazz, Object object) throws IllegalAccessException {
        Field[] fields = clazz.getDeclaredFields();
        Map<String, Object> result = new HashMap<>();
        for (Field field : fields) {
            field.setAccessible(true);
            result.put(field.getName(), field.get(object));
        }
        result.remove("beanName");
        return result;
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext=applicationContext;
    }
}

首先來看覆蓋方法determineCurrentLookupKey(),框架在每次呼叫資料來源時會先呼叫這個方法,以便知道使用哪個資料來源。在本文的場景中,資料來源是由程式設計師在即將切換資料來源之前,將要使用的那個資料來源的名稱放到當前執行緒的ThreadLocal中,這樣在determineCurrentLookupKey()方法中就可以從ThreadLocal中拿到當前請求鑰匙用的資料來源,從而進行初始化資料來源並返回該資料來源的操作。在ThreadLocal變數中,我們儲存了一個DataSourceBuilder,這是一個建造者模式,不是本文的關鍵。我們在後面說。讀者直接把他理解為是一個數據源的描述就好。因此,determineCurrentLookupKey()方法的流程就是:先從ThreadLocal中拿出要使用的資料來源資訊,然後看當前的targetDataSources中是否有了這個資料來源。如果有直接返回。如果沒有,建立一個這樣的資料來源,放到targetDataSources中然後返回。這個過程需要加鎖,為何?這是典型的判斷後插入場景,在多執行緒中會有執行緒安全問題,所以要加鎖!至囑!

由於targetDataSources是父類AbstractRoutingDataSource中的一個私有域,因此想要獲得他的例項只能通過Java反射機制。這也是下面的方法存在的意義!

   private Map<Object, Object> getTargetDataSources() throws NoSuchFieldException, IllegalAccessException {
        Field field = AbstractRoutingDataSource.class.getDeclaredField(DATA_SOURCES_NAME);
        field.setAccessible(true);
        return (Map<Object, Object>) field.get(this);
    }

然後,我們來看具體是怎麼建立資料來源的。

 private Object createDataSource(DataSourceBean dataSourceBean) throws IllegalAccessException {
        //在spring容器中建立並且宣告bean
        ConfigurableApplicationContext context = (ConfigurableApplicationContext) applicationContext;
        DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) context.getBeanFactory();
        BeanDefinitionBuilder beanDefinitionBuilder = BeanDefinitionBuilder.genericBeanDefinition(BasicDataSource.class);
        //將dataSourceBean中的屬性值賦給目標bean
        Map<String, Object> properties = getPropertyKeyValues(DataSourceBean.class, dataSourceBean);
        for (Map.Entry<String, Object> entry : properties.entrySet()) {
            beanDefinitionBuilder.addPropertyValue((String) entry.getKey(), entry.getValue());
        }
        beanFactory.registerBeanDefinition(dataSourceBean.getBeanName(), beanDefinitionBuilder.getBeanDefinition());
        return applicationContext.getBean(dataSourceBean.getBeanName());
    }


大家知道,Spring最主要的功能是作為bean容器,即他負責bean生命週期的管理。因此,我們自定義的datasource也不能“逍遙法外”,必須交給Spring容器來管理。這也正是DynamicDataSource類需要實現ApplicationContextAware並且注入ApplicationContext的原因。上面的程式碼就是根據指定的資訊建立一個數據源。這種建立是Spring容器級別的建立。建立完畢之後,需要把剛剛建立的這個資料來源放到targetDataSources中,並且還要通知Spring容器,targetDataSources物件變了。下面的方法就是在做這樣的事情:

  private void addNewDataSourceToTargerDataSources(DataSourceBean dataSourceBean) throws NoSuchFieldException, IllegalAccessException {
        getTargetDataSources().put(dataSourceBean.getBeanName(), createDataSource(dataSourceBean));
        super.afterPropertiesSet();//通知spring有bean更新
    }

上面的這一步很重要。沒有這一步的話,Spring壓根就不會知道targetDataSources中多了一個數據源。至此DynamicDataSource類就講完了。其實仔細想想,思路還是很清晰的。啃掉了DynamicDataSource類這塊硬骨頭,下面就是一些輔助類了。比如說DataSourceHolder,業務程式碼通過使用這個類來通知DynamicDataSource中的determineCurrentLookupKey()方法到底使用那個資料來源:

public final class DataSourceHolder {
    private static ThreadLocal<DataSourceBeanBuilder> threadLocal=new ThreadLocal<DataSourceBeanBuilder>(){
        @Override
        protected DataSourceBeanBuilder initialValue() {
            return null;
        }
    };

    static DataSourceBeanBuilder getDataSource(){
        return threadLocal.get();
    }

    public static void setDataSource(DataSourceBeanBuilder dataSourceBeanBuilder){
        threadLocal.set(dataSourceBeanBuilder);
    }


    public static void clearDataSource(){
        threadLocal.remove();
    }
}

再比如這個DataSourceBean類,實際上就是用於儲存從那個預設資料庫中拿出來的資料來源資訊,只不過為了安全起見,使用了builder模式,關於builder模式,可參見構建器模式

final class DataSourceBean {
    private final String beanName;
    private final String driverClassName;
    private final String url;
    private final String username;
    private final String password;
    private final String validationQuery;
    private final boolean testOnBorrow;

    public DataSourceBean(DataSourceBeanBuilder beanBuilder){
        this.beanName=beanBuilder.getBeanName();
        this.driverClassName=beanBuilder.getDriverClassName();
        this.url=beanBuilder.getUrl();
        this.password=beanBuilder.getPassword();
        this.testOnBorrow=beanBuilder.isTestOnBorrow();
        this.username=beanBuilder.getUsername();
        this.validationQuery=beanBuilder.getValidationQuery();
    }

    public String getBeanName() {
        return beanName;
    }

    public String getDriverClassName() {
        return driverClassName;
    }

    public String getUrl() {
        return url;
    }

    public String getUsername() {
        return username;
    }

    public String getPassword() {
        return password;
    }

    public String getValidationQuery() {
        return validationQuery;
    }

    public boolean isTestOnBorrow() {
        return testOnBorrow;
    }


    @Override
    public String toString() {
        return "DataSourceBean{" +
                "driverClassName='" + driverClassName + '\'' +
                ", url='" + url + '\'' +
                ", username='" + username + '\'' +
                ", password='" + password + '\'' +
                ", validationQuery='" + validationQuery + '\'' +
                ", testOnBorrow=" + testOnBorrow +
                '}';
    }
}


自此,多資料來源動態切換的元件就搞完了,有木有趕腳身體被掏空。那麼身體掏空被掏空後是誰在受益呢?當然是......

額,不要想多,我是說業務程式碼。來看下業務程式碼如何切換資料來源:

  @Override
    public HttpResult<Boolean> testMultiDataSource(UserCreateReqDTO userCreateReqDTO) {
        if (userCreateReqDTO == null) {
            return HttpResult.successResult(Boolean.FALSE);
        }

        UserDO userDO = UserConvent.conventToUserDO(userCreateReqDTO);
        //先向預設資料來源插入
        if (!userDao.createUser(userDO)) {
            throw new BusinessException(ErrorEnum.TEST_MULTI_DATASOURCE_EXCEPTION);
        }

        //再向起他資料來源插入
        List<DataSourceDO> dataSourceDOList = this.dataSourceDao.query();
        for (DataSourceDO dataSourceDO : dataSourceDOList) {
            DataSourceBeanBuilder builder = new DataSourceBeanBuilder(
                    dataSourceDO.getDatabaseName(),
                    dataSourceDO.getDatabaseIp(),
                    dataSourceDO.getDatabasePort(),
                    dataSourceDO.getDatasourceName(),
                    dataSourceDO.getUsername(),
                    dataSourceDO.getPassword());
            DataSourceContext.setDataSource(builder);
            if (!userDao.createUser(userDO)) {
                throw new BusinessException(ErrorEnum.TEST_MULTI_DATASOURCE_EXCEPTION);
            }
            DataSourceContext.clearDataSource();
        }

        return HttpResult.successResult(Boolean.TRUE);
    }


可以看到,當不適用DataSourceContext.setDataSource()方法設定資料來源的時候,框架使用預設的資料來源,即defaultDataSource引數配置的資料來源。當時用DataSource.setDataSource()方法設定資料來源之後,框架會使用指定的資料來源。使用完畢後執行DataSource.clearDataSource()就又會切回到預設的資料來源。

筆者已經有了一個是實現好的例子在我的github上,具體地址為點選開啟連結。該工程是一個完整的ssm demo,並且其中包含了一些常用的元件,筆者還將繼續增強他的功能。

筆者開設了一個知乎live,詳細的介紹的JAVA從入門到精通該如何學,學什麼?