最新版Mybatis-plus3.5.X全面攻略(三)简易租户隔离

第二章:最新版Mybatis-plus3.5.0全面攻略(一)代码生成器和初步使用

第二章:最新版Mybatis-plus3.5.0全面攻略(二)自动填充和逻辑删除的实际方案

前言

我们在生产环境或多或少会要遇到租户隔离类的需求,尤其是Saas系统,大致是系统内根据租户、组织、群组等概念的数据完全隔离,某个租户内的数据对于其他租户不可见,但某些公共数据又要全局查询(比如用户名手机号码重复性等等)。

所以我们分析一下需求:

  • 要实现租户与租户间数据的隔离
  • 某些特殊数据要全局搜索,不能隔离
  • 要有超管用户的全局查看能力

物理隔离与逻辑隔离

对于大型的系统来说,最好的方案 是分库分表这类的物理隔离方案,安全性佳,也易于迁移。但如果你的系统是小型项目,不想使用物理隔离的模式,而想使用单表的逻辑分离,那么可以让mybatis-plus帮助你更方便地实现。下面是常见的物理隔离与逻辑隔离的底层逻辑区别:

需求 物理隔离 逻辑隔离
租户数据的隔离 不同租户的数据放在 不同的物理库或者表里 表的租户id字段来区分, 查询时通过该字段过滤
特殊数据的全局搜索 特殊数据可以考虑放在 特殊的统一收束库里 查询时不带上租户id
超管的全局查看权限 做所有库的统一查询收集逻辑 查询时不带上租户id

可以看到,逻辑隔离的方式虽然有着不易拓展,安全性不足等缺点,但如果你的系统只是中小型系统,对这些缺点没有太高的要求,那么逻辑隔离对你来说也有着操作灵活等优点。

Mybatis-plus的租户隔离实现

公共字段

想要实现租户的逻辑隔离 ,你的业务表就必须要添加一个用于标记租户id的公共字段,类似常用的创建时间、更新人、逻辑删除等字段。

这里我使用的是bigint类型的字段organization_id,因为我这里的小型系统是按组织这个概念隔离的,你可以使用自己设定的字段名和字段类型,但重要的是各表的此字段要统一
再设计添加好这个公共的表字段后,在我们的mybatis-plus的代码的entity类里需要加上这个字段,可以使用代码生成器生成,也可以手动添加,当然最好是使用上一章讲的公共父entity。 这里除了主键id、创建更新信息、逻辑删除之外,添加了前文说的organization_id字段。可以看到,我们在organizationId字段上加上了mybatis-plus的自动填充INSERT注解。

这是因为,如果要实现按organization_id字段的逻辑隔离,那么在新增数据时就要给这行数据赋予其属于的隔离区id (组织id、租户id或者群组id等)。

于是在我们关于mybatis-plus的自动填充配置类里(上一章有详细讲,见文首链接)就需要添加关于organization_id的自动填充逻辑:

java 复制代码
@Override
public void insertFill(MetaObject metaObject) {
    // 创建时间,取当前时间,也可以自定义
    this.strictInsertFill(metaObject, "createTime", Date.class, new Date());
    // 更新时间,取当前时间,也可以自定义
    this.strictInsertFill(metaObject, "updateTime", Date.class, new Date());
    String userName = null;
    Long organizationId = CommonConstants.ORGANIZATION_ID_DEFAULT;
    if (QueryContext.getUserInfoThreadLocal() != null && QueryContext.getUserInfoThreadLocal().get() != null) {
        userName = QueryContext.getUserInfoThreadLocal().get().getUsername();
        organizationId = QueryContext.getUserInfoThreadLocal().get().getOrganizationId();
    }
    // 创建人
    this.strictInsertFill(metaObject, "createUser", String.class, userName);
    // 更新人
    this.strictInsertFill(metaObject, "updateUser", String.class, userName);
    // 组织id
    this.strictInsertFill(metaObject, "organizationId", Long.class, organizationId);
}

这里的organizationId和userName的值是我自己的获取逻辑,为线程过滤器一开始放置在上下文对象QueryContext中的,各位可以自行修改获取逻辑。

其中有一点需要注意:对于某些特殊情况,比如无组织用户、全局业务逻辑等,这时候可能无法获取到组织id,则可以考虑填充一个默认的值,我自己这里给的0。
但退一步说,这里的自动填充不是必需的,开发者可以自己编写这个字段的赋值逻辑。

核心配置类

做完之前的准备工作后,我们可以开始编写配置类了,说是配置类,其实是提供了一个Handler静态方法:

java 复制代码
    public static TenantLineHandler organizationHandler() {
        return new TenantLineHandler() {

            // 配置拼接的租户值,取上下文中的组织id
            @Override
            public Expression getTenantId() {
                Long organizationId;
                try {
                    // 若组织id为空 则默认赋值
                    organizationId = QueryContext.getUserInfoThreadLocal().get().getOrganizationId() == null
                            ? CommonConstants.ORGANIZATION_ID_DEFAULT
                            : QueryContext.getUserInfoThreadLocal().get().getOrganizationId();
                } catch (Exception e) {
                    organizationId = 0L;
                }
                return new LongValue(organizationId);
            }

            // 配置拼接的租户列名
            @Override
            public String getTenantIdColumn() {
                return "organization_id";
            }
            
            // 忽略方法
            @Override
            public boolean ignoreTable(String tableName) {
                UserInfo userInfo = QueryContext.getUserInfoThreadLocal().get();
                // 自定义类,手动解锁
                return TenantLimit.tenantUnLock() ||
                        // 超管解锁
                        userInfo.isAdmin()||
                        // 不在设置的白名单内也忽略
                        MpTenantFilter.patterns.stream().noneMatch(
                                i -> i.matcher(tableName).matches()
                        );
            }
        };
    }
}

这个方法很长,但分为三个部分:

  • 提供会话当前的组织id的用于过滤
  • 提供用来过滤的表字段
  • 提供不执行过滤的开关,入参是表名 第一个当前组织id的值,很好理解,和刚才自动填充的获取一样就行,含义就是当前查询的会话属于哪个组织,对于全局查询来说可能为null值或者0等。
    第二个表字段很简单,直接返回organization_id就行了。 第三个方法就比较抽象了,ignoreTable的原意是指忽略哪些表,也即对哪些表不做数据隔离,比如菜单表、枚举表等公共表。但因为这个方法的返回boolean值是通用的,因此我们通过手动返回true来做到手动关闭租户隔离 。比如在校验全局手机号码唯一性的时候,虽然用户表是组织隔离体系下的,但在校验时可以手动关闭租户隔离,校验完再打开;又比如校验到当前查询用户是超级管理员时,也可以手动关闭租户隔离。总之这个方法可操作性很高,开发者可以自己定制,不要被tableName这个入参限制了思维

最后,在mybatis-plus的核心配置类里添加上租户插件,就是配置分页插件的那个类:

java 复制代码
@Configuration
public class MyBatisPlusConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 租户插件(租户插件要先于分页插件)
        interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(MpTenantFactory.organizationHandler()));
        // 分页插件
        PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor();
        paginationInnerInterceptor.setDbType(DbType.MYSQL);
        interceptor.addInnerInterceptor(paginationInnerInterceptor);
        return interceptor;
    }

}

到此,所有mybatis-plus涉及的查询语句,包括常规的条件查询、分页查询、更新时的条件过滤等等,再包括自己编写的mybatis.xml文件里的sql,都会自动添加上organization_id = ?,只需要我们在xml的sql里把每个表以及过程表的别名都加上,mybatis-plus就能够把涉及到的表都加上租户隔离。当然,剔除我们在Handler的ignoreTable中忽略的表。

java 复制代码
Page<PolicyEntity> entityPage = this.page(pageQuery.build(), Wrappers.<PolicyEntity>lambdaQuery()
        .like(StrUtil.isNotBlank(name), PolicyEntity::getName, name)
        .orderByDesc(PolicyEntity::getId));
sql 复制代码
SELECT COUNT(*) AS total FROM sys_policy WHERE deleted = 0
AND sys_policy.organization_id = 23

查询代码里只有关于姓名的模糊查询(没传值不生效)和id的排序,没有编写和organizationId相关的代码,这里的AND sys_policy.organization_id = 23是租户插件自动带上的。 这个过程是动态判断的,假设我们的复杂sql里涉及5张数据库表,而在Handler类中配置的忽略名单里包含其中的两张表,那么最终生成的执行sql里只会对其中剩下的三张表加上organization_id的过滤。

租户锁

前文Handler配置的ignoreTable方法里,我们使用了一个自定义的类TenantLimit来实现手动关闭租户隔离功能。下面是TenantLimit代码示范:

java 复制代码
public class TenantLimit {

    private TenantLimit() {
    }

    private static final ThreadLocal<Boolean> UN_LOCKED = new ThreadLocal<>();

    /**
     * 判断是否解锁
     */
    public static boolean tenantUnLock() {
        return Optional.ofNullable(UN_LOCKED.get()).orElse(false);
    }

    /**
     * 解锁
     */
    public static void unlock() {
        UN_LOCKED.set(true);
    }

    /**
     * 重新恢复租户限制
     */
    public static void lock() {
        UN_LOCKED.set(false);
    }

    public static void remove() {
        UN_LOCKED.remove();
    }
}

代码不负责,就是一个内容为布尔值的ThreadLocal对象,这个对象在线程之间是独立的,我们这里用它来存放当前线程的租户锁定开关。需要注意的是:对于springboot这类使用线程池的场景来说,一次新的接口调用使用的并非是新线程,而是线程池里取的旧线程,所以ThreadLocal里的值并非是完全独立的,需要在每次线程开始前重置该值。

我个人的做法是在类似Filter或者Interceptor等逻辑中重置这些值,对于本文的租户锁,强烈建议在打开后执行逻辑代码完立刻关闭。

java 复制代码
// 其他逻辑代码
// ...
// 解锁租户隔离
TenantLimit.unlock();
// 执行全局查询逻辑代码
// ...
// 恢复租户限制
TenantLimit.lock();
// 其他逻辑代码
// ...

结语

租户隔离TenantLineInnerInterceptor是mybatis-plus提供给我们的一个简易的可插拔的功能插件,对于小型系统来说实现数据分区块隔离还是个不错的选择。不过如果你的系统对安全性的要求非常高,且规模日益增大,还是推荐你使用更彻底的物理隔离更为合适。

相关推荐
Viktor_Ye16 分钟前
高效集成易快报与金蝶应付单的方案
java·前端·数据库
hummhumm18 分钟前
第 25 章 - Golang 项目结构
java·开发语言·前端·后端·python·elasticsearch·golang
一二小选手23 分钟前
【Maven】IDEA创建Maven项目 Maven配置
java·maven
J老熊28 分钟前
JavaFX:简介、使用场景、常见问题及对比其他框架分析
java·开发语言·后端·面试·系统架构·软件工程
猿java33 分钟前
什么是 Hystrix?它的工作原理是什么?
java·微服务·面试
AuroraI'ncoding34 分钟前
时间请求参数、响应
java·后端·spring
所待.3831 小时前
JavaEE之线程初阶(上)
java·java-ee
Winston Wood1 小时前
Java线程池详解
java·线程池·多线程·性能
手握风云-1 小时前
数据结构(Java版)第二期:包装类和泛型
java·开发语言·数据结构
许苑向上1 小时前
Dubbo集成SpringBoot实现远程服务调用
spring boot·后端·dubbo