多数据源
本小节主要介绍 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。
- 查看微服务调用栈。下图中红框已标记出,服务间以及服务内方法的调用栈,每一层调用的执行时间。另外在右上角的位置,我们还可以调整显示方式。
结语
赠人玫瑰,手有余香,感谢您的支持和关注,选择橙单,效率乘三,收入翻番。