oplog-spring-boot icon indicating copy to clipboard operation
oplog-spring-boot copied to clipboard

spring boot support for operation log

Java support License Maven Central GitHub Stars GitHub Forks GitHub issues GitHub Contributors GitHub repo size


本组件已经发布到 maven 中央仓库,依赖于 Spring Boot 3.0+、JDK 17+,大家可以体验一下。GAV信息如下:

<dependency>
	<groupId>io.github.dk900912</groupId>
	<artifactId>oplog-spring-boot-starter</artifactId>
	<version>1.4.2</version>
</dependency>

1 快速上手

分别实现OperatorServiceLogRecordPersistenceService接口,并将实现类声明为一个 Bean。更多拓展点,请大家自行阅读源码!!!

1.1 声明式风格

@Validated
@RestController
@RequestMapping(path = "/customer/v1/vpc")
public class VpcController {

   @OperationLog(
           bizCategory = BizCategory.FIND,
           bizTarget = "VPC", bizNo = "#target")
   @GetMapping
   public AppResult get(@RequestParam("target") String target) {
      return AppResult.builder().code(200).build();
   }

   @OperationLog(
           bizCategory = BizCategory.UPDATE,
           bizTarget = "VPC",
           bizNo = "#vpc.id",
           diffSelector = "io.github.xiaotou.oplog.VpcService#findVpcById(Long)"
   )
   @PostMapping
   public AppResult post(@RequestBody Vpc vpc) {
      return AppResult.builder().build();
   }
}

1.2 编程式风格

final SimpleOperationLogCallback<Object, Throwable> simpleOperationLogCallback
        = new SimpleOperationLogCallback<>(BizCategory.UPDATE, "VPC", 123L, bizNo -> vpcService.findVpcById((long)bizNo)) {
    @Override
    public Object doBizAction() {
        System.out.println("=== UPDATE VPC ===");
        return "success";
    }
};
operationLogTemplate.execute(simpleOperationLogCallback);

2 进阶

  1. 支持多租户,其实一个租户往往就是一个特定服务,比如:订单服务。租户信息可以通过spring.oplog.tenant配置项来指定。

  2. LogRecordPersistenceService用于持久化操作日志,接入方可以基于该接口来定制化持久化逻辑,如:MySQL、ElasticSearch 等; 如果不自行实现 LogRecordPersistenceService 接口,那么本组件会有一个默认的实现,持久化逻辑也就是仅输出一条日志。

    @Bean
    @ConditionalOnMissingBean(LogRecordPersistenceService.class)
    public LogRecordPersistenceService logRecordPersistenceService() {
        return new DefaultLogRecordPersistenceServiceImpl();
    }

显然,从上述内容可以看出:如果接入方自定实现了持久化逻辑并且将其声明为一个 Bean,那么本组件所声明的默认持久化策略将不再生效。OperatorService的拓展机制同样如此!

  1. 为什么要为 OperationLogPointcutAdvisor 设定 order 属性呢?或者说为什么对外提供spring.oplog.advisor.order配置项呢?OperationLog 注解并不局限于 Controller 层面,也可以将其用于 Service 中的业务方法,无论用于哪一层级,有时需要关注 OperationLogPointcutAdvisor 的执行顺序。 比如:当 OperationLog 注解应用于一个 Transactional 业务方法上,那也许要确保 OperationLogPointcutAdvisor 优先级高于 BeanFactoryTransactionAttributeSourceAdvisor,否则 OperationLogPointcutAdvisor 中的切面逻辑(持久化、RPC调用等)会拉长整个事务,如果大家想避免这种情况,那么这里就可以自行配置。

  2. 在同一个类中,如果业务方法 A 调用了业务方法 B,且 A 和 B 这俩方法都由 @OperationLog 标记,那么 B 方法中并不会记录操作日志,这是 Spring AOP 的老问题了,官方也提供了解决方法,比如使用AopContext.currentProxy()

  3. 在不同的类中,如果类 A 中方法 m1 调用了 类 B 中方法 m2,且 m1 与 m2 均由 @OperationLog 标记,那么在解析 bizNo 的过程中会不会串了呢?不会。

  4. 在数据更新场景中,往往需要对同一个类型的实例进行diff,用于实现某人对哪些字段内容进行了修改以及修改前后的内容。diff 功能依托于开源组件,而如何实现更新前后的实例查询(一般就是根据业务 ID 从数据库中查询一条数据)呢?有两个想法:

    1)定义一个DiffSelector接口,接入方可能需要定义非常多的实现类,对于接入方来说非常不友好;

    2)完全依托于@DiffSelector注解,该注解需要指定接入方 Service Bean 的名称、方法名、参数、参数类型,然后解析并反射调用方法,但这样会搞得@OperationLog注解很臃肿,难看。

    @RequestMapping注解得到了灵感,定义一个@DiffSelector注解,接入方将该注解标记在相关 Service Bean 的实例查询方法上,那么在程序启动阶段自动探测并构建方法名DiffSelectorMethod实例的映射关系,后续接入方只需要在@OperationLog注解中指定方法名即可。

  5. 业务 ID 并不局限于 String,也可以是 int、long 等,而 bizNo 解析出来的一定是一个 String 类型,所以这里涉及一个类型转换,直接使用ConversionService实现的

    private Object convertBizNoIfNecessary(Object bizNo, Class<?> bizNoClazz) {
        if (conversionService.canConvert(bizNoClazz, bizNoClazz)) {
            try {
                return conversionService.convert(bizNo, bizNoClazz);
            } catch (ConversionFailedException e) {
                logger.warn("BizNo convert failed, from {} to {}", bizNo.getClass(), bizNoClazz);
            }
        } else {
            logger.warn("ConversionService can not convert this bizNo, bizNo = {}", bizNo);
        }
        return bizNo;
    }
  1. diff仅仅支持普通的数据类型(基础数据类型、LocalDate、LocalDateTime、ZonedDateTime、LocalTime、Date 等),不支持集合等类型,但这一点应该是刚好够用了。

  2. diff结果在并发场景下是有可能串掉的,但这并不是本组件的 bug,应该是大家没有做好“对共享资源的互斥访问”吧。

  3. 在运行过程中,可能会提示若干条日志,如:Bean 'operationLogTemplate' of type [io.github.dk900912.oplog.support.OperationLogTemplate] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying) 。 大家不用慌张,直接忽略就好了,因为本组件声明的 Bean 并不需要走一遍所有的 BPP(比如有一个比较重要的 BPP 是用来生成代理 Bean 的,本组件所声明的 Bean 同样不需要为其生成代理类)。

  4. 在编程式更新场景中,DiffSelector 如何指定呢?直接塞进去一个Function即可,比如:bizNo -> vpcService.findVpcById((long)bizNo)

  5. OperationLogContext中保存了一些上下文信息,主要是围绕@OperationLog注解属性的一些内容,比如:OperationLogInfo实例和diff-selector查询到的previous content。而 OperationLogContext 实例贮存在何处呢? 没错,就是ThreadLocal,本组件内置了一个实现,即ThreadLocalOperationLogContextImplStrategy。当然,大家也可以基于ITLTTL来实现,这样的拓展是完全支持的,如下所示。

public class OperationLogSynchronizationManager {

	private static String strategyName = System.getProperty("spring.oplog.context.strategy");

	private static OperationLogContextImplStrategy strategy;

	static {
		initialize();
	}

	private OperationLogSynchronizationManager() {}

	private static void initialize() {
		if (!StringUtils.hasText(strategyName)) {
			strategyName = DEFAULT_CONTEXT_STRATEGY;
		}

		if (strategyName.equals(DEFAULT_CONTEXT_STRATEGY)) {
			strategy = new ThreadLocalOperationLogContextImplStrategy();
		} else {
			try {
				Class<?> clazz = Class.forName(strategyName);
				Constructor<?> customStrategy = clazz.getConstructor();
				strategy = (OperationLogContextImplStrategy) customStrategy.newInstance();
			} catch (Exception ex) {
				ReflectionUtils.handleReflectionException(ex);
			}
		}
	}
}

上面代码清晰地交代了替换OperationLogContextImplStrategy实现类的方式,即通过 VM Options 来追加-Dspring.oplog.context.strategy=xxx.TtlOperationLogContextImplStrategy

话说回来,究竟什么时候需要使用阿里的 TTL 替换 TL 呢?其实是没必要的,虽然 OperationLogContext 实例是有父 OperationLogContext 的,但目前代码中并不存在这样的逻辑:当前子OperationLogContext父OperationLogContext中获取继承的信息。 唯一的影响如下场景中:父子 OperationLogContext 实例的关联关系断掉了而已。

@Validated
@RestController
@RequestMapping(path = "/customer/v1/vpc")
public class VpcController {

    @OperationLog(
            bizCategory = BizCategory.FIND,
            bizTarget = "HI", bizNo = "#target")
    @GetMapping
    public AppResult get(@RequestParam("target") String target) {
        final VpcController o = (VpcController) AopContext.currentProxy();
        o.delete(target);
        return AppResult.builder().code(200).build();
    }

    @Async("customThreadPoolTaskExecutor")
    @OperationLog(
            bizCategory = BizCategory.DELETE,
            bizTarget = "HI", bizNo = "#target")
    @DeleteMapping
    public void delete(@RequestParam("target") String target) {
        System.out.println("deleted");
    }
}

DEBUG 日志如下:

2023-09-18T16:32:52.646+08:00 DEBUG 2684 --- [nio-8081-exec-1] i.g.d.o.a.a.OperationLogInterceptor      : 0={======> OperationLogContextSupport[id='1601237157', parent='0', context.operation_log_info='{bizCategory=FIND, bizTarget=HI, bizNo=999, diffSelector=}'] <======}=0
2023-09-18T16:32:52.657+08:00 DEBUG 2684 --- [nsole_network_1] i.g.d.o.a.a.OperationLogInterceptor      : 0={======> OperationLogContextSupport[id='756422871', parent='0', context.operation_log_info='{bizCategory=DELETE, bizTarget=HI, bizNo=999, diffSelector=}'] <======}=0

为什么会出现这样的问题呢?customThreadPoolTaskExecutor 线程池在启动阶段就已完成了初始化,TL 就是会串掉的,TTL 也正是为了解决这一问题而诞生的。

TL 替换为 TTL 后,再看 父子 OperationLogContext 实例的关联关系已经接上了:

2023-09-18T16:36:48.671+08:00 DEBUG 21304 --- [nio-8081-exec-1] i.g.d.o.a.a.OperationLogInterceptor      : 0={======> OperationLogContextSupport[id='554254994', parent='0', context.operation_log_info='{bizCategory=FIND, bizTarget=HI, bizNo=999, diffSelector=}'] <======}=0
2023-09-18T16:36:48.685+08:00 DEBUG 21304 --- [nsole_network_1] i.g.d.o.a.a.OperationLogInterceptor      : 0={======> OperationLogContextSupport[id='995785809', parent='554254994', context.operation_log_info='{bizCategory=DELETE, bizTarget=HI, bizNo=999, diffSelector=}'] <======}=0