转载

SaaS 系统架构,Spring Boot 动态数据源实现!

这段时候在准备从零开始做一套 SaaS 系统,之前的经验都是开发单数据库系统并没有接触过SaaS系统,所以接到这个任务的时候也有也些头疼,不过办法部比困难多,难得的机会。

在网上找了很多关于 SaaS 的资料,看完后使我受益匪浅,写文章之前也一直在关注 SaaS 系统的开发,通过几天的探索也有一些方向。

多租户系统首先要解决的问题就是 如何组织租户的数据问题 ,通常情况有三种解决方案:

按数据的隔离级别依次为:

Schema

以上三种数据组织方案网上都有一些介绍,就不多啰嗦了。理解三种隔离模式后,起初觉得还是蛮简单的真正开始实施的时候困难不少。

租户标识接口

定义一个 TenantInfo 来标识租户信息,关于获取当前租户的方式,后面会再提到。

public interface TenantInfo {

    /**
     * 获取租户id
     * @return
     */
    Long getId();


    /**
     * 租户数据模式
     * @return
     */
    Integer getSchema();


    /**
     * 租户数据库信息
     * @return
     */
    TenantDatabase getDatabase();

    /**
     * 获取当前租户信息
     * @return
     */
    static Optional<TenantInfo> current(){
        return Optional.ofNullable(
                TenantInfoHolder.get()
        );
    }
}

DataSource 路由

以前开发的系统基本都是一个 DataSource ,但是切换为多租户后我暂时分了两种数据源:

  • 租户数据源(TenantDataSource)
  • 系统数据源(SystemDataSource)

起初我的设想是使用 Schema级 但是由于是使用的 Mysql 中的 SchemaDatabase 是差不多的概念,所以后来的实现是基于 数据库级 的。使用 数据库级 的因为是系统是基于企业级用户的,数据都比较重要,企业客户很看重数据安全性方面的问题。

下面来一步步的解决动态数据源的问题。

DataSource 枚举

public enum DataSourceType {
    /**
     * 系统数据源
     */
    SYSTEM,
    /**
     * 多租户数据源
     */
    TENANT,
}

DataSource 注解

定义 DataSourceType 枚举后,然后定义一个 DataSource 注解,名称可以随意,一时没想到好名称,大家看的时候不要跟 javax.sql.DataSource 类混淆了:

@Retention(RetentionPolicy.RUNTIME)
@Documented
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface DataSource {


    /**
     * 数据源key
     * @return
     */
    com.csbaic.datasource.core.DataSourceType value() default com.csbaic.datasource.core.DataSourceType.SYSTEM;


}

处理 SpringBoot 自动装配的 DataSource

如果你熟悉 SpringBoot ,应该知道有一个 DataSourceAutoConfiguration 配置会自动创建一个 javax.sql.DataSource ,由于在多租户环境下随时都有可能要切换数据源,所以需要将自动装配的 javax.sql.DataSource 替换掉:

@Slf4j
public class DataSourceBeanPostProcessor implements BeanPostProcessor {


    @Autowired
    private  ObjectProvider<RoutingDataSourceProperties> dataSourceProperties;

    @Autowired
    private  ObjectProvider<TenantDataSourceFactory> factory;



    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {

        if(bean instanceof DataSource){
            log.debug("process DataSource: {}", bean.getClass().getName());
            return new RoutingDataSource((DataSource) bean, factory, dataSourceProperties);
        }


        return bean;
    }
}

基于 BeanPostProcessor 的处理,将自动装配的数据源替换成 RoutingDataSource ,关于 RoutingDataSource 后面会再提到。这样可将自动装配的数据源直接作为 系统数据源 其他需要使用数据源的地方不用特殊处理,也不需要在每个服务中排除 DataSourceAutoConfiguration 的自动装配。

使用 ThreadLocal 保存数据源类型

数据源的切换是根据前面提到的数据源类型枚举 DataSourceType 来的,当需要切换不到的数据源时将对应的数据源类型设置进 ThreadLocal 中:

public class DataSourceHolder {

    private static final ThreadLocal<Stack<DataSourceType>> datasources = new ThreadLocal<>();

    /**
     * 获取当前线程数据源
     * @return
     */
    public static DataSourceType get(){
        Stack<DataSourceType> stack = datasources.get();
        return stack != null ? stack.peek() : null;
    }


    /**
     * 设置当前线程数据源
     * @param type
     */
    public static void push(DataSourceType type){
        Stack<DataSourceType> stack = datasources.get();
        if(stack == null){
            stack = new Stack<>();
            datasources.set(stack);
        }

        stack.push(type);
    }


    /**
     * 移除数据源配置
     */
    public static void remove(){
        Stack<DataSourceType> stack = datasources.get();
        if(stack == null){
            return;
        }

        stack.pop();

        if(stack.isEmpty()){
            datasources.remove();
        }
    }

}

DataSourceHolder.datasources 是使用的 Stack 而不是直接持有 DataSource 这样会稍微灵活一点,试想一下从 方法A 中调用 方法B ,A,B方法中各自要操作不同的数据源,当 方法B 执行完成后,回到 方法A 中,如果是在 ThreadLocal 直接持有 DataSource 的话, A方法 继续操作就会对数据源产生不确定性。

AOP 切换数据源

要是在每个类方法都需要手机切换数据源,那也太不方便了,得益于AOP编程可以在调用需要切换数据源的方法的时候做一些手脚:

@Slf4j
@Aspect
public class DataSourceAspect {


    @Pointcut(value = "(@within(com.csbaic.datasource.annotation.DataSource) || @annotation(com.csbaic.datasource.annotation.DataSource)) && within(com.csbaic..*)")
    public void dataPointCut(){

    }

    @Before("dataPointCut()")
    public void before(JoinPoint joinPoint){
        Class<?> aClass = joinPoint.getTarget().getClass();
        // 获取类级别注解
        DataSource classAnnotation = aClass.getAnnotation(DataSource.class);
        if (classAnnotation != null){
            com.csbaic.datasource.core.DataSourceType dataSource = classAnnotation.value();
            log.info("this is datasource: "+ dataSource);
            DataSourceHolder.push(dataSource);
        }else {
            MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
            Method method = methodSignature.getMethod();
            DataSource methodAnnotation = method.getAnnotation(DataSource.class);
            if (methodAnnotation != null){
                com.csbaic.datasource.core.DataSourceType dataSource = methodAnnotation.value();
                log.info("this is dataSource: "+ dataSource);
                DataSourceHolder.push(dataSource);
            }
        }
    }
    @After("dataPointCut()")
    public void after(JoinPoint joinPoint){
        log.info("执行完毕!");
        DataSourceHolder.remove();
    }
}

DataSourceAspect 很简单在有 com.csbaic.datasource.annotation.DataSource 注解的方法或者类中切换、还原使用 DataSourceHolder 类切换数据源。

动态获取、构造数据源

前面说了那么多都是在为获取、构建数据源做准备工作,一但数据源切换成功,业务服务获取数据时就会使用 javax.sql.DataSource 获取数据库连接,这里就要说到 RoutingDataSource 了:

@Slf4j
public class RoutingDataSource extends AbstractDataSource {


    /**
     * 已保存的DataSource
     */
    private final DataSource systemDataSource;

    /**
     * 租户数据源工厂
     */
    private final ObjectProvider<TenantDataSourceFactory> factory;



    /**
     * 解析数据源
     * @return
     */
    protected DataSource resolveDataSource(){

        DataSourceType type =  DataSourceHolder.get();

        RoutingDataSourceProperties pros = properties.getIfAvailable();
        TenantDataSourceFactory tenantDataSourceFactory = factory.getIfAvailable();




        if(tenantDataSourceFactory == null){
            throw new DataSourceLookupFailureException("租户数据源不正确");
        }

        if(pros == null){
            throw new DataSourceLookupFailureException("数据源属性不正确");
        }

        if(type == null){
            log.warn("没有显示的设置数据源,使用默认数据源:{}", pros.getDefaultType());
            type = pros.getDefaultType();
        }



        log.warn("数据源类型:{}", type);
        if(type == DataSourceType.SYSTEM){
            return systemDataSource;
        }else if(type == DataSourceType.TENANT){
            return tenantDataSourceFactory.create();
        }

        throw new DataSourceLookupFailureException("解析数据源失败");
    }
}

resolveDataSource 方法中,首先获取数据源类型:

DataSourceType type =  DataSourceHolder.get();

然后根据数据源类型获取数据源:

if(type == DataSourceType.SYSTEM){
        return systemDataSource;
    }else if(type == DataSourceType.TENANT){
        return tenantDataSourceFactory.create();
    }

系统类型的数据源较简单直接返回,在租户类型的数据时就要作额外的操作,如果是 数据库级 的隔离模式就需要为每个租户创建数据源,这里封装了一个 TenantDataSourceFactory 来构建租户数据源:

public interface TenantDataSourceFactory {


    /**
     * 构建一个数据源
     * @return
     */
    DataSource create();


    /**
     * 构建一个数据源
     * @return
     */
    DataSource create(TenantInfo info);
}

实现方面大致就是从系统数据源中获取租户的数据源配置信息,然后构造一个 javax.sql.DataSource

注意:租户数据源一定要缓存起来,每次都构建太浪费。。。

小结

经过上面的一系统配置后,相信切换数据已经可以实现了。业务代码不关心使用的数据源,后续切换成隔离模式也比较方便。但是呢,总觉得只支持一种隔离模式又不太好,隔离模式更高的模式也可以作为收费项的麻。。。

使用 Mybatis Plus 实现行级隔离模式

上前提到动态数据源都是基于数据库级的,一个租户一个数据库消耗还是很大的,难达到SaaS的规模效应,一但租户增多数据库管理、运维都是成本。

比如有些试用用户不一定用购买只是想试用,直接开个数据库也麻烦,况且前期开发也麻烦的很,数据备份、还原、字段修改都要花时间和人力的,所以能不能同时支持多种数据隔离模式呢?答案是肯定的,利益于 Mybatis Plus 可的 多租户 SQL 解析器 以轻松实现,详细文档可参考:

多租户 SQL 解析器:https://mp.baomidou.com/guide/tenant.html

只需要配置 TenantSqlParserTenantHandler 就可以实现行级的数据隔离模式:

public class RowTenantHandler implements TenantHandler {


    @Override
    public Expression getTenantId(boolean where) {
        TenantInfo tenantInfo = TenantInfo.current().orElse(null);
        if(tenantInfo == null){
            throw new IllegalStateException("No tenant");
        }

        return new LongValue(tenantInfo.getId());
    }

    @Override
    public String getTenantIdColumn() {
        return TenantConts.TENANT_COLUMN_NAME;
    }

    @Override
    public boolean doTableFilter(String tableName) {
        TenantInfo tenantInfo = TenantInfo.current().orElse(null);

        //忽略系统表或者没有解析到租户id,直接过滤
        return tenantInfo == null || tableName.startsWith(SystemInfo.SYS_TABLE_PREFIX);
    }
}

回想一下上面使用的 TenantDataSourceFactory 接口,对于行级的隔离模式,构造不同的数据源就可以了。

如何解析当前租户信息?

多租户环境下,对于每一个http请求可能是对系统数据或者租户数据的操作,如何区分租户也是个问题。

以下列举几种解析租户的方式:

  • 系统为每个用户生成一个二级域名如: tenant-{id}.csbaic.com 业务系统使用 HostOriginX-Forwarded-Host 等请求头按指定的模式解析租户
  • 前端携带租户id参数如: www.csbaic.com?tenantId=xxx
  • 根据请求uri路径获取如: www.csbaic.com/api/{tenantId}
  • 解析前端传递的token,获取租户信息
  • 租户自定义域名解析,有些功能租户可以绑定自己的域名

解析方式现在大概只知道这些,如果有好的方案欢迎大家补充。为了以为扩展方便定义一个 TenantResolver 接口:

/**
 * 解析租户
 */
public interface TenantResolver {


    /**
     * 从请求中解析租户信息
     * @param request 当前请求
     * @return
     */
    Long resolve(HttpServletRequest request);
}

然后可以将所有的解析方式都聚合起来统一处理:

/**
     *
     * @param domainMapper
     * @return
     */
    @Bean
    public TenantResolver tenantConsoleTenantResolver(TenantDomainMapper domainMapper, ITokenService tokenService){
        return new CompositeTenantResolver(
                new SysDomainTenantResolver(),
                new RequestHeaderTenantResolver(),
                new RequestQueryTenantResolver(),
                new TokenTenantResolver(tokenService),
                new CustomDomainTenantResolver(domainMapper)
        );
    }

最后再定义一个 Filter 来调用解析器,解析租户:

public class UaaTenantServiceFilter implements Filter {


    private final TenantInfoService tenantInfoService;


    public UaaTenantServiceFilter(TenantInfoService tenantInfoService) {
        this.tenantInfoService = tenantInfoService;
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        //从request解析租户信息
        try{
            TenantInfo tenantInfo = tenantInfoService.getTenantInfo((HttpServletRequest) request);
            TenantInfoHolder.set(tenantInfo);
            chain.doFilter(request,response);
        }finally {
            TenantInfoHolder.remove();
        }


    }
}

TenantInfoService 是获取租户信息的接口,内部还是通过 TenantResolver 来解析租户Id,然后通过id从系统数据库获取当前租户的信息。

总结

解决完动态数据源、租户信息获取两个问题后,只是一小步,后续还有很多问题需要处理如:系统权限和租户权限、统一登陆和鉴权、数据统计等等。。。,相信这些问题都会解决的,后续再来分享。

推荐阅读

  • 十分钟入门RocketMQ
  • Spring Boot 构建多租户 SaaS 平台核心技术指南
  • Redis 缓存和MySQL数据一致性方案详解
  • Nginx 限流配置
  • 深入探秘 Netty、Kafka中的零拷贝技术!

学习资料分享

12 套微服务、Spring Boot、Spring Cloud 核心技术资料,这是部分资料目录:

  • Spring Security 认证与授权
  • Spring Boot 项目实战(中小型互联网公司后台服务架构与运维架构)
  • Spring Boot 项目实战(企业权限管理项目))
  • Spring Cloud 微服务架构项目实战(分布式事务解决方案)
  • ...

公众号后台回复 arch028 获取资料::

SaaS 系统架构,Spring Boot 动态数据源实现!

原文  http://www.cnblogs.com/xwgblog/p/13265593.html
正文到此结束
Loading...