Spring程式碼中動態切換資料來源
最近專案中遇到一個場景,需要能夠在一個方法中操作多個具有相同表結構資料庫(你可以理解為一個表中的資料被水平拆分到多個庫中,查詢時需要遍歷這多個庫)。經過筆者幾天的研究,最終解決了問題,並且寫了一個demo共享到我的github。
關注筆者部落格的小夥伴一定知道之前的這篇文章點選開啟連結,這篇部落格中的解決方案僅僅適用讀寫分離的場景。就是說,當你在開發的時候已經確定了使用寫庫一讀庫的形式。筆者今天要寫的這篇文章具有普適性,適合所有需要在Spring工程中動態切換資料來源的場景,而且本文中的解決方案對工程的程式碼基本沒有侵入性。下面就來說下該方案的實現原理:
在Spring-Mybatis中,有這樣一個類
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從入門到精通該如何學,學什麼?