代码生成

在橙单生成器中,仅当工程配置选择支持「操作日志」时,才会为生成后工程提供「操作日志」的采集、保存和查询等功能的前后端代码。

声明注解

在 Controller 的接口声明中添加 @OperationLog 注解,当接口被调用时,就会产生操作日志数据,并存入操作日志表 zz_sys_operation_log,详见以下代码和关键注释。

@OperationLog(type = SysOperationLogType.ADD)
@PostMapping("/add")
public ResponseResult<Integer> add(@MyRequestBody("materialEdition") MaterialEditionDto materialEditionDto) {
   // 忽略部分业务代码 ... ...
   return ResponseResult.success(materialEdition.getEditionId());
}
// 对于下载和导出等调用,通过将注解参数saveResponse设置为false,可以不保存应答数据。
@OperationLog(type = SysOperationLogType.DOWNLOAD, saveResponse = false)
@GetMapping("/download")
public void download(
       @RequestParam(required = false) Long courseId,
       @RequestParam String fieldName,
       @RequestParam String filename,
       @RequestParam Boolean asImage,
       HttpServletResponse response) {
   // 忽略业务代码 ... ...
}

操作类型

通过 OperationLog 注解的 type 参数,可为接口调用定义不同的操作类型。

public final class SysOperationLogType {

   // 其他。
   public static final int OTHER = -1;

   // 登录。
   public static final int LOGIN = 0;

   // 登出。
   public static final int LOGOUT = 5;

   // 新增。
   public static final int ADD = 10;

   // 修改。
   public static final int UPDATE = 15;

   // 删除。
   public static final int DELETE = 20;
   
   // ... ...
}

单体工程

单体工程的日志收集策略是将操作日志数据直接插入到日志数据库的操作日志表 zz_sys_operation_log。

服务配置

默认情况下,橙单生成器会为每个业务服务的配置文件,生成以下配置项。

common-log:
  # 操作日志配置,对应配置文件common-log/OperationLogProperties.java
  operation-log:
    enabled: true

日志收集

操作日志的数据采集是由 OperationLogAspect 切面类统一完成的,单体工程的日志收集仅支持数据表直接插入的方式,在目前的实现中,我们会将所有操作日志数据统一插入到操作日志表 zz_sys_operation_log。核心逻辑实现可见如下代码及其关键性注释,完整代码可参考 common-log 模块的 OperationLogAspect.java 文件。

@Aspect
@Slf4j
public class OperationLogAspect {

   // 所有controller方法。
   @Pointcut("execution(public * com.orangeforms..controller..*(..))")
   public void operationLogPointCut() {
       // 空注释,避免sonar警告
   }
   @Around("operationLogPointCut()")
   public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
       long start = System.currentTimeMillis();
       HttpServletRequest request = ContextUtil.getHttpRequest();
       HttpServletResponse response = ContextUtil.getHttpResponse();
     
       // 中间忽略部分实现代码 ... ...
       // 这里首先判断服务的配置中,是否enable了操作日志收集功能。
       boolean saveOperationLog = prop.isEnabled();
       if (saveOperationLog) {
           // 这里继续判断当前controller接口,是否添加了OperationLog注解。
           operationLogAnnotation = getOperationLogAnnotation(joinPoint);
           saveOperationLog = (operationLogAnnotation != null);
       }
       if (saveOperationLog) {
           // 根据当前请求数据和接口信息,组装操作日志实体对象的数据。
           operationLog = this.buildSysOperationLog(
                   operationLogAnnotation, joinPoint, params, traceId, tokenData);
       }
       try {
           // 调用controller中的接口方法。
           result = joinPoint.proceed();
           String respData = result == null ? "null" : JSON.toJSONString(result);
           // 记录接口执行时长。
           Long elapse = System.currentTimeMillis() - start;
           if (saveOperationLog) {
               // 根据接口执行后的数据,继续补充完善操作日志对象的数据。如:执行时长、应答数据等。
               this.operationLogPostProcess(operationLogAnnotation, respData, operationLog, result);
           }
       } catch (Exception e) {
           // 中间忽略部分实现代码 ... ...
           throw e;
       } finally {
           if (saveOperationLog) {
               // 对于单体工程,为了保证效率,这里是异步插入的。
               operationLog.setElapse(System.currentTimeMillis() - start);
               operationLogService.saveNewAsync(operationLog);
           }
       }
       return result;
   }
}

日志数据源

每个被注解 @OperationLog 标注的 Controller 接口方法,都会产生一条操作日志数据,并最终存入数据库的 zz_sys_operation_log 表中,由此可见,操作日志的表数据量可能会较大。为了尽可能降低对业务服务的影响,我们为操作日志独立出一个专有的数据源。开始时两个数据源可以同库,今后随着业务的发展,可以极为方便的将操作日志改到独立的专有数据库中。

业务服务配置成多数据源,其中一个固定的数据源类型值 ApplicationConstant.OPERATION_LOG_DATASOURCE_TYPE,是专门为「操作日志」所在数据库配置的。

在以下代码中,多数据配置类 MultiDataSourceConfig 也是属于业务服务的。

@Configuration
@EnableTransactionManagement
@MapperScan(value = {"com.orangeforms.webadmin.*.dao", "com.orangeforms.common.*.dao"})
public class MultiDataSourceConfig {
   @Bean(initMethod = "init", destroyMethod = "close")
   @ConfigurationProperties(prefix = "spring.datasource.druid.main")
   public DataSource mainDataSource() {
       return super.applyCommonProps(DruidDataSourceBuilder.create().build());
   }
 
   // 默认生成的用于保存操作日志的数据源,可根据需求修改。
   // 这里我们还是非常推荐给操作日志使用独立的数据源,这样便于今后的数据迁移。
   @Bean(initMethod = "init", destroyMethod = "close")
   @ConfigurationProperties(prefix = "spring.datasource.druid.operation-log")
   public DataSource operationLogDataSource() {
       return DruidDataSourceBuilder.create().build();
   }
 
   @Bean
   @Primary
   public DynamicDataSource dataSource() {
       Map<Object, Object> targetDataSources = new HashMap<>(2);
       targetDataSources.put(DataSourceType.MAIN, mainDataSource());
       // 忽略其他数据注入 ... ...
       // 这里的OPERATION_LOG = ApplicationConstant.OPERATION_LOG_DATASOURCE_TYPE。
       // 同时请务必确保OPERATION_LOG关联到上面定义的操作日志指定数据源。
       targetDataSources.put(DataSourceType.OPERATION_LOG, operationLogDataSource());
       DynamicDataSource dynamicDataSource = new DynamicDataSource();
       dynamicDataSource.setTargetDataSources(targetDataSources);
       dynamicDataSource.setDefaultTargetDataSource(mainDataSource());
       return dynamicDataSource;
   }
}

以下代码片段位于 common-log 模块的 SysOperationLogServiceImpl 类,请仔细阅读代码中给出的详细注释。

/**
 * 操作日志服务实现类。
 * 这里需要重点解释下MyDataSource注解。在单数据源服务中,没有DataSourceAspect的切面类,所以该注解不会
 * 有任何作用和影响。然而在多数据源情况下,由于每个服务都有自己的DataSourceType常量对象,表示不同的数据源。
 * 而common-log在公用模块中,不会依赖业务服务,因此我们给出一个固定值。在业务的DataSourceType中,也要
 * 使用该值ApplicationConstant.OPERATION_LOG_DATASOURCE_TYPE,去关联操作日志所需的数据源配置。
 */
@MyDataSource(ApplicationConstant.OPERATION_LOG_DATASOURCE_TYPE)
@Service
public class SysOperationLogServiceImpl extends BaseService<SysOperationLog, Long> implements SysOperationLogService {
 
   @Autowired
   private SysOperationLogMapper sysOperationLogMapper;
 
   // 操作操作日志。
   // 为了保证不影响请求的并发,这里采用了异步插入数据库的方式。
   @Async
   @Transactional(rollbackFor = Exception.class)
   @Override
   public void saveNew(SysOperationLog operationLog) {
       sysOperationLogMapper.insert(operationLog);
   }
}

微服务工程

微服务工程的日志收集策略支持将操作日志数据直接插入到日志数据库的操作日志表 zz_sys_operation_log,同时还支持将所有业务服务的操作日志发送到 Kafka,再由 Kafka 的消费者服务 operation-log-consumer 进行统一的收集处理,最后存入操作日志数据库的操作日志表 zz_sys_operation_log。

服务配置

默认情况下,橙单生成器会为每个业务服务的配置文件,生成以下配置项。在默认生成的配置中,我们使用了基于 kafka 的统一日志采集方式。

由于默认生成的微服务工程中,操作日志配置项 common-log.operation-log.useKafka = true,同时除「upms」权限服务之外的其他所有业务微服务,均未配置 operation-log 的数据源,因此如果要将 useKafka 改为 false,则需为所有业务微服务提供 operation-log 的多数据源配置。具体配置方式可见下面的「微服务日志数据源」小节。

common-log:
  # 操作日志配置,对应配置文件common-log/OperationLogProperties.java
  operation-log:
    enabled: true
    # 设置为true会使用kafka进行日志收集,否则将操作日志直接插入到操作日志数据库的操作日志表。
    useKafka: true
    kafkaTopic: SysOperationLog

日志收集

操作日志的数据采集是由 OperationLogAspect 切面类统一完成的,微服务工程的默认日志收集方式是将操作日志数据发送到 Kafka,再由独立的操作日志消费者服务 operation-log-consumer 进行统一的收集处理,最后存入操作日志数据库的操作日志表 zz_sys_operation_log 支持数据表直接插入的方式,核心逻辑实现可见如下代码及其关键性注释,完整代码可参考 common-log 模块的 OperationLogAspect.java 文件。

@Aspect
@Slf4j
public class OperationLogAspect {
 
   @Autowired
   private KafkaTemplate<String, Object> kafkaTemplate;
 
   // 所有controller方法。
   @Pointcut("execution(public * com.orangeforms..controller..*(..))")
   public void operationLogPointCut() {
       // 空注释,避免sonar警告
   }
   @Around("operationLogPointCut()")
   public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
       long start = System.currentTimeMillis();
       HttpServletRequest request = ContextUtil.getHttpRequest();
       HttpServletResponse response = ContextUtil.getHttpResponse();
     
       // 中间忽略部分实现代码 ... ...
       // 这里首先判断服务的配置中,是否enable了操作日志收集功能。
       boolean saveOperationLog = prop.isEnabled();
       if (saveOperationLog) {
           // 这里继续判断当前controller接口,是否添加了OperationLog注解。
           operationLogAnnotation = getOperationLogAnnotation(joinPoint);
           saveOperationLog = (operationLogAnnotation != null);
       }
       if (saveOperationLog) {
           // 根据当前请求数据和接口信息,组装操作日志实体对象的数据。
           operationLog = this.buildSysOperationLog(
                   operationLogAnnotation, joinPoint, params, traceId, tokenData);
       }
       try {
           // 调用controller中的接口方法。
           result = joinPoint.proceed();
           String respData = result == null ? "null" : JSON.toJSONString(result);
           // 记录接口执行时长。
           Long elapse = System.currentTimeMillis() - start;
           if (saveOperationLog) {
               // 根据接口执行后的数据,继续补充完善操作日志对象的数据。如:执行时长、应答数据等。
               this.operationLogPostProcess(operationLogAnnotation, respData, operationLog, result);
           }
       } catch (Exception e) {
           // 中间忽略部分实现代码 ... ...
           throw e;
       } finally {
           if (saveOperationLog) {
               operationLog.setElapse(System.currentTimeMillis() - start);
               // 对于微服务工程,会根据配置参数 useKafka 的配置值决定日志数据的采集方式。
               if (properties.isUseKafka()) {
                   kafkaTemplate.send(properties.getKafkaTopic(), JSON.toJSONString(operationLog));
               } else {
                   operationLogService.saveNewAsync(operationLog);
               }
           }
       }
       return result;
   }
}

日志数据源

由于默认生成的微服务工程中,操作日志配置项 common-log.operation-log.useKafka = true,同时因为只有「upms」权限服务需要访问操作日志表 zz_sys_operation_log,并提供操作日志的查询功能。因此默认情况下,我们只为该服务的配置文件生成了 operation-log 数据源配置,具体可见工程中的 upms-dev.yaml 配置文件。

如果需要将配置项 useKafka 改为 false,需要将本小节所属的代码和配置项,复制到其他业务微服务。

多数据源配置,其中一个固定的数据源类型值 ApplicationConstant.OPERATION_LOG_DATASOURCE_TYPE,是专门为「操作日志」所在数据库配置的。

日志消费服务

仅当每个业务微服务的 common-log.operation-log.useKafka 配置值为 true 时,日志消费者服务 operation-log-consumer 才需启动。在该服务中,消费者监听器对象 OperationLogConsumer 会批量读取 Kafka 中的消息数据,并批量插入到操作日志数据库的操作日志表中,从而保证整个采集过程的效率最优化。

@Component
public class OperationLogConsumer {
 
   // 中间忽略部分代码 ... ...
 
   // 注解中使用了批量消费工厂对象(batchFactory)。
   @KafkaListener(topics = {"${common-log.operation-log.kafkaTopic}"}, containerFactory = "batchFactory")
   public void listen(List<ConsumerRecord<?, ?>> recordList, Acknowledgment ack) {
       if (CollectionUtils.isNotEmpty(recordList)) {
           List<SysOperationLog> operationLogList = new LinkedList<>();
           for (ConsumerRecord<?, ?> record : recordList) {
               Optional<?> message = Optional.ofNullable(record.value());
               if (message.isPresent()) {
                   SysOperationLog operationLog =
                           JSONObject.parseObject(message.get().toString(), SysOperationLog.class);
                   operationLogList.add(operationLog);
               }
           }
           // 执行数据库的批量插入。
           if (CollectionUtils.isNotEmpty(operationLogList)) {
               sysOperationLogService.batchSave(operationLogList);
           }
       }
       ack.acknowledge();
   }
}

多租户工程

多租户工程包含两个应用,分别是「租户管理」和「租户运营」。其中前者为单体工程,而后者为微服务架构,因此单体工程租户管理「tenant-admin」的操作日志采集机制和单体工程完全一致,既直接将操作日志数据插入到操作日志数据库中的操作日志表 zz_sys_operation_log,具体实现可参考本章前面的 单体工程配置 小节。为了简化初始环境的搭建过程,橙单默认使用了「租户管理」服务的业务数据库,用户可根据实际需求进行修改。反观基于微服务架构的租户运营服务,则默认使用了微服务架构的操作日志采集策略,具体实现可参考本章前面的 微服务工程配置 小节。

最后,我们补充说明一下为什么要将租户运营和租户管理的操作日志数据分开,具体原因如下。

  • 安全隔离。租户管理系统通常为租户平台内部使用,因此要尽量减少外部网络对其产生的负面影响。
  • 性能原因。租户运营的操作日志中,每个租户管理员只能查看租户自己的操作日志数据,所以租户运营系统的操作日志表,需要为 tenant_id 字段创建索引。而对于租户管理系统的操作日志表,则无需为 tenant_id 字段创建索引。

线上问题调试

本小节仅面向微服务工程 (包括多租户工程)。这里主要介绍如何打通操作日志和 ELK + SkyWalking 的关联关系,以便于我们可以快速定位并解决生产环境中的运行时问题。

  • 在 OperationLogAspect 的切面方法中,首先尝试获取当前请求的 traceId,如不存在,则为该请求生成 traceId,并同时将该 TraceId 存储到 zz_sys_operation_log 操作日志表和基于日志框架 (log4j2 / logback) 的本地日志文件中。
  • 下图以日志框架 log4j2 的配置为例,在 LOG_PATTERN_EX 中将操作日志的 traceId 和 SkyWalking 的 traceId 同时输出到 ELK 中,从而彻底打通「操作日志 + ELK + SkyWalking」的关联关系。
  • 到 ELK 中查询该 traceId,可以获取到该请求输出的更多日志信息。特别需要注意的是 SkyWalking 中的 traceId。
  • 通过上一步在 ELK 中得到的 SkyWalking TraceId,我们可以继续在 SkyWalking 中查询整个调用链路的更多执行细节。

结语

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