当前位置: 首页 > article >正文

java每日精进 3.11 【多租户】

1.多租户概念

1. 多租户是什么?

多租户,简单来说是指一个业务系统,可以为多个组织服务,并且组织之间的数据是隔离的。

例如说,在服务上部署了一个MyTanant系统,可以支持多个不同的公司使用。这里的一个公司就是一个租户,每个用户必然属于某个租户。因此,用户也只能看见自己租户下面的内容,其它租户的内容对他是不可见的。

2. 数据隔离方案

多租户的数据隔离方案,可以分成分成三种:

  1. DATASOURCE 模式:独立数据库
  2. SCHEMA(表隔离) 模式:共享数据库,独立 Schema
  3. COLUMN(行隔离) 模式:共享数据库,共享 Schema,共享数据表

2.1 DATASOURCE 模式

一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本也高。

  • 优点:为不同的租户提供独立的数据库,有助于简化数据模型的扩展设计,满足不同租户的独特需求;如果出现故障,恢复数据比较简单。
  • 缺点:增大了数据库的安装数量,随之带来维护成本和购置成本的增加。

2.2 SCHEMA(表隔离) 模式

多个或所有租户共享数据库,但一个租户一个表。

  • 优点:为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离;每个数据库可以支持更多的租户数量。
  • 缺点:如果出现故障,数据恢复比较困难,因为恢复数据库将牵扯到其他租户的数据; 如果需要跨租户统计数据,存在一定困难。

2.3 COLUMN(行隔离) 模式

共享数据库,共享数据架构。租户共享同一个数据库、同一个表,但在表中通过 tenant_id 字段区分租户的数据。这是共享程度最高、隔离级别最低的模式。

  • 优点:维护和购置成本最低,允许每个数据库支持的租户数量最多。
  • 缺点:隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量;数据备份和恢复最困难,需要逐表逐条备份和还原。
  • 一般情况下,可以考虑采用 COLUMN 模式,开发、运维简单,以最少的服务器为最多的租户提供服务。
  • 租户规模比较大,或者一些租户对安全性要求较高,可以考虑采用 DATASOURCE 模式,当然它也相对复杂的多。
  • 不推荐采用 SCHEMA 模式,因为它的优点并不明显,而且它的缺点也很明显,同时对复杂 SQL 支持一般。

2.简单多租户实现

2.1依赖导入

<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.example</groupId>
    <artifactId>RabbitMq_Consumer</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>war</packaging>

    <name>RabbitMq_Consumer Maven Webapp</name>
    <!-- FIXME change it to the project's website -->
    <url>http://www.example.com</url>

    <!--SpringBoot依赖-->
    <parent>
        <groupId> org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.6.13</version>
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
    </properties>

    <dependencies>
        <!-- Spring Boot Starter -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- Spring Data JPA -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <!-- MySQL 驱动 -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <!-- HikariCP 连接池 -->
        <dependency>
            <groupId>com.zaxxer</groupId>
            <artifactId>HikariCP</artifactId>
        </dependency>
        <!-- Spring AOP -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>

</project>

2.2 application.yml

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/multi_tenant_db?useSSL=false&serverTimezone=UTC
    username: root
    password: 123456
    hikari:
      maximum-pool-size: 10
      minimum-idle: 2
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true

2.1.1 spring.datasource 部分

这一部分配置了 Spring Boot 数据库连接的相关信息。

  • url: jdbc:mysql://localhost:3306/multi_tenant_db?useSSL=false&serverTimezone=UTCmulti_tenant_db:数据库名称,表明连接到名为 multi_tenant_db 的数据库。

    • useSSL=false:禁用 SSL 连接。
    • serverTimezone=UTC:设置服务器的时区为 UTC,确保在不同时区中操作时的时间一致性。
  • username: root

    • 指定连接 MySQL 数据库时使用的用户名。这里使用的是 MySQL 默认的管理员用户 root
  • password: 123456

    • 指定连接 MySQL 数据库时使用的密码。
  • hikari:

    • 这部分是 HikariCP 连接池的配置,用于管理数据库连接池。

    • maximum-pool-size: 10

      • 设置连接池中的最大连接数为 10。这意味着最多允许 10 个数据库连接同时存在。
    • minimum-idle: 2

      • 设置连接池中最小的空闲连接数为 2。如果连接池中的空闲连接少于 2,HikariCP 会创建新的连接来满足最小连接数要求。

2.1.2 spring.jpa 部分

这一部分配置了与 JPA 相关的属性,JPA 用于在 Spring Boot 应用中执行数据库操作(例如实体类的持久化)。

  • hibernate.ddl-auto: update

    • 这个设置控制 Hibernate 的数据库模式自动更新行为。update 表示每次应用启动时,Hibernate 会根据实体类的变化自动更新数据库模式(表结构)。如果数据库表与实体类不一致,Hibernate 会尝试调整数据库结构以匹配实体类。这对于开发阶段很有用,但生产环境中通常会设置为 none 或 validate
  • show-sql: true

    • 设置为 true 时,Spring Boot 会将 SQL 语句打印到控制台,便于开发者查看实际执行的 SQL 语句,帮助调试和分析查询。

总结

  • 这段配置连接到本地的 MySQL 数据库 multi_tenant_db
  • 使用 HikariCP 连接池进行数据库连接管理,最大连接数为 10,最小空闲连接数为 2。
  • 配置了 Hibernate 自动更新数据库结构,并在控制台显示 SQL 语句。、

3.复杂多租户实现

透明化的多租户能力,针对 Web、Security、DB、Redis、AOP、Job、MQ、Async 等多个层面进行封装。

3.1 创建新租户,以及其角色和对应的管理员

@PostMapping("/create")
    @Operation(summary = "创建租户")
    @PreAuthorize("@ss.hasPermission('system:tenant:create')")
    public CommonResult<Long> createTenant(@Valid @RequestBody TenantSaveReqVO createReqVO) {
        return success(tenantService.createTenant(createReqVO));
    }

创建租户以及其对应的角色和管理员用户

@Override
    @DSTransactional // 多数据源,使用 @DSTransactional 保证本地事务,以及数据源的切换
    public Long createTenant(TenantSaveReqVO createReqVO) {
        // 校验租户名称是否重复
        validTenantNameDuplicate(createReqVO.getName(), null);
        // 校验租户域名是否重复
        validTenantWebsiteDuplicate(createReqVO.getWebsite(), null);
        // 校验套餐被禁用
        TenantPackageDO tenantPackage = tenantPackageService.validTenantPackage(createReqVO.getPackageId());

        // 创建租户
        TenantDO tenant = BeanUtils.toBean(createReqVO, TenantDO.class);
        tenantMapper.insert(tenant);
        // 创建租户的管理员
        TenantUtils.execute(tenant.getId(), () -> {
            // 创建角色
            Long roleId = createRole(tenantPackage);
            // 创建用户,并分配角色
            Long userId = createUser(roleId, createReqVO);
            // 修改租户的管理员
            tenantMapper.updateById(new TenantDO().setId(tenant.getId()).setContactUserId(userId));
        });
        return tenant.getId();
    }

创建角色

@Override
    @Transactional(rollbackFor = Exception.class)
    @LogRecord(type = SYSTEM_ROLE_TYPE, subType = SYSTEM_ROLE_CREATE_SUB_TYPE, bizNo = "{{#role.id}}",
            success = SYSTEM_ROLE_CREATE_SUCCESS)
    public Long createRole(RoleSaveReqVO createReqVO, Integer type) {
        // 1. 校验角色
        validateRoleDuplicate(createReqVO.getName(), createReqVO.getCode(), null);

        // 2. 插入到数据库
        RoleDO role = BeanUtils.toBean(createReqVO, RoleDO.class)
                .setType(ObjectUtil.defaultIfNull(type, RoleTypeEnum.CUSTOM.getType()))
                .setStatus(ObjUtil.defaultIfNull(createReqVO.getStatus(), CommonStatusEnum.ENABLE.getStatus()))
                .setDataScope(DataScopeEnum.ALL.getScope()); // 默认可查看所有数据。原因是,可能一些项目不需要项目权限
        roleMapper.insert(role);

        // 3. 记录操作日志上下文
        LogRecordContext.putVariable("role", role);
        return role.getId();
    }

创建用户

@Override
    @Transactional(rollbackFor = Exception.class)
    @LogRecord(type = SYSTEM_USER_TYPE, subType = SYSTEM_USER_CREATE_SUB_TYPE, bizNo = "{{#user.id}}",
            success = SYSTEM_USER_CREATE_SUCCESS)
    public Long createUser(UserSaveReqVO createReqVO) {
        // 1.1 校验账户配合
        tenantService.handleTenantInfo(tenant -> {
            long count = userMapper.selectCount();
            if (count >= tenant.getAccountCount()) {
                throw exception(USER_COUNT_MAX, tenant.getAccountCount());
            }
        });
        // 1.2 校验正确性
        validateUserForCreateOrUpdate(null, createReqVO.getUsername(),
                createReqVO.getMobile(), createReqVO.getEmail(), createReqVO.getDeptId(), createReqVO.getPostIds());
        // 2.1 插入用户
        AdminUserDO user = BeanUtils.toBean(createReqVO, AdminUserDO.class);
        user.setStatus(CommonStatusEnum.ENABLE.getStatus()); // 默认开启
        user.setPassword(encodePassword(createReqVO.getPassword())); // 加密密码
        userMapper.insert(user);
        // 2.2 插入关联岗位
        if (CollectionUtil.isNotEmpty(user.getPostIds())) {
            userPostMapper.insertBatch(convertList(user.getPostIds(),
                    postId -> new UserPostDO().setUserId(user.getId()).setPostId(postId)));
        }

        // 3. 记录操作日志上下文
        LogRecordContext.putVariable("user", user);
        return user.getId();
    }

分配角色

// ========== 用户-角色的相关方法  ==========

    @Override
    @DSTransactional // 多数据源,使用 @DSTransactional 保证本地事务,以及数据源的切换
    @CacheEvict(value = RedisKeyConstants.USER_ROLE_ID_LIST, key = "#userId")
    public void assignUserRole(Long userId, Set<Long> roleIds) {
        // 获得角色拥有角色编号
        Set<Long> dbRoleIds = convertSet(userRoleMapper.selectListByUserId(userId),
                UserRoleDO::getRoleId);
        // 计算新增和删除的角色编号
        Set<Long> roleIdList = CollUtil.emptyIfNull(roleIds);
        Collection<Long> createRoleIds = CollUtil.subtract(roleIdList, dbRoleIds);
        Collection<Long> deleteMenuIds = CollUtil.subtract(dbRoleIds, roleIdList);
        // 执行新增和删除。对于已经授权的角色,不用做任何处理
        if (!CollectionUtil.isEmpty(createRoleIds)) {
            userRoleMapper.insertBatch(CollectionUtils.convertList(createRoleIds, roleId -> {
                UserRoleDO entity = new UserRoleDO();
                entity.setUserId(userId);
                entity.setRoleId(roleId);
                return entity;
            }));
        }
        if (!CollectionUtil.isEmpty(deleteMenuIds)) {
            userRoleMapper.deleteListByUserIdAndRoleIdIds(userId, deleteMenuIds);
        }
    }

3.2 租户上下文

TenantContextHolder是租户上下文,通过 ThreadLocal 实现租户编号的共享与传递。

通过调用 TenantContextHolder 的 #getTenantId() 静态方法,获得当前的租户编号。绝绝绝大多数情况下,并不需要。

/**
 * 多租户上下文 Holder
 *
 * @author 芋道源码
 */
public class TenantContextHolder {

    /**
     * 当前租户编号
     */
    private static final ThreadLocal<Long> TENANT_ID = new TransmittableThreadLocal<>();

    /**
     * 是否忽略租户
     */
    private static final ThreadLocal<Boolean> IGNORE = new TransmittableThreadLocal<>();

    /**
     * 获得租户编号
     *
     * @return 租户编号
     */
    public static Long getTenantId() {
        return TENANT_ID.get();
    }

    /**
     * 获得租户编号。如果不存在,则抛出 NullPointerException 异常
     *
     * @return 租户编号
     */
    public static Long getRequiredTenantId() {
        Long tenantId = getTenantId();
        if (tenantId == null) {
            throw new NullPointerException("TenantContextHolder 不存在租户编号!可参考文档:"
                + DocumentEnum.TENANT.getUrl());
        }
        return tenantId;
    }

    public static void setTenantId(Long tenantId) {
        TENANT_ID.set(tenantId);
    }

    public static void setIgnore(Boolean ignore) {
        IGNORE.set(ignore);
    }

    /**
     * 当前是否忽略租户
     *
     * @return 是否忽略
     */
    public static boolean isIgnore() {
        return Boolean.TRUE.equals(IGNORE.get());
    }

    public static void clear() {
        TENANT_ID.remove();
        IGNORE.remove();
    }

}

3.3 Web层

默认情况下,前端的每个请求 Header 必须带上 tenant-id,值为租户编号,即 system_tenant 表的主键编号;

如果不带该请求头,会报“租户的请求未传递,请进行排查”错误提示。

/**
 * 多租户 Context Web 过滤器
 * 将请求 Header 中的 tenant-id 解析出来,添加到 {@link TenantContextHolder} 中,这样后续的 DB 等操作,可以获得到租户编号。
 *
 * @author 芋道源码
 */
public class TenantContextWebFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        // 设置
        Long tenantId = WebFrameworkUtils.getTenantId(request);
        if (tenantId != null) {
            TenantContextHolder.setTenantId(tenantId);
        }
        try {
            chain.doFilter(request, response);
        } finally {
            // 清理
            TenantContextHolder.clear();
        }
    }

}
  1. 客户端发送HTTP请求,请求头中包含租户ID(例如 X-Tenant-Id: 123)。

  2. TenantContextWebFilter 拦截请求,提取租户ID并存储到 TenantContextHolder 中。

  3. 请求进入业务逻辑层,业务代码可以通过 TenantContextHolder.getTenantId() 获取当前租户ID。

  4. 请求处理完成后,过滤器清理 TenantContextHolder 中的租户ID。

3.4 Security层

/**
 * 多租户 Security Web 过滤器
 * 1. 如果是登陆的用户,校验是否有权限访问该租户,避免越权问题。
 * 2. 如果请求未带租户的编号,检查是否是忽略的 URL,否则也不允许访问。
 * 3. 校验租户是合法,例如说被禁用、到期
 *
 * @author 芋道源码
 */
@Slf4j
public class TenantSecurityWebFilter extends ApiRequestFilter {

    private final TenantProperties tenantProperties;

    private final AntPathMatcher pathMatcher;

    private final GlobalExceptionHandler globalExceptionHandler;
    private final TenantFrameworkService tenantFrameworkService;

    public TenantSecurityWebFilter(TenantProperties tenantProperties,
                                   WebProperties webProperties,
                                   GlobalExceptionHandler globalExceptionHandler,
                                   TenantFrameworkService tenantFrameworkService) {
        super(webProperties);
        this.tenantProperties = tenantProperties;
        this.pathMatcher = new AntPathMatcher();
        this.globalExceptionHandler = globalExceptionHandler;
        this.tenantFrameworkService = tenantFrameworkService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        Long tenantId = TenantContextHolder.getTenantId();
        // 1. 登陆的用户,校验是否有权限访问该租户,避免越权问题。
        LoginUser user = SecurityFrameworkUtils.getLoginUser();
        if (user != null) {
            // 如果获取不到租户编号,则尝试使用登陆用户的租户编号
            if (tenantId == null) {
                tenantId = user.getTenantId();
                TenantContextHolder.setTenantId(tenantId);
            // 如果传递了租户编号,则进行比对租户编号,避免越权问题
            } else if (!Objects.equals(user.getTenantId(), TenantContextHolder.getTenantId())) {
                log.error("[doFilterInternal][租户({}) User({}/{}) 越权访问租户({}) URL({}/{})]",
                        user.getTenantId(), user.getId(), user.getUserType(),
                        TenantContextHolder.getTenantId(), request.getRequestURI(), request.getMethod());
                ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.FORBIDDEN.getCode(),
                        "您无权访问该租户的数据"));
                return;
            }
        }

        // 如果非允许忽略租户的 URL,则校验租户是否合法
        if (!isIgnoreUrl(request)) {
            // 2. 如果请求未带租户的编号,不允许访问。
            if (tenantId == null) {
                log.error("[doFilterInternal][URL({}/{}) 未传递租户编号]", request.getRequestURI(), request.getMethod());
                ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.BAD_REQUEST.getCode(),
                        "请求的租户标识未传递,请进行排查"));
                return;
            }
            // 3. 校验租户是合法,例如说被禁用、到期
            try {
                tenantFrameworkService.validTenant(tenantId);
            } catch (Throwable ex) {
                CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex);
                ServletUtils.writeJSON(response, result);
                return;
            }
        } else { // 如果是允许忽略租户的 URL,若未传递租户编号,则默认忽略租户编号,避免报错
            if (tenantId == null) {
                TenantContextHolder.setIgnore(true);
            }
        }

        // 继续过滤
        chain.doFilter(request, response);
    }

    private boolean isIgnoreUrl(HttpServletRequest request) {
        // 快速匹配,保证性能
        if (CollUtil.contains(tenantProperties.getIgnoreUrls(), request.getRequestURI())) {
            return true;
        }
        // 逐个 Ant 路径匹配
        for (String url : tenantProperties.getIgnoreUrls()) {
            if (pathMatcher.match(url, request.getRequestURI())) {
                return true;
            }
        }
        return false;
    }

}
  • tenantProperties:用于获取租户相关的配置,例如忽略的 URL 列表。

  • pathMatcher:用于匹配 URL 路径,支持 Ant 风格的路径匹配。

  • globalExceptionHandler:全局异常处理器,用于处理校验过程中抛出的异常。

  • tenantFrameworkService:租户服务,用于校验租户的合法性(如是否被禁用、是否到期等)。

  • 构造函数接收四个参数,分别是租户配置、Web 配置、全局异常处理器和租户服务。

  • 调用父类 ApiRequestFilter 的构造函数,并初始化本类的成员变量。

  • 如果用户已登录(user != null),则进行租户权限校验:

    • 如果请求中没有传递租户编号,则使用登录用户的租户编号。

    • 如果请求中传递了租户编号,则校验该租户编号是否与登录用户的租户编号一致。如果不一致,记录日志并返回 403 错误(无权访问)。

    • 如果请求的 URL 不在忽略列表中(!isIgnoreUrl(request)),则进行以下校验:

      • 如果请求中没有传递租户编号,记录日志并返回 400 错误(请求参数错误)。

      • 调用 tenantFrameworkService.validTenant(tenantId) 校验租户的合法性(如是否被禁用、是否到期等)。如果校验失败,调用全局异常处理器处理异常并返回错误信息。

    • 如果请求的 URL 在忽略列表中,且未传递租户编号,则设置忽略租户编号(TenantContextHolder.setIgnore(true))。

    • 如果所有校验都通过,则继续执行过滤链中的下一个过滤器。

    • isIgnoreUrl 方法用于判断当前请求的 URL 是否在忽略列表中。

    • 首先使用 CollUtil.contains 快速匹配,如果匹配成功则返回 true

    • 如果快速匹配失败,则逐个使用 AntPathMatcher 进行路径匹配。如果匹配成功,返回 true,否则返回 false

3.4.1租户配置
@ConfigurationProperties(prefix = "yudao.tenant")
@Data
public class TenantProperties {

    /**
     * 租户是否开启
     */
    private static final Boolean ENABLE_DEFAULT = true;

    /**
     * 是否开启
     */
    private Boolean enable = ENABLE_DEFAULT;

    /**
     * 需要忽略多租户的请求
     *
     * 默认情况下,每个请求需要带上 tenant-id 的请求头。但是,部分请求是无需带上的,例如说短信回调、支付回调等 Open API!
     */
    private Set<String> ignoreUrls = Collections.emptySet();

    /**
     * 需要忽略多租户的表
     *
     * 即默认所有表都开启多租户的功能,所以记得添加对应的 tenant_id 字段哟
     */
    private Set<String> ignoreTables = Collections.emptySet();

    /**
     * 需要忽略多租户的 Spring Cache 缓存
     *
     * 即默认所有缓存都开启多租户的功能,所以记得添加对应的 tenant_id 字段哟
     */
    private Set<String> ignoreCaches = Collections.emptySet();

}
3.4.2 Web 配置
@ConfigurationProperties(prefix = "yudao.web")
@Validated
@Data
public class WebProperties {

    @NotNull(message = "APP API 不能为空")
    private Api appApi = new Api("/app-api", "**.controller.app.**");
    @NotNull(message = "Admin API 不能为空")
    private Api adminApi = new Api("/admin-api", "**.controller.admin.**");

    @NotNull(message = "Admin UI 不能为空")
    private Ui adminUi;

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    @Valid
    public static class Api {

        /**
         * API 前缀,实现所有 Controller 提供的 RESTFul API 的统一前缀
         *
         *
         * 意义:通过该前缀,避免 Swagger、Actuator 意外通过 Nginx 暴露出来给外部,带来安全性问题
         *      这样,Nginx 只需要配置转发到 /api/* 的所有接口即可。
         *
         * @see YudaoWebAutoConfiguration#configurePathMatch(PathMatchConfigurer)
         */
        @NotEmpty(message = "API 前缀不能为空")
        private String prefix;

        /**
         * Controller 所在包的 Ant 路径规则
         *
         * 主要目的是,给该 Controller 设置指定的 {@link #prefix}
         */
        @NotEmpty(message = "Controller 所在包不能为空")
        private String controller;

    }

    @Data
    @Valid
    public static class Ui {

        /**
         * 访问地址
         */
        private String url;

    }

}
3.4.3 全局异常处理器
GlobalExceptionHandler
/**
     * 处理所有异常,主要是提供给 Filter 使用
     * 因为 Filter 不走 SpringMVC 的流程,但是我们又需要兜底处理异常,所以这里提供一个全量的异常处理过程,保持逻辑统一。
     *
     * @param request 请求
     * @param ex 异常
     * @return 通用返回
     */
    public CommonResult<?> allExceptionHandler(HttpServletRequest request, Throwable ex) {
        if (ex instanceof MissingServletRequestParameterException) {
            return missingServletRequestParameterExceptionHandler((MissingServletRequestParameterException) ex);
        }
        if (ex instanceof MethodArgumentTypeMismatchException) {
            return methodArgumentTypeMismatchExceptionHandler((MethodArgumentTypeMismatchException) ex);
        }
        if (ex instanceof MethodArgumentNotValidException) {
            return methodArgumentNotValidExceptionExceptionHandler((MethodArgumentNotValidException) ex);
        }
        if (ex instanceof BindException) {
            return bindExceptionHandler((BindException) ex);
        }
        if (ex instanceof ConstraintViolationException) {
            return constraintViolationExceptionHandler((ConstraintViolationException) ex);
        }
        if (ex instanceof ValidationException) {
            return validationException((ValidationException) ex);
        }
        if (ex instanceof NoHandlerFoundException) {
            return noHandlerFoundExceptionHandler((NoHandlerFoundException) ex);
        }
//        if (ex instanceof NoResourceFoundException) {
//            return noResourceFoundExceptionHandler(request, (NoResourceFoundException) ex);
//        }
        if (ex instanceof HttpRequestMethodNotSupportedException) {
            return httpRequestMethodNotSupportedExceptionHandler((HttpRequestMethodNotSupportedException) ex);
        }
        if (ex instanceof ServiceException) {
            return serviceExceptionHandler((ServiceException) ex);
        }
        if (ex instanceof AccessDeniedException) {
            return accessDeniedExceptionHandler(request, (AccessDeniedException) ex);
        }
        return defaultExceptionHandler(request, ex);
    }

上述注释说明了该方法的作用是为 Filter 提供统一的异常处理,由于 Filter 不遵循 SpringMVC 的异常处理流程,所以此方法用于兜底处理各种异常。

方法接收HttpServletRequest类型的request参数,代表 HTTP 请求;Throwable类型的ex参数,代表捕获到的异常对象。返回值类型为CommonResult<?>,是一个通用的返回结果类。

异常类型判断及处理

if (ex instanceof MissingServletRequestParameterException) {
    return missingServletRequestParameterExceptionHandler((MissingServletRequestParameterException) ex);
}
/**
     * 处理 SpringMVC 请求参数缺失
     *
     * 例如说,接口上设置了 @RequestParam("xx") 参数,结果并未传递 xx 参数
     */
    @ExceptionHandler(value = MissingServletRequestParameterException.class)
    public CommonResult<?> missingServletRequestParameterExceptionHandler(MissingServletRequestParameterException ex) {
        log.warn("[missingServletRequestParameterExceptionHandler]", ex);
        return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数缺失:%s", ex.getParameterName()));
    }

当捕获到的异常是MissingServletRequestParameterException(请求参数缺失异常)时,调用missingServletRequestParameterExceptionHandler方法处理该异常,并返回处理结果。

if (ex instanceof MethodArgumentTypeMismatchException) {
    return methodArgumentTypeMismatchExceptionHandler((MethodArgumentTypeMismatchException) ex);
}
/**
     * 处理 SpringMVC 请求参数类型错误
     *
     * 例如说,接口上设置了 @RequestParam("xx") 参数为 Integer,结果传递 xx 参数类型为 String
     */
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public CommonResult<?> methodArgumentTypeMismatchExceptionHandler(MethodArgumentTypeMismatchException ex) {
        log.warn("[methodArgumentTypeMismatchExceptionHandler]", ex);
        return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数类型错误:%s", ex.getMessage()));
    }

若异常为MethodArgumentTypeMismatchException(方法参数类型不匹配异常),则调用methodArgumentTypeMismatchExceptionHandler方法处理并返回结果。

if (ex instanceof MethodArgumentNotValidException) {
    return methodArgumentNotValidExceptionExceptionHandler((MethodArgumentNotValidException) ex);
}
/**
     * 处理 SpringMVC 参数校验不正确
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public CommonResult<?> methodArgumentNotValidExceptionExceptionHandler(MethodArgumentNotValidException ex) {
        log.warn("[methodArgumentNotValidExceptionExceptionHandler]", ex);
        FieldError fieldError = ex.getBindingResult().getFieldError();
        assert fieldError != null; // 断言,避免告警
        return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数不正确:%s", fieldError.getDefaultMessage()));
    }

当遇到MethodArgumentNotValidException(方法参数验证不通过异常),调用methodArgumentNotValidExceptionExceptionHandler方法处理。

if (ex instanceof BindException) {
    return bindExceptionHandler((BindException) ex);
}
/**
     * 处理 SpringMVC 参数绑定不正确,本质上也是通过 Validator 校验
     */
    @ExceptionHandler(BindException.class)
    public CommonResult<?> bindExceptionHandler(BindException ex) {
        log.warn("[handleBindException]", ex);
        FieldError fieldError = ex.getFieldError();
        assert fieldError != null; // 断言,避免告警
        return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数不正确:%s", fieldError.getDefaultMessage()));
    }

若为BindException(数据绑定异常),则通过bindExceptionHandler方法处理。

if (ex instanceof ConstraintViolationException) {
    return constraintViolationExceptionHandler((ConstraintViolationException) ex);
}
/**
     * 处理 Validator 校验不通过产生的异常
     */
    @ExceptionHandler(value = ConstraintViolationException.class)
    public CommonResult<?> constraintViolationExceptionHandler(ConstraintViolationException ex) {
        log.warn("[constraintViolationExceptionHandler]", ex);
        ConstraintViolation<?> constraintViolation = ex.getConstraintViolations().iterator().next();
        return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数不正确:%s", constraintViolation.getMessage()));
    }

对于ConstraintViolationException(约束违反异常),调用

constraintViolationExceptionHandler方法处理。

if (ex instanceof ValidationException) {
    return validationException((ValidationException) ex);
}
/**
     * 处理 Dubbo Consumer 本地参数校验时,抛出的 ValidationException 异常
     */
    @ExceptionHandler(value = ValidationException.class)
    public CommonResult<?> validationException(ValidationException ex) {
        log.warn("[constraintViolationExceptionHandler]", ex);
        // 无法拼接明细的错误信息,因为 Dubbo Consumer 抛出 ValidationException 异常时,是直接的字符串信息,且人类不可读
        return CommonResult.error(BAD_REQUEST);
    }

遇到ValidationException(验证异常),调用validationException方法处理。

if (ex instanceof NoHandlerFoundException) {
    return noHandlerFoundExceptionHandler((NoHandlerFoundException) ex);
}
/**
     * 处理 SpringMVC 请求地址不存在
     *
     * 注意,它需要设置如下两个配置项:
     * 1. spring.mvc.throw-exception-if-no-handler-found 为 true
     * 2. spring.mvc.static-path-pattern 为 /statics/**
     */
    @ExceptionHandler(NoHandlerFoundException.class)
    public CommonResult<?> noHandlerFoundExceptionHandler(NoHandlerFoundException ex) {
        log.warn("[noHandlerFoundExceptionHandler]", ex);
        return CommonResult.error(NOT_FOUND.getCode(), String.format("请求地址不存在:%s", ex.getRequestURL()));
    }

若为NoHandlerFoundException(未找到处理程序异常),则通过noHandlerFoundExceptionHandler方法处理。

//        if (ex instanceof NoResourceFoundException) {
//            return noResourceFoundExceptionHandler(request, (NoResourceFoundException) ex);
//        }

这部分代码被注释掉了,原本意图是当异常为NoResourceFoundException(未找到资源异常)时,调用noResourceFoundExceptionHandler方法处理,但目前不生效。

if (ex instanceof HttpRequestMethodNotSupportedException) {
    return httpRequestMethodNotSupportedExceptionHandler((HttpRequestMethodNotSupportedException) ex);
}

当捕获到HttpRequestMethodNotSupportedException(HTTP 请求方法不支持异常),调用httpRequestMethodNotSupportedExceptionHandler方法处理。

if (ex instanceof ServiceException) {
    return serviceExceptionHandler((ServiceException) ex);
}

若为自定的ServiceException(服务异常),则通过serviceExceptionHandler方法处理。

if (ex instanceof AccessDeniedException) {
    return accessDeniedExceptionHandler(request, (AccessDeniedException) ex);
}
/**
     * 处理 Spring Security 权限不足的异常
     *
     * 来源是,使用 @PreAuthorize 注解,AOP 进行权限拦截
     */
    @ExceptionHandler(value = AccessDeniedException.class)
    public CommonResult<?> accessDeniedExceptionHandler(HttpServletRequest req, AccessDeniedException ex) {
        log.warn("[accessDeniedExceptionHandler][userId({}) 无法访问 url({})]", WebFrameworkUtils.getLoginUserId(req),
                req.getRequestURL(), ex);
        return CommonResult.error(FORBIDDEN);
    }

遇到AccessDeniedException(访问拒绝异常),调用accessDeniedExceptionHandler方法处理。

默认异常处理

return defaultExceptionHandler(request, ex);
/**
     * 处理系统异常,兜底处理所有的一切
     */
    @ExceptionHandler(value = Exception.class)
    public CommonResult<?> defaultExceptionHandler(HttpServletRequest req, Throwable ex) {
        // 情况一:处理表不存在的异常
        CommonResult<?> tableNotExistsResult = handleTableNotExists(ex);
        if (tableNotExistsResult != null) {
            return tableNotExistsResult;
        }

        // 情况二:处理异常
        log.error("[defaultExceptionHandler]", ex);
        // 插入异常日志
        createExceptionLog(req, ex);
        // 返回 ERROR CommonResult
        return CommonResult.error(INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg());
    }

如果上述特定的异常类型都不匹配,即遇到其他未知类型的异常,则调用defaultExceptionHandler方法进行默认的异常处理,并返回结果。

整体来看,这段代码实现了一个较为全面的异常处理机制,根据不同的异常类型调用对应的处理方法,以保证在 Filter 中也能统一处理各种异常情况,并返回规范的结果。

3.4.4 租户服务
@RequiredArgsConstructor
public class TenantFrameworkServiceImpl implements TenantFrameworkService {

    private final TenantApi tenantApi;

    /**
     * 针对 {@link #getTenantIds()} 的缓存
     */
    private final LoadingCache<Object, List<Long>> getTenantIdsCache = buildAsyncReloadingCache(
            Duration.ofMinutes(1L), // 过期时间 1 分钟
            new CacheLoader<Object, List<Long>>() {

                @Override
                public List<Long> load(Object key) {
                    return tenantApi.getTenantIdList().getCheckedData();
                }

            });

    /**
     * 针对 {@link #validTenant(Long)} 的缓存
     */
    private final LoadingCache<Long, CommonResult<Boolean>> validTenantCache = buildAsyncReloadingCache(
            Duration.ofMinutes(1L), // 过期时间 1 分钟
            new CacheLoader<Long, CommonResult<Boolean>>() {

                @Override
                public CommonResult<Boolean> load(Long id) {
                    return tenantApi.validTenant(id);
                }

            });

    @Override
    @SneakyThrows
    public List<Long> getTenantIds() {
        return getTenantIdsCache.get(Boolean.TRUE);
    }

    @Override
    @SneakyThrows
    public void validTenant(Long id) {
        validTenantCache.get(id).checkError();
    }

}

查询所有的tenentids,并放入缓存,检测时即可使用;

3.5 DB 层

基于 MyBatis Plus 的多租户功能,通过拦截器(TenantDatabaseInterceptor)和基类(TenantBaseDO)来实现数据库层面的多租户数据隔离。

  1. 多租户(Multi-tenancy)

    • 多租户是一种架构模式,允许多个租户共享同一个应用程序实例,但每个租户的数据是隔离的。

    • 在数据库层面,通常通过为每个表添加 tenant_id 字段来实现数据隔离。

  2. TenantBaseDO

    • 这是一个抽象基类,用于扩展多租户功能。

    • 所有需要支持多租户的实体类可以继承此类,自动获得 tenantId 字段。

  3. TenantDatabaseInterceptor

    • 这是一个 MyBatis Plus 的拦截器,实现了 TenantLineHandler 接口。

    • 它的作用是在 SQL 查询时自动添加 tenant_id 条件,并忽略某些不需要多租户处理的表。

/**
 * 基础实体对象
 *
 * 为什么实现 {@link TransPojo} 接口?
 * 因为使用 Easy-Trans TransType.SIMPLE 模式,集成 MyBatis Plus 查询
 *
 * @author 芋道源码
 */
@Data
@JsonIgnoreProperties(value = "transMap") // 由于 Easy-Trans 会添加 transMap 属性,避免 Jackson 在 Spring Cache 反序列化报错
public abstract class BaseDO implements Serializable, TransPojo {

    /**
     * 创建时间
     */
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    /**
     * 最后更新时间
     */
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    /**
     * 创建者,目前使用 SysUser 的 id 编号
     *
     * 使用 String 类型的原因是,未来可能会存在非数值的情况,留好拓展性。
     */
    @TableField(fill = FieldFill.INSERT, jdbcType = JdbcType.VARCHAR)
    private String creator;
    /**
     * 更新者,目前使用 SysUser 的 id 编号
     *
     * 使用 String 类型的原因是,未来可能会存在非数值的情况,留好拓展性。
     */
    @TableField(fill = FieldFill.INSERT_UPDATE, jdbcType = JdbcType.VARCHAR)
    private String updater;
    /**
     * 是否删除
     */
    @TableLogic
    private Boolean deleted;

}
/**
 * 拓展多租户的 BaseDO 基类
 *
 * @author 芋道源码
 */
@Data
@EqualsAndHashCode(callSuper = true)
public abstract class TenantBaseDO extends BaseDO {

    /**
     * 多租户编号
     */
    private Long tenantId;

}
  • @Data

    • Lombok 注解,自动生成 gettersettertoStringequals 和 hashCode 方法。

  • @EqualsAndHashCode(callSuper = true)

    • Lombok 注解,表示在生成 equals 和 hashCode 方法时,会考虑父类的字段。

  • TenantBaseDO

    • 这是一个抽象基类,继承自 BaseDO(假设 BaseDO 是一个通用的数据库实体基类)。

    • 添加了 tenantId 字段,用于存储当前租户的 ID。

    • 所有需要支持多租户的实体类可以继承此类。

/**
 * 基于 MyBatis Plus 多租户的功能,实现 DB 层面的多租户的功能
 *
 * @author 芋道源码
 */
public class TenantDatabaseInterceptor implements TenantLineHandler {

    private final Set<String> ignoreTables = new HashSet<>();

    public TenantDatabaseInterceptor(TenantProperties properties) {
        // 不同 DB 下,大小写的习惯不同,所以需要都添加进去
        properties.getIgnoreTables().forEach(table -> {
            ignoreTables.add(table.toLowerCase());
            ignoreTables.add(table.toUpperCase());
        });
        // 在 OracleKeyGenerator 中,生成主键时,会查询这个表,查询这个表后,会自动拼接 TENANT_ID 导致报错
        ignoreTables.add("DUAL");
    }

    @Override
    public Expression getTenantId() {
        return new LongValue(TenantContextHolder.getRequiredTenantId());
    }

    @Override
    public boolean ignoreTable(String tableName) {
        return TenantContextHolder.isIgnore() // 情况一,全局忽略多租户
                || CollUtil.contains(ignoreTables, SqlParserUtils.removeWrapperSymbol(tableName)); // 情况二,忽略多租户的表
    }

}
  • TenantDatabaseInterceptor

    • 这是一个 MyBatis Plus 的拦截器,实现了 TenantLineHandler 接口。

    • 用于在 SQL 查询时自动添加 tenant_id 条件,并忽略某些不需要多租户处理的表。

  • ignoreTables

    • 一个 Set<String> 集合,用于存储不需要多租户处理的表名。

    • 由于不同数据库对表名的大小写处理不同,代码中将表名转换为小写和大写并存储。

  • 构造函数

    • 接收 TenantProperties 参数,用于获取配置中指定的忽略表。

    • 将忽略的表名添加到 ignoreTables 集合中。

    • 特别处理了 Oracle 数据库中的 DUAL 表,因为 MyBatis Plus 在生成主键时可能会查询此表,自动添加 tenant_id 会导致错误。

  • getTenantId

    • 实现 TenantLineHandler 接口的方法,用于获取当前租户的 ID。

    • 返回一个 Expression 对象,表示 SQL 中的租户 ID 值。

    • 通过 TenantContextHolder.getRequiredTenantId() 获取当前租户 ID,并将其封装为 LongValue

  • ignoreTable

    • 实现 TenantLineHandler 接口的方法,用于判断是否需要忽略某个表的多租户处理。

    • 返回 true 表示忽略,false 表示不忽略。

  • 忽略条件

    1. 全局忽略多租户

      • 通过 TenantContextHolder.isIgnore() 判断是否全局忽略多租户。

    2. 忽略特定表

      • 通过 CollUtil.contains(ignoreTables, SqlParserUtils.removeWrapperSymbol(tableName)) 判断表名是否在 ignoreTables 集合中。

      • SqlParserUtils.removeWrapperSymbol(tableName) 用于去除表名的符号(例如反引号 ` 或双引号 ")。

工作流程

  1. 客户端发送请求,TenantContextHolder 中存储了当前租户 ID。

  2. 执行 SQL 查询时,TenantDatabaseInterceptor 拦截 SQL 并自动添加 tenant_id = ? 条件。

  3. 如果表名在 ignoreTables 集合中,或者全局忽略多租户,则不添加 tenant_id 条件。

  4. 查询结果返回给客户端。

3.6 Redis 层

一个支持多租户的 Redis 缓存管理器TenantRedisCacheManager),它在操作指定名称的缓存时,会自动将租户 ID 拼接到缓存名称中,从而实现多租户环境下的缓存隔离。

/**
 * 多租户的 {@link RedisCacheManager} 实现类
 *
 * 操作指定 name 的 {@link Cache} 时,自动拼接租户后缀,格式为 name + ":" + tenantId + 后缀
 *
 * @author airhead
 */
@Slf4j
public class TenantRedisCacheManager extends TimeoutRedisCacheManager {

    private final Set<String> ignoreCaches;

    public TenantRedisCacheManager(RedisCacheWriter cacheWriter,
                                   RedisCacheConfiguration defaultCacheConfiguration,
                                   Set<String> ignoreCaches) {
        super(cacheWriter, defaultCacheConfiguration);
        this.ignoreCaches = ignoreCaches;
    }

    @Override
    public Cache getCache(String name) {
        // 如果开启多租户,则 name 拼接租户后缀
        if (!TenantContextHolder.isIgnore()
                && TenantContextHolder.getTenantId() != null
                && !CollUtil.contains(ignoreCaches, name)) {
            name = name + ":" + TenantContextHolder.getTenantId();
        }

        // 继续基于父方法
        return super.getCache(name);
    }

}
  • TenantRedisCacheManager

    • 继承自 TimeoutRedisCacheManager,表示这是一个支持超时的 Redis 缓存管理器。

  • ignoreCaches

    • 一个 Set<String> 集合,用于存储不需要多租户处理的缓存名称。

    • 这些缓存名称在拼接租户 ID 时会被忽略。

  • RedisCacheWriter cacheWriter:Redis 缓存写入器,用于操作 Redis。

  • RedisCacheConfiguration defaultCacheConfiguration:默认的 Redis 缓存配置。

  • Set<String> ignoreCaches:不需要多租户处理的缓存名称集合。

  • getCache

    • 重写父类方法,用于获取指定名称的缓存。

    • 如果开启了多租户功能,并且当前租户 ID 不为空,且缓存名称不在 ignoreCaches 集合中,则将租户 ID 拼接到缓存名称中。

  • 拼接租户 ID

    • 格式为 name + ":" + tenantId,例如 userCache:123,其中 123 是租户 ID。

    • 这种格式可以确保不同租户的缓存数据在 Redis 中是隔离的。

  • 调用父类方法

    • 最终调用 super.getCache(name),基于拼接后的缓存名称获取缓存对象。


关键组件
  1. TenantContextHolder

    • 一个线程上下文工具类,用于存储当前请求的租户 ID。

    • 提供 getTenantId() 方法获取租户 ID。

    • 提供 isIgnore() 方法判断是否全局忽略多租户。

  2. TimeoutRedisCacheManager

    • 一个支持超时的 Redis 缓存管理器,可能是自定义的父类。

    • 提供基础的缓存管理功能。

  3. ignoreCaches

    • 用于存储不需要多租户处理的缓存名称。

    • 例如,全局共享的缓存(如配置缓存)可以添加到 ignoreCaches 中。


工作流程
  1. 客户端发送请求,TenantContextHolder 中存储了当前租户 ID。

  2. 在获取缓存时,TenantRedisCacheManager 拦截缓存名称,并根据租户 ID 拼接缓存名称。

  3. 如果缓存名称在 ignoreCaches 集合中,或者全局忽略多租户,则不拼接租户 ID。

  4. 最终调用父类方法获取缓存对象。

3.7 AOP

1. @TenantIgnore 注解
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface TenantIgnore {
}

功能

  • 这是一个自定义注解,用于标记某个方法在执行时忽略多租户的自动过滤。

  • 主要适用于数据库(DB)场景,因为数据库的多租户过滤是通过在 SQL 中添加 tenant_id 条件实现的。

  • 对于 Redis 和 MQ 场景,多租户的实现方式不同,因此该注解的意义有限。

注解属性

  • @Target({ElementType.METHOD}):表示该注解只能用于方法上。

  • @Retention(RetentionPolicy.RUNTIME):表示该注解在运行时生效。

  • @Inherited:表示该注解可以被子类继承。

2. TenantIgnoreAspect 切面
/**
 * 忽略多租户的 Aspect,基于 {@link TenantIgnore} 注解实现,用于一些全局的逻辑。
 * 例如说,一个定时任务,读取所有数据,进行处理。
 * 又例如说,读取所有数据,进行缓存。
 */
@Aspect
@Slf4j
public class TenantIgnoreAspect {

    @Around("@annotation(tenantIgnore)")
    public Object around(ProceedingJoinPoint joinPoint, TenantIgnore tenantIgnore) throws Throwable {
        Boolean oldIgnore = TenantContextHolder.isIgnore();
        try {
            TenantContextHolder.setIgnore(true);
            // 执行逻辑
            return joinPoint.proceed();
        } finally {
            TenantContextHolder.setIgnore(oldIgnore);
        }
    }
}

功能

  • 这是一个切面类,基于 Spring AOP 实现。

  • 它的作用是拦截所有被 @TenantIgnore 注解标记的方法,在执行方法时临时关闭多租户过滤,方法执行完成后恢复原来的多租户过滤状态。

关键逻辑

  1. @Around("@annotation(tenantIgnore)")

    • 使用 @Around 注解定义环绕通知,拦截所有被 @TenantIgnore 注解标记的方法。

    • tenantIgnore 参数是 @TenantIgnore 注解的实例。

  2. 保存旧状态

    • Boolean oldIgnore = TenantContextHolder.isIgnore();:保存当前的多租户过滤状态。

  3. 设置忽略多租户

    • TenantContextHolder.setIgnore(true);:临时关闭多租户过滤。

  4. 执行目标方法

    • return joinPoint.proceed();:执行被拦截的方法。

  5. 恢复旧状态

    • TenantContextHolder.setIgnore(oldIgnore);:方法执行完成后,恢复原来的多租户过滤状态。


3. 协同工作流程
  1. 标记方法

    • 在需要忽略多租户过滤的方法上添加 @TenantIgnore 注解。例如:

      @TenantIgnore
      public void processAllData() {
          // 读取所有租户的数据并进行处理
      }
  2. 切面拦截

    • 当调用被 @TenantIgnore 注解标记的方法时,TenantIgnoreAspect 切面会拦截该方法。

  3. 临时关闭多租户过滤

    • 切面会调用 TenantContextHolder.setIgnore(true),临时关闭多租户过滤。

  4. 执行方法逻辑

    • 方法内部的数据库查询等操作不会自动添加 tenant_id 条件,从而可以访问所有租户的数据。

  5. 恢复多租户过滤

    • 方法执行完成后,切面会调用 TenantContextHolder.setIgnore(oldIgnore),恢复原来的多租户过滤状态。


4. 使用场景
  1. 定时任务

    • 某些定时任务需要读取所有租户的数据进行处理,可以使用 @TenantIgnore 注解。

  2. 缓存加载

    • 在加载全局缓存时,可能需要读取所有租户的数据,可以使用 @TenantIgnore 注解。

  3. 全局数据操作

    • 某些全局逻辑(如数据迁移、统计分析)需要访问所有租户的数据,可以使用 @TenantIgnore 注解。


5. 注意事项
  1. Redis 和 MQ 场景

    • 该注解主要用于数据库场景,对于 Redis 和 MQ 场景,多租户的实现方式不同,因此该注解的意义有限。

  2. 线程安全性

    • TenantContextHolder 是基于 ThreadLocal 实现的,确保多线程环境下不会出现状态混乱。

  3. 与 TenantUtils#executeIgnore 的一致性

    • 代码注释中提到,TenantIgnoreAspect 的实现需要与 TenantUtils#executeIgnore 保持一致,确保逻辑统一。


总结

  • @TenantIgnore 注解用于标记需要忽略多租户过滤的方法。

  • TenantIgnoreAspect 切面通过 AOP 拦截被注解标记的方法,在执行时临时关闭多租户过滤,执行完成后恢复原来的状态。

  • 这种设计可以灵活地支持全局逻辑(如定时任务、缓存加载)访问所有租户的数据,同时确保多租户隔离的核心逻辑不受影响。

其他问题:

// RoleServiceImpl.java
public class RoleServiceImpl implements RoleService {

    @Resource
    @Lazy // 注入自己,所以延迟加载
    private RoleService self;
    
    @Override
    @PostConstruct
    @TenantIgnore // 忽略自动多租户,全局初始化缓存
    public void initLocalCache() {
        // ... 从数据库中,加载角色
    }

    @Scheduled(fixedDelay = SCHEDULER_PERIOD, initialDelay = SCHEDULER_PERIOD)
    public void schedulePeriodicRefresh() {
        self.initLocalCache(); // <x> 通过 self 引用到 Spring 代理对象
    }
}
  • @TenantIgnore 注解

    • 标记 initLocalCache 方法忽略多租户过滤。

    • 这样在加载角色缓存时,可以访问所有租户的数据,而不是只加载当前租户的数据。

  • self.initLocalCache()

    • 通过 self 调用 initLocalCache 方法,确保 AOP 生效。

    • 如果直接使用 this.initLocalCache(),AOP 不会生效,因为 Spring AOP 是基于代理实现的,this 指向的是当前对象,而不是代理对象。

3.8 Job

多租户忽略功能,通过自定义注解 @TenantIgnore 和 Spring AOP 切面 TenantIgnoreAspect,可以在某些方法上临时关闭多租户的自动过滤。

/**
 * 多租户 Job 注解
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface TenantJob {
}
/**
 * 多租户 JobHandler AOP
 * 任务执行时,会按照租户逐个执行 Job 的逻辑
 * 注意,需要保证 JobHandler 的幂等性。因为 Job 因为某个租户执行失败重试时,之前执行成功的租户也会再次执行。
 */
@Aspect
@RequiredArgsConstructor
@Slf4j
public class TenantJobAspect {

    private final TenantFrameworkService tenantFrameworkService;

    @Around("@annotation(tenantJob)")
    public void around(ProceedingJoinPoint joinPoint, TenantJob tenantJob) {
        // 获得租户列表
        List<Long> tenantIds = tenantFrameworkService.getTenantIds();
        if (CollUtil.isEmpty(tenantIds)) {
            return;
        }

        // 逐个租户,执行 Job
        Map<Long, String> results = new ConcurrentHashMap<>();
        AtomicBoolean success = new AtomicBoolean(true); // 标记,是否存在失败的情况
        XxlJobContext xxlJobContext = XxlJobContext.getXxlJobContext(); // XXL-Job 上下文
        tenantIds.parallelStream().forEach(tenantId -> {
            //先通过 parallel 实现并行;1)多个租户,是一条执行日志;2)异常的情况
            TenantUtils.execute(tenantId, () -> {
                try {
                    XxlJobContext.setXxlJobContext(xxlJobContext);
                    // 执行 Job
                    Object result = joinPoint.proceed();
                    results.put(tenantId, StrUtil.toStringOrEmpty(result));
                } catch (Throwable e) {
                    results.put(tenantId, ExceptionUtil.getRootCauseMessage(e));
                    success.set(false);
                    // 打印异常
                    XxlJobHelper.log(StrUtil.format("[多租户({}) 执行任务({}),发生异常:{}]",
                            tenantId, joinPoint.getSignature(), ExceptionUtils.getStackTrace(e)));
                }
            });
        });
        // 记录执行结果
        if (success.get()) {
            XxlJobHelper.handleSuccess(JsonUtils.toJsonString(results));
        } else {
            XxlJobHelper.handleFail(JsonUtils.toJsonString(results));
        }
    }

}
TenantJob功能
  • 这是一个自定义注解,用于标记某个方法是多租户任务调度的方法。

  • 被标记的方法会被 TenantJobAspect 切面拦截,按照租户逐个执行任务逻辑。

注解属性

  • @Target({ElementType.METHOD})

    • 表示该注解只能用于方法上。

  • @Retention(RetentionPolicy.RUNTIME)

    • 表示该注解在运行时生效,可以通过反射读取。

TenantJobAspect功能
  • 这是一个 Spring AOP 切面类,用于拦截被 @TenantJob 注解标记的方法。

  • 在任务执行时,按照租户逐个执行任务逻辑,并支持并行处理。

关键逻辑

  1. 获取租户列表

    • 通过 tenantFrameworkService.getTenantIds() 获取所有租户的 ID 列表。

    • 如果租户列表为空,则直接返回。

  2. 逐个租户执行任务

    • 使用 tenantIds.parallelStream().forEach() 并行处理每个租户的任务。

    • 对于每个租户,调用 TenantUtils.execute(tenantId, () -> { ... }),在指定租户的上下文中执行任务逻辑。

  3. 任务执行逻辑

    • 在租户上下文中,调用 joinPoint.proceed() 执行被拦截的方法(即任务逻辑)。

    • 如果任务执行成功,将结果保存到 results 中。

    • 如果任务执行失败,捕获异常并记录错误信息,同时将 success 标记为 false

  4. 记录执行结果

    • 如果所有租户的任务都执行成功,调用 XxlJobHelper.handleSuccess() 记录成功结果。

    • 如果有租户的任务执行失败,调用 XxlJobHelper.handleFail() 记录失败结果。


协同工作流程

  1. 标记方法

    • 在需要多租户任务调度的方法上添加 @TenantJob 注解。例如:

      @TenantJob
      public void processData() {
          // 任务逻辑
      }
  2. 切面拦截

    • 当任务调度框架(如 XXL-Job)调用被 @TenantJob 注解标记的方法时,TenantJobAspect 切面会拦截该方法。

  3. 逐个租户执行任务

    • 切面会获取所有租户的 ID 列表,并逐个租户执行任务逻辑。

    • 每个租户的任务在独立的上下文中执行,确保数据隔离。

  4. 记录执行结果

    • 切面会记录每个租户的任务执行结果,并根据结果调用 XxlJobHelper.handleSuccess() 或 XxlJobHelper.handleFail()


4. 使用场景

  1. 多租户任务调度

    • 某些任务需要为每个租户独立执行,例如数据同步、报表生成等。

  2. 并行处理

    • 使用 parallelStream() 并行处理每个租户的任务,提高执行效率。

  3. 任务幂等性

    • 由于任务可能会重试,需要确保任务逻辑的幂等性,避免重复执行导致数据不一致。


注意事项

  1. 任务幂等性

    • 由于任务可能会重试,需要确保任务逻辑的幂等性,避免重复执行导致数据不一致。

  2. 异常处理

    • 捕获任务执行过程中的异常,并记录错误信息,确保任务调度框架能够正确处理失败情况。

  3. 并行处理

    • 使用 parallelStream() 并行处理每个租户的任务时,需要注意线程安全问题。


示例代码

// JobHandler.java
public class JobHandler {

    @TenantJob
    public void processData() {
        // 任务逻辑
    }
}

关键点

  1. @TenantJob 注解

    • 标记 processData 方法为多租户任务调度的方法。

  2. 任务逻辑

    • 在 processData 方法中实现具体的任务逻辑。


总结

  • @TenantJob 注解用于标记多租户任务调度的方法。

  • TenantJobAspect 切面通过 AOP 拦截被注解标记的方法,按照租户逐个执行任务逻辑,并支持并行处理。

  • 这种设计可以灵活地支持多租户任务调度,确保每个租户的任务能够独立执行,同时提高任务执行效率。

3.9 MQ

通过租户对 MQ 层面的封装,实现租户上下文,可以继续传递到 MQ 消费的逻辑中,避免丢失的问题。实现原理是:

  • 发送消息时,MQ 会将租户上下文的租户编号,记录到 Message 消息头 tenant-id 上。
  • 消费消息时,MQ 会将 Message 消息头 tenant-id,设置到租户上下文的租户编号。
/**
 * 多租户的 RabbitMQ 初始化器
 *
 * @author 芋道源码
 */
public class TenantRabbitMQInitializer implements BeanPostProcessor {

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        if (bean instanceof RabbitTemplate) {
            RabbitTemplate rabbitTemplate = (RabbitTemplate) bean;
            rabbitTemplate.addBeforePublishPostProcessors(new TenantRabbitMQMessagePostProcessor());
        }
        return bean;
    }

}
  • postProcessAfterInitialization:在 Bean 初始化完成后调用。

  • RabbitTemplate:RabbitMQ 的消息发送模板。

  • addBeforePublishPostProcessors:添加一个消息处理器,在消息发送前执行。

/**
 * RabbitMQ 消息队列的多租户 {@link ProducerInterceptor} 实现类
 *
 * 1. Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中
 * 2. Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中,通过 {@link InvocableHandlerMethod} 实现
 *
 * @author 芋道源码
 */
public class TenantRabbitMQMessagePostProcessor implements MessagePostProcessor {

    @Override
    public Message postProcessMessage(Message message) throws AmqpException {
        Long tenantId = TenantContextHolder.getTenantId();
        if (tenantId != null) {
            message.getMessageProperties().getHeaders().put(HEADER_TENANT_ID, tenantId);
        }
        return message;
    }

}
  • results:用于存储每个租户的任务执行结果。

  • success:用于标记是否有租户的任务执行失败。

  • XxlJobContext:XXL-Job 的上下文对象,用于在任务执行过程中传递上下文信息。

  • tenantIds.parallelStream().forEach(tenantId -> { ... }):并行处理每个租户的任务。

  • TenantUtils.execute(tenantId, () -> { ... }):在指定的租户上下文中执行任务。

  • joinPoint.proceed():执行被拦截的任务方法。

  • 如果任务执行成功,将结果存入 results 中;如果执行失败,记录异常信息并标记 success 为 false

  • 如果所有租户的任务都执行成功,调用 XxlJobHelper.handleSuccess 记录成功结果。

  • 如果有租户的任务执行失败,调用 XxlJobHelper.handleFail 记录失败结果。

3.10 Async异步租户

核心功能:自动修改 ThreadPoolTaskExecutor,确保 @Async 任务可以继承父线程的 ThreadLocal 数据,防止上下文丢失。

  • BeanPostProcessor:Spring 的 Bean 后置处理器,允许在 Bean 初始化前后 进行额外操作。
  • 这里的 作用 是在 Spring 容器创建 ThreadPoolTaskExecutor 线程池时,修改其行为
  • BeanPostProcessor 是 Spring 提供的一个扩展点,允许在 Spring 容器初始化 Bean 之前或之后对 Bean 进行自定义处理。

  • 这里通过实现 BeanPostProcessor,对 ThreadPoolTaskExecutor 类型的 Bean 进行增强。

  • 这是 BeanPostProcessor 的一个方法,会在 Spring 容器初始化 Bean 之前调用。

  • 在该方法中,判断当前 Bean 是否是 ThreadPoolTaskExecutor 类型,如果是,则对其进行增强。

executor.setTaskDecorator(TtlRunnable::get)

  • TaskDecorator 是 Spring 提供的一个接口,用于对提交到线程池的任务进行装饰。

  • TtlRunnable::get 是 TransmittableThreadLocal 提供的方法,用于将当前线程的 TransmittableThreadLocal 上下文传递到异步任务中。

  • 通过设置 TaskDecorator,确保异步任务在执行时能够正确获取到父线程的 TransmittableThreadLocal 上下文。

3.11 Rpc

TenantRequestInterceptor 作用是 在 Feign 请求中,自动添加租户 ID 到 Header,实现 租户 ID 透传
✅ 适用于 微服务架构,确保不同服务之间能正确识别租户,实现数据隔离
✅ 结合 TenantContextHolderFilter,可以在 每个服务正确解析租户 ID

多租户微服务架构 中,这是 核心组件,可以确保跨服务调用时,租户 ID 不会丢失! 🚀

/**
 * Tenant 的 RequestInterceptor 实现类:Feign 请求时,将 {@link TenantContextHolder} 设置到 header 中,继续透传给被调用的服务
 */
public class TenantRequestInterceptor implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate requestTemplate) {
        Long tenantId = TenantContextHolder.getTenantId();
        if (tenantId != null) {
            requestTemplate.header(HEADER_TENANT_ID, String.valueOf(tenantId));
        }
    }

}
  • 实现 RequestInterceptor 接口RequestInterceptorFeign 的拦截器接口,用于在请求发送前,对请求进行修改
  • 作用:在 Feign 发送 HTTP 请求前,将 租户 ID 放入 header,让下游服务知道当前请求来自哪个租户。
  • requestTemplate.header(...) 用于修改 Feign 请求,在请求的 HTTP Header添加租户 ID
  • 这样,调用 下游微服务 时,它能从 Header 里获取到 tenantId,从而知道这个请求属于哪个租户

假设我们的系统有 两个微服务

  • order-service(订单服务)
  • user-service(用户服务)

order-service 配置 Feign 客户端

@FeignClient(name = "user-service", path = "/users", configuration = TenantFeignConfig.class)
public interface UserFeignClient {

    @GetMapping("/{id}")
    UserDTO getUser(@PathVariable("id") Long id);
}

这里的 TenantFeignConfig.class 配置了 Feign 拦截器,用于 自动透传租户 ID

配置 Feign 拦截器

@Configuration
public class TenantFeignConfig {

    @Bean
    public RequestInterceptor tenantRequestInterceptor() {
        return new TenantRequestInterceptor();
    }

}
  • @Configuration:标明这是一个 Spring 配置类,会在 Spring 启动时被加载。
  • @Bean:向 Spring 容器 注册 RequestInterceptor,让 Feign 在发送请求时使用这个拦截器。

order-service 发起 Feign 请求

UserDTO user = userFeignClient.getUser(1L);

实际会发送http请求

GET http://user-service/users/1
Headers:
  tenant-id: 123  // 自动带上了租户 ID

user-service 解析租户 ID

@Component
public class TenantFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String tenantId = httpRequest.getHeader("tenant-id");

        if (StrUtil.isNotEmpty(tenantId)) {
            TenantContextHolder.setTenantId(Long.valueOf(tenantId));
        }

        try {
            chain.doFilter(request, response);
        } finally {
            TenantContextHolder.clear();
        }
    }
}

这样,user-service 就能从 Header 解析租户 ID,并在整个请求链中使用它。

4. 租户独立域名

  1. 子域名解析

    • 用户在浏览器中访问 a.iocoder.cn 或 b.iocoder.cn

    • Nginx 通过泛域名解析(*.iocoder.cn)将请求转发到前端项目(如 Vue3 管理后台)。

  2. 租户识别

    • 前端根据当前访问的域名(window.location.host),向后端请求对应的租户 ID。

    • 后端根据域名查询 system_tenant 表,返回对应的租户 ID。

  3. 租户上下文传递

    • 前端在后续请求中携带租户 ID(如通过 HTTP Header 或请求参数)。

    • 后端根据租户 ID 处理租户特定的逻辑。

表结构设计

CREATE TABLE system_tenant (
    id BIGINT PRIMARY KEY COMMENT '租户 ID',
    name VARCHAR(255) NOT NULL COMMENT '租户名称',
    website VARCHAR(255) NOT NULL COMMENT '租户独立域名',
    -- 其他字段...
);

Nginx 配置

Nginx 通过泛域名解析将所有子域名请求转发到前端项目。

server {
    listen 80;
    server_name ~^(.*)\.iocoder\.cn$;  # 泛域名解析,匹配所有子域名
    location / {
        proxy_pass http://frontend-app;  # 前端项目地址
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

前端根据当前访问的域名,向后端请求租户 ID,并在后续请求中携带租户 ID。

后端根据域名查询租户 ID,并在后续请求中根据租户 ID 处理租户特定的逻辑。


http://www.kler.cn/a/582995.html

相关文章:

  • ST电机库电流采样 三电阻单ADC
  • Oracle VirtualBox安装CentOS 7
  • FFmpeg入门:最简单的音视频播放器(Plus优化版)
  • dns劫持是什么?常见的劫持类型有哪些?如何预防?
  • XML Schema 实例
  • 深入理解 HTML 链接:网页导航的核心元素
  • 【Deepseek基础篇】--v3基本架构
  • 初学者快速入门Python爬虫 (无废话版)
  • 从零开始的python学习(五)P75+P76+P77+P78+P79+P80
  • Linux ALSA 驱动核心知识点解析
  • OpenHarmony-分布式硬件关键技术
  • redis增加ip白名单
  • 【day11】智慧导览:AR内容加载性能指标
  • 电力行业中 对输电和配电的所有数据实现实时监测与控制
  • 四种 API 架构风格及对比
  • 监控推特信息并发送到微信
  • Qt常用控件之标签页控件QTabWidget
  • CTFHub-上传文件
  • HPC超算系列4——官方指南文档
  • 计算机网络:Socket编程 Tcp协议 第二弹