背景
需求場景是需要實現一個支持多租戶多數據源的系統,每個租戶的數據庫完全隔離。並且系統需求通過區分不同租戶的請求進行動態數據源的切換。
系統底層框架是使用的SpringCloud + MyBatisPlus(一個mybatis的增強框架),數據庫連接池是Druid。
熟悉SpringBoot的同學都知道SpringBoot本身是可以配置多個數據源的,但是SpringBoot的多數據做不到動態的切換,只能在代碼里面通過注解或寫死。
基於以上情況,設計實現了一個動態切換數據源的實現方案。
實現功能
- 通過域名進行租戶自動識別
- 通過租戶識別信息,動態的選擇數據源
- 各個spring微服務之間進行租戶信息傳遞
- 通過注射方式進行強制數據源制定
下面介紹一下功能的核心實現。
核心實現
租戶識別
租戶信息的識別通過Nginx代理來實現,核心思路就是域名中包含租戶信息,然后通過Nginx代理時,在請求頭和相應頭中添加租戶的識別信息。
servier {
listen 80;
server_name ~^(?<sub>.+)\.zane\.com$; #按后綴匹配域名,並截取租戶標示
...
location / {
...
proxy_set_header tenant $sub; #請求頭添加租戶標示
add_header tenant $sub; #響應頭添加租戶標示
}
}
如上配置后,通過Nginx代理后的請求都會帶上租戶信息。eg: abc.zane.com通過這個域名訪問系統時會識別出租戶為abc。
動態切換
這個是方案的核心部分,重寫了MyBatis的數據源初始化過程。
講解一下核心實現原理及核心的代碼部分。
主要通過以下步驟實現:
- 配置Spring攔截器,設置租戶標示
public class DataSourceInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
try{
String tenantHeader = request.getHeader("tenant");
if (StringUtils.hasText(tenantHeader)) {
DataSourceTenantContextHolder.setCurrentTanent(tenantHeader);
} else {
DataSourceTenantContextHolder.setDefaultTenant();
}
}catch (Exception e){
DataSourceTenantContextHolder.setDefaultTenant();
}
return true;
}
}
自定義攔截器,攔截先前Nginx設置的租戶信息,並設置到DataSourceTenantContextHolder中。
public class DataSourceTenantContextHolder {
public static final String DEFAULT_TENANT = "default";
private static final InheritableThreadLocal<String> DATASOURCE_HOLDER = new InheritableThreadLocal<String>(){
@Override
protected String initialValue() {
return DEFAULT_TENANT;
}
};
//設置默認數據源
public static void setDefaultTenant() {
setCurrentTanent(DataSourceTenantContextHolder.DEFAULT_TENANT);
}
//獲取當前數據源配置租戶標識
public static String getCurrentTenant() {
return DATASOURCE_HOLDER.get();
}
//設置當前數據源配置租戶標識
public static void setCurrentTanent(String tenant) {
DATASOURCE_HOLDER.set(tenant);
}
}
將租戶信息設置到InheritableThreadLocal中,實現線程內的租戶信息可見。
@Configuration
public class InterceptorConfiguration implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry){
registry.addInterceptor(new DataSourceInterceptor());
}
}
將自定義攔截器加到Spring容器中。
通過以上實現了一個請求內的租戶信息可見。
- 重寫MyBatis的數據源初始化
@Configuration
@MapperScan(sqlSessionFactoryRef = "sqlSessionFactory")
public class DataSourceConfiguration {
@Bean(name = "dataSource")
@ConfigurationProperties(prefix = "spring.datasource")
public DataSource dataSource() {
DataSourceBuilder<?> dataSourceBuilder = DataSourceBuilder.create();
dataSourceBuilder.type(DynamicDataSource.class);
return dataSourceBuilder.build();
}
@Bean(name = "sqlSessionFactory")
public SqlSessionFactory getSqlSessionFactory(@Qualifier("dataSource") DataSource dataSource)throws Exception {
MybatisSqlSessionFactoryBean bean = new MybatisSqlSessionFactoryBean();
bean.setDataSource(dataSource);
...
return bean.getObject();
}
}
使用自定義數據源實現DynamicDataSource代替原始的DruidDataSource。
將自定義的數據源設置到MyBatis的工產類中
- 通過租戶標示初始化數據
public class DynamicDataSource extends DruidDataSource {
@Override
public DruidPooledConnection getConnection() throws SQLException,PaaSException {
String tenant = DataSourceTenantContextHolder.getCurrentTenant();
// 根據當前id獲取數據源
DruidDataSource datasource = initDatasource(tenant);
if (null == datasource){
throw new PaaSException(String.format("Error DynamicDataSource Config %s %s", tenant));
}
return datasource.getConnection();
}
prvate DruidDataSource initDatasource(String tenant){
...
}
}
數據源需要實現通過租戶來初始化的邏輯,具體的初始化可以按需求實現initDatasource方法。
租戶傳遞
基於SpringCloud各個服務間是通過Feign來通信,那么只要實現一個簡單的Feign攔截器就可以。
@Component
public class FeignTenantInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
String tenant = DataSourceTenantContextHolder.getCurrentTenant();
template.header("tenant",tenant);
}
}
注釋指定數據源
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface Tenant {
String value() default DataSourceTenantContextHolder.DEFAULT_TENANT;
}
自定義一個租戶的注解類
@Aspect
@Component
@Order(0)
@Slf4j
public class DatasourceSelectorAspect {
@Around("@annotation(tenant)")
public Object beforeTenant(ProceedingJoinPoint joinPoint, Tenant tenant) throws Exception{
String sourceTenant = DataSourceTenantContextHolder.getCurrentTenant();
try{
String tenantName = tenant.value();
DataSourceTenantContextHolder.setCurrentTanent(tenantName);
}catch (Exception e){
log.warn("",e);
}
Object result;
try {
result = joinPoint.proceed();
} catch (Throwable e) {
throw new Exception(e);
} finally {
DataSourceTenantContextHolder.setCurrentTanent(sourceTenant);
}
return result;
}
}
通過環繞型的AOP攔截, 在打了注解的方法上進行租戶的切換。實現注解指定數據源。
思考
上面方法基本已經滿足了前面的需求,可以做到不同租戶的動態數據源的切換。
但是還是有許多地方需要完善,比如:
- 不同租戶的數據源的緩存,避免重復初始化
- 重寫了MyBatis的工廠類,那么MyBatis plus的相關特性怎么保留。
- 如果一個租戶下也有多個數據怎么實現
大家可以思考一下這些問題。