多数据源

本小节主要介绍 Spring 内置支持的动态路由多数据源。

  • 在橙单代码生成器中,为业务服务配置多个数据库链接,见下图。
  • 从上图可见,我们为该服务配置了三个数据库链接,那么在该服务的 application-dev.yml 配置文件中,就会出现三个数据源的配置信息,见如下配置代码。
spring:
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    druid:
      # 按照配置项的常用命名规则,配置项使用小写字母,单词间中划线连接,如course-paper。
      # 对应上图的UPMS数据库连接。
      upms:
        url: jdbc:mysql://localhost:3306/zzdemo-upms?characterEncoding=utf8&useSSL=true
        username: root
        password: 123456
      # 对应上图的TRANS数据库连接。
      trans:
        url: jdbc:mysql://localhost:3306/zzdemo-trans?characterEncoding=utf8&useSSL=true
        username: root
        password: 123456
      # 对应上图的STATS数据库连接。
      stats:
        url: jdbc:mysql://localhost:3306/zzdemo-stats?characterEncoding=utf8&useSSL=true
        username: root
        password: 123456
      driver-class-name: com.mysql.cj.jdbc.Driver
      # 其余配置项省略 ... ...
  • 下面是位于 config 包内的多数据源配置类 MultiDataSourceConfig.java,该配置类与上面的配置信息一一对应。
@Configuration
public class MultiDataSourceConfig {
   // ConfigurationProperties注解的prefix参数,对应于upms连接的配置路径。  
   @Bean(initMethod = "init", destroyMethod = "close")
   @ConfigurationProperties(prefix = "spring.datasource.druid.upms")
   public DataSource upmsDataSource() {
       return DruidDataSourceBuilder.create().build();
   }
   // ConfigurationProperties注解的prefix参数,对应于trans连接的配置路径。  
   @Bean(initMethod = "init", destroyMethod = "close")
   @ConfigurationProperties(prefix = "spring.datasource.druid.trans")
   public DataSource transDataSource() {
       return DruidDataSourceBuilder.create().build();
   }
   // ConfigurationProperties注解的prefix参数,对应于stats连接的配置路径。  
   @Bean(initMethod = "init", destroyMethod = "close")
   @ConfigurationProperties(prefix = "spring.datasource.druid.stats")
   public DataSource statsDataSource() {
       return DruidDataSourceBuilder.create().build();
   }
   // @Primary注解指向主数据源。这里的主数据源是动态路由数据源。该数据源包含上面
   // 的三个数据源,运行时会根据他们注册的类型进行路由。
   @Bean
   @Primary
   public DynamicDataSource dataSource() {
       Map<Object, Object> targetDataSources = new HashMap<>(3);
       // 这里我们正在将上面的三个bean与我们的常量对象字段建立起一对一的关联关系。
       targetDataSources.put(DataSourceType.UPMS, upmsDataSource());
       targetDataSources.put(DataSourceType.TRANS, transDataSource());
       targetDataSources.put(DataSourceType.STATS, statsDataSource());
       DynamicDataSource dynamicDataSource = new DynamicDataSource();
       dynamicDataSource.setTargetDataSources(targetDataSources);
       // 这里将upms设置为缺省数据源。因为在橙单生成器的配置中,他是该服务的第一个数据源。
       dynamicDataSource.setDefaultTargetDataSource(upmsDataSource());
       return dynamicDataSource;
   }
}
  • 通常是在 Service 方法被调用时,才会触发多数据源的切换,因此我们需要为 ServiceImpl 实现类添加 @MyDataSource 注解,注解参数 DataSourceType.TRANS,是与「trans」数据源对应的类型值。
@MyDataSource(DataSourceType.TRANS)
@Service
public class CourseServiceImpl extends BaseJobService<Course, Long> {
   @Autowired
   private CourseMapper courseMapper;
   @Override
   protected BaseJobMapper<Course> mapper() {
       return courseMapper;
   }
}
  • 为啥注解 @MyDataSource 可以触发多数据源的切换呢?是因为 DataSourceAspect 切面类会织入被该注解标记的所有类方法 ( public)。并在被织入方法执行前,先行完成多数据源的类型切换。具体实现可参考以下代码及关键性注释。
@Aspect
@Component
@Order(1)
@Slf4j
public class DataSourceAspect {

   // 我们的切点是,所有配置了@MyDataSource注解的Service
   @Pointcut("execution(public * com.demo.multi..service..*(..))")
   public void datasourcePointCut() {}
  
   @Around("datasourcePointCut()")
   public Object around(ProceedingJoinPoint point) throws Throwable {
       Class<?> clazz = point.getTarget().getClass();
       MyDataSource ds = clazz.getAnnotation(MyDataSource.class);
       // 通过@MyDataSource注解的参数值,来决定当前方法应该使用哪个数据源。
       // 在上一步的代码中,注解值ds.value()为"DataSourceType.TRANS"。
       // 下一行代码就完成了多数据源的切换。
       DataSourceContextHolder.setDataSourceType(ds.value());
       log.debug("set datasource is " + ds.value());
       try {
           // 这里执行被织入的方法。
           return point.proceed();
       } finally {
           DataSourceContextHolder.clear();
           log.debug("clean datasource");
       }
   }
}
  • 最后介绍一下 Spring 是如何根据 DataSourceContextHolder.setDataSourceType(ds.value()) 代码的执行结果,实现多数据源的动态切换。
public class DynamicDataSource extends AbstractRoutingDataSource {
   @Override
   protected Object determineCurrentLookupKey() {
       // 因为在同一个线程内,这里get返回的对象值,就是aop中设定的
       // DatasourceType.TRANS。本例中的DatasourceType.TRANS常量值,
       // 又该对应哪个数据源。见上面第二步中的代码,
       // 位于MultiDataSourceConfig.java文件内的dataSource()方法。
       // targetDataSources.put(DataSourceType.TRANS, transDataSource());
       return DataSourceContextHolder.getDataSourceType();
   }
}

多数据源解析器

相比于前面的动态路由多数据源,本小节介绍的「多数据源解析器」机制则更加灵活。在橙单中,最为典型的场景就是「多租户的物理隔离机制」。该用例需要解析当前请求用户的 tenantId 值,并根据该值进行多数据源类型值的动态切换,具体实现步骤如下。

  • 为服务实现类 (TeacherServiceImpl) 添加用于多数据源切换的解析器注解  @MyDataSourceResolver,该注解参数指定了具体的解析器实现类 TenantDataSourceResolver。
// 由于TeacherServiceImpl被MyDataSourceResolver注解标记,切面类DataSourceResolveAspect会自动织入
// 下面的getTeacherListWithRelation方法,也就是说,切面的方法会在该查询方法被调用之前先行执行。
@MyDataSourceResolver(resolver = TenantDataSourceResolver.class)
@Slf4j
@Service("teacherService")
public class TeacherServiceImpl extends BaseService<Teacher, Long> implements TeacherService {
   // ... ... 这里省略该服务实现类中的大量其他代码。
   
   @Override
   public List<Teacher> getTeacherList(Teacher filter, String orderBy) {
       return teacherMapper.getTeacherList(null, null, filter, orderBy);
   }
}
  • 上一步中的注解 @MyDataSourceResolver 会被 DataSourceResolveAspect 切面类织入,在被织入方法 (getTeacherList) 执行前,先行调用解析器实现类 (TenantDataSourceResolver) 的 resolve 方法,并根据该方法的返回值进行多数据源的动态切换。
@Aspect
@Component
@Order(1)
@Slf4j
public class DataSourceResolveAspect {
   private final Map<Class<? extends DataSourceResolver>, DataSourceResolver> resolverMap = new HashMap<>();

   // 所有配置MyDataSourceResovler注解的Service实现类。
   @Pointcut("execution(public * com.orangeforms.demo.multitenant..service..*(..)) " +
           "&& @target(com.orangeforms.demo.multitenant.common.core.annotation.MyDataSourceResolver)")
   public void datasourceResolverPointCut() {
       // 空注释,避免sonar警告
   }
   @Around("datasourceResolverPointCut()")
   public Object around(ProceedingJoinPoint point) throws Throwable {
       // clazz为上面的TeacherServiceImpl类。
       Class<?> clazz = point.getTarget().getClass();
       // 获取添加到TeacherServiceImpl类上的MyDataSourceResolver注解对象。
       MyDataSourceResolver dsr = clazz.getAnnotation(MyDataSourceResolver.class);
       // 获取注解参数,既上一步中的TenantDataSourceResolver解析器实现类。
       Class<? extends DataSourceResolver> resolverClass = dsr.resolver();
       // 通过bean搜索的方式,获取该解析器的bean实例。
       DataSourceResolver resolver = ApplicationContextHolder.getBean(resolverClass);
       // 下面调用的就是TenantDataSourceResolver的resolve方法,该方法会根据参数值,动态计算
       // 当前请求数据所在的数据源。
       int type = resolver.resolve(dsr.arg(), point.getArgs());
       // 下面的代码执行了多数据源的切换。
       Integer originalType = DataSourceContextHolder.setDataSourceType(type);
       log.debug("set datasource is " + type);
       try {
           // 这里就是调用上例中的getTeacherList的方法了。
           return point.proceed();
       } finally {
           // 执行业务方法后,将多数据源的类型值,恢复如初。
           DataSourceContextHolder.unset(originalType);
           log.debug("unset datasource is " + originalType);
       }
   }
}
  • 再介绍一下解析器实现类 TenantDataSourceResolver 的 resolve 方法,是如何根据输入参数,动态计算当前请求的数据源类型值。
@Component
public class TenantDataSourceResolver implements DataSourceResolver {
   // 上面代码中的 int type = resolver.resolve(dsr.arg(), point.getArgs()),就是调用的该方法。
   @Override
   public int resolve(String arg, Object[] methodArgs) {
       // 获取当前用户会话的TokenData对象。
       TokenData tokenData = TokenData.takeFromRequest();
       // 并从当前会话对象中,获取当前租户所在的数据库路由键值。
       Integer databaseRouteKey = tokenData.getDatabaseRouteKey();
       // 这里就是我们自己的示例代码,是hardcode的,只是为了代码演示方便。
       // 每个数据源路由键databaseRouteKey,会对应一个数据源类型值,同时返回该类型值,
       // 以便spring完成多数据源的动态切换。
       if (databaseRouteKey == 1) {
           return DatasourceType.UPMS_ADMIN;
       } else if (databaseRouteKey == 2) {
           return DatasourceType.ORANGE_FORM_TEST;
       }
       throw new MyRuntimeException("Unsupported DatabaseRouteKey")
   }
}
  • 最后补充说明一下,前一小节与本小节介绍的多数据源切换机制是完全相同,都是利用 Spring  内置的动态路由多数据源机制。不同的是,前者的数据源类型值是静态的定义在 @MyDataSource 注解中,而后者则是通过自定义解析器实现类 (DataSourceResolver) 的解析方法 (resolve),根据参数动态计算本次请求所需使用的数据源类型值。

数据脱敏

对于脱敏字段,我们会在数据表中存储原始数据,而非脱敏后数据。另外需要突出强调的是,本小节中的所有示例代码,均会根据生成器中的配置,自动生成。

代码生成

  • 在生成器中,为指定字段配置「是否脱敏」标记,同时配置该字段的「脱敏规则」。需要注意的是,只有「字符型」字段才能设置脱敏标记。
  • 在生成后代码的实体类中,会为脱敏字段添加「@MaskField」注解,并在注解参数中,设置上图配置的「脱敏规则」 ,详见以下代码及关键性注释说明。
@Data
@TableName(value = "zz_knowledge")
public class Knowledge {
   @TableId(value = "knowledge_id")
   private Long knowledgeId;
   // 内置类型的处理相对简单,将maskType参数设置为MaskFieldTypeEnum的其他枚举值即可。
   // MyCustomMaskFieldHandler是我们默认生成的自定义规则处理器对象,仅作为占位符使用。实际开发中一定不要直接使用。
   // 在后面的文档中,我们将以自己编写的YourCustomFieldHandler为例。
   // @MaskField(maskType = MaskFieldTypeEnum.CUSTOM, handler = MyCustomMaskFieldHandler.class)
   @MaskField(maskType = MaskFieldTypeEnum.CUSTOM, handler = YourCustomMaskFieldHandler.class)
   @TableField(value = "knowledge_name")
   private String knowledgeName;
   
   // ... ... 省略其他字段定义。
}

内置脱敏规则

具体可参考 common-core 模块中 MaskFieldUtil 类的工具方法注释。该方法的实现基本来自于 hutool 的工具类。重写是因为 hutool 中实现的方法不能指定掩码字符参数 「maskChar」,仅支持「星号 (*)」作为默认的掩码字符。

自定义注解规则

在上面的截图中,我们可以为字段配置「自定义」类型的脱敏规则。默认生成的代码中,将使用 MyCustomMaskFieldHandler 处理器对象作为默认的自定义处理器类。在实际的开发中,请使用自己开发的自定义脱敏处理器对象,从而避免在 common-core 的代码中包含任何业务元素。目前我们提供两种自定义脱敏处理的实现方式。

  • 在自己开发的 YourCustomMaskFieldHandler 中,根据实体对象名 (modelName) 和对象字段名 (fieldName) 参数进行判断,为不同的对象字段提供不同的脱敏逻辑。
@Component
public class YourCustomMaskFieldHandler implements MaskFieldHandler {
   @Override
   public String handleMask(String modelName, String fieldName, String data, char maskChar) {
       // 1. 实现类必须是bean对象,如当前类用@Component注解标记。
       // 2. 可以让多个脱敏字段的自定义规则,都使用同一个处理器。
       // 3. 为了保证文档的连贯性,这里继续使用前面配置的Knowledge对象和knowledgeName字段为例。
       if (StrUtil.equals("Knowledge", modelName) && StrUtil.equals(fieldName, "knowledgeName")) {
           return MaskFieldUtil.noMaskPrefixAndSuffix(data, 1, 1, maskChar);
       } else {
           // 这里可以添加更多的else if条件,分别为不同表的不同脱敏字段,提供不同的脱敏处理分支。
           return data;
       }
   }
}
  • 实现新的自定义脱敏处理器对象,并在 @MaskField 注解的「handler」参数中指定。需要重点说明的是,自定义脱敏处理机类必须为「Bean」对象。
@Data
@TableName(value = "zz_knowledge")
public class Knowledge {
   @TableId(value = "knowledge_id")
   private Long knowledgeId;
 
   // 下面的KnowledgeNameCustomMaskFieldHandler处理器类,仅仅是我们的说明示例,
   // 表示可以为不同的脱敏字段提供独立的自定义处理器对象,该处理器可以不在判断modelName和fieldName,而是仅实现knowledgeName的脱敏处理逻辑。
   @MaskField(maskType = MaskFieldTypeEnum.CUSTOM, handler = KnowledgeNameCustomMaskFieldHandler.class)
   @TableField(value = "knowledge_name")
   private String knowledgeName;
   // ... ... 为了节省篇幅,省略其他字段的定义。
}

脱敏数据显示

  • 出于安全起见,在默认生成的「/list」和「/view」接口代码中,在返回数据之前进行脱敏处理。以下代码为「仅仅主表」中存在脱敏字段的示例,请务必详细阅读代码中的关键性注释。

重点解释一下,我们为何没有在接口参数中提供类似的 ignoreMaskFields 字段名集合参数。主要还是考虑到安全原因,因为路由表单的代码比较容易修改,我们更推荐不同的接口代码,固定给出哪些脱敏字段是可以忽略的,而不是完全来自于参数动态处理。

@RestController
@RequestMapping("/admin/app/knowledge")
public class KnowledgeController {
   @PostMapping("/list")
   public ResponseResult<MyPageData<KnowledgeVo>> list(
           @MyRequestBody KnowledgeDto knowledgeDtoFilter,
           @MyRequestBody MyOrderParam orderParam,
           @MyRequestBody MyPageParam pageParam) {
       if (pageParam != null) {
           PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize());
       }
       Knowledge knowledgeFilter = MyModelUtil.copyTo(knowledgeDtoFilter, Knowledge.class);
       String orderBy = MyOrderParam.buildOrderBy(orderParam, Knowledge.class);
       List<Knowledge> knowledgeList = 
               knowledgeService.getKnowledgeListWithRelation(knowledgeFilter, orderBy);
       // 对查询列表进行脱敏处理,maskFieldDataList的第二个参数,是无需进行脱敏处理的脱敏Java对象字段名集合,
       // 如CollUtil.newHashSet("mobilePhone")。如果是null,表示全部脱敏字段均需做脱敏处理。
       knowledgeService.maskFieldDataList(knowledgeList, null);
       return ResponseResult.success(MyPageUtil.makeResponseData(knowledgeList, Knowledge.INSTANCE));
   }
   @GetMapping("/view")
   public ResponseResult<KnowledgeVo> view(@RequestParam Long knowledgeId) {
       if (MyCommonUtil.existBlankArgument(knowledgeId)) {
           return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST);
       }
       Knowledge knowledge = knowledgeService.getByIdWithRelation(knowledgeId, MyRelationParam.full());
       if (knowledge == null) {
           return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST);
       }
       // 针对单个对象的脱敏处理方法。
       knowledgeService.maskFieldData(knowledge, null);
       KnowledgeVo knowledgeVo = Knowledge.INSTANCE.fromModel(knowledge);
       return ResponseResult.success(knowledgeVo);
   }
   // ... ... 为了节省篇幅,省略其他接口方法的代码。
}
  • 以下代码为「主表及其关联表」中同时存在脱敏字段的示例,请务必详细阅读代码中的关键性注释。
@PostMapping("/list")
public ResponseResult<MyPageData<KnowledgeVo>> list(
       @MyRequestBody KnowledgeDto knowledgeDtoFilter,
       @MyRequestBody MyOrderParam orderParam,
       @MyRequestBody MyPageParam pageParam) {
   if (pageParam != null) {
       PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize());
   }
   Knowledge knowledgeFilter = MyModelUtil.copyTo(knowledgeDtoFilter, Knowledge.class);
   String orderBy = MyOrderParam.buildOrderBy(orderParam, Knowledge.class);
   // 注意,默认生成的代码中,list接口不会动态集成一对多关联,因此当且仅当"一对一"关联中存在脱敏字段时,
   // 下面的查询为getKnowledgeList,而非getKnowledgeListWithRelation,也就是说,下面的查询将
   // 仅仅返回主表数据列表。
   List<Knowledge> knowledgeList = knowledgeService.getKnowledgeList(knowledgeFilter, orderBy);
   MyRelationParam relationParam = MyRelationParam.normal();
   // 手动传入需要忽略的脱敏的脱敏字段,数据项格式为"关联从表实体对象名.对象属性名",如SysUser.mobilePhone。
   // 如果所有关联表中的脱敏字段均不忽略,直接传递null即可。
   relationParam.setIgnoreMaskFieldSet(null);
   // 手动调用从表的数据关联,该方法执行后,关联从表中的数据,将会完成脱敏。
   knowledgeService.buildRelationForDataList(knowledgeList, relationParam);
   // 主表数据字段脱敏。需要注意的是,第二个参数只需传递主表对象的字段名即可,如mobilePhone。
   knowledgeService.maskFieldDataList(knowledgeList, null);
   return ResponseResult.success(MyPageUtil.makeResponseData(knowledgeList, Knowledge.INSTANCE));
}
@GetMapping("/view")
public ResponseResult<KnowledgeVo> view(@RequestParam Long knowledgeId) {
   if (MyCommonUtil.existBlankArgument(knowledgeId)) {
       return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST);
   }
   // 注意,默认生成的代码中,view接口会同时动态集成一对一和一对多关联,因此当这些关联中存在脱敏字段时,
   // 下面的查询为getById,而非getByIdWithRelation,也就是说,下面的查询将仅仅返回主表数据对象。
   Knowledge knowledge = knowledgeService.getById(knowledgeId);
   if (knowledge == null) {
       return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST);
   }
   // 下面选择full,就会同时关联一对一和一对多从表数据。
   MyRelationParam relationParam = MyRelationParam.full();
   // 手动传入需要忽略的脱敏的脱敏字段,数据项格式为"关联从表实体对象名.对象属性名",如SysUser.mobilePhone。
   // 如果所有关联表中的脱敏字段均不忽略,直接传递null即可。
   relationParam.setIgnoreMaskFieldMap(null);
   // 手动调用从表的数据关联,该方法执行后,关联从表中的数据,将会完成脱敏。
   knowledgeService.buildRelationForData(knowledge, relationParam);
   // 主表数据字段脱敏。需要注意的是,第二个参数只需传递主表对象的字段名即可,如mobilePhone。
   knowledgeService.maskFieldData(knowledge, null);
   KnowledgeVo knowledgeVo = Knowledge.INSTANCE.fromModel(knowledge);
   return ResponseResult.success(knowledgeVo);
}
  • 前端直接显示脱敏处理后的数据值。

脱敏数据添加

在数据新增时,用户输入的脱敏字段数据中,不能包含该字段的脱敏掩码字符,否则后台接口会报错,并给出相应的提示。以下代码片段为数据新增接口调用的服务方法,用于新数据的保存。

@Transactional(rollbackFor = Exception.class)
@Override
public Knowledge saveNew(Knowledge knowledge) {
   // 下面的函数是BaseService中的公用方法,会扫描当前对象中带有MaskField注解的
   // 字段,并根据注解参数maskChar获取掩码字符,再和当前对象的脱敏字段值比对,
   // 如果包含掩码字符,就会抛出异常,并给出具体的错误信息。
   // 我们会在MyExceptionHandler中,统一拦截MyRuntimeException异常,并返回
   // 具体的错误信息。
   this.verifyMaskFieldData(knowledge);
   knowledgeMapper.insert(this.buildDefaultValue(knowledge));
   return knowledge;
}

脱敏数据更新

  • 更新操作提交的脱敏字段数据中,如果不包含「掩码字符」 ,则视为新数据,并更新该字段的值。

  • 当包含「掩码字符」时,会在更新过程中,与该字段的原数据脱敏结果进行比对,如相同,则视为该字段数据没有变化,继续使用原数据。如不相同,则返回错误信息「数据验证失败,不能仅修改部分脱敏数据!」。

  • 以下代码片段为数据更新接口调用的服务方法,用于更新后的数据保存。
@Transactional(rollbackFor = Exception.class)
@Override
public boolean update(Knowledge knowledge, Knowledge originalKnowledge) {
   // 以下方法为BaseService中的公用方法,会扫描当前对象中带有MaskField注解的
   // 字段,然后进行上一步中介绍的新老数据比对,验证,原数据数据覆盖,以及验证失败后
   // 的异常抛出等。我们会在MyExceptionHandler中,统一拦截MyRuntimeException异
   // 常,并返回具体的错误信息。
   this.compareAndSetMaskFieldData(knowledge, originalKnowledge);
   knowledge.setCreateUserId(originalKnowledge.getCreateUserId());
   knowledge.setCreateTime(originalKnowledge.getCreateTime());
   UpdateWrapper<Knowledge> uw = 
           this.createUpdateQueryForNullValue(knowledge, knowledge.getKnowledgeId());
   return knowledgeMapper.update(knowledge, uw) == 1;
}

异常拦截

在业务应用中,存在大量的异常需要处理,如权限不足、数据验证失败、数据操作违规等。对于这些异常的处理方式,我们也会根据业务场景上的差异,采取不同的处理措施,具体方式如下。

  • 对于可知异常,如果需要返回准确的错误信息给前端,我们通常原地即时处理。见如下代码:
@PostMapping("/update")
public ResponseResult<Void> update(
       @MyRequestBody SysPermCodeDto sysPermCodeDto, 
       @MyRequestBody String permIdListString) {
   // 前面忽略若干代码 ... ...
   try {
       if (!sysPermCodeService.update(sysPermCode, originalSysPermCode, permIdSet)) {
           errorMessage = "数据验证失败,当前权限字并不存在,请刷新后重试!";
           return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, errorMessage);
       }
   } catch (DuplicateKeyException e) {
       errorMessage = "数据操作失败,权限字编码已经存在!";
       return ResponseResult.error(ErrorCodeEnum.DUPLICATED_UNIQUE_KEY, errorMessage);
   }
   return ResponseResult.success();
}
  • 对于有些通用性较强的业务异常,为了保持代码的整洁度,我们可以对其进行统一处理。最典型的实现方式是,利用 Spring 内置的 @RestControllerAdvice 注解,统一拦截并处理对外抛出的异常,见如下代码示例。
@Slf4j
@RestControllerAdvice
public class MyExceptionHandler {
      
   @ExceptionHandler(value = Exception.class)
   public ResponseResult<?> exceptionHandle(Exception ex, HttpServletRequest request) {
       log.error("Unhandled exception from URL [" + request.getRequestURI() + "]", ex);
       return ResponseResult.error(ErrorCodeEnum.UNHANDLED_EXCEPTION);
   }
  
   @ExceptionHandler(value = DuplicateKeyException.class)
   public ResponseResult<?> duplicateKeyExceptionHandle(Exception ex, HttpServletRequest request) {
       log.error("DuplicateKeyException exception from URL [" + request.getRequestURI() + "]", ex);
       return ResponseResult.error(ErrorCodeEnum.DUPLICATED_UNIQUE_KEY);
   }
  
   @ExceptionHandler(value = RedisCacheAccessException.class)
   public ResponseResult<?> redisCacheAccessExceptionHandle(Exception ex, HttpServletRequest request) {
       log.error("RedisCacheAccessException exception from URL [" + request.getRequestURI() + "]", ex);
       if (ex.getCause() instanceof TimeoutException) {
           return ResponseResult.error(ErrorCodeEnum.REDIS_CACHE_ACCESS_TIMEOUT);
       }
       return ResponseResult.error(ErrorCodeEnum.REDIS_CACHE_ACCESS_STATE_ERROR);
   }
   // 中间忽略若干异常类型的处理代码 ... ...
}

字典缓存

在任何业务系统中,字典数据的应用都是非常广泛且极为高频的。因此,优化字典数据的处理效率,对于系统整体性能的提升,有着极为重要的帮助。目前业内最为通用做法是「全量缓存字典数据」,以尽可能缩短数据的访问时间,减少与数据库的交互次数。

我们目前已支持最为常见的四种字典类型如「常量字典」、「全局编码字典」、「字典表字典」和「数据表字典」。本小节仅介绍支持缓存功能的 「全局编码字典」和「字典表字典」。

全局编码字典

全局编码字典的相关代码位于 common-dict 包内,包含全局字典表 (zz_global_dict) 和全局编码字典数据项表 (zz_global_dict_item),具体可见下图。

缓存粒度

如上图所示,全局编码字典的缓存粒度是按照「字典编码」进行划分的,即每个编码字典都有独立的缓存键 (Redis Key),这样在查询字典数据时,仅需获取指定编码字典的数据即可,以便降低 Redis 的网络开销。以下代码示例中的 getGlobalDictItemListFromCache 方法,会被所有字典查询接口调用。

  • 通过字典编码 dictCode,计算 Redis 缓存键。
  • 根据字典编码的 Redis 缓存键,先从 Redis 中获取字典数据,如果存在就直接返回。
  • 如不存在,则会从数据表 zz_global_dict_item 中获取,并将查询结果同步到 Redis,最后返回给调用方法。
// 该代码位于common-dict包内的GlobalDictServiceImpl.java文件。
@Override
public List<GlobalDictItem> getGlobalDictItemListFromCache(String dictCode, Set<Serializable> itemIds) {
   if (CollUtil.isNotEmpty(itemIds) && !(itemIds.iterator().next() instanceof String)) {
       itemIds = itemIds.stream().map(Object::toString).collect(Collectors.toSet());
   }
   List<GlobalDictItem> dataList;
   RMap<Serializable, String> cachedMap =
           redissonClient.getMap(RedisKeyUtil.makeGlobalDictKey(dictCode));
   if (cachedMap.isExists()) {
       Map<Serializable, String> dataMap =
               CollUtil.isEmpty(itemIds) ? cachedMap.readAllMap() : cachedMap.getAll(itemIds);
       dataList = dataMap.values().stream()
               .map(c -> JSON.parseObject(c, GlobalDictItem.class)).collect(Collectors.toList());
   } else {
       dataList = globalDictItemService.getGlobalDictItemListByDictCode(dictCode);
       this.putCache(dictCode, dataList);
       if (CollUtil.isNotEmpty(itemIds)) {
           Set<Serializable> tmpItemIds = itemIds;
           dataList = dataList.stream()
                   .filter(c -> tmpItemIds.contains(c.getItemId())).collect(Collectors.toList());
       }
   }
   return dataList;
}

缓存失效

字典数据的缓存粒度是基于「字典编码」的 ,因此编码字典数据的任何变化,都会导致当前编码字典的缓存失效。在如下所示的代码中,我们仅给出了「新增编码字典数据项」的方法,其「修改」和「删除」操作同样也会导致当前编码字典的缓存数据失效。

// 该代码位于common-dict包内的GlobalDictItemServiceImpl.java文件。
@Override
public GlobalDictItem saveNew(GlobalDictItem globalDictItem) {
   // 在插入新数据之前,先移除当前dictCode的缓存字典数据。
   globalDictService.removeCache(globalDictItem.getDictCode());
   globalDictItem.setId(idGenerator.nextLongId());
   // 忽略部分无关代码 ... ...
   globalDictItemMapper.insert(globalDictItem);
   return globalDictItem;
}

字典表字典

字典表字典是基于字典表的数据字典。字典表通常仅包含字典值、字典显示名和字典状态等字段。见下图示例。

缓存粒度

每个字典表字典对应一个数据库字典表,在 Redis 的缓存中亦是如此。这里以橙单示例项目中的「年级字典 (Grade)」为例,其所依赖的字典表就是上图所示的 zz_grade。通过如下代码可以看出,所有的字典表字典服务实现类 (GradeServiceImpl),必须继承自 BaseDictService 基类。

@Slf4j
@Service("gradeService")
public class GradeServiceImpl extends BaseDictService<Grade, Integer> implements GradeService {
   @Autowired
   private GradeMapper gradeMapper;
   @Autowired
   private RedissonClient redissonClient;
   @PostConstruct
   public void init() {
       this.dictionaryCache = RedisDictionaryCache.create(
               redissonClient, "Grade", Grade.class, Grade::getGradeId);
   }
   
   @Override
   protected BaseDaoMapper<Grade> mapper() {
       return gradeMapper;
   }
}

以下代码位于 common-core 包内的 BaseDictService 类中,示例中的 getAllListFromCache 方法,会被所有字典查询接口调用。

@Override
public List<M> getAllListFromCache() {
   // 由此可见,每个字典表字典都会有自己的独立缓存。
   // 缓存键的计算细节,可以自行参考RedisDictionaryCache实现类中的代码。
   List<M> resultList = dictionaryCache.getAll();
   // 如果缓存中存在,就直接返回了。
   if (CollUtil.isNotEmpty(resultList)) {
       return resultList;
   }
   // 如果不存在,就会重新独立字典表数据,并同步到缓存中。
   this.reloadCachedData(true);
   // 再次从换存中读取并返回。
   return dictionaryCache.getAll();
}

缓存失效

字典表数据的「增删改」操作,都会引发该字典表的缓存数据失效。以下代码仅以删除字典数据为例。

// 该段代码同样位于common-core包内的BaseDictService.java文件中。
@Transactional(rollbackFor = Exception.class)
@Override
public boolean remove(K id) {
   // 在删除字典表数据之前,先将该字典的缓存数据全部删除。
   dictionaryCache.invalidateAll();
   // 从数据库字典表中删除指定的字典数据。
   return mapper().deleteById(id) == 1;
}

强制刷新

尽管我们已经实现了相对比较严谨的缓存数据同步逻辑,但仍然存在各种意外场景,会导致缓存与数据表中的数据不一致。通过下图操作,可完成字典数据的强制缓存同步。

规则编码计算

对于在线表单、路由表单和流程工单,我们可以为指定字段设置编码计算规则。

配置示例

  • 在线表单。
  • 流程工单。

技术实现

  • - 基于 Redisson 的原子计算器对象 RAtomicLong 实现,不仅可以保证计算后数据单调递增且不会重复,与此同时,在高并发场景下依然可以保持卓越的性能。
  • - 编码的计算规则为 RAtomicLong 对象在 Redis 中的 KEY,具体计算方式为 前缀 + 精确到时间 + 后缀 = Redis KEY。如:前缀 (HT) + 精确到月 (20230520) + 后缀 (BH) = HT20230520BH。
  • - 完整编码值的计算规则为 前缀 + 精确到时间 + 后缀 + RAtomicLong 对象的计算值 (宽度不足前面补0)。如:前缀 (HT) + 精确到月 (20230520) + 后缀 (BH) + 2 (ID宽度为5)= HT20230520BH00002。
  • - 具体代码详见 CommonRedisUtil 工具类。下面给出相关的代码实现详解和关键性注释。
// 该方法用于计算Redis RAtomicLong对象的KEY。从下面的代码可以更为清晰的了解到KEY的计算规则。
public String calculateTransIdPrefix(String prefix, String precisionTo, String middle) {
   String key = prefix;
   if (key == null) {
       key = "";
   }
   DateTime dateTime = new DateTime();
   switch (precisionTo) {
       case "YEAR":
           key = key + dateTime.toString("yyyy");
           break;
       case "MONTH":
           key = key + dateTime.toString("yyyyMM");
           break;
       case "DAYS":
           key = key + dateTime.toString("yyyyMMdd");
           break;
       case "HOURS":
           key = key + dateTime.toString("yyyyMMddHH");
           break;
       case "MINUTES":
           key = key + dateTime.toString("yyyyMMddHHmm");
           break;
       case "SECONDS":
           key = key + dateTime.toString("yyyyMMddHHmmss");
           break;
       default:
           throw new UnsupportedOperationException("Only Support YEAR/MONTH/DAYS/HOURS/MINUTES/SECONDS");
   }
   return middle != null ? key + middle : key;
}
public String generateTransId(String prefix, String precisionTo, String middle, int idWidth) {
   TimeUnit unit = EnumUtil.fromString(TimeUnit.class, precisionTo, null);
   int unitCount = 1;
   // 因为Redis KEY过期参数TimeUnit并不支持月份和年,所以要转化为DAYS的枚举值。
   if (unit == null) {
       unit = TimeUnit.DAYS;
       DateTime now = DateTime.now();
       if (StrUtil.equals(precisionTo, "MONTH")) {
           DateTime endOfMonthDay = DateUtil.endOfMonth(now);
           // KEY的过期时间为月底最后一天距离当前天的天数。
           unitCount = endOfMonthDay.getField(DateField.DAY_OF_MONTH) - now.getField(DateField.DAY_OF_MONTH) + 1;
       } else if (StrUtil.equals(precisionTo, "YEAR")) {
           DateTime endOfYearDay = DateUtil.endOfYear(now);
           // KEY的过期时间为年底最后一天距离当前天的天数。
           unitCount = endOfYearDay.getField(DateField.DAY_OF_YEAR) - now.getField(DateField.DAY_OF_YEAR) + 1;
       }
   }
   // 根据参数计算出Redis AtomicLong对象的KEY值。
   String key = this.calculateTransIdPrefix(prefix, precisionTo, middle);
   RAtomicLong atomicLong = redissonClient.getAtomicLong(key);
   long value = atomicLong.incrementAndGet();
   // 如果等于1,说明之前并不存在,而是本次请求新建的KEY,因此需要为其设置过期时间。
   if (value == 1L) {
       atomicLong.expire(unitCount, unit);
   }
   // 拼接最终计算后编码的时候,计算器值value,如果不足指定宽度,前面则补0。
   return key + StrUtil.padPre(String.valueOf(value), idWidth, "0");
}

可靠性补偿

通过上一小节我们可以了解到编号计数器是基于 Redisson 的 RAtomicLong 对象实现的,如果当前 Redis 被误执行 flushall 命令,或出现数据破坏性崩溃时,之前的计数器值均会丢失。对于精确到小时、天、月和年这种时间跨度较长的编码规则,由于重新计算后的计数器会从 1 开始计算,那么该值极有可能已经存在于数据表的编码字段中,这样插入操作就会抛出「重复键的异常」。 下图为规则编码数据高可靠性自动补偿的逻辑流程图,具体实现代码可参考 FlowWorkOrderServiceImpl 的 saveNew 方法。

分布式事务

在橙单中,我们已为微服务和多租户工程提供了分布式事务的支持,所选技术组件为 Alibaba 开源的分布式事务框架 Seata。在默认生成的工程中,已经包含了 Seata 的完整集成和配置,本小节将对默认生成的代码和配置进行简单的解析,以便大家可以更好的理解与之相关的实现逻辑和注意事项。

  • 启动 seata-server 服务,在生成后工程的 zz-resource/docker-files/docker-compose.yml 文件中,已经包含了 seata-server 的 docker 启动项,因此正常启动 docker-compose.yml 文件即可。
  • 在所有需要支持 Seata 的业务数据库中,执行 zz-resource/db-scripts/seata-script.sql 脚本文件,创建 Seata 运行时所需的重做日志表 (undo_log)。
  • 在每个业务微服务的 pom.xml 中引用如下依赖。
<!-- 分布式事务组件。这里需要覆盖一下seata的版本,否则仍然使用spring-cloud-alibaba中自带版本 -->
<dependency>
   <groupId>com.alibaba.cloud</groupId>
   <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
   <exclusions>
       <exclusion>
           <artifactId>seata-spring-boot-starter</artifactId>
           <groupId>io.seata</groupId>
       </exclusion>
   </exclusions>
</dependency>
<dependency>
   <groupId>io.seata</groupId>
   <artifactId>seata-spring-boot-starter</artifactId>
   <version>${seata.version}</version>
</dependency>
<dependency>
   <groupId>io.seata</groupId>
   <artifactId>seata-serializer-kryo</artifactId>
   <version>${seata.version}</version>
</dependency>
  • 在每个业务微服务的启动配置文件 (bootstrap.yml) 中,添加如下配置。
seata:
  client:
    undo:
      log-serialization: kryo
  tx-service-group: my_test_tx_group
  service:
    grouplist:
      default: 127.0.0.1:8091
  vgroup-mapping: my_test_tx_group
  • 这里我们以课程表 (zz_course) 和班级表 (zz_studnet_class) 的多对多关系为例,他们分别位于不同的数据库中,其中多对多关联表 (zz_class_course) 和班级表同库。
  • 以下为「课程删除」的代码实现。在删除课程时,需要同时删除与之存在多对多关联的「班级课程表 (zz_class_course)」数据。由于 zz_course 和 zz_class_course 位于不同的数据库中,因此需要使用分布式事务来保证两者之间的数据一致性。详见以下代码和关键性注释。
// 1. 此时课程数据是主表数据,因此需要在课程的remove接口添加Seata的全局事务注解@GlobalTransactional。
// 2. 同时也要添加本地事务注解@Transactional。
@GlobalTransactional(rollbackFor = Exception.class)
@Transactional(rollbackFor = Exception.class)
@Override
public boolean remove(Long courseId) {
   // 先删除课程表(主表)数据。
   if (courseMapper.deleteById(courseId) == 0) {
       return false;
   }
   // 开始删除与远程多对多关联的“班级课程表(zz_class_course)”数据。
   ResponseResult<Integer> courseResult = studentClassClient.deleteClassCourseByCourseId(courseId);
   // 下面的代码处理方式非常非常重要,如果远程服务执行失败,一定要在主表的事务中,手动回滚整个分布式事务。
   // 同时还要主动抛出异常,回滚本地事务数据。
   if (!courseResult.isSuccess()) {
       String errorMessage = MyCommonUtil.makeDeleteRelationGlobalTransError(
               getClass(), studentClassClient.getClass(), courseResult.getErrorMessage(), courseId);
       log.error(errorMessage);
       try {
           // 手动回滚整个分布式事务。
           // 这是因为我们为FeignClient接口提供了降级逻辑,因此即便远程接口抛出了异常,
           // 也会被降级逻辑吞掉,并以错误信息和错误码的方式返回给调用者。正是因为降级方法
           // 吞掉了异常,所以这里我们要主动调用分布式事务的全局回滚方法,通知seata-server,
           // 回滚所有事务数据。
           GlobalTransactionContext.reload(RootContext.getXID()).rollback();
       } catch (TransactionException e) {
           e.printStackTrace();
       }
       // 主动抛出异常。
       throw new MyRuntimeException(errorMessage);
   }
   return true;
}
  • 在上述代码逻辑中,被远程调用的 deleteClassCourseByCourseId 接口方法的代码示例。
// 和普通接口的代码实现完全一样。
@PostMapping("/deleteClassCourseByCourseId")
public ResponseResult<Integer> deleteClassCourseByCourseId(@RequestParam Long courseId) {
   Integer removeCount = studentClassService.removeClassCourseByCourseId(courseId);
   return ResponseResult.success(removeCount);
}
// 删除多对多中间表数据的Service方法,仅用本地事务注解(@Transactional)标注即可。
@Transactional(rollbackFor = Exception.class)
@Override
public Integer removeClassCourseByCourseId(Long courseId) {
   ClassCourse classCourse = new ClassCourse();
   classCourse.setCourseId(courseId);
   return classCourseMapper.delete(new QueryWrapper<>(classCourse));
}

分布式ID

我们使用了 Snowflake 算法用于分布式 ID 的计算。在开始阅读本小节之前,推荐您先阅读以下两篇文章。

Snowflake的优势

分布式 ID 通常用于数据表主键字段的计算,相比于自增 ID 和 UUID 又有哪些技术优势呢?我们先看一下他们之间的基本对照。

  Snowflake 自增主键 UUID
数据库兼容性 不好
跨库全局唯一性 支持 不支持 支持
数值类型 数值型 数值型 字符型
存储空间
数据顺序性 随时间递增 顺序递增 无序
  • 不同数据库的自增主键实现方式不同,比如 Oracle 是基于 Sequence 的。因此在开发支持多种数据库的应用时,这种不兼容型多少会带来一些开发上的不便。
  • 在分库分表的情况下,自增 ID 无法做到数据主键的全局唯一,比如按区域划分的「订单表」位于多个数据库时,我们仍然会要求其主键 ID 是全局唯一的。
  • 与 UUID 相比,查询和存储性能更高。因为 UUID 只能生成字符型数据,而 Snowflake 可以计算出随时间顺序单调递增的数值型数据。两者相比,数值型数据的查询比较和数据存储的效率,都明显优于字符型数据。
  • 与 UUID 相比,作为主键 ID 时,数据插入效率更高。UUID 只能生成随机的字符型数据,而 Snowflake 可以计算出随时间顺序单调递增的数值型数据。两者相比,因为 UUID 数据是无序的,插入数据时,将会引发数据表数据存储位置的频繁变动,因此会严重影响高频数据插入的性能。

解决时钟问题

Snowflake 的计算过程是依赖时钟的,因此在进行时间同步时 (NTP),如果主机时间在同步后倒拨,那么就有可能导致计算出的 ID 出现重复。在橙单中,我们照搬并裁剪了「美团 LEAF」的实现方式,考虑到大家阅读上的连续性,下面给出了「美团 LEAF」解决时间倒拨问题的代码流程图。

单体服务集成

  • 在业务服务的 pom 中依赖 common-sequence 组件,其底层实现是基于 hutool 自带的 Snowflake 功能。
  • 在业务服务的配置文件中 (application-dev.yml),添加如下配置。

重点!如果同一服务启动多个服务实例,切记每个服务实例的如下参数必须不同,请使用命令行动态传入。    

如:java -jar orange-forms-demo.jar --sequence.snowflakeWorkNode=xxx

common-sequence:
  # Snowflake 分布式Id生成算法所需的WorkNode参数值。
  snowflakeWorkNode: 1
  • 在橙单中,为了同时兼容 hutool 和「美团 LEAF」,我们提供了封装器对象 IdGeneratorWrapper,通过该对象可以更为方便的在两者之间进行切换,同时对于自定义的扩展实现,也能快速接入。
@Component
public class IdGeneratorWrapper {
   @Autowired
   private IdGeneratorProperties properties;

   // Id生成器接口对象。
   private MyIdGenerator idGenerator;

   // 今后如果支持更多Id生成器时,可以在该函数内实现不同生成器的动态选择。
   @PostConstruct
   public void init() {
       idGenerator = new BasicIdGenerator(properties.getSnowflakeWorkNode());
   }
   
   // 由于底层实现为synchronized方法,因此计算过程串行化,且线程安全。
   public long nextLongId() {
       return idGenerator.nextLongId();
   }
   // ... ... 省略部分代码实现。
}
  • 在服务实现类中,直接引用 IdGeneratorWrapper 的 BEAN 对象,在方法代码中直接使用即可。
@Slf4j
@Service("courseService")
public class CourseServiceImpl extends BaseService<Course, Long> implements CourseService {
   @Autowired
   private CourseMapper courseMapper;
   @Autowired
   private IdGeneratorWrapper idGenerator;
   // ... ... 省略部分代码实现。
   @Transactional(rollbackFor = Exception.class)
   @Override
   public Course saveNew(Course course) {
       // 数值型主键直接使用nextLongId即可。
       course.setCourseId(idGenerator.nextLongId());
       TokenData tokenData = TokenData.takeFromRequest();
       course.setCreateUserId(tokenData.getUserId());
       Date now = new Date();
       course.setCreateTime(now);
       course.setUpdateTime(now);
       courseMapper.insert(course);
       return course;
   }
   // ... ... 省略部分代码实现。
}

微服务集成

  • 在业务服务的 pom 中依赖 common-sequence 组件,其底层实现是基于「美团 LEAF」开源库。在橙单中,我们对其进行了裁剪,只是保留了最为核心的几个代码文件,有兴趣的开发者可以参考 common-sequence 模块内的 SnowflakeIdGenerator.java 和 SnowflakeZookeeperHolder.java。
  • 在业务服务的配置文件中,添加如下配置。

重点!因为我们使用了「美团 LEAF」开源库的核心代码,因此无需像单体服务那样,为每个微服务实例设置 Snowflake 算法所需的 WorkerId 配置值。因为在「美团 LEAF」的内部实现中,Zookeeper 会保证不同服务实例所用的 WorkId 是全局唯一的。

common-sequence:
  # 是否使用基于美团Leaf的分布式Id生成器。
  advanceIdGenerator: true
  # 多个zk服务之间逗号分隔。
  zkAddress: localhost:2181
  # 与本机的ip一起构成zk中标识不同服务实例的key值。
  idPort: 19000
  # zk中生成WorkNode的路径。不同的业务可以使用不同的路径,以免冲突。
  zkPath: com/orangeforms/multi
  • 在业务代码中的集成和使用方式,与上面的单体服务无异,这里不再重复给出了。

服务间接口

本小节内容主要面向微服务工程。

配置生成

在橙单生成器中,通过如下配置即可为「数据源」生成远程调用接口,以及对应的接口实现代码。

接口代码

在生成后的微服务工程中,我们会为每个业务服务生成两个子模块,分别是 xxxx-api 和 xxxx-service。其中远程调用接口,及其相关的文件均位于 xxxx-api 中,以便于其他业务微服务依赖后调用。

对于支持「远程调用」的数据源,我们会生成 FeignClient 接口、DTO 和 VO 对象等三个代码文件,具体可见下图中的「CourseClient.java」、「CourseDto.java」和「CourseVo.java」。

服务降级

我们会为每个 FeignClient 接口对象,提供一个与之对应的「接口降级工厂类」,该方式是 Spring Cloud 处理服务降级的内置机制,见如下代码示例。

// 利用FeignClient注解参数,指定该接口的降级对象。
// 为了保持代码逻辑的紧凑性,我们使用静态内部类的方式声明了与该FeignClient接口
// 对应的降级处理类 CourseClient.CourseClientFallbackFactory。
@FeignClient(
       name = "course-paper",
       configuration = FeignConfig.class,
       fallbackFactory = CourseClient.CourseClientFallbackFactory.class)
public interface CourseClient extends BaseClient<CourseDto, CourseVo, Long> {

   // 基于主键的(In-list)条件获取远程数据接口。
   @Override
   @PostMapping("/course/listByIds")
   ResponseResult<List<CourseVo>> listByIds(
           @RequestParam("courseIds") Set<Long> courseIds,
           @RequestParam("withDict") Boolean withDict);

   // 基于主键Id,获取远程对象。
   @Override
   @PostMapping("/course/getById")
   ResponseResult<CourseVo> getById(
           @RequestParam("courseId") Long courseId,
           @RequestParam("withDict") Boolean withDict);
   // ... ... 为了节省篇幅,省略了大部分自动生成的远程调用接口方法。
 
   // 该降级类必都被注册为bean。
   @Component("CoursePaperCourseClientFallbackFactory")
   @Slf4j
   class CourseClientFallbackFactory
           extends BaseFallbackFactory<CourseDto, CourseVo, Long, CourseClient> implements CourseClient {
       // 这里只有create方法用于创建降级对象。其他对调用接口对应的降级方法声明,
       // 均在BaseFallbackFactory中,提供了缺省实现。如有需求,可以在当前类重载。
       @Override
       public CourseClient create(Throwable throwable) {
           // 这个log是非常有必要的。因为如果远程接口抛出异常,就需要错误异常栈输出到日志文件,
           // 以便于后期调试和问题定位。
           // 从FeignClient降级机制来讲,如果这里不输出准确的错误异常,
           // 后面就没有地方再能输出了,因为降级接口会吃掉远程接口抛出的异常。
           // 调用方只能通过返回值获取具体的错误信息,但是无法通过异常栈快速定位远程接口的问题了。
           log.error("Exception For Feign Remote Call.", throwable);
           return new CourseClientFallbackFactory();
       }
   }
}

接口实现

对于以上示例代码中的 CourseClient.java 接口方法 listByIds 和 getById,我们会在 xxxx-service 业务实现模块内,生成对应的 Controller 接口方法,见如下代码示。

@Slf4j
@RestController
@RequestMapping("/course")
public class CourseController extends BaseController<Course, CourseDto, Long> {
   // ... ... 为了节省篇幅,省略其他无关代码。
 
   // 这里看到listByIds和getById接口在CourseClient中也有定义。
   // 他们的方法签名必须完全一致。另外,他们指向的url接口地址也必须完全一致。
   // 为了避免出现大量重复代码,我们在BaseController中提供了大量的缺省实现。
   // 很多情况下,直接调用即可。
   @PostMapping("/listByIds")
   public ResponseResult<List<CourseVo>> listByIds(
           @RequestParam Set<Long> courseIds, @RequestParam Boolean withDict) {
       return super.baseListByIds(courseIds, withDict, Course.INSTANCE);
   }
  
   @PostMapping("/getById")
   public ResponseResult<CourseVo> getById(
           @RequestParam Long courseId, @RequestParam Boolean withDict) {
       return super.baseGetById(courseId, withDict, Course.INSTANCE);
   }
}

调用示例

在调用者服务 xxxx-service 的 pom 中,需要依赖被调用服务的 xxxx-api 子模块,见下图。

如以下代码所示,我们以 Bean 的方式依赖远程接口对象 TeacherClient,该 FeignClient 接口类是在上图所示的 upms-api 模块中声明的。具体的调用方式就是正常的 Java 对象间调用。这里我们集成了 FeignClient 服务间调用的降级机制,因此下面的远程调用代码 (teacherClient.existId) 不会抛出异常,一旦调用失败,只会返回被调用接口的错误信息,或降级接口提供的默认错误信息,而不会输出远程调用接口的异常栈错误信息。该问题的具体配置方式,请参考下面的「调用错误日志」小节。

@Service
public class CourseServiceImpl extends BaseService<Course, CourseDto, Long> implements CourseService {
   @Autowired
   private TeacherClient teacherClient;
   // 中间忽略若干代码 ... ...
 
   public CallResult verifyRemoteRelatedData(
           Course course, Course originalCourse) {
       String errorMessageFormat = "数据验证失败,关联的%s并不存在,请刷新后重试!";
       if (this.needToVerify(course, originalCourse, Course::getTeacherId)) {
           ResponseResult<Boolean> responseResult = teacherClient.existId(course.getTeacherId());
           if (this.hasErrorOfVerifyRemoteRelatedData(responseResult)) {
               return CallResult.error(String.format(errorMessageFormat, "主讲老师Id"));
           }
       }
       // 中间省略若干代码 ... ...
       return CallResult.ok();
   }
}

调用错误日志

缺省情况下,服务间的调用日志是不会被输出的,这对于开发阶段的调试是非常不便的,下面我们就介绍一下如何通过配置解决这一问题。注意,所有的配置修改都要在 「配置中心服务 Nacos」中配置才能生效。

  • 打开微服务工程中共享配置文件 resources/config/application-dev.yml,见下图红框标记部分,日志输出的最详细级别是 full,该级别会将所有调用数据全部输出,不仅包括调用地址、执行时间,同时还有 request-headers 和 request-body 等。在生产环境中,我们建议将该值设置为 basic。更多细节可仔细阅读下图中的配置注释部分。
  • 这里以 upms 服务为例,打开本地资源目录下的 resources/config/upms-dev.yml 文件。通过下图我们可以看到,工程包名下所有日志的输出级别为 info,然而这一级别是无法输出 FeignClient 的调用日志,因此我们需要将 FeignClient 接口所在包的日志级别设置为 debug。下图以 upms 调用 course-paper 服务为例。

存储插件

在橙单目前的版本中,我们已经提供了「本地存储、Minio 分布式存储、阿里云、腾讯云和华为云的分布式存储」四种存储类型的插件实现,本小节将以 Minio 分布式存储插件为例进行详解。

插件开发

  • 自动化配置加载,该功能为 Spring Boot 的内置机制。具体可见 common-minio 组件内的 resources/META-INF/spring.factories 文件。
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.demo.single.common.minio.config.MinioAutoConfiguration   
  • 与上面对应的自动化配置对象的代码实现。
@EnableConfigurationProperties(MinioProperties.class)
@ConditionalOnProperty(prefix = "minio", name = "enabled")
public class MinioAutoConfiguration {

   // 将minio原生的客户端类封装成bean对象,便于集成,同时也可以灵活使用客户端的所有功能。
   @Bean
   @ConditionalOnMissingBean
   public MinioClient minioClient(MinioProperties p) {
       MinioClient client = new MinioClient(p.getEndpoint(), p.getAccessKey(), p.getSecretKey());
       if (!client.bucketExists(p.getBucketName())) {
           client.makeBucket(p.getBucketName());
       }
       return client;
   }
   @Bean
   @ConditionalOnMissingBean
   public MinioTemplate minioTemplate(MinioProperties p, MinioClient c) {
       return new MinioTemplate(p, c);
   }
}
  • 在上一步的自动加载配置类 MinioAutoConfiguration 中,通过注解 EnableConfigurationProperties 引用了配置属性对象 MinioProperties。
@Data
@ConfigurationProperties(prefix = "minio")
public class MinioProperties {

   // 访问入口地址。
   private String endpoint;

   // 访问安全的key。
   private String accessKey;

   // 访问安全的密钥。
   private String secretKey;

   // 缺省桶名称。
   private String bucketName;
}
  • 最后是插件对象的具体代码实现。请重点关注插件自动注册功能的实现。
@Component
@ConditionalOnProperty(prefix = "minio", name = "enabled")
public class MinioUpDownloader extends BaseUpDownloader {
   @Autowired
   private MinioTemplate minioTemplate;
   @Autowired
   private UpDownloaderFactory factory;
   // 将当前类的this对象注册到工厂对象中,以便当调用者传递的存储类型与当前对象注册的
   // 类型匹配时,工厂方法可以将当前已注册的实现类返回给调用方法,从而最大限度实现解耦。
   @PostConstruct
   public void doRegister() {
       factory.registerUpDownloader(UploadStoreTypeEnum.MINIO_SYSTEM, this);
   }
   // 下面省略部分实现代码 ... ...
}

业务应用

  • 在业务服务的 pom 中依赖相关的分布式存储插件,如 common-minio。
  • 在业务服务的配置文件中 (application-dev.yaml),添加所依赖插件的配置项。本小节仅以 common-minio 的配置项为例。阿里云和腾讯云存储插件的配置项是与之不同的。
minio:
  # 改为false,可以禁用该组件。
  enabled: true
  endpoint: http://localhost:19000
  accessKey: admin
  secretKey: admin123456
  bucketName: application
  • 在业务服务中,需要为支持「上传和下载」的实体类字段,添加注解 @UploadFlagColumn,并在注解参数中指定存储类型。
@Data
@TableName(value = "zz_course")
public class Course {

   // 主键Id。
   @TableId(value = "course_id")
   private Long courseId;
   
   // ... ... 忽略部分代码。
   
   // 注解参数指定了MINIO为存储插件。
   @UploadFlagColumn(storeType = UploadStoreTypeEnum.MINIO_SYSTEM)
   @TableField(value = "picture_url")
   private String pictureUrl;
}
  • 最后一步就是在业务服务的「上传和下载」接口中,使用指定的插件来实现我们文件存储的功能。补充一句,该功能代码可以由代码生成器生成,无需从 0 开始手写。
@OperationLog(type = SysOperationLogType.UPLOAD, saveResponse = false)
@PostMapping("/upload")
public void upload(
       @RequestParam String fieldName,
       @RequestParam Boolean asImage,
       @RequestParam("uploadFile") MultipartFile uploadFile) throws Exception {
   UploadStoreInfo storeInfo = MyModelUtil.getUploadStoreInfo(Course.class, fieldName);
   // 这里就会判断参数中指定的字段,是否支持上传操作。
   if (!storeInfo.isSupportUpload()) {
       ResponseResult.output(HttpServletResponse.SC_FORBIDDEN,
               ResponseResult.error(ErrorCodeEnum.INVALID_UPLOAD_FIELD));
       return;
   }
   // 根据字段注解中的存储类型,通过工厂方法获取匹配的上传下载实现类,从而解耦。
   BaseUpDownloader upDownloader = upDownloaderFactory.get(storeInfo.getStoreType());
   UploadResponseInfo responseInfo = upDownloader.doUpload(appConfig.getServiceContextPath(),
           appConfig.getUploadFileBaseDir(), Course.class.getSimpleName(), fieldName, asImage, uploadFile);
   if (responseInfo.getUploadFailed()) {
       ResponseResult.output(HttpServletResponse.SC_FORBIDDEN,
               ResponseResult.error(ErrorCodeEnum.UPLOAD_FAILED, responseInfo.getErrorMessage()));
       return;
   }
   cacheHelper.putSessionUploadFile(responseInfo.getFilename());
   ResponseResult.output(ResponseResult.success(responseInfo));
}

ELK日志跟踪

该功能仅微服务和多租户工程可用,具体功能如下。

  • 每个业务微服务根据日志框架 (log4j2 / logback) 定义的输出格式,将日志数据发送到 Kafka。
  • ELK 中的 Logstash 组件,负责从 Kafka 中抽取日志数据,并集中存入 ElasticSearch 组件。
  • 开发者可通过 ELK 中的 Kibana 组件,进行日志数据的搜索和可视化查询。

组件启动

在工程的 zz_resource/docker-files/ 子目录下,执行以下命令,启动所需的全部中间件组件。

docker-compose -f docker-compose-full.yml up -d

log4j2日志配置

  • 在每个业务服务的 resources 子目录下,包含日志框架所需的配置文件 log4j2.xml。
  • 在 properties 标签中,添加 LOG_PATTERN 属性配置日志输出格式。其中 [%traceId] 是链接调用监控工具 (SkyWalking) 所需的服务调用链路跟踪全局唯一 ID。在后面的「链路监控」小节中会给出更为详细的介绍。
  • 在 appenders 标签中,添加 Kafka 子标签,同时设定 Kafka 的地址和日志主题。
  • 最后在 Loggers 标签中,配置日志输出添加器,将上一步配置的 kafka_log 加入其中。
  • 具体配置见下图,重点部分已用红框标记。需要说明的是,为了配合截图,日志文件内容有所删减。

logback日志配置

  • 在每个业务服务的 resources 子目录下,包含日志框架所需的配置文件 logback-spring.xml。
  • 添加 property 标签,name 为 LOG_PATTERN,value 为日志输出格式。其中 {PtxId} 和 {PspanId} 是链接调用监控工具 (PinPoint) 所需的服务调用链路跟踪全局唯一 ID。在后面的「链路监控」小节中会给出更为详细的介绍。
  • 添加 appender 标签,name 是 kafka_log,class 为 com.github.danielwegener.logback.kafka.KafkaAppender,同时设定 Kafka 的地址和日志主题。
  • 最后在 logger 标签中,配置日志输出添加器,将上一步配置的 kafka_log 加入其中。
  • 具体配置见下图,重点部分已用红框标记。需要说明的是,为了配合截图,部分日志文件内容被折叠。

启动业务服务

这里可以仅启动 gateway 和 upms 服务,并调用登录接口完成正常登录即可。业务服务启动可参考开发文档 [服务配置章节微服务启动小节](../service-config/#微服务)。

日志查询

所有的业务服务日志采集工作,都是由 Kafka + ELK 自动完成的。最后需要做的是,访问 Kibana 控制台首页 localhost:5601,配置日志索引后即可看到如下页面。

链路监控

该功能仅微服务和多租户工程可用。橙单目前已支持 SkyWalking 和 PinPoint 两种最为主流的微服务链路调用跟踪工具。

SkyWalking

  • 搭建 SkyWalking 的服务环境,具体可参考开发文档 环境准备章节
  • 在工程的 zz_resource/docker-files 子目录下执行如下命令,启动全部所需中间件。
docker-compose -f docker-compose-full.yml up -d
  • 配置业务微服务启动时所需的 SkyWalking Agent。具体可以参考开发文档 系统启动章节的应用服务启动小节
  • 在业务服务的日志配置文件中,添加 SkyWalking Agent 所需的 traceId 变量。
  • 启动 gateway 和 upms 等服务,注意启动项中必须包含 SkyWalking Agent 相关参数。
  • 在前端完成正常的登录操作。
  • 访问 Kibana 控制台 localhost:5601,查看 traceid 是否写入到日志信息中。

  • 拷贝该 traceId 值后,进入 SkyWalking 控制台,缺省访问地址为 http://localhost:8080。由于 8080 端口容易产生冲突,因此我们在开发文档的「环境准备章节」中,建议将控制台的缺省端口改为 8085。
  • 关于 SkyWalking 控制台,更多内容可参考官方文档。查看微服务调用链拓扑图。
  • 粘贴日志中的 traceId,然后在 SkyWalking 控制台中进行搜索。通过搜索结果可以看到,与我们在 ELK 中看到的调用接口完全一致,都是登录接口 /login/doLogin。下图右侧的红框中也标记出,当前的调用横跨了两个服务 gateway 和 upms。

  • 查看微服务调用栈。下图中红框已标记出,服务间以及服务内方法的调用栈,每一层调用的执行时间。另外在右上角的位置,我们还可以调整显示方式。

PinPoint

  • 搭建 PinPoint 的服务环境,具体参考开发文档 环境准备章节
  • 在工程的 zz_resource/docker-files 子目录下执行如下命令,启动全部所需中间件。
docker-compose -f docker-compose-full.yml up -d
  • 配置业务微服务启动时所需的 PinPoint Agent。具体可以参考开发文档 系统启动章节的应用服务启动小节
  • 在业务服务的日志配置文件中,添加 PinPoint Agent 所需的 PtxId 和 PspanId 变量。
  • 启动 gateway 和 upms 等服务,注意启动项中必须包含 PinPoint Agent 相关参数。

  • 在前端完成正常的登录操作。
  • 访问 Kibana 控制台 localhost:5601,查看 TxId 是否写入到日志信息中。

  • 拷贝上图中的 TxId 值,然后打开 PinPoint 控制台 localhost:8080,这里我们仅介绍几个常用的操作,更多内容可参考官方文档。粘贴日志中的 TxId,然后在 PinPoint 控制台中进行搜索。通过搜索结果可以看到,与我们在 ELK 中看到的调用接口完全一致,都是登录接口 /login/doLogin。下图右侧的红框中也标记出,当前的调用横跨了两个服务 gateway 和 upms。

  • 查看微服务调用栈。下图中红框已标记出,服务间以及服务内方法的调用栈,每一层调用的执行时间。另外在右上角的位置,我们还可以调整显示方式。

结语

赠人玫瑰,手有余香,感谢您的支持和关注,选择橙单,效率乘三,收入翻番。