SpringBoot+Mybatis Plus 多租户动态数据源

313 阅读4分钟

背景

需求场景是需要实现一个支持多租户多数据源的系统,每个租户的数据库完全隔离。并且系统需求通过区分不同租户的请求进行动态数据源的切换。

系统底层框架是使用的 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 的数据源初始化过程。

讲解一下核心实现原理及核心的代码部分。

主要通过以下步骤实现:

  1. 配置 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 中,实现线程内的租户信息可见。

@Configurationpublic class InterceptorConfiguration implements WebMvcConfigurer {    @Override    public void addInterceptors(InterceptorRegistry registry){       registry.addInterceptor(new DataSourceInterceptor());    }}

将自定义拦截器加到 Spring 容器中。

通过以上实现了一个请求内的租户信息可见。

  1. 重写 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 的工产类中

  1. 通过租户标示初始化数据
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 拦截器就可以。

@Componentpublic 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)@Slf4jpublic 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 拦截, 在打了注解的方法上进行租户的切换。实现注解指定数据源。

思考

上面方法基本已经满足了前面的需求,可以做到不同租户的动态数据源的切换。

但是还是有许多地方需要完善,比如:

  1. 不同租户的数据源的缓存,避免重复初始化
  2. 重写了 MyBatis 的工厂类,那么 MyBatis plus 的相关特性怎么保留。
  3. 如果一个租户下也有多个数据怎么实现

大家可以思考一下这些问题。