写点什么

Mybatis 技术专题之 MybatisPlus 自带强大功能之多租户插件实现原理和实战分析

作者:浩宇天尚
  • 2022 年 2 月 01 日
  • 本文字数:3612 字

    阅读完需:约 12 分钟

Mybatis技术专题之MybatisPlus自带强大功能之多租户插件实现原理和实战分析

http://mp.baomidou.com/guide/tenant.html

前提介绍

一般的程序应用当使用者访问不同,并且进入相对应的程序页面,则会把用户相关数据传输到后台这里。在传输的时候需要带上标识(租户 ID),以便程序将数据进行隔离。当不同的租户使用同一个程序服务,这里就需要考虑一个数据隔离的情况。

什么是多租户技术

  • 多租户技术或称多重租赁技术,是一种软件架构技术,是实现如何在多用户环境下(此处的多用户一般是面向企业)共用相同的系统或程序组件,并且确保各用户间数据隔离性。

  • 在一台服务器上运行单个应用实例,它为多个租户(客户)提供服务。从定义中我们可以理解:多租户是一种架构,目的是为了让多用户环境下使用同一套程序,且保证用户间数据隔离。多租户的重点就是同程序下实现多用户数据的隔离。

数据隔离有三种方案:

  1. 独立数据库:简单来说就是一个租户使用一个数据库,这种数据隔离级别最高,安全性最好,但是提高成本。

  2. 共享数据库、隔离数据架构:多租户使用同一个数据裤,但是每个租户对应一个 Schema(数据库 user)。

  3. 共享数据库、共享数据架构:使用同一个数据库,同一个 Schema,但是在表中增加了租户 ID 的字段,这种共享数据程度最高,隔离级别最低。

多租户具体实现

按照综合实际考虑,一般都会采用方案三,即共享数据库,共享数据架构,因为这种方案服务器成本最低,但是提高了开发成本。

MybatisPlus 实现逻辑

Mybatis-plus 实现多租户方案

Mybatis-plus 就提供了一种多租户的解决方案,实现方式是基于分页插件(拦截器)进行实现的;


  • 第一步:应用添加维护一张 tenant(租户表),需要进行隔离的数据表上新增租户 id,例如,现在有数据库表(user)如下:



将 tenantId 视为租户 ID,用来隔离租户与租户之间的数据,如果要查询当前服务商的用户,SQL 大致如下:


SELECT * FROM table t WHERE  t.tenantId = 1;
复制代码


  • 第二步:实现 TenantHandler 接口并实现它的方法:


public interface TenantHandler {  /**   * 获取租户 ID 值表达式,支持多个 ID 条件查询   * 支持自定义表达式,比如:tenant_id in (1,2) @since 2019-8-2   * @param where 参数 true 表示为 where 条件 false 表示为 insert 或者 select 条件   * @return 租户 ID 值表达式   */  Expression getTenantId(boolean where);
/** * 获取租户字段名 * @return 租户字段名 */ String getTenantIdColumn();
/** * 根据表名判断是否进行过滤 * @param tableName 表名 * @return 是否进行过滤, true:表示忽略,false:需要解析多租户字段 */ boolean doTableFilter(String tableName);}
复制代码
PreTenantHandler 实现 TenantHandler
@Slf4j@Componentpublic class PreTenantHandler implements TenantHandler {
@Autowired private PreTenantConfigProperties configProperties; /** * 租户Id * @return */ @Override public Expression getTenantId(boolean where) { //可以通过过滤器从请求中获取对应租户id Long tenantId = PreTenantContextHolder.getCurrentTenantId(); log.debug("当前租户为{}", tenantId); if (tenantId == null) { return new NullValue(); } return new LongValue(tenantId); }
/** * 租户字段名 * @return */ @Override public String getTenantIdColumn() { return configProperties.getTenantIdColumn(); }
/** * 根据表名判断是否进行过滤 * 忽略掉一些表:如租户表(sys_tenant)本身不需要执行这样的处理 * @param tableName * @return */ @Override public boolean doTableFilter(String tableName) { return configProperties.getIgnoreTenantTables().stream().anyMatch((e) -> e.equalsIgnoreCase(tableName)); }}
复制代码


  • 第三步:配置 mybatisPlus 的分页插件配置


租户相关的表,我们都需要不厌其烦的加上 AND t.tenantId = ?查询条件,稍不注意就会导致数据越界,数据安全问题让人担忧。好在有了 MybatisPlus 这个神器,可以极为方便的实现多租户 SQL 解析器。

核心配置:TenantSqlParser

@Configuration@MapperScan("com.wuwenze.mybatisplusmultitenancy.mapper")public class MybatisPlusConfig {
private static final String SYSTEM_TENANT_ID = "provider_id"; private static final List<String> IGNORE_TENANT_TABLES = Lists.newArrayList("provider");
@Autowired private ApiContext apiContext;
@Bean public PaginationInterceptor paginationInterceptor() { PaginationInterceptor paginationInterceptor = new PaginationInterceptor(); // SQL解析处理拦截:增加租户处理回调。 TenantSqlParser tenantSqlParser = new TenantSqlParser() .setTenantHandler(new TenantHandler() { @Override public Expression getTenantId() { // 从当前系统上下文中取出当前请求的服务商ID,通过解析器注入到SQL中。 Long currentProviderId = apiContext.getCurrentProviderId(); if (null == currentProviderId) { throw new RuntimeException("#1129 getCurrentProviderId error."); } return new LongValue(currentProviderId); } @Override public String getTenantIdColumn() { return SYSTEM_TENANT_ID; } @Override public boolean doTableFilter(String tableName) { // 忽略掉一些表:如租户表(provider)本身不需要执行这样的处理。 return IGNORE_TENANT_TABLES.stream().anyMatch((e) -> e.equalsIgnoreCase(tableName)); } }); paginationInterceptor.setSqlParserList(Lists.newArrayList(tenantSqlParser)); return paginationInterceptor; } @Bean(name = "performanceInterceptor") public PerformanceInterceptor performanceInterceptor() { return new PerformanceInterceptor(); }}
复制代码


配置好之后,不管是查询、新增、修改删除方法,MP 都会自动加上租户 ID 的标识,测试如下:


@Testpublic void select(){  List<User> users = userMapper.selectList(Wrappers.<User>lambdaQuery().eq(User::getAge, 18));  users.forEach(System.out::println);}
复制代码

运行 sql 实例:

DEBUG==> Preparing: SELECT id, login_name, name, password,       email, salt, sex, age, phone, user_type, status,     organization_id, create_time, update_time, version,     tenant_id FROM sys_user    WHERE sys_user.tenant_id = '001' AND is_delete = '0' AND age = ?
复制代码


注:特定 SQL 过滤,如果在程序中,有部分 SQL 不需要加上租户 ID 的表示,需要过滤特定的 sql,可以通过如下两种方式:


在配置分页插件中加上配置 ISqlParserFilter 解析器,配置 SQL 很多,比较麻烦,不建议;


paginationInterceptor.setSqlParserFilter(new ISqlParserFilter() {      @Override      public boolean doFilter(MetaObject metaObject) {        MappedStatement ms = SqlParserHelper.getMappedStatement(metaObject);        // 对应Mapper、dao中的方法        if("com.example.demo.mapper.UserMapper.selectList".equals(ms.getId())){          return true;        }        return false;      }});
复制代码


通过租户注解 @SqlParser(filter = true) 的形式,目前只能作用于 Mapper 的方法上:


public interface UserMapper extends BaseMapper<User> {    /**   * 自定Wrapper修改   *   * @param userWrapper 条件构造器   * @param user    修改的对象参数   * @return   */  @SqlParser(filter = true)  int updateByMyWrapper(@Param(Constants.WRAPPER) Wrapper<User> userWrapper, @Param("user") User user);}
复制代码

ApiContext

@Componentpublic class ApiContext {    private static final String KEY_CURRENT_PROVIDER_ID = "KEY_CURRENT_PROVIDER_ID";    private static final Map<String, Object> mContext = Maps.newConcurrentMap();
public void setCurrentProviderId(Long providerId) { mContext.put(KEY_CURRENT_PROVIDER_ID, providerId); }
public Long getCurrentProviderId() { return (Long) mContext.get(KEY_CURRENT_PROVIDER_ID); }}
复制代码


发布于: 2022 年 02 月 01 日阅读数: 155
用户头像

浩宇天尚

关注

🏆InfoQ写作平台-签约作者🏆 2020.03.25 加入

【个人简介】酷爱计算机科学、醉心编程技术、喜爱健身运动、热衷悬疑推理的“极客达人” 【技术格言】任何足够先进的技术都与魔法无异 【技术范畴】Java领域、Spring生态、MySQL专项、微服务/分布式体系和算法设计等

评论 (8 条评论)

发布
用户头像
新年强势首作!
2022 年 02 月 07 日 09:08
回复
过年不休息的吗
2022 年 02 月 07 日 09:09
回复
哈哈自律了

2022 年 02 月 07 日 12:35
回复
我愿称你为卷王!!!
2022 年 02 月 07 日 16:02
回复
查看更多回复
没有更多了
Mybatis技术专题之MybatisPlus自带强大功能之多租户插件实现原理和实战分析_多租户_浩宇天尚_InfoQ写作社区