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

第二章:最新版Mybatis-plus3.5.0全面攻略(一)代码生成器和初步使用
第二章:最新版Mybatis-plus3.5.0全面攻略(二)自动填充和逻辑删除的实际方案

前言

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

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

物理隔离与逻辑隔离

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

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

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

Mybatis-plus的租户隔离实现

公共字段

想要实现租户的逻辑隔离,你的业务表就必须要添加一个用于标记租户id的公共字段,类似常用的创建时间、更新人、逻辑删除等字段。
这里我使用的是bigint类型的字段organization_id,因为我这里的小型系统是按组织这个概念隔离的,你可以使用自己设定的字段名和字段类型,但重要的是各表的此字段要统一
image.png 再设计添加好这个公共的表字段后,在我们的mybatis-plus的代码的entity类里需要加上这个字段,可以使用代码生成器生成,也可以手动添加,当然最好是使用上一章讲的公共父entity。 image.png 这里除了主键id、创建更新信息、逻辑删除之外,添加了前文说的organization_id字段。可以看到,我们在organizationId字段上加上了mybatis-plus的自动填充INSERT注解。
这是因为,如果要实现按organization_id字段的逻辑隔离,那么在新增数据时就要给这行数据赋予其属于的隔离区id(组织id、租户id或者群组id等)。
于是在我们关于mybatis-plus的自动填充配置类里(上一章有详细讲,见文首链接)就需要添加关于organization_id的自动填充逻辑:

@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静态方法:

    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的核心配置类里添加上租户插件,就是配置分页插件的那个类:

@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中忽略的表。

Page<PolicyEntity> entityPage = this.page(pageQuery.build(), Wrappers.<PolicyEntity>lambdaQuery()
        .like(StrUtil.isNotBlank(name), PolicyEntity::getName, name)
        .orderByDesc(PolicyEntity::getId));
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代码示范:

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等逻辑中重置这些值,对于本文的租户锁,强烈建议在打开后执行逻辑代码完立刻关闭。

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

结语

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

全部评论

相关推荐

点赞 收藏 评论
分享
牛客网
牛客企业服务