1.背景

开发过SaaS体系渠道的小伙伴必定对多租户这个概念不生疏,简略来说一个租户便是一个公司客户,多个租户共用同一个SaaS体系,一旦SaaS体系不可用,那么一切的租户都不可用。你能够这么了解SaaS体系就像一栋大楼,而租户便是大楼里面租办公楼层的公司,平常每家公司做着自己的事务,互不干扰,可是一旦大楼的电梯坏了,那么影响到的便是一切的公司。

多租户问题,其是一种架构规划方法,便是在一台或者一组服务器上运转的SaaS体系,能够为多个租户(客户)供给服务,意图是为了让多个租户在互联网环境下运用同一套程序,且确保租户间的数据阻隔。从这种架构规划的模式上,不难看出来,多租户架构的重点便是同一套程序下多个租户数据的阻隔。因为租户数据是集中存储的,所以要完成数据的安全性,便是看能否完成对租户数据的阻隔,避免租户数据不经意或被他人歹意地获取和篡改。在讲多租户数据阻隔完成之前,先来看看什么是SaaS体系

什么是SaaS体系

SaaS渠道是运营saas软件的渠道。SaaS供给商为企业搭建信息化所需求的一切网络基础设施及软件、硬件运作渠道,并担任一切前期的施行、后期的保护等一系列服务,租户(企业)无需购买软硬件、建设机房、招聘IT人员,即可经过互联网运用信息体系。SaaS 是一种软件布局模型,其使用专为网络交付而规划,便于用户经过互联网保管、布置及接入。

简略来说便是租户给SaaS渠道付租金就能运用渠道供给的功用服务,当下比较典型便是各种云渠道、云服务厂商。

项目引荐:基于SpringBoot2.x、SpringCloud和SpringCloudAlibaba企业级体系架构底层结构封装,处理事务开发时常见的非功用性需求,避免重复造轮子,方便事务快速开发和企业技能栈结构一致管理。引入组件化的思维完成高内聚低耦合并且高度可装备化,做到可插拔。严格操控包依靠和一致版别管理,做到最少化依靠。注重代码标准和注释,十分适合个人学习和企业运用

Github地址:github.com/plasticene/…

Gitee地址:gitee.com/plasticene3…

微信公众号Shepherd进阶笔记

2.多租户数据阻隔架构规划

现在saas多租户体系的数据阻隔有三种架构规划,即为每个租户供给独立的数据库、独立的表空间、按字段区别租户,每种计划都有其各自的适用情况。

一个租户独立一个数据库

一个租户独立运用一个数据库,那就意味着咱们的SaaS体系需求连接多个数据库,这种完成计划其实就和分库分表架构规划是相同的,好处便是数据阻隔等级高、安全性好,毕竟一个租户单用一个数据库,可是物理硬件本钱,保护本钱也变高了。

独立的表空间

这种计划的完成方法,便是一切租户共用一个数据库体系,可是每个租户在数据库体系中具有一个独立的表空间。

按租户id字段阻隔租户

这种计划是多租户计划中最简略的数据阻隔方法,即在每张表中都增加一个用于区别租户的字段(如tenant_id或org_id啥的)来标识每条数据属于哪个租户,当进行查询的时分每条语句都要增加该字段作为过滤条件,其特点是一切租户的数据全都存放在同一个表中,数据的阻隔性是最低的,完全是经过字段来区其他,很容易把数据搞串或者误操作。

三种数据阻隔架构规划的对比如下:

阻隔计划 本钱 支撑租户数量 长处 缺点
独立数据库体系 数据阻隔等级高,安全性,能够针对单个租户开发个性化需求 数据库独立装置,物理本钱和保护本钱都比较高
独立的表空间 较多 供给了必定程度的逻辑数据阻隔,一个数据库体系可支撑多个租户 数据库管理比较困难,表繁多,一起数据修复稍杂乱
按租户id字段区别 保护和置办本钱最低,每个数据库能够支撑的租户数量最多 阻隔等级最低,安全性也最低

大部分公司都是选用第三种:按租户id字段阻隔租户架构规划完成多租户数据阻隔的。接下来咱们就来看看代码层面怎样完成多租户数据阻隔的。

3.mybatis-plus高雅完成多租户数据权限阻隔

上面咱们说过按租户id字段阻隔租户这种方法便是在获取数据的时分对每一条SQL语句增加租户id作为过滤条件来阻隔租户数据的。可是这样意味着每个查询SQL都必须加上租户id这个过滤条件,如果漏加就意味着会查询出不同租户的数据,这是绝对不允许的,一起每个查询接口都需求手动设置过滤条件,重复劳动,一点都不够高雅。这时分就不得不说说mybatis-plus的多租户插件了,看看它怎么高雅完成多租户阻隔的?再讲述之前,咱们先思考一下怎么高雅完成数据阻隔?首先咱们要求每一条SQL都加上租户id这个过滤条件,这意味着咱们需求解析原始SQL在合适的地方加上租户id过滤条件,咱们知道mybatis供给扩展点便是拦截器,能够对SQL语句处理前后进行增强逻辑,分页插件便是这么做的,所以咱们这儿要增强SQL自然也是这样,接下来咱们就来看看mybatis-plus多租户插件是怎样完成多租户数据阻隔的,插件官网介绍地址:www.baomidou.com/pages/aef2f…,该拦截器部分源码如下:

public class TenantLineInnerInterceptor extends JsqlParserSupport implements InnerInterceptor {
  // 多租户处理器
  private TenantLineHandler tenantLineHandler;
​
  // 改SQL,增加多租户id条件
  public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
    if (!InterceptorIgnoreHelper.willIgnoreTenantLine(ms.getId())) {
      MPBoundSql mpBs = PluginUtils.mpBoundSql(boundSql);
      mpBs.sql(this.parserSingle(mpBs.sql(), (Object)null));
     }
   }
​
  public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) {
    MPStatementHandler mpSh = PluginUtils.mpStatementHandler(sh);
    MappedStatement ms = mpSh.mappedStatement();
    SqlCommandType sct = ms.getSqlCommandType();
    if (sct == SqlCommandType.INSERT || sct == SqlCommandType.UPDATE || sct == SqlCommandType.DELETE) {
      if (InterceptorIgnoreHelper.willIgnoreTenantLine(ms.getId())) {
        return;
       }
​
      MPBoundSql mpBs = mpSh.mPBoundSql();
      mpBs.sql(this.parserMulti(mpBs.sql(), (Object)null));
     }
​
   }
 
 // 碍于篇幅问题,下面省略的代码便是继承抽象类JsqlParserSupport解析SQL然后增加多租户id条件的,能够自行查看源码
  ......
}
​

接着咱们来看看处理器TenantLineHandler,这是一个接口,需求咱们供给自定义完成,指定多租户相关装备:

public class TenantDatabaseHandler implements TenantLineHandler {
  private final Set<String> ignoreTables = new HashSet<>();
​
  public TenantDatabaseHandler(TenantProperties properties) {
    // 将装备文件装备的疏忽表名同步大小写,适配不同写法
    properties.getIgnoreTables().forEach(table -> {
      ignoreTables.add(table.toLowerCase());
      ignoreTables.add(table.toUpperCase());
     });
   }
​
  /**
   * 获取租户字段名
   * <p>
   * 默许字段名叫: tenant_id,我这儿运用org_id
   *
   * @return 租户字段名
   */
  @Override
   public String getTenantIdColumn() {
    return "org_id";
   }
​
​
  @Override
  public Expression getTenantId() {
    // 这儿经过登录信息上下文回来租户id给多租户拦截器增强SQL运用
    return new LongValue(RequestUserHolder.getCurrentUser().getOrgId());
   }
​
  @Override
  public boolean ignoreTable(String tableName) {
    // 疏忽多租户的表
    return CollUtil.contains(ignoreTables, tableName);
   }
}

装备属性如下:

@ConfigurationProperties(prefix = "ptc.tenant")
@Data
public class TenantProperties {
​
​
  /**
   * 全局操控是否敞开多租户功用
   */
  private Boolean enable = Boolean.TRUE;
​
  /**
   * 需求疏忽多租户的表
   *
   * 即默许一切表都敞开多租户的功用,所以记住增加对应的 tenant_id 字段哟
   */
  private Set<String> ignoreTables = Collections.emptySet();
}
​

接下来注入拦截器插件即可:

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(TenantProperties properties) {
    MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor();
    // 必须确保多租户插件在分页插件之前,这个是 MyBatis-plus 的规则
    if (properties.getEnable()) {
      mybatisPlusInterceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantDatabaseHandler(properties)));
     }
    // 分页插件
    mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor());
    return mybatisPlusInterceptor;
   }

运用示例如下:这儿供给了一个常见的事例:用户和人物相关查询的SQL:getUserList()

  <select id="getUserList" resultType="com.plasticene.textile.entity.User">
     select u.* from user u
     left join user_role r on u.id = r.user_id
    <where>
      <if test="query.status != null">
         and u.status = #{query.status}
      </if>
      <if test="query.roleId != null">
         and r.role_id = #{query.roleId}
      </if>
      <if test="query.keyword != null">
         and ((u.name like concat('%',#{query.keyword},'%')) or (u.mobile like concat(#{query.keyword},'%')))
      </if>
      <if test="query.startEntryTime != null">
         and u.entry_time >= #{query.startEntryTime}
      </if>
      <if test="query.endEntryTime != null">
        <![CDATA[ and u.entry_time <= #{query.endEntryTime}]]>
      </if></where>
     group by u.id
     order by u.id desc
  </select>

发动项目,先登录之后运用token掉接口履行下面代码逻辑:

  public PageResult<UserDTO> getList(UserQuery query) {
    Page<UserDTO> page = new Page<>(query.getPageNo(), query.getPageSize());
    List<User> userList = userDAO.getUserList(page, query);
    List<UserDTO> userDTOS = toUserDTOList(userList);
    return new PageResult<>(userDTOS, page.getTotal(), page.getPages());
   }

查看操控台发现:

[1658720355293990912] [DEBUG] [2023-05-17 14:25:25.504] [http-nio-16688-exec-1@23652]  com.plasticene.textile.dao.UserDAO.getUserList debug : ==>  Preparing: SELECT u.* FROM user u LEFT JOIN user_role r ON u.id = r.user_id AND r.org_id = 3 WHERE u.org_id = 3 GROUP BY u.id ORDER BY u.id DESC LIMIT ?
[1658720355293990912] [DEBUG] [2023-05-17 14:25:25.505] [http-nio-16688-exec-1@23652]  com.plasticene.textile.dao.UserDAO.getUserList debug : ==> Parameters: 20(Long)

user表u加上u.org_id=3这个多租户过滤条件,user_role也同样加上了,阐明多租户插件起作用了。

当然如果想疏忽掉表user,咱们只需求在装备文件如下装备即可:

ptc:
  tenant:
   ignore-tables: user

这样user表u就不会再加上u.org_id=3这个多租户过滤条件,可是这儿有一个细节需求留意,因为user在MySQL中是关键字,所以我有时分为了标准书写SQL,会按照如下编写:

select u.* from `user` u
    left join user_role r on u.id = r.user_id

这时分你会发现上面装备的疏忽表user不起作用,还是会加上u.org_id=3这个多租户过滤条件,跟源码才发现咱们上面自定义的多租户处理器TenantLineHandler只对表名进行了大小写适配,然而这儿SQL解析出来的表名是: `user` ,所以匹配不到装备不起作用。

当然咱们有或许需求针对单一SQL语句不加多租户过滤条件,能够运用@InterceptorIgnore注解:

public interface UserDAO extends BaseMapperX<User> {
​
  @InterceptorIgnore(tenantLine = "true")
  List<User> getUserList(IPage<UserDTO> userPage, @Param("query") UserQuery query);
}

这样调用getUserList()不再会加多租户过滤条件了。

经过上面咱们知道了这个多租户插件其实便是经过解析SQL,然后进行拼接多租户id过滤条件来完成SQL增强然后做到数据阻隔,解析SQL的结构叫:JSqlParser,官方文档:github.com/JSQLParser/…,之前我总结过一篇关于 Druid解析动态SQL。Druid也能够解析SQL,咱们都知道SQL语句会生成语法树,两者对SQL解析的孰强孰弱(特别是杂乱SQL)不得而知,能够自行验证对比,我这儿给出一个JSqlParser解析出错的情况,把上面的SQL语句user_role r 改为 user_role ur

select u.* from user u
     left join user_role ur on u.id = ur.user_id

按照上面相同调用履行getUserList(), 会报解析错误:

Caused by: com.baomidou.mybatisplus.core.exceptions.MybatisPlusException: Failed to process, Error SQL: select u.* from user u
left join user_role ur on u.id = ur.user_id
group by u.id
order by u.id desc
at com.baomidou.mybatisplus.core.toolkit.ExceptionUtils.mpe(ExceptionUtils.java:39)
at com.baomidou.mybatisplus.extension.parser.JsqlParserSupport.parserSingle(JsqlParserSupport.java:52)
at com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor.beforeQuery(TenantLineInnerInterceptor.java:65)
at com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor.intercept(MybatisPlusInterceptor.java:78)
at org.apache.ibatis.plugin.Plugin.invoke(Plugin.java:62)
at com.sun.proxy.$Proxy178.query(Unknown Source)
at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:151)
... 101 common frames omitted
Caused by: net.sf.jsqlparser.parser.ParseException: Encountered unexpected token: "ur" <K_ISOLATION>
at line 2, column 29.

我在mybatis-plus的官方提了一个issue:github.com/baomidou/my…,也得到官方保护者的迅速回应说是JSqlParser解析的问题,不是mybatis-plus的问题~~~,给出的主张便是把别号ur改成其他,或者升级到JSqlParser的最新版别。

4.总结

至此,咱们对多租户体系数据阻隔完成计划,架构规划,以及怎么高雅完成全局操作数据阻隔都讲完了,一起也对mybati-plus的多租户插件完成原理和源码流程套路进行了浅析,也对实践使用事例中进行了举证并论述了相关细节点。当然数据权限不止停留在租户(公司)层面上面,大多数体系的数据权限会按照事务安排架构人物来操控,数据权限其套路和根据人物判别菜单权限一回事。因为数据权限通常与公司事务相关,比较个性化,每家公司事务安排架构不尽相同,所以实践开发项意图数据权限阻隔还需求大家按实践需求进行修正,但总的来说咱们能够仿照多租户阻隔完成方法,比如说一个事务体系安排架构有公司(org_id),公司下有多个部分(dept_id),部分下有多个团队分组(team_id),团队下有多个人员(user_id)。不同人物只能看到不同数据,部分经理只能看到自己部分的数据,小组长只能看到自己小组的数据,这些完成逻辑套路都能够仿照多租户插件的方法进行高雅完成,这也是我后面有时间想研究的,后续会再出一篇数据权限的完成计划总结。