JAVA后端开发——多租户

数据隔离是多租户系统中的核心概念,确保一个租户(在这个系统中可能是一个公司或一个独立的客户)的数据对其他租户是不可见的。在 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 的插件机制 在底层实现的。具体如下:

  1. **注册拦截器 (应用启动时):**在 ruoyi-framework 模块中看到的 MybatisPlusConfig.java 文件是这一切的起点。这是一个配置类。interceptor.addInnerInterceptor(tenantLineInnerInterceptor)是"连接点"!这行代码明确地将配置好的 TenantLineInnerInterceptor 注册 到了 MyBatis-Plus 的拦截器链中。

    当 Spring Boot 应用启动时,它会扫描到这个配置,执行 mybatisPlusInterceptor() 方法,创建一个包含了租户拦截器的 MybatisPlusInterceptor Bean。

  2. MyBatis 插件工作原理:

    MyBatis 自身设计了一套插件(Interceptor)机制,它允许在 SQL 执行过程的某些关键节点进行干预。这些节点包括:Executor: SQL 执行器、StatementHandler: SQL 语法构建处理器、ParameterHandler: 参数处理器、ResultSetHandler: 结果集处理器。TenantLineInnerInterceptor 正是利用了这套机制,它主要拦截的是 StatementHandler。

  3. **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 时手动干预。

这种设计的好处是 透明和无侵入:业务开发人员只需要关注业务逻辑,而不需要关心多租户的过滤细节,框架会自动保证数据安全,大大减少了编码工作量和出错的可能性。

相关推荐
快乐肚皮1 分钟前
Spring Framework 6:核心升级特性
java·spring
南瓜胖胖2 分钟前
【R语言编程——数据调用】
开发语言·r语言
henreash6 分钟前
C# dll版本冲突解决方案
开发语言·c#
&岁月不待人&15 分钟前
实现弹窗随键盘上移居中
java·kotlin
残*影21 分钟前
Spring Bean的初始化过程是怎么样的?
java·后端·spring
黎䪽圓27 分钟前
【Java多线程从青铜到王者】单例设计模式(八)
java·开发语言·设计模式
Java技术小馆28 分钟前
面试被问 Java为什么有这么多O
java·后端·面试
崔lc1 小时前
Springboot项目集成Ai模型(阿里云百炼-DeepSeek)
java·spring boot·后端·ai
摸鱼仙人~1 小时前
Redux Toolkit 快速入门指南:createSlice、configureStore、useSelector、useDispatch 全面解析
开发语言·javascript·ecmascript