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=UTCusername: rootpassword: 123456hikari:maximum-pool-size: 10minimum-idle: 2jpa:hibernate:ddl-auto: updateshow-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 {@Overrideprotected 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;}@Overrideprotected 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@Validpublic 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@Validpublic 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 CommonResultreturn 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>>() {@Overridepublic 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>>() {@Overridepublic CommonResult<Boolean> load(Long id) {return tenantApi.validTenant(id);}});@Override@SneakyThrowspublic List<Long> getTenantIds() {return getTenantIdsCache.get(Boolean.TRUE);}@Override@SneakyThrowspublic 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;/*** 是否删除*/@TableLogicprivate 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");}@Overridepublic Expression getTenantId() {return new LongValue(TenantContextHolder.getRequiredTenantId());}@Overridepublic 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;}@Overridepublic 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;}// 逐个租户,执行 JobMap<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);// 执行 JobObject 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 {@TenantJobpublic 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 {@Overridepublic 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 {@Overridepublic 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 {@Overridepublic 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 {@Beanpublic 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 {@Overridepublic 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.rhkb.cn/news/31552.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

NAT NAPT

NAT NAT&#xff08;Network Address Translation&#xff0c;网络地址转换&#xff09; 主要用于在不同网络&#xff08;如私有网络和公共互联网&#xff09;之间进行 IP 地址转换&#xff0c;解决IP 地址短缺问题&#xff0c;并提供一定的安全性。 IPv4 地址是 32 位&#xf…

固定表头、首列 —— uniapp、vue 项目

项目实地&#xff1a;也可以在 【微信小程序】搜索体验&#xff1a;xny.handbook 另一个体验项目&#xff1a;官网 一、效果展示 二、代码展示 &#xff08;1&#xff09;html 部分 <view class"table"><view class"tr"><view class&quo…

微服务——网关、网关登录校验、OpenFeign传递共享信息、Nacos共享配置以及热更新、动态路由

之前学习了Nacos&#xff0c;用于发现并注册、管理项目里所有的微服务&#xff0c;而OpenFeign简化微服务之间的通信&#xff0c;而为了使得前端可以使用微服务项目里的每一个微服务的接口&#xff0c;就应该将所有微服务的接口管理起来方便前端调用&#xff0c;所以有了网关。…

[免费]微信小程序(图书馆)自习室座位预约管理系统(SpringBoot后端+Vue管理端)(高级版)【论文+源码+SQL脚本】

大家好&#xff0c;我是java1234_小锋老师&#xff0c;看到一个不错的微信小程序(图书馆)自习室座位预约管理系统(SpringBoot后端Vue管理端)(高级版)&#xff0c;分享下哈。 项目视频演示 【免费】微信小程序(图书馆)自习室座位预约管理系统(SpringBoot后端Vue管理端)(高级版…

Android15请求动态申请存储权限完整示例

效果: 1.修改AndroidManifest.xml增加如下内容: <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" /><uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /><uses-perm

深度学习系列79:Text2sql调研

参考 https://github.com/topics/text-to-sql 这里是一些资源&#xff1a;https://github.com/eosphoros-ai/Awesome-Text2SQL/blob/main/README.zh.md 这里是综述文章&#xff1a;https://zhuanlan.zhihu.com/p/647249972 1. 数据集 Spider: 一个跨域的复杂text2sql数据集&a…

Deepseek应用技巧-chatbox搭建前端问答

目标&#xff1a;书接上回&#xff0c;由于本地私有化部署了deepseek的大模型&#xff0c;那怎么能够投入生产呢&#xff0c;那就必须有一个前端的应用界面&#xff0c;好在已经有很多的前人已经帮我们把前段应用给搭建好了&#xff0c;我们使用就可以啦&#xff0c;今天我们就…

Bootstrap:图标库的安装及其使用

一、安装 使用npm包管理器安装。 npm i bootstrap-icons 二、使用图标 首先先引入bootstrap-icons.css库&#xff0c;然后从官方网站选择想要的图标然后复制使用代码。 示例代码&#xff1a;使用vue引入对应css文件后&#xff0c;使用库图标。 <script setup>import &qu…

Linux网络之数据链路层协议

目录 数据链路层 MAC地址与IP地址 数据帧 ARP协议 NAT技术 代理服务器 正向代理 反向代理 上期我们学习了网络层中的相关协议&#xff0c;为IP协议。IP协议通过报头中的目的IP地址告知了数据最终要传送的目的主机的IP地址&#xff0c;从而指引了数据在网络中的一步…

TCP7680端口是什么服务

WAF上看到有好多tcp7680端口的访问信息 于是上网搜索了一下&#xff0c;确认TCP7680端口是Windows系统更新“传递优化”功能的服务端口&#xff0c;个人理解应该是Windows利用这个TCP7680端口&#xff0c;直接从内网已经具备更新包的主机上共享下载该升级包&#xff0c;无需从微…

“量子心灵AI“的监控仪表盘 - javascript网页设计案例

【前端实战】基于Three.js和Chart.js打造未来科技风AI监控仪表盘 本文通过AI辅助开发&#xff0c;详细记录了一个高级前端项目的完整实现过程。文章包含核心代码片段、技术要点及遇到的问题与解决方案。适合有一定前端基础的开发者学习参考。 1. 项目概述 本文详细介绍了一个名…

vtkDepthSortPolyData 根据相机视图方向对多边形数据进行排序

1. 作用 在 3D 渲染中&#xff0c;透明对象的渲染顺序非常重要。如果透明对象的渲染顺序不正确&#xff0c;可能会导致错误的视觉效果&#xff08;例如&#xff0c;远处的透明对象遮挡了近处的透明对象&#xff09;。vtkDepthSortPolyData 通过对多边形数据进行深度排序&#…

第十五章:go package 包的管理

import f "fmt"   // 注意 这里 f 是包的别名 init初始化函数 在每一个Go源文件中&#xff0c;都可以定义任意个如下格式的特殊函数&#xff1a; func init(){// ... } package&#xff1a;声明包的关键字 packagename&#xff1a;包名&#xff0c;可以不与文…

【从零开始学习计算机科学】计算机组成原理(七)存储器与存储器系统

【从零开始学习计算机科学】计算机组成原理(七)存储器与存储器系统 存储器存储器相关概念存储器分类存储器系统存储器性能指标存储器层次概述程序访问的局部性原理SRAM存储器存储器的读写周期DRAM存储器DRAM控制器高性能的主存储器存储器扩展只读存储器ROM光擦可编程只读存储…

开源!速度100Kb/s的有线和无线双模ESP32S3芯片的DAP-Link调试器

开源&#xff01;速度100Kb/s的有线和无线双模ESP32S3芯片的DAP-Link调试器 目录 开源&#xff01;速度100Kb/s的有线和无线双模ESP32S3芯片的DAP-Link调试器本项目未经授权&#xff0c;禁止商用&#xff01;本项目未经授权&#xff0c;禁止商用&#xff01;本项目未经授权&…

20250212:linux系统DNS解析卡顿5秒的bug

问题: 1:人脸离线识别记录可以正常上传云端 2:人脸在线识别请求却一直超时 3:客户使用在线网络 思路:

爱普生温补晶振 TG5032CFN高精度稳定时钟的典范

在科技日新月异的当下&#xff0c;众多领域对时钟信号的稳定性与精准度提出了极为严苛的要求。爱普生温补晶振TG5032CFN是一款高稳定性温度补偿晶体振荡器&#xff08;TCXO&#xff09;。该器件通过内置温度补偿电路&#xff0c;有效抑制环境温度变化对频率稳定性的影响&#x…

【病毒分析】熊猫烧香病毒分析及其查杀修复

目录 前言 一、样本概况 1.1 样本信息 1.2 测试环境及工具 1.3 分析目标 二、具体行为分析 2.1 主要行为 2.1.1 恶意程序对用户造成的危害 2.2 恶意代码分析 2.2.1 加固后的恶意代码树结构图(是否有加固) 2.2.2 恶意程序的代码分析片段 三、解决方案(或总结) 3.1 …

JavaWeb后端基础(7)AOP

AOP是Spring框架的核心之一&#xff0c;那什么是AOP&#xff1f;AOP&#xff1a;Aspect Oriented Programming&#xff08;面向切面编程、面向方面编程&#xff09;&#xff0c;其实说白了&#xff0c;面向切面编程就是面向特定方法编程。AOP是一种思想&#xff0c;而在Spring框…

AutoDL平台租借GPU,创建transformers环境,使用VSCode SSH登录

AutoDL平台租借GPU&#xff0c;创建transformers环境&#xff0c;使用VSCode SSH登录 一、AutoDl平台租用GPU 1.注册并登录AutoDl官网&#xff1a;https://www.autodl.com/home 2.选择算力市场&#xff0c;找到需要的GPU&#xff1a; 我这里选择3090显卡 3.这里我们就选择P…