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

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 处理租户特定的逻辑。

相关文章:

java每日精进 3.11 【多租户】

1.多租户概念 1. 多租户是什么&#xff1f; 多租户&#xff0c;简单来说是指一个业务系统&#xff0c;可以为多个组织服务&#xff0c;并且组织之间的数据是隔离的。 例如说&#xff0c;在服务上部署了一个MyTanant系统&#xff0c;可以支持多个不同的公司使用。这里的一个公…...

2.2 企业级ESLint/Prettier规则定制

文章目录 1. 为什么需要企业级代码规范2. 工具选型对比3. 完整配置流程3.1 项目初始化3.2 ESLint深度配置3.3 Prettier精细配置3.4 解决规则冲突4. 高级定制方案4.1 自定义ESLint规则4.2 扩展Prettier插件5. 团队协作策略5.1 配置共享方案5.2 版本控制策略6. CI/CD集成7. 常见问…...

Ubuntu 源码安装 Qt5

1.开发背景 Ubuntu 下安装指定版本的 Qt&#xff0c;最新的Qt官方已经不支持 Qt5.15.2 版本以下版本&#xff0c;所以有必要用旧的源码编译 Qt 库。 2.开发需求 源码安装 Qt5.12.2 3.开发环境 开发环境&#xff1a;Ubuntu18.04 目标版本&#xff1a;Qt5.12.2 4.实现步骤 4…...

【Linux】权限相关知识点

思考 我们平时使用Linux创建文件或目录时的默认权限是多少&#xff1f; [rootlocalhost test]: mkdir dir [rootlocalhost test]: touch file [rootlocalhost test]: ll total 0 drwxr-xr-x 2 root root 6 Mar 8 15:23 dir #755 -rw-r--r-- 1 root root 0 Mar 8 15:23 f…...

SSH 安全致命漏洞:渗透路径与防御策略

作为远程管理的核心协议&#xff0c;SSH 的 22 端口在全球服务器中广泛部署&#xff0c;却也成为攻击者的首要目标。本文将以技术视角还原黑客通过 22 端口渗透的完整路径&#xff0c;并结合最新漏洞&#xff08;如 CVE-2024-6387&#xff09;提供防御建议&#xff0c;帮助企业…...

使用ngnix进行负载均衡部署deepseek蒸馏版

使用ngnix进行负载均衡部署deepseek蒸馏版 一、安装及配置nginx1.1.安装依赖:1.2. 导入Nginx签名密钥1.3. 添加Nginx软件源1.4. 更新软件包列表并安装Nginx1.5. 启动Nginx服务1.6. 验证安装1.7.修改配置文件,将自己的内容加进去1.8、重新加载Nginx配置:二、模型启动2.1、分布…...

面试之《TypeScript泛型》

在 TypeScript&#xff08;TS&#xff09;里&#xff0c;泛型是一项极为重要的特性&#xff0c;它能让你编写可复用、类型安全且灵活的代码。以下从多个方面为你详细介绍 TS 中的泛型&#xff1a; 基本概念 泛型允许你创建可重用的组件&#xff0c;这些组件能够处理多种数据类…...

PyTorch系列教程:Tensor.view() 方法详解

这篇简明扼要的文章是关于PyTorch中的tensor.view()方法的介绍与应用&#xff0c;与reshape()方法的区别&#xff0c;同时给出示例进行详细解释。 Tensor基础 Tensor(张量)的视图是一个新的Tensor&#xff0c;它与原始Tensor共享相同的底层数据&#xff0c;但具有不同的形状或…...

IDEA(十一)调整新版本的工具栏显示Git操作(pull、commit、push、revert等)

目录 一、背景二、操作步骤2.1 开启新 UI 样式2.2 设置 Tool Window 工具栏 一、背景 好久没有更新 IDEA 了&#xff0c;更新之后发现 IDEA 的工具栏消失了。一番操作之后&#xff0c;终于把 IDEA 的工具栏的设置调整好了&#xff0c;在此进行记录调整步骤&#xff0c;供大家学…...

基于Prometheus+Grafana的Deepseek性能监控实战

文章目录 1. 为什么需要专门的大模型监控?2. 技术栈组成2.1 vLLM(推理引擎层)2.2 Prometheus(监控采集层)2.3 Grafana(数据可视化平台)3. 监控系统架构4. 实施步骤4.1 启动DeepSeek-R1模型4.2 部署 Prometheus4.2.1 拉取镜像4.2.2 编写配置文件4.2.3 启动容器4.3 部署 G…...

windows下docker的安装

前言 早期的docker只能在Linux下使用&#xff0c;随着技术的发展&#xff0c;目前docker在Windows下也能方便的使用了。 一、docker的下载 从docker官网下载“docker desktop” 下载这个&#xff1a; 二、Windows下docker的安装 安装完毕后&#xff0c;重启的系统进行登录&am…...

Nginx正向代理HTTPS配置指南(仅供参考)

要使用Nginx作为正向代理访问HTTPS网站&#xff0c;需通过CONNECT方法建立隧道。以下是操作详细步骤&#xff1a; 1. 安装Nginx及依赖模块 需要模块&#xff1a;ngx_http_proxy_connect_module&#xff08;支持CONNECT方法&#xff09;。 安装方式&#xff1a;需重新编译Nginx…...

01_LVGL 对象与盒子模型详解

1. LVGL 的对象 ​ 在LVGL中&#xff0c;⽤⼾界⾯的 基本组成部分 是对象&#xff08;控件&#xff09;&#xff0c;也称为 Widgets。例如&#xff0c;⼀个 按钮、标签、图像、列表、图表 或者 ⽂本区域。所有的对象都使⽤ lv_obj_t 指针作为句柄进⾏引⽤。之后可以使⽤该指针…...

【redis】string应用场景:共享会话和手机验证码

文章目录 共享会话实现思路 手机验证码实现思路伪代码实现生成验证码验证验证码 共享会话 实现思路 如果每个应用服务器&#xff0c;维护自己的会话数据&#xff0c;此时彼此之间胡共享&#xff0c;用户请求访问到不同的服务器上&#xff0c;就可能会出现一些不能正确处理的情…...

【保姆级教程】使用 oh-my-posh 和 clink 打造个性化 PowerShell 和 CMD

内容预览 ≧∀≦ゞ Windows终端美化指南&#xff1a;美化你的命令行界面&#xff01;引言一、准备工作包管理器&#xff1a;scoop为什么选择使用 Scoop 安装&#xff1f;安装 scoop 字体终端离线安装步骤配置 Windows Terminal 二、配置美化 PowerShell安装 oh-my-posh激活 oh-…...

刷leetcode hot100--动态规划3.11

第一题&#xff1a;最长递增子序列[10:53] 1.dp数组及下标含义&#xff1a;dp[n]:nums[0...n]的最长严格递增子序列长度【无法进行后续比较】 dp[n]以nums[n]结尾的最长严格递增子序列对应的长度 2.初始化&#xff1a;注意&#xff01;&#xff01;这里应该初始化为1&#x…...

网络安全基础与应用习题 网络安全基础答案

1.列出并简要给出SSH的定义。 正确答案&#xff1a; 答&#xff1a;6.10传输层协议&#xff1a;提供服务器身份验证、数据保密性和数据完整性&#xff0c;并具有前向保密性&#xff08;即&#xff0c;如果在一个会话期间密钥被破坏&#xff0c;则知识不会影响早期会话的安全性&…...

利用python生成excel中模板范围对应的shape文件

利用python生成excel中模板范围对应的shape文件 # -*- coding: utf-8 -*- import os.pathimport pandas as pd from shapely.geometry import Polygon from shapely.wkt import dumps import argparse# 创建解析器 parser argparse.ArgumentParser(description"这是一个…...

方案精读:IBM方法论-IT规划方法论

该文档聚焦 IT 规划方法论&#xff0c;适合企业高层管理者、IT 部门负责人、业务部门主管以及参与企业信息化建设的相关人员阅读。 &#xff08;本解读资料已包含在绑定资源内&#xff09; 主要内容围绕 IT 规划展开&#xff1a;首先明确 IT 规划需基于企业核心战略&#xff0…...

JAVA面试_进阶部分_正确使用 Volatile 变量

Java 语言中的 volatile 变量可以被看作是一种 “程度较轻的 synchronized”&#xff1b;与 synchronized 块相比&#xff0c;volatile 变量所需的编码较少&#xff0c;并且运行时开销也较少&#xff0c;但是它所能实现的功能也仅是 synchronized 的一部分。本文介绍了几种有效…...

ArcGIS Pro中字段的新建方法与应用

一、引言 在地理信息系统&#xff08;GIS&#xff09;的数据管理和分析过程中&#xff0c;字段操作起着至关重要的作用。 无论是进行地图制作、空间分析还是数据统计&#xff0c;字段都是承载属性信息的基本单元。 ArcGIS Pro作为一款功能强大的GIS软件&#xff0c;为用户提…...

c++ 中的引用

引用与指针经常混淆&#xff0c;总结一下 文章目录 1. 引用与指针的区别2. 引用传递数组3. 通过引用传递容器和类4. 多线程传递容器时用 std:: ref 替代引用传递 1. 引用与指针的区别 引用&#xff08;Reference&#xff09;&#xff1a;引用是变量的别名&#xff0c;本质上不…...

使用jest测试用例之入门篇

Jest使用 Jest 是由 Facebook 开发的一个 js 测试框架&#xff0c;jest 主要侧重于被用于做单元测试和集成测试 安装 npm i jest -D运行 **package.json**里面配置命令 // scripts添加测试脚本 {"test": "jest" /* 运行后便会使用 jest 执行所有的 .t…...

k8s面试题总结(十四)

什么是Helm&#xff1f; Helm是一个k8s的包管理工具&#xff0c;它简化了应用程序在k8s集群中的部署&#xff0c;管理和维护。类似于rpm包和yum之间的关系。 K8s传统方式&#xff1a;类似于rpm安装包的方式&#xff0c;逐步进行安装&#xff0c;遇到依赖还得解决依赖问题 he…...

后端面试高频笔试题(非常规LeetCode类型)

目录 1. 常见的五种单例模式的实现⽅式 2. 约瑟夫环 &#xff08;递归&#xff09; 3. 交替打印奇偶数 &#xff08;Semaphore、synchronized搭配wait、notify&#xff09; 4. 交替打印 ABC &#xff08;Semaphore&#xff09; 5. 三个线程交替打印 1 到 99 &#xff08;Semap…...

el-table 通过 slot=“header“ 自定义表头,遇到数据不更新的问题。

从表中可以看到我要的数据为空&#xff0c;但是在控制台输出数据又不为空&#xff0c;由此判断是自定义表头的内容未在数据变化时触发重新渲染 在 Element UI 官方示例中&#xff0c;若通过旧式插槽语法 slot"header" 实现自定义表头&#xff0c;并在表头内集成 el-s…...

ESP32S3N16R8驱动ST7701S屏幕(vscode+PlatfoemIO)

1.开发板配置 本人开发板使用ESP32S3-wroom1-n16r8最小系统板 由于基于vscode与PlatformIO框架开发&#xff0c;无espidf框架&#xff0c;因此无法直接烧录程序&#xff0c;配置开发板参数如下&#xff1a; 在platformio.ini文件中&#xff0c;配置使用esp32-s3-devkitc-1开发…...

ios 小组件和数据共享

创建主工程就不必讲了 1 创建小组件 创建子工程 [new Target ] 选择 [ Widger Extension] 小组件入口是WidgetBundle文件&#xff0c;可以进行多个小组件的调试 TestWidget2文件是主要操作&#xff0c;小组件使用swiftUI布局&#xff0c;使用 AppIntent进行事件处理&#xff…...

鸿蒙开发可以从事的岗位

学完鸿蒙开发方向后&#xff0c;可以从事的岗位主要集中在以下几个领域&#xff1a; 鸿蒙系统开发工程师 负责鸿蒙操作系统的开发、优化、维护和更新工作&#xff0c;包括系统层、框架层、应用层的开发等。 嵌入式软件开发工程师 鸿蒙系统广泛应用于物联网设备、智能硬件等领域…...

深度学习和机器学习的差异

一、技术架构的本质差异 传统机器学习&#xff08;Machine Learning&#xff09;建立在统计学和数学优化基础之上&#xff0c;其核心技术是通过人工设计的特征工程&#xff08;Feature Engineering&#xff09;构建模型。以支持向量机&#xff08;SVM&#xff09;为例&#xf…...

OpenCV常用函数以及使用场景

类别函数名参数功能使用场景经验值/注意事项返回值图像 I/Ocv2.imread()filename (str): 文件路径。flags (int, 可选): 读取标志。常用值: * cv2.IMREAD_COLOR (默认): 读取彩色图像 (BGR)。 * cv2.IMREAD_GRAYSCALE: 读取灰度图像。 * cv2.IMREAD_UNCHANGED: 读取包含 Alpha…...

【iOS逆向与安全】sms短信转发插件与上传服务器开发

一、目标 一步步分析并编写一个短信自动转发的deb插件 二、工具 mac系统已越狱iOS设备:脱壳及frida调试IDA Pro:静态分析测试设备:iphone6s-ios14.1.1三、步骤 1、守护进程 ​ 守护进程(daemon)是一类在后台运行的特殊进程,用于执行特定的系统任务。例如:推送服务、人…...

Linux内核实时机制19 - RT调度器2 - 更新时间 update_curr_rt

update_curr_rt update_curr_rt函数用来更新当前实时进程的运行时间统计值,//kernel/sched/rt.c 1009 static void update_curr_rt(struct rq *rq) 1010 {...

《Android应用性能优化全解析:常见问题与解决方案》

目录 一、UI卡顿/掉帧 二、内存泄漏&#xff08;Memory Leak&#xff09; 三、ANR&#xff08;Application Not Responding&#xff09; 四、列表滑动卡顿&#xff08;RecyclerView/ListView&#xff09; 五、冷启动耗时过长 六、内存抖动&#xff08;Memory Churn&#x…...

Mybatis批量更新数据

批量传参样例&#xff1a; [{"sid": "111", "createTime": "2025-03-11 09:12:00", "pbilId": "pbil_id_111"}, {"sid": "222", "createTime": "2025-03-11 09:13:00"…...

HTML 超链接(简单易懂较详细)

在 HTML 中&#xff0c;超链接是通过 <a> 标签&#xff08;anchor tag&#xff09;创建的。超链接允许用户通过点击文本、图像或其他元素跳转到另一个网页、文件或页面的特定部分。本文将详细介绍 HTML 超链接的语法、属性和应用场景。 一、基本语法 <a href"U…...

计算机网络--访问一个网页的全过程

文章目录 访问一个网页的全过程应用层在浏览器输入URL网址http://www.aspxfans.com:8080/news/index.aspboardID5&ID24618&page1#r_70732423通过DNS获取IP地址生成HTTP请求报文应用层最后 传输层传输层处理应用层报文建立TCP连接传输层最后 网络层网络层对TCP报文进行处…...

LLaMA-Factory训练DeepSeek大模型+本地部署

前言 前面我们介绍了基于硅基流动训练私有大模型《10分钟教你微调自己的私有大模型》&#xff0c;该平台有三个不好的点就是可选的模型有限&#xff0c;训练时间需要排队等待耗时长&#xff0c;另外还要 给钱。今天我们换一个平台&#xff0c;使用&#xff1a;魔搭平台 llama…...

mapbox-gl的Popup的使用详解

下面提供一个完整的 HTML 示例代码,展示了如何使用 mapbox-gl 的 Popup。代码中包含了两种使用方式: 在地图加载时直接创建一个 Popup;结合 Marker,在点击 Marker 或地图任意位置时显示 Popup。请确保将 YOUR_MAPBOX_ACCESS_TOKEN 替换为你自己的 Mapbox 访问令牌。下面是代…...

【K8s】专题十六(3):Kubernetes 包管理工具之 Helm 语法

本文内容均来自个人笔记并重新梳理&#xff0c;如有错误欢迎指正&#xff01; 如果对您有帮助&#xff0c;烦请点赞、关注、转发、订阅专栏&#xff01; 专栏订阅入口 | 精选文章 | Kubernetes | Docker | Linux | 羊毛资源 | 工具推荐 | 往期精彩文章 【Docker】&#xff08;全…...

仅仅使用pytorch来手撕transformer架构(3):编码器模块和编码器类的实现和向前传播

仅仅使用pytorch来手撕transformer架构(2)&#xff1a;编码器模块和编码器类的实现和向前传播 往期文章&#xff1a; 仅仅使用pytorch来手撕transformer架构(1)&#xff1a;位置编码的类的实现和向前传播 最适合小白入门的Transformer介绍 仅仅使用pytorch来手撕transformer…...

LWIP网络模型及接口简介(DAY 01)

目录 1.网络协议分层模型 2. LWIP三种编程接口 1.网络协议分层模型 其中各层级的封装与拆封过程 2. LWIP三种编程接口 LwIP 提供了三种编程接口&#xff0c;分别为 RAW/Callback API、NETCONN API、SOCKET API。它们的易用性从左到右依次提高&#xff0c;而执行效率从左到右依…...

Docker构建启动jar包

Docker构建启动jar包 1、首先是把java服务打包成jar包 mvn clean install -Dmaven.skip.testtrue package -Pprod这个命令的意思是&#xff0c;跳过测试&#xff0c;打包prod环境。 2、编写Dockerfile文件 # 拉取jdk8作为基础镜像 FROM registry.supos.ai/library/openjdk:…...

基于Python+Vue开发的电影订票管理系统源码+运行步骤

项目简介 该项目是基于PythonVue开发的电影订票管理系统&#xff08;前后端分离&#xff09;&#xff0c;这是一项为大学生课程设计作业而开发的项目。该系统旨在帮助大学生学习并掌握Python编程技能&#xff0c;同时锻炼他们的项目设计与开发能力。通过学习基于Python的电影订…...

javase集合框架List篇

一、Vector和ArrayList、LinkedList联系和区别&#xff0c;分别的使用场景 ArrayList&#xff1a;底层是数组实现&#xff0c;线程不安全&#xff0c;查询和修改非常快&#xff0c;但是增加和删除慢 LinkedList: 底层是双向链表&#xff0c;线程不安全&#xff0c;查询和修改…...

Kafka×DeepSeek:智能决策破取经八十一难!

《西游记》的故事中&#xff0c;唐僧师徒四人历经九九八十一难&#xff0c;从东土大唐前往西天取经。一路上&#xff0c;火焰山酷热难耐、通天河水位忽高忽低、妖怪神出鬼没…… 现在&#xff0c;唐僧师徒取经路上的种种难题&#xff0c;在KafkaDeepSeek双引擎加持下有了全新解…...

python-leetcode-反转字符串中的元音字母

345. 反转字符串中的元音字母 - 力扣&#xff08;LeetCode&#xff09; 使用双指针的方法高效地反转字符串中的元音字母。以下是 Python 代码实现&#xff1a; def reverseVowels(s: str) -> str:vowels set("aeiouAEIOU")s list(s) # 将字符串转换为列表&…...

Blender UV纹理贴图,导出FBX到Unity

加载ps好的模型贴图。右下角选择《材质》基础色里面选择《图像纹理》&#xff0c;选择你的图片。 选择上面UV选项卡。左上角选择UV编辑器。选中物体&#xff0c;TAB进入编辑模式。即可调整映射的图像范围。 其中渲染设置可以在左侧下边脱出。 导出带纹理FBX模型 路径选择复…...

AttributeError: module ‘backend_interagg‘ has no attribute ‘FigureCanvas‘

AttributeError: module backend_interagg has no attribute FigureCanvas 这个错误通常是由于 Matplotlib 的后端配置问题引起的。具体来说&#xff0c;Matplotlib 在尝试加载某个后端时&#xff0c;发现该后端模块中缺少必要的属性&#xff08;如 FigureCanvas&#xff09;&a…...

调试正常 ≠ 运行正常:Keil5中MicroLIB的“量子态BUG”破解实录

调试正常 ≠ 运行正常&#xff1a;Keil5中MicroLIB的“量子态BUG”破解实录——从勾选一个选项到理解半主机模式&#xff0c;嵌入式开发的认知升级 &#x1f4cc; 现象描述&#xff1a;调试与烧录的诡异差异 在线调试时 程序正常运行 - 独立运行时 设备无响应 ! 编译过程 0 Err…...