数据隔离是多租户系统中的核心概念,确保一个租户(在这个系统中可能是一个公司或一个独立的客户)的数据对其他租户是不可见的。在 RuoYi 框架(您当前项目所使用的基础框架)中,这通常是通过在数据表中增加一个租户标识(如 tenant_id),并在所有的数据库查询中自动加入这个租户ID作为过滤条件来实现的。
使用MyBatis-Plus实现了多租户功能
1、MybatisPlusConfig.java:配置类,通过TenantLineInnerInterceptor开启租户功能。该拦截器是关键,能自动向SQL查询添加WHERE条件。
public class MybatisPlusConfig {
@Autowired
private TenantProperties tenantProperties;
@Autowired
private MyTenantLineHandler myTenantLineHandler;
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
if (tenantProperties.isEnable()) { // 根据配置文件决定是否启用多租户插件
TenantLineInnerInterceptor tenantLineInnerInterceptor = new TenantLineInnerInterceptor();
tenantLineInnerInterceptor.setTenantLineHandler(myTenantLineHandler); // 设置我们自定义的处理器
interceptor.addInnerInterceptor(tenantLineInnerInterceptor);
System.out.println("INFO: TenantLineInnerInterceptor has been added to MybatisPlusInterceptor."); // 用于启动时确认
}
// 分页插件
interceptor.addInnerInterceptor(paginationInnerInterceptor());
// 乐观锁插件
interceptor.addInnerInterceptor(optimisticLockerInnerInterceptor());
// 阻断插件
interceptor.addInnerInterceptor(blockAttackInnerInterceptor());
return interceptor;
}
/**
* 分页插件,自动识别数据库类型
*/
public PaginationInnerInterceptor paginationInnerInterceptor() {
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor();
// 设置数据库类型为mysql
paginationInnerInterceptor.setDbType(DbType.MYSQL);
// 设置最大单页限制数量,默认 500 条,-1 不受限制
paginationInnerInterceptor.setMaxLimit(-1L);
return paginationInnerInterceptor;
}
/**
* 乐观锁插件
*/
public OptimisticLockerInnerInterceptor optimisticLockerInnerInterceptor() {
return new OptimisticLockerInnerInterceptor();
}
/**
* 如果是对全表的删除或更新操作,就会终止该操作
*/
public BlockAttackInnerInterceptor blockAttackInnerInterceptor() {
return new BlockAttackInnerInterceptor();
}
}
2、MyTenantLineHandler.java:自定义的TenantLineHandler实现,负责提供租户ID、列名以及指定需要过滤的表。它通过TenantUtils.getCurrentTenantId()获取租户ID,并从TenantProperties获取列名,同时判断是否需要对特定表应用过滤器。
package com.ruoyi.framework.config;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import com.ruoyi.common.utils.TenantUtils;
import com.ruoyi.framework.config.properties.TenantProperties;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Set;
/**
* 这个类实现了 MyBatis Plus 的 TenantLineHandler 接口,负责两件事:
* 告诉拦截器当前操作的租户ID是什么。
* 告诉拦截器当前操作的表是否应该被忽略。
*/
@Component
public class MyTenantLineHandler implements TenantLineHandler {
private static final Logger log = LoggerFactory.getLogger(MyTenantLineHandler.class);
@Autowired
private TenantProperties tenantProperties;
/**
* 【核心修改】
* 只有当用户选择了租户时,才返回真实的租户ID。
* 否则,我们返回一个特殊的、无效的ID(如0L),并配合 shouldFilter 方法来确保拦截器不工作。
*/
@Override
public Expression getTenantId() {
Long currentTenantId = TenantUtils.getCurrentTenantId();
if (currentTenantId == null) {
// 当没有租户ID时,返回一个不可能匹配任何记录的ID
// 真实的过滤逻辑由 shouldFilter 控制
return new LongValue(0L);
}
return new LongValue(currentTenantId);
}
/**
* 获取租户ID的数据库字段名
*/
@Override
public String getTenantIdColumn() {
return tenantProperties.getColumn(); // 从配置文件获取
}
/**
* 这是一个总开关。只有当用户登录并明确选择了某个租户后,才允许拦截器进行SQL处理。
* 否则,拦截器应忽略所有表。
* @param tableName 表名
* @return 是否忽略。true = 忽略,false = 不忽略(进行处理)
*/
@Override
public boolean ignoreTable(String tableName) {
// 如果用户还未选择租户,则忽略所有表
Long currentTenantId = TenantUtils.getCurrentTenantId();
if (currentTenantId == null) {
log.debug("No tenant selected, ignoring all tables.");
return true;
}
// 如果用户已选择租户,则根据配置文件中的 ignore-tables 列表来判断
Set<String> ignoreTables = tenantProperties.getIgnoreTables();
boolean shouldIgnore = ignoreTables != null && ignoreTables.stream()
.anyMatch(item -> item.equalsIgnoreCase(tableName.trim()));
if (shouldIgnore) {
log.debug("Table [{}] is in ignore-tables list, skipping.", tableName);
} else {
log.debug("Table [{}] is not in ignore-tables list, applying tenant filter.", tableName);
}
return shouldIgnore;
}
}
3、TenantProperties.java:从配置文件加载多租户配置,包括是否启用、租户列名(tenant_id)和忽略的表。
package com.ruoyi.framework.config.properties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;
@Component
@ConfigurationProperties(prefix = "tenant")
public class TenantProperties {
/**
* 是否开启多租户功能
*/
private boolean enable = true; // 默认开启,可以在YML中覆盖
/**
* 多租户 ID 的数据库字段名
*/
private String column = "tenant_id"; // 默认列名为 tenant_id
/**
* 需要忽略多租户处理的表名集合 (在配置文件中用逗号分隔)
*/
private Set<String> ignoreTables = new HashSet<>();
// --- Getters and Setters ---
public boolean isEnable() {
return enable;
}
public void setEnable(boolean enable) {
this.enable = enable;
}
public String getColumn() {
return column;
}
public void setColumn(String column) {
this.column = column;
}
public Set<String> getIgnoreTables() {
return ignoreTables;
}
// 这个setter允许YML中用逗号分隔的字符串配置 ignoreTables
public void setIgnoreTables(String ignoreTablesStr) {
if (ignoreTablesStr != null && !ignoreTablesStr.isEmpty()) {
this.ignoreTables = Set.of(ignoreTablesStr.toLowerCase().split(","))
.stream()
.map(String::trim)
.collect(Collectors.toSet());
} else {
this.ignoreTables = new HashSet<>();
}
}
}
整个拦截流程
请求进入SysProjectController后,最终调用projectMapper.selectProjectList(...)。MyBatis-Plus的TenantLineInnerInterceptor会拦截该调用,通过MyTenantLineHandler获取当前租户ID和列名,然后检查sys_project表是否需要过滤。如果需要,拦截器会自动在SQL查询的WHERE子句中添加AND tenant_id = ?,从而确保只返回当前租户的数据。 这个"拦截"行为不是在业务代码(如 Controller 或 Service)中能直接看到的,它是通过 Spring Boot 的自动配置 和 MyBatis 的插件机制 在底层实现的。具体如下:
-
**注册拦截器 (应用启动时):**在 ruoyi-framework 模块中看到的 MybatisPlusConfig.java 文件是这一切的起点。这是一个配置类。interceptor.addInnerInterceptor(tenantLineInnerInterceptor)是"连接点"!这行代码明确地将配置好的 TenantLineInnerInterceptor 注册 到了 MyBatis-Plus 的拦截器链中。
当 Spring Boot 应用启动时,它会扫描到这个配置,执行 mybatisPlusInterceptor() 方法,创建一个包含了租户拦截器的 MybatisPlusInterceptor Bean。
-
MyBatis 插件工作原理:
MyBatis 自身设计了一套插件(Interceptor)机制,它允许在 SQL 执行过程的某些关键节点进行干预。这些节点包括:Executor: SQL 执行器、StatementHandler: SQL 语法构建处理器、ParameterHandler: 参数处理器、ResultSetHandler: 结果集处理器。TenantLineInnerInterceptor 正是利用了这套机制,它主要拦截的是 StatementHandler。
-
**SQL 执行时的拦截过程 (请求处理时) :**前端应用发送一个请求;经过Controller -> Service -> Mapper;MyBatis 执行;进入拦截点: 在 MyBatis 将 XML 中的 SQL (select ... from sys_project ...) 转换成最终可以在数据库执行的 PreparedStatement 之前,它会触发 StatementHandler 的处理流程;TenantLineInnerInterceptor 生效: 因为我们在启动时已经注册了 TenantLineInnerInterceptor,此时它就会被激活。拦截器拿到原始的 SQL 语句。它调用我们提供的 MyTenantLineHandler,获取到当前的 tenantId (例如 123) 和租户列名 (tenant_id)。它会智能地分析原始 SQL,找到 FROM sys_project 这部分,并自动在 WHERE 条件中(或者新建一个 WHERE 条件)补充上租户过滤。
总结
拦截的动作发生在 MyBatis 执行 SQL 的生命周期内部,而不是在业务代码层面。MybatisPlusConfig.java 中的配置,就像是给 MyBatis 的执行引擎装上了一个"插件"或"mod"。一旦装上,它就会对所有经过的 SQL "自动审查和加工",无需在每次调用 Mapper 时手动干预。
这种设计的好处是 透明和无侵入:业务开发人员只需要关注业务逻辑,而不需要关心多租户的过滤细节,框架会自动保证数据安全,大大减少了编码工作量和出错的可能性。