第二章:最新版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提供给我们的一个简易的可插拔的功能插件,对于小型系统来说实现数据分区块隔离还是个不错的选择。不过如果你的系统对安全性的要求非常高,且规模日益增大,还是推荐你使用更彻底的物理隔离更为合适。