實現Spring動態註冊多資料來源

ODMark發表於2018-01-17

最近在做SaaS應用,資料庫採用了單例項多schema的架構(詳見參考資料1),每個租戶有一個獨立的schema,同時整個資料來源有一個共享的schema,因此需要解決動態增刪、切換資料來源的問題。

在網上搜了很多文章後,很多都是講主從資料來源配置,或都是在應用啟動前已經確定好資料來源配置的,甚少講在不停機的情況如何動態載入資料來源,所以寫下這篇文章,以供參考。

使用到的技術

  • Java8
  • Spring + SpringMVC + MyBatis
  • Druid連線池
  • Lombok
  • (以上技術並不影響思路實現,只是為了方便瀏覽以下程式碼片段)

思路

當一個請求進來的時候,判斷當前使用者所屬租戶,並根據租戶資訊切換至相應資料來源,然後進行後續的業務操作。

程式碼實現

TenantConfigEntity(租戶資訊)

@EqualsAndHashCode(callSuper = false)
@Data
@FieldDefaults(level = AccessLevel.PRIVATE)
public class TenantConfigEntity {
    /**
     * 租戶id
     **/
    Integer tenantId;
    /**
     * 租戶名稱
     **/
    String tenantName;
    /**
     * 租戶名稱key
     **/
    String tenantKey;
    /**
     * 資料庫url
     **/
    String dbUrl;
    /**
     * 資料庫使用者名稱
     **/
    String dbUser;
    /**
     * 資料庫密碼
     **/
    String dbPassword;
    /**
     * 資料庫public_key
     **/
    String dbPublicKey;
}
複製程式碼

DataSourceUtil(輔助工具類,非必要)

public class DataSourceUtil {

    private static final String DATA_SOURCE_BEAN_KEY_SUFFIX = "_data_source";

    private static final String JDBC_URL_ARGS = "?useUnicode=true&characterEncoding=UTF-8&useOldAliasMetadataBehavior=true&zeroDateTimeBehavior=convertToNull";

    private static final String CONNECTION_PROPERTIES = "config.decrypt=true;config.decrypt.key=";
    /**
     * 拼接資料來源的spring bean key
     */
    public static String getDataSourceBeanKey(String tenantKey) {
        if (!StringUtils.hasText(tenantKey)) {
            return null;
        }
        return tenantKey + DATA_SOURCE_BEAN_KEY_SUFFIX;
    }
    /**
     * 拼接完整的JDBC URL
     */
    public static String getJDBCUrl(String baseUrl) {
        if (!StringUtils.hasText(baseUrl)) {
            return null;
        }
        return baseUrl + JDBC_URL_ARGS;
    }
    /**
     * 拼接完整的Druid連線屬性
     */
    public static String getConnectionProperties(String publicKey) {
        if (!StringUtils.hasText(publicKey)) {
            return null;
        }
        return CONNECTION_PROPERTIES + publicKey;
    }
}
複製程式碼

DataSourceContextHolder

使用ThreadLocal儲存當前執行緒的資料來源key name,並實現set、get、clear方法;

public class DataSourceContextHolder {

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

    public static void setDataSourceKey(String tenantKey) {
        dataSourceKey.set(tenantKey);
    }

    public static String getDataSourceKey() {
        return dataSourceKey.get();
    }

    public static void clearDataSourceKey() {
        dataSourceKey.remove();
    }
}
複製程式碼

DynamicDataSource(重點)

繼承AbstractRoutingDataSource(建議閱讀其原始碼,瞭解動態切換資料來源的過程),實現動態選擇資料來源;

public class DynamicDataSource extends AbstractRoutingDataSource {

    @Autowired
    private ApplicationContext applicationContext;

    @Lazy
    @Autowired
    private DynamicDataSourceSummoner summoner;

    @Lazy
    @Autowired
    private TenantConfigDAO tenantConfigDAO;

    @Override
    protected String determineCurrentLookupKey() {
        String tenantKey = DataSourceContextHolder.getDataSourceKey();
        return DataSourceUtil.getDataSourceBeanKey(tenantKey);
    }

    @Override
    protected DataSource determineTargetDataSource() {
        String tenantKey = DataSourceContextHolder.getDataSourceKey();
        String beanKey = DataSourceUtil.getDataSourceBeanKey(tenantKey);
        if (!StringUtils.hasText(tenantKey) || applicationContext.containsBean(beanKey)) {
            return super.determineTargetDataSource();
        }
        if (tenantConfigDAO.exist(tenantKey)) {
            summoner.registerDynamicDataSources();
        }
        return super.determineTargetDataSource();
    }
}
複製程式碼

DynamicDataSourceSummoner(重點中的重點)

從資料庫載入資料來源資訊,並動態組裝和註冊spring bean,

@Slf4j
@Component
public class DynamicDataSourceSummoner implements ApplicationListener<ContextRefreshedEvent> {

    // 跟spring-data-source.xml的預設資料來源id保持一致
    private static final String DEFAULT_DATA_SOURCE_BEAN_KEY = "defaultDataSource";

    @Autowired
    private ConfigurableApplicationContext applicationContext;
    @Autowired
    private DynamicDataSource dynamicDataSource;
    @Autowired
    private TenantConfigDAO tenantConfigDAO;

    private static boolean loaded = false;
    /**
     * Spring載入完成後執行
     */
    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        // 防止重複執行
        if (!loaded) {
            loaded = true;
            try {
                registerDynamicDataSources();
            } catch (Exception e) {
                log.error("資料來源初始化失敗, Exception:", e);
            }
        }
    }
    /**
     * 從資料庫讀取租戶的DB配置,並動態注入Spring容器
     */
    public void registerDynamicDataSources() {
        // 獲取所有租戶的DB配置
        List<TenantConfigEntity> tenantConfigEntities = tenantConfigDAO.listAll();
        if (CollectionUtils.isEmpty(tenantConfigEntities)) {
            throw new IllegalStateException("應用程式初始化失敗,請先配置資料來源");
        }
        // 把資料來源bean註冊到容器中
        addDataSourceBeans(tenantConfigEntities);
    }
    /**
     * 根據DataSource建立bean並註冊到容器中
     */
    private void addDataSourceBeans(List<TenantConfigEntity> tenantConfigEntities) {
        Map<Object, Object> targetDataSources = Maps.newLinkedHashMap();
        DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) applicationContext.getAutowireCapableBeanFactory();
        for (TenantConfigEntity entity : tenantConfigEntities) {
            String beanKey = DataSourceUtil.getDataSourceBeanKey(entity.getTenantKey());
            // 如果該資料來源已經在spring裡面註冊過,則不重新註冊
            if (applicationContext.containsBean(beanKey)) {
                DruidDataSource existsDataSource = applicationContext.getBean(beanKey, DruidDataSource.class);
                if (isSameDataSource(existsDataSource, entity)) {
                    continue;
                }
            }
            //  組裝bean
            AbstractBeanDefinition beanDefinition = getBeanDefinition(entity, beanKey);
            //  註冊bean
            beanFactory.registerBeanDefinition(beanKey, beanDefinition);
            //  放入map中,注意一定是剛才建立bean物件
            targetDataSources.put(beanKey, applicationContext.getBean(beanKey));
        }
        //  將建立的map物件set到 targetDataSources;
        dynamicDataSource.setTargetDataSources(targetDataSources);
        //  必須執行此操作,才會重新初始化AbstractRoutingDataSource 中的 resolvedDataSources,也只有這樣,動態切換才會起效
        dynamicDataSource.afterPropertiesSet();
    }
    /**
     * 組裝資料來源spring bean
     */
    private AbstractBeanDefinition getBeanDefinition(TenantConfigEntity entity, String beanKey) {
        BeanDefinitionBuilder builder = BeanDefinitionBuilder.genericBeanDefinition(DruidDataSource.class);
        builder.getBeanDefinition().setAttribute("id", beanKey);
        // 其他配置繼承defaultDataSource
        builder.setParentName(DEFAULT_DATA_SOURCE_BEAN_KEY);
        builder.setInitMethodName("init");
        builder.setDestroyMethodName("close");
        builder.addPropertyValue("name", beanKey);
        builder.addPropertyValue("url", DataSourceUtil.getJDBCUrl(entity.getDbUrl()));
        builder.addPropertyValue("username", entity.getDbUser());
        builder.addPropertyValue("password", entity.getDbPassword());
        builder.addPropertyValue("connectionProperties", DataSourceUtil.getConnectionProperties(entity.getDbPublicKey()));
        return builder.getBeanDefinition();
    }
    /**
     * 判斷Spring容器裡面的DataSource與資料庫的DataSource資訊是否一致
     * 備註:這裡沒有判斷public_key,因為另外三個資訊基本可以確定唯一了
     */
    private boolean isSameDataSource(DruidDataSource existsDataSource, TenantConfigEntity entity) {
        boolean sameUrl = Objects.equals(existsDataSource.getUrl(), DataSourceUtil.getJDBCUrl(entity.getDbUrl()));
        if (!sameUrl) {
            return false;
        }
        boolean sameUser = Objects.equals(existsDataSource.getUsername(), entity.getDbUser());
        if (!sameUser) {
            return false;
        }
        try {
            String decryptPassword = ConfigTools.decrypt(entity.getDbPublicKey(), entity.getDbPassword());
            return Objects.equals(existsDataSource.getPassword(), decryptPassword);
        } catch (Exception e) {
            log.error("資料來源密碼校驗失敗,Exception:{}", e);
            return false;
        }
    }
}
複製程式碼

spring-data-source.xml

	<!-- 引入jdbc配置檔案 -->
    <context:property-placeholder location="classpath:data.properties" ignore-unresolvable="true"/>

	<!-- 公共(預設)資料來源 -->
    <bean id="defaultDataSource" class="com.alibaba.druid.pool.DruidDataSource"
          init-method="init" destroy-method="close">
        <!-- 基本屬性 url、user、password -->
        <property name="url" value="${ds.jdbcUrl}" />
        <property name="username" value="${ds.user}" />
        <property name="password" value="${ds.password}" />

        <!-- 配置初始化大小、最小、最大 -->
        <property name="initialSize" value="5" />
        <property name="minIdle" value="2" />
        <property name="maxActive" value="10" />

        <!-- 配置獲取連線等待超時的時間,單位是毫秒 -->
        <property name="maxWait" value="1000" />

        <!-- 配置間隔多久才進行一次檢測,檢測需要關閉的空閒連線,單位是毫秒 -->
        <property name="timeBetweenEvictionRunsMillis" value="5000" />

        <!-- 配置一個連線在池中最小生存的時間,單位是毫秒 -->
        <property name="minEvictableIdleTimeMillis" value="240000" />

        <property name="validationQuery" value="SELECT 1" />
        <!--單位:秒,檢測連線是否有效的超時時間-->
        <property name="validationQueryTimeout" value="60" />
        <!--建議配置為true,不影響效能,並且保證安全性。申請連線的時候檢測,如果空閒時間大於timeBetweenEvictionRunsMillis,執行validationQuery檢測連線是否有效-->
        <property name="testWhileIdle" value="true" />
        <!--申請連線時執行validationQuery檢測連線是否有效,做了這個配置會降低效能。-->
        <property name="testOnBorrow" value="true" />
        <!--歸還連線時執行validationQuery檢測連線是否有效,做了這個配置會降低效能。-->
        <property name="testOnReturn" value="false" />

        <!--Config Filter-->
        <property name="filters" value="config" />
        <property name="connectionProperties" value="config.decrypt=true;config.decrypt.key=${ds.publickey}" />
    </bean>

    <!-- 事務管理器 -->
    <bean id="txManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
        <property name="dataSource" ref="multipleDataSource"/>
    </bean>

    <!--多資料來源-->
    <bean id="multipleDataSource" class="a.b.c.DynamicDataSource">
        <property name="defaultTargetDataSource" ref="defaultDataSource"/>
        <property name="targetDataSources">
            <map>
                <entry key="defaultDataSource" value-ref="defaultDataSource"/>
            </map>
        </property>
    </bean>

	<!-- 註解事務管理器 -->
    <!--這裡的order值必須大於DynamicDataSourceAspectAdvice的order值-->
    <tx:annotation-driven transaction-manager="txManager" order="2"/>

    <!-- 建立SqlSessionFactory,同時指定資料來源 -->
    <bean id="mainSqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
        <property name="dataSource" ref="multipleDataSource"/>
    </bean>

    <!-- DAO介面所在包名,Spring會自動查詢其下的DAO -->
    <bean id="mainSqlMapper" class="org.mybatis.spring.mapper.MapperScannerConfigurer">
        <property name="sqlSessionFactoryBeanName" value="mainSqlSessionFactory"/>
        <property name="basePackage" value="a.b.c.*.dao"/>
    </bean>

	<bean id="defaultSqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
        <property name="dataSource" ref="defaultDataSource"/>
    </bean>

    <bean id="defaultSqlMapper" class="org.mybatis.spring.mapper.MapperScannerConfigurer">
        <property name="sqlSessionFactoryBeanName" value="defaultSqlSessionFactory"/>
        <property name="basePackage" value="a.b.c.base.dal.dao"/>
    </bean>

    <!-- 其他配置省略 -->
複製程式碼

DynamicDataSourceAspectAdvice

利用AOP自動切換資料來源,僅供參考;

@Slf4j
@Aspect
@Component
@Order(1) // 請注意:這裡order一定要小於tx:annotation-driven的order,即先執行DynamicDataSourceAspectAdvice切面,再執行事務切面,才能獲取到最終的資料來源
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class DynamicDataSourceAspectAdvice {

    @Around("execution(* a.b.c.*.controller.*.*(..))")
    public Object doAround(ProceedingJoinPoint jp) throws Throwable {
        ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = sra.getRequest();
        HttpServletResponse response = sra.getResponse();
        String tenantKey = request.getHeader("tenant");
        // 前端必須傳入tenant header, 否則返回400
        if (!StringUtils.hasText(tenantKey)) {
            WebUtils.toHttp(response).sendError(HttpServletResponse.SC_BAD_REQUEST);
            return null;
        }
        log.info("當前租戶key:{}", tenantKey);
        DataSourceContextHolder.setDataSourceKey(tenantKey);
        Object result = jp.proceed();
        DataSourceContextHolder.clearDataSourceKey();
        return result;
    }
}
複製程式碼

其他

另外,部分資訊(例如:租戶配置、省市縣等共用資訊)是存放在公共schema裡面的,也就是說一個請求裡面有可能包括查詢當前租戶資料來源公共資料來源,建議為公共資料來源單獨建立一個sqlSessionFactorysqlMapper(分別對應xml配置裡的defaultSqlSessionFactorydefaultSqlMapper),或乾脆把公共資料來源的資料單獨做成一個微服務,以後水平擴充套件也方便。

參考資料

  1. SaaS多租戶資料隔離的三種方案

  2. Spring MVC+Mybatis 多資料來源配置

  3. spring 動態建立bean

  4. Saas Spring動態載入、編輯資料來源

相關文章