一、前言
工作中遇到了多組戶的需求,因爲以前並沒有接觸過,所以多番查找資料,最後總算做出來了,再此做個總結,記錄一下以便日後複習也可以幫助用得着的朋友。
實現多租戶大體可以分爲三種方案:
1、獨立數據庫,通過動態切換數據源來實現多租戶,安全性最高,但成本也高。
2、共享數據庫,隔離數據架構,比如使用oracle用多個schema。
3、共享數據庫,共享數據庫表,使用字段來區分不同租戶,此方案成本最低,但同時安全性最低。
詳細介紹可以點這裏參考這篇文章。
本項目因爲對數據安全性要求較高,所以選擇的第一種獨立數據庫切換動態數據源的方案。
二、實現方案
(一)AbstractRoutingDataSource
首先了解下 AbstractRoutingDataSource,看名字是一個數據源的路由,也就是由它來確定數據源,咱們先看一下源碼
public abstract class AbstractRoutingDataSource extends AbstractDataSource implements InitializingBean {
@Nullable
private Map<Object, Object> targetDataSources;
@Nullable
private Object defaultTargetDataSource;
private boolean lenientFallback = true;
private DataSourceLookup dataSourceLookup = new JndiDataSourceLookup();
@Nullable
private Map<Object, DataSource> resolvedDataSources;
@Nullable
private DataSource resolvedDefaultDataSource;
public void setTargetDataSources(Map<Object, Object> targetDataSources) {
this.targetDataSources = targetDataSources;
}
public void setDefaultTargetDataSource(Object defaultTargetDataSource) {
this.defaultTargetDataSource = defaultTargetDataSource;
}
public void setLenientFallback(boolean lenientFallback) {
this.lenientFallback = lenientFallback;
}
public void setDataSourceLookup(@Nullable DataSourceLookup dataSourceLookup) {
this.dataSourceLookup = (dataSourceLookup != null ? dataSourceLookup : new JndiDataSourceLookup());
}
@Override
public void afterPropertiesSet() {
if (this.targetDataSources == null) {
throw new IllegalArgumentException("Property 'targetDataSources' is required");
}
this.resolvedDataSources = new HashMap<>(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);
}
}
protected Object resolveSpecifiedLookupKey(Object lookupKey) {
return lookupKey;
}
protected DataSource resolveSpecifiedDataSource(Object dataSource) throws IllegalArgumentException {
if (dataSource instanceof DataSource) {
return (DataSource) dataSource;
}
else if (dataSource instanceof String) {
return this.dataSourceLookup.getDataSource((String) dataSource);
}
else {
throw new IllegalArgumentException(
"Illegal data source value - only [javax.sql.DataSource] and String supported: " + dataSource);
}
}
@Override
public Connection getConnection() throws SQLException {
return determineTargetDataSource().getConnection();
}
@Override
public Connection getConnection(String username, String password) throws SQLException {
return determineTargetDataSource().getConnection(username, password);
}
@Override
@SuppressWarnings("unchecked")
public <T> T unwrap(Class<T> iface) throws SQLException {
if (iface.isInstance(this)) {
return (T) this;
}
return determineTargetDataSource().unwrap(iface);
}
@Override
public boolean isWrapperFor(Class<?> iface) throws SQLException {
return (iface.isInstance(this) || determineTargetDataSource().isWrapperFor(iface));
}
protected DataSource determineTargetDataSource() {
Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
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;
}
@Nullable
protected abstract Object determineCurrentLookupKey();
}
1. afterPropertiesSet()
可以看到裏面維護了一個 targetDataSources 和 defaultTargetDataSource,初始化時將數據源分別進行復制到resolvedDataSources和resolvedDefaultDataSource中,代碼如下
@Override
public void afterPropertiesSet() {
if (this.targetDataSources == null) {
throw new IllegalArgumentException("Property 'targetDataSources' is required");
}
this.resolvedDataSources = new HashMap<>(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);
}
}
2. getConnection()
調用此方法獲取連接的時候,如下代碼determineTargetDataSource().getConnection(),先調用determineTargetDataSource()方法返回當前的DataSource,然後再調用getConnection()。
3. determineTargetDataSource
此方法的就是根據lookupkey獲取map中的dataSource,而lookupkey是從determineCurrentLookupKey方法返回的,如下:
protected DataSource determineTargetDataSource() {
Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
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;
}
4.determineCurrentLookupKey
此方法要我們自己實現,是切換數據源的方法,通過自己的實現返回lookupKey,根據lookupKey獲取對應數據源達到切換動態切換的功能。
(二)自定義DynamicDataSource
自定義DynamicDataSource繼承 AbstractRoutingDataSource,由上得知,我們先要有個方法能設置 targetDataSources,然後要重寫determineCurrentLookupKey方法,來實現動態切換,代碼如下:
/**
* (切換數據源必須在調用service之前進行,也就是開啓事務之前)
* 動態數據源實現類
* @author Louis
* @date Oct 31, 2018
*/
public class DynamicDataSource extends AbstractRoutingDataSource {
/**
* 如果不希望數據源在啓動配置時就加載好,可以定製這個方法,從任何你希望的地方讀取並返回數據源
* 比如從數據庫、文件、外部接口等讀取數據源信息,並最終返回一個DataSource實現類對象即可
*/
@Override
protected DataSource determineTargetDataSource() {
return super.determineTargetDataSource();
}
/**
* 如果希望所有數據源在啓動配置時就加載好,這裏通過設置數據源Key值來切換數據,定製這個方法
*/
@Override
protected Object determineCurrentLookupKey() {
return DynamicDataSourceContextHolder.getDataSourceKey();
}
/**
* 設置默認數據源
* @param defaultDataSource
*/
public void setDefaultDataSource(Object defaultDataSource) {
super.setDefaultTargetDataSource(defaultDataSource);
}
/**
* 設置數據源
* @param dataSources
*/
public void setDataSources(Map<Object, Object> dataSources) {
super.setTargetDataSources(dataSources);
// 將數據源的 key 放到數據源上下文的 key 集合中,用於切換時判斷數據源是否有效
DynamicDataSourceContextHolder.addDataSourceKeys(dataSources.keySet());
}
}
(三)DynamicDataSourceContextHolder
爲了線程安全,我們要把lookupKey放入ThreadLocal裏面,因此我們寫了一個DynamicDataSourceContextHolder來切換數據源,就是改變當前線程保存的lookupKey,上面DynamicDataSource.determineCurrentLookupKey從當前線程取出即可,代碼如下:
/**
* (切換數據源必須在調用service之前進行,也就是開啓事務之前)
* 動態數據源上下文
* @author guomh
* @date 2019/11/06
*/
public class DynamicDataSourceContextHolder {
private static final ThreadLocal<String> contextHolder = new ThreadLocal<String>() {
/**
* 將 master 數據源的 key作爲默認數據源的 key
*/
@Override
protected String initialValue() {
return "master";
}
};
/**
* 數據源的 key集合,用於切換時判斷數據源是否存在
*/
public static List<Object> dataSourceKeys = new ArrayList<>();
/**
* 切換數據源
* @param key
*/
public static void setDataSourceKey(String key) {
contextHolder.set(key);
}
/**
* 獲取數據源
* @return
*/
public static String getDataSourceKey() {
return contextHolder.get();
}
/**
* 重置數據源
*/
public static void clearDataSourceKey() {
contextHolder.remove();
}
/**
* 判斷是否包含數據源
* @param key 數據源key
* @return
*/
public static boolean containDataSourceKey(String key) {
return dataSourceKeys.contains(key);
}
/**
* 添加數據源keys
* @param keys
* @return
*/
public static boolean addDataSourceKeys(Collection<? extends Object> keys) {
return dataSourceKeys.addAll(keys);
}
}
(四)初始化數據源
1. tenant_info表
以上配置好了,就差配置數據源了,爲了便於維護數據源,我們可以有一個主數據源,裏面建一張表來維護租戶的數據源,這表可以根據自己需求建立,粘一下我的表結構
CREATE TABLE `tenant_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`TENANT_ID` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '租戶id',
`TENANT_NAME` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '租戶名稱',
`DATASOURCE_URL` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '數據源url',
`DATASOURCE_USERNAME` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '數據源用戶名',
`DATASOURCE_PASSWORD` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '數據源密碼',
`DATASOURCE_DRIVER` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '數據源驅動',
`SYSTEM_ACCOUNT` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '系統賬號',
`SYSTEM_PASSWORD` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '賬號密碼',
`SYSTEM_PROJECT` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '系統PROJECT',
`STATUS` tinyint(1) NULL DEFAULT NULL COMMENT '是否啓用(1是0否)',
`CREATE_TIME` datetime(0) NULL DEFAULT NULL COMMENT '創建時間',
`UPDATE_TIME` datetime(0) NULL DEFAULT NULL COMMENT '更新時間',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
2. 配置動態數據源生效、默認主數據源
看下mybatis的配置如下
/**
* @Author: guomh
* @Date: 2019/11/06
* @Description: mybatis配置*
*/
@EnableTransactionManagement
@Configuration
@MapperScan({"com.sino.teamwork.base.dao","com.sino.teamwork.*.*.mapper"})
public class MybatisPlusConfig {
@Bean("master")
@Primary
@ConfigurationProperties(prefix = "spring.datasource")
public DataSource master() {
return DataSourceBuilder.create().build();
}
@Bean("dynamicDataSource")
public DataSource dynamicDataSource() {
DynamicDataSource dynamicDataSource = new DynamicDataSource();
Map<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put("master", master());
// 將 master 數據源作爲默認指定的數據源
dynamicDataSource.setDefaultDataSource(master());
// 將 master 和 slave 數據源作爲指定的數據源
dynamicDataSource.setDataSources(dataSourceMap);
return dynamicDataSource;
}
@Bean
public MybatisSqlSessionFactoryBean sqlSessionFactoryBean() throws Exception {
MybatisSqlSessionFactoryBean sessionFactory = new MybatisSqlSessionFactoryBean();
/**
* 重點,使分頁插件生效
*/
Interceptor[] plugins = new Interceptor[1];
plugins[0] = paginationInterceptor();
sessionFactory.setPlugins(plugins);
//配置數據源,此處配置爲關鍵配置,如果沒有將 dynamicDataSource作爲數據源則不能實現切換
sessionFactory.setDataSource(dynamicDataSource());
sessionFactory.setTypeAliasesPackage("com.sino.teamwork.*.*.entity,com.sino.teamwork.base.model"); // 掃描Model
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
sessionFactory.setMapperLocations(resolver.getResources("classpath*:mapper/*/*Mapper.xml")); // 掃描映射文件
return sessionFactory;
}
@Bean
public PlatformTransactionManager transactionManager() {
// 配置事務管理, 使用事務時在方法頭部添加@Transactional註解即可
return new DataSourceTransactionManager(dynamicDataSource());
}
/**
* 加載分頁插件
* @return
*/
@Bean
public PaginationInterceptor paginationInterceptor() {
PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
List<ISqlParser> sqlParserList = new ArrayList<>();
// 攻擊 SQL 阻斷解析器、加入解析鏈
sqlParserList.add(new BlockAttackSqlParser());
paginationInterceptor.setSqlParserList(sqlParserList);
return paginationInterceptor;
}
}
可以看到有如下配置:
- 配置了主數據源叫master,主數據源放在spring配置文件裏
- 配置動態數據源,並將主數據源加入動態數據源中,設爲默認數據源
- 配置sqlSessionfactoryBean,並將動態數據源注入,sessionFactory.setDataSource(dynamicDataSource());
- 配置事務管理器,並將動態數據源注入new DataSourceTransactionManager(dynamicDataSource());
- 注意事項:
- 此處還有一點容易出錯,就是分頁問題,因爲之前按spring默認配置,是不用在此配置數據源跟sqlSessionFactoryBean,配置了分頁插件後,spring默認給你注入到了sqlSessionFactoryBean,但是此處因我們自己配置了sqlSessionFactoryBean,所以要自己手動注入,不然分頁會無效,如下
/**
* 重點,使分頁插件生效
*/
Interceptor[] plugins = new Interceptor[1];
plugins[0] = paginationInterceptor();
sessionFactory.setPlugins(plugins);
還有一點要配置的,就是去掉springboot默認自動配置數據源
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class})
@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true)
@EnableScheduling
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class})
public class TeamworkApplication {
public static void main(String[] args) {
SpringApplication.run(TeamworkApplication.class, args);
}
}
3. 初始化加載租戶的數據源
我們寫一個類來初始化加載所有租戶的數據源,代碼也很簡單,就是查詢主數據源的數據庫,查出所有租戶的數據源信息,添加到動態數據源中(此處也可以加上把動態數據源交託spring管理)
/**
* 初始化動態數據源
* @author guomh
* @date 2019/11/06
*/
@Slf4j
@Configuration
public class DynamicDataSourceInit {
@Autowired
private ITenantInfoService tenantInfoService;
@PostConstruct
public void InitDataSource() {
log.info("=====初始化動態數據源=====");
DynamicDataSource dynamicDataSource = (DynamicDataSource)ApplicationContextProvider.getBean("dynamicDataSource");
HikariDataSource master = (HikariDataSource)ApplicationContextProvider.getBean("master");
Map<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put("master", master);
List<TenantInfo> tenantList = tenantInfoService.InitTenantInfo();
for (TenantInfo tenantInfo : tenantList) {
log.info(tenantInfo.toString());
HikariDataSource dataSource = new HikariDataSource();
dataSource.setDriverClassName(tenantInfo.getDatasourceDriver());
dataSource.setJdbcUrl(tenantInfo.getDatasourceUrl());
dataSource.setUsername(tenantInfo.getDatasourceUsername());
dataSource.setPassword(tenantInfo.getDatasourcePassword());
dataSource.setDataSourceProperties(master.getDataSourceProperties());
dataSourceMap.put(tenantInfo.getTenantId(), dataSource);
}
//設置數據源
dynamicDataSource.setDataSources(dataSourceMap);
/**
* 必須執行此操作,纔會重新初始化AbstractRoutingDataSource 中的 resolvedDataSources,也只有這樣,動態切換纔會起效
*/
dynamicDataSource.afterPropertiesSet();
}
}
4. DynamicDataSourceAspect
我們可以使用面向切面編程,自動切換數據源,我是在用戶登錄時,將用戶的租戶信息放入session,租戶的ID就對應數據源的lookupKey
@Slf4j
@Aspect
@Component
@Order(1) // 請注意:這裏order一定要小於tx:annotation-driven的order,即先執行DynamicDataSourceAspectAdvice切面,再執行事務切面,才能獲取到最終的數據源
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class DynamicDataSourceAspect {
@Around("execution(* com.sino.teamwork.core.*.controller.*.*(..)) "
+ "or execution(* com.sino.teamwork.base.action.*.*(..))")
public Object doAround(ProceedingJoinPoint jp) throws Throwable {
ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpSession session= sra.getRequest().getSession(true);
String tenantId = (String)session.getAttribute("tenantId");
log.info("當前租戶Id:{}", tenantId);
DynamicDataSourceContextHolder.setDataSourceKey(tenantId);
Object result = jp.proceed();
DynamicDataSourceContextHolder.clearDataSourceKey();
return result;
}
}
三、結束語
以上