多租户数据隔离与管理是在一个共享的软件应用程序中托管多个租户(客户)的数据,并确保每个租户的数据完全隔离、安全和可管理。需要支持相同数据(记录)可由具有不同容量的多个租户。
mybatis-plus租户插件使用
由于业务数据,都需要通过对数据库的crud,而此时mybatis-plus作为orm框架,提供了租户功能,我们可以合理利用拓展,应用于自己的业务范畴中
我们通过实现TenantLineHandler,即可实现对数据库中的表,进行租户过滤
属性名 | 类型 | 默认值 | 描述 |
---|---|---|---|
TenantLineHandler | TenantLineHandler | 租户处理器( TenantId 行级 ) |
实现租户过滤
ps:配置完后,就可实现对表进行租户筛选,读者们可自行测试
由于后面我对该代码进行了加工,该代码会出现一些自定义逻辑在里面,但原理是不变的
/**
* @author zhanghp
* @since 2023/11/20 14:29
*/
@Configuration
public class TenantConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(TenantProperties properties) {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
// 默认租户为1
return new LongValue(1);
}
@Override
public String getTenantIdColumn() {
return properties.getTenantId();
}
// 这是 default 方法,默认返回 false 表示所有表都需要拼多租户条件
@Override
public boolean ignoreTable(String tableName) {
return false;
}
}));
return interceptor;
}
}
各依赖版本继承至父依赖,可自行查看
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-configuration-processorartifactId>
<optional>trueoptional>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-aopartifactId>
<scope>providedscope>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
<scope>testscope>
dependency>
<dependency>
<groupId>com.baomidougroupId>
<artifactId>mybatis-plus-boot-starterartifactId>
dependency>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
<scope>runtimescope>
dependency>
<dependency>
<groupId>com.baomidougroupId>
<artifactId>mybatis-plus-generatorartifactId>
dependency>
<dependency>
<groupId>org.apache.velocitygroupId>
<artifactId>velocity-engine-coreartifactId>
dependency>
<dependency>
<groupId>com.alibabagroupId>
<artifactId>transmittable-thread-localartifactId>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
dependency>
<dependency>
<groupId>cn.hutoolgroupId>
<artifactId>hutool-allartifactId>
dependency>
dependencies>
完整配置:application.yml
这里我沿用了Spring自带的属性配置spring.sql.init
,初始化数据库的schema,data。
建表sql | 数据
当启动项目后,自动进行建表,初始化表及相应的数据
spring.io官网对配置文件的概述:可自行搜索
spring.sql.init
解锁更多使用方式
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: zhp.1221
url: jdbc:mysql://127.0.0.1:3306/test?useSSL=false&useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
sql:
init:
# 建表sql
schema-locations: classpath:db/schema.sql
# 数据
data-locations: classpath:db/data.sql
# 生成模式
mode: always
调用代码生成Generate.java,自动生成表实体类的相关类
这里就不进行展示了,自动代码生成若有不懂的地方可看Mybatis plus 自动生成代码与自定义模板
MpTest.java:测试对db的增删改查
/**
* @author zhanghp
* @since 2023/11/20 14:21
*/
public class MpTest extends AppTest {
@Resource
private DemoMapper demoMapper;
@Test
public void select() {
demoMapper.selectList(null);
}
@Test
public void insert() {
demoMapper.insert(Demo.builder().id(100).age(18).name("草莓熊").build());
}
@Test
public void update() {
demoMapper.updateById(Demo.builder().id(100).age(20).name("皮卡").build());
}
@Test
public void delete() {
demoMapper.deleteById(Demo.builder().id(100).build());
}
}
select查询结果日志:
insert插入日志:
update修改日志:
delete删除日志:
Saas多租户使用时,其实面临着许多技术问题,我拿几个公司所遇见的经典问题进行举例
一个租户的设值,一般分为两种:
设值后,需要找个地方存储,这里引用alibaba的TransmittableThreadLocal进行值的存储,也方便后续取值,所以这里新建立一个TenantContextHolder类
TENAN_ID:进行值的存储与读取
TENANT_SKIP:进行租户是否过滤,也就是对db是否进行租户过滤的判断(where tenant_id = ?),下一节会讲到
@UtilityClass:对所有方法加static,对类加final,不允许new
/**
* @author zhanghp
* @since 2023/11/20 20:28
*/
@Data
@UtilityClass
public class TenantContextHolder {
/**
* tenant_id
*/
private final ThreadLocal<Long> TENAT_ID = new TransmittableThreadLocal<>();
/**
* 获取租户
* @return 租户id
*/
public Long getTenantId() {
return TENAT_ID.get();
}
/**
* 设置租户
* @param tenantId 租户
*/
public void setTenantId(Long tenantId) {
TENAT_ID.set(tenantId);
}
/**
* 清空租户信息
*/
public void clear() {
TENAT_ID.remove();
}
}
当有了这个类后,那我们需要重新实现TenantLineHandler,由于之前已经定义了一个TenantConfig的bean来对租户处理器并注入到容器中,若我们再次定义一个会导致bean冗余,让spring找不到使用哪个,进而导致启动报错。
那么问题来了,有没有一种方式我既要能控制新定义的TenangLineHandler存在,老的也兼容呢?
这时我们使用一下@ConditionalOnMissingBean使用在TenantConfig上,和@Order使用在DeepTenantConfig即可,读者可自行浏览
DeepTenantConfig
@ConditionalOnProperty(prefix = “custom.tenant”, value = “deep-config”, havingValue = “true”):
代表我是否要使用这个bean注入,为什么要有这个注解来控制bean注入呢?是因为要兼容上一章的租户使用TenantConfig,选择注入哪个,若没有该注解,则都会默认注入DeepTenantConfig的bean,控制该bean的注入在application.yml里的custom.tenant.deep-config
/**
* @author zhanghp
* @since 2023/11/24 17:51
*/
@Configuration
public class DeepTenantConfig {
@Bean
@Order(Integer.MIN_VALUE)
@ConditionalOnProperty(prefix = "custom.tenant", value = "deep-config", havingValue = "true")
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
// 获取租户
Long tenantId = TenantContextHolder.getTenantId();
// 租户为空则返回空
if (tenantId == null) {
return new NullValue();
}
// 返回租户
return new LongValue(tenantId);
}
// 这是 default 方法,默认返回 false 表示所有表都需要拼多租户条件
@Override
public boolean ignoreTable(String tableName) {
// 租户为空,则对该表不进行租户的操作
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
return Boolean.TRUE;
}
return false;
}
}));
return interceptor;
}
}
这些都处理好后,我们在配置中把custom.tenant.deep-config
设置为true,这样读取的是我们上面定义的bean
我们在创建一个请求处理器类,进行租户的处理
TenantInterceptor
当一次请求进来,会进行租户值当处理,并存储在刚才我们所建立的TenantContextHolder类里,进行租户值处理
/**
* @author zhanghp
* @date 2023-11-25 13:48
*/
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (StrUtil.isNotBlank("tenant-id")) {
TenantContextHolder.setTenantId(Convert.toLong(tenantId));
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
TenantContextHolder.clear();
}
}
将拦截器添加到拦截器注册类里
/**
* @author zhanghp
* @date 2023-11-25 16:53
*/
@Configuration
public class CustomInterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(getRequestContextInterceptor());
}
@Bean
public TenantInterceptor getRequestContextInterceptor() {
return new TenantInterceptor();
}
}
现在我们创建一个controller
/**
*
* mybatis - demo表 前端控制器
*
*
* @author zhp
* @since 2023-11-20
*/
@RestController
@RequestMapping("/zhanghp/demo")
public class DemoController {
@Resource
private DemoService demoService;
@GetMapping("/get")
public void getall(){
List<Demo> all = demoService.getAll();
if (IterUtil.isEmpty(all)) {
return;
}
all.forEach(System.out::println);
}
}
现在我们打开apifox/postman,进行一次请求操作,看是否会生效
结果日志打印
⭐️场景:
其实在业务中,有一些配置表,字典表其实是不需要租户的,就比如城市配置表,城市就这些,加租户也是没有任何意义的
⭐️解决:
实现其实也是很简单,只需要在DeepTenantConfig里的的ignoreTables方法进行一些逻辑处理就可以
实现步骤:
这里由于读取了配置,我喜欢把自定义的配置定义一个类,通过该类对配置读取,读者可自行查看,这里就不赘述了
配置:
custom:
tenant:
# 是否注入deepTenantConfig的bean开关
deep-config: true
# 租户字段名称
tenant-id: tenant_id
# 忽略租户的表
ignore-tables:
- demo01
- demo02
逻辑新增
/**
* @author zhanghp
* @since 2023/11/24 17:51
*/
@Configuration
public class DeepTenantConfig {
@Bean
@Order(Integer.MIN_VALUE)
@ConditionalOnProperty(prefix = "custom.tenant", value = "deep-config", havingValue = "true")
public MybatisPlusInterceptor mybatisPlusInterceptor(TenantProperties properties) {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
// 获取租户
Long tenantId = TenantContextHolder.getTenantId();
// 租户为空则返回空
if (tenantId == null) {
return new NullValue();
}
// 返回租户
return new LongValue(tenantId);
}
@Override
public String getTenantIdColumn() {
return properties.getTenantId();
}
// 这是 default 方法,默认返回 false 表示所有表都需要拼多租户条件
@Override
public boolean ignoreTable(String tableName) {
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
return Boolean.TRUE;
}
// 忽略表对配置为空,则所有表都进行租户操作
if (IterUtil.isEmpty(properties.getIgnoreTables())) {
return false;
}
// 指定的表永远不进行租户操作
if (properties.getIgnoreTables().contains(tableName)) {
return true;
}
return false;
}
}));
return interceptor;
}
}
读者自行练习就可,就不再演示
⭐️场景:
在一个请求里,这个请求涉及到的表是不需要租户过滤,那么读者就会想到,那在上一节的配置文件加啊。
这里的一个请求不需要对A表进行过滤,但另一个请求对A是需要进行租户过滤的,那这时加配置文件是行不通的
⭐️解决:
/**
* @author zhanghp
* @since 2023/11/20 20:28
*/
@Data
@UtilityClass
public class TenantContextHolder {
/**
* tenant_id
*/
private final ThreadLocal<Long> TENAT_ID = new TransmittableThreadLocal<>();
/**
* 租户过滤标识
*/
private final ThreadLocal<Boolean> TENANT_SKIP = new TransmittableThreadLocal<>();
/**
* 获取租户
* @return 租户id
*/
public Long getTenantId() {
return TENAT_ID.get();
}
/**
* 设置租户
* @param tenantId 租户
*/
public void setTenantId(Long tenantId) {
TENAT_ID.set(tenantId);
}
/**
* 设置是否过滤的标识
*/
public void setTenantSkip() {
TENANT_SKIP.set(Boolean.TRUE);
}
/**
* 获取是否跳过租户过滤的标识
* @return true-过滤 false-不过滤
*/
public Boolean getTenantSkip() {
return TENANT_SKIP.get() != null && TENANT_SKIP.get();
}
/**
* 清空租户信息
*/
public void clear() {
TENAT_ID.remove();
clearSkip();
}
/**
* 清空租户过滤标识
*/
public void clearSkip(){
TENANT_SKIP.remove();
}
}
DeepTenantConfig
/**
* @author zhanghp
* @since 2023/11/24 17:51
*/
@Configuration
public class DeepTenantConfig {
@Bean
@Order(Integer.MIN_VALUE)
@ConditionalOnProperty(prefix = "custom.tenant", value = "deep-config", havingValue = "true")
public MybatisPlusInterceptor mybatisPlusInterceptor(TenantProperties properties) {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
// 获取租户
Long tenantId = TenantContextHolder.getTenantId();
// 租户为空则返回空
if (tenantId == null) {
return new NullValue();
}
// 返回租户
return new LongValue(tenantId);
}
@Override
public String getTenantIdColumn() {
return properties.getTenantId();
}
// 这是 default 方法,默认返回 false 表示所有表都需要拼多租户条件
@Override
public boolean ignoreTable(String tableName) {
// 1.是否跳过租户对该表对操作
if (TenantContextHolder.getTenantSkip()) {
return Boolean.TRUE;
}
// 2.租户为空,则对该表不进行租户的操作
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
return Boolean.TRUE;
}
// 3.忽略表对配置为空,则所有表都进行租户操作
if (IterUtil.isEmpty(properties.getIgnoreTables())) {
return false;
}
// 4.指定的表永远不进行租户操作
if (properties.getIgnoreTables().contains(tableName)) {
return true;
}
return false;
}
}));
return interceptor;
}
}
在controller层新加一个TenantContextHolder.setTenantSkip()即可
@GetMapping("/get2")
public void getall2(){
TenantContextHolder.setTenantSkip();
List<Demo> all = demoService.list();
if (IterUtil.isEmpty(all)) {
return;
}
all.forEach(System.out::println);
}
读者自行练习即可
⭐️场景:
在一个请求里,进行了对A表和B表操作,但是A表是不需要租户,而B表又是需要租户
⭐️解决:
这里我写了一个注解@TenanClear
作用域:方法,类
作用:执行当前方法里的所有db操作,都不带租户,通过属性globalFlag进行控制后续方法是否添加租户过滤
ps:是=实现原理在 TenantClearAspect
/**
* @author zhanghp
* @since 2023/11/21 20:41
*/
@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface TenantClear {
/**
* 执行该方法后,是否全局过滤租户标志
*
* - true:执行完该线程之前,后续对db操作不添加租户操作
* - false:执行完当前方法后,后续方法的db操作添加租户操作
*
*
* @return 默认后续不添加租户操作
*/
boolean globalFlag() default true;
}
该注解的作用:当添加该注解后当前方法进行的所有db操作都不带租户处理,后续方法执行是否带租户,通过属性globalFlag决定
创建a方法,并创建b方法
在a方法添加@TenantClear(globalFlag = false),然后在controller层先调用a,在调用b
输出结果,a不带租户,b带租户
/**
*
* mybatis - demo表 服务实现类
*
*
* @author zhp
* @since 2023-11-20
*/
@Service
public class DemoServiceImpl extends ServiceImpl<DemoMapper, Demo> implements DemoService {
@Override
public void b() {
super.list();
}
@Override
@TenantClear(globalFlag = false)
public void a(){
super.list();
}
}
在controller层调用
@GetMapping("/tenant-clear")
public void tenantClear(){
Console.log("执行a方法");
demoService.a();
Console.log("执行b方法");
demoService.b();
}
结果打印
由于本项目是单体项目,不涉及微服务,在这里只是简单概述
实现步骤:
public class CustomerFeignInterceptor implements RequestInterceptor {
public CustomerFeignInterceptor() {
}
public void apply(RequestTemplate requestTemplate) {
if (TenantContextHolder.getTenantId() == null) {
log.debug("租户ID为空,feign不传递");
} else {
requestTemplate.header("tenant-id", new String[]{TenantContextHolder.getTenantId().toString()});
}
}
}
⭐️gitee:https://gitee.com/zhp1221/java/tree/master/lab_02_mybatis_plus/tenant