这段时候在准备从零开始做一套 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
,但是切换为多租户后我暂时分了两种数据源:
起初我的设想是使用 Schema级
但是由于是使用的 Mysql
中的 Schema
和 Database
是差不多的概念,所以后来的实现是基于 数据库级
的。使用 数据库级
的因为是系统是基于企业级用户的,数据都比较重要,企业客户很看重数据安全性方面的问题。
下面来一步步的解决动态数据源的问题。
public enum DataSourceType { /** * 系统数据源 */ SYSTEM, /** * 多租户数据源 */ TENANT, }
定义 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
,应该知道有一个 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
的自动装配。
数据源的切换是根据前面提到的数据源类型枚举 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编程可以在调用需要切换数据源的方法的时候做一些手脚:
@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
。
经过上面的一系统配置后,相信切换数据已经可以实现了。业务代码不关心使用的数据源,后续切换成隔离模式也比较方便。但是呢,总觉得只支持一种隔离模式又不太好,隔离模式更高的模式也可以作为收费项的麻。。。
上前提到动态数据源都是基于数据库级的,一个租户一个数据库消耗还是很大的,难达到SaaS的规模效应,一但租户增多数据库管理、运维都是成本。
比如有些试用用户不一定用购买只是想试用,直接开个数据库也麻烦,况且前期开发也麻烦的很,数据备份、还原、字段修改都要花时间和人力的,所以能不能同时支持多种数据隔离模式呢?答案是肯定的,利益于 Mybatis Plus
可的 多租户 SQL 解析器
以轻松实现,详细文档可参考:
多租户 SQL 解析器:https://mp.baomidou.com/guide/tenant.html
只需要配置 TenantSqlParser
和 TenantHandler
就可以实现行级的数据隔离模式:
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
业务系统使用 Host
、 Origin
、 X-Forwarded-Host
等请求头按指定的模式解析租户 www.csbaic.com?tenantId=xxx
www.csbaic.com/api/{tenantId}
解析方式现在大概只知道这些,如果有好的方案欢迎大家补充。为了以为扩展方便定义一个 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从系统数据库获取当前租户的信息。
解决完动态数据源、租户信息获取两个问题后,只是一小步,后续还有很多问题需要处理如:系统权限和租户权限、统一登陆和鉴权、数据统计等等。。。,相信这些问题都会解决的,后续再来分享。
12 套微服务、Spring Boot、Spring Cloud 核心技术资料,这是部分资料目录:
arch028
获取资料::