如何优雅的记录操作日志()

1. 背景 日志几乎存在于所有系统中,开发调试日志的记录我们有log4j,logback等来实现,但对于要展示给用户看的日志,我并没有发现一个简单通用的实现方案。所以决定为之后的开发项目提供一个通用的操作日志组件。
2. 系统日志和操作日志 所有系统都会有日志,但我们区分了 系统日志 和 操作日志

  • 系统日志:主要用于开发者调试排查系统问题的,不要求固定格式和可读性
  • 操作日志:主要面向用户的,要求简单易懂,反映出用户所做的动作。
通过操作日志可追溯到 某人在某时干了某事情,如:
租户 操作人 时间 操作 内容
A租户 小明 2022/2/27 20:15:00 新增 新增了一个用户:Mr.Wang
B租户 大米 2022/2/28 10:35:00 更新 修改订单 [xxxxxx] 价格为 xx 元
C租户 老王 2022/2/28 22:55:00 查询 查询了名为: [xx] 的所有交易
3. 需要哪些功能 3.1 诉求:
  1. 基于SpringBoot能够快速接入
  2. 对业务代码具有低入侵性
3.2 解决思路:
基于以上两点,我们想想如何实现。
spingboot快速接入,需要我们来自定义spring boot starter;
业务入侵性低,首先想到了AOP,一般操作日志都是在增删改查的方法中,所以我们可以使用注解在这些方法上,通过AOP拦截这些方法。
3.3 待实现:
因此,我们需要实现以下功能:
  • 自定义spring boot starter
  • 定义日志注解
  • AOP拦截日志注解方法
  • 定义日志动态内容模板
模板中又需要实现:
  • 动态模板表达式解析:用强大的SpEL来解析表达式
  • 自定义函数:支持目标方法前置/后置的自定义函数
3.4 展现
所以我们最终期望的大概是这样:
@EasyLog(module = "用户模块", type = "新增", content = "测试 {functionName{#userDto.name}}", condition = "#userDto.name == 'easylog'") public String test(UserDto userDto) { return "test"; }

4. 实现步骤 4.1 定义日志注解
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface EasyLog { String tenant() default ""; String operator() default ""; String module() default ""; String type() default ""; String bizNo() default ""; String content(); String fail() default ""; String detail() default ""; String condition() default ""; }

字段 意义 支持SpEl表达式 必填
tenant 租户,SAAS系统中区分不同租户
operator 操作者
module 模块,区分不同业务模块
type 操作类型,形如:增删改查
bizNo 业务编号,便于查询
content 日志模板内容
fail 操作失败时的模板内容
detail 额外的记录信息
condition 是否记录的条件 (默认:true 记录)
4.2 自定义函数
【如何优雅的记录操作日志()】这里的自定义函数,并不是指SpEL中的自定义函数,因为SpEL中的自定义函数必须是静态方法才可以注册到其中,因为静态方法使用中并没有我们自己定义方法来的方便,所以这里的自定义函数仅仅指代我们定义的一个普通方法。
public interface ICustomFunction { /** * 目标方法执行前 执行自定义函数 * @return 是否是前置函数 */ boolean executeBefore(); /** * 自定义函数名 * @return 自定义函数名 */ String functionName(); /** * 自定义函数 * @param param 参数 * @return 执行结果 */ String apply(String param); }

我们定义好自定义函数接口,实现交给使用者。使用者将实现类交给Spring容器管理,我们解析的时候从Spring容器中获取即可。
4.3 SpEL表达式解析
主要牵涉下面几个核心类:
  • 解析器ExpressionParser,用于将字符串表达式转换为Expression表达式对象。
  • 表达式Expression,最后通过它的getValute方法对表达式进行计算取值。
  • 上下文EvaluationContext,通过上下文对象结合表达式来计算最后的结果。
ExpressionParser parser =new SpelExpressionParser(); // 创建一个表达式解析器 StandardEvaluationContext ex = new StandardEvaluationContext(); // 创建上下文 ex.setVariables("name", "easylog"); // 将自定义参数添加到上下文 Expression exp = parser.parseExpression("'欢迎你! '+ #name"); //模板解析 String val = exp.getValue(ex,String.class); //获取值

我们只需要拿到日志注解中的动态模板即可通过SpEL来解析。
4.4 自定义函数的解析
我们采用 { functionName { param }} 的形式在模板中展示自定义函数,解析整个模板前,我们先来解析下自定义函数,将解析后的值替换掉模板中的字符串即可。
if (template.contains("{")) { Matcher matcher = PATTERN.matcher(template); while (matcher.find()) { String funcName = matcher.group(1); String param = matcher.group(2); if (customFunctionService.executeBefore(funcName)) { String apply = customFunctionService.apply(funcName, param); } } }

4.5 获取操作者信息
一般我们都是将登录者信息存入应用上下文中,所以我们不必每次都在日志注解中指出,我们可统一设置,定义一个获取操作者接口,由使用者实现。
public interface IOperatorService { // 获取当前操作者 String getOperator(); // 当前租户 String getTenant(); }

4.6 定义日志内容接收
我们要将解析完成后的日志内容实体信息发送给我们的使用者,所以我们需要定义一个日志接收的接口,具体的实现交给使用者来实现,无论他接收到日志存储在数据库,MQ还是哪里,让使用者来决定。
public interface ILogRecordService { /** * 保存 log * @param easyLogInfo 日志实体 */ void record(EasyLogInfo easyLogInfo); }

4.7 定义AOP拦截
@Aspect @Component @AllArgsConstructor public class EasyLogAspect {@Pointcut("@annotation(**.EasyLog)") public void pointCut() {}// 环绕通知 @Around("pointCut() && @annotation(easyLog)") public Object around(ProceedingJoinPoint joinPoint, EasyLog easyLog) throws Throwable {//前置自定义函数解析 try { result = joinPoint.proceed(); } catch (Throwable e) { } //SpEL解析 //后置自定义函数解析 return result; } }

4.8 自定义 spring boot starter
创建自动配置类,将定义的一些来交给Spring容器管理:
@Configuration @ComponentScan("**") public class EasyLogAutoConfiguration {@Bean @ConditionalOnMissingBean(ICustomFunction.class) @Role(BeanDefinition.ROLE_APPLICATION) public ICustomFunction customFunction(){ return new DefaultCustomFunction(); }@Bean @ConditionalOnMissingBean(IOperatorService.class) @Role(BeanDefinition.ROLE_APPLICATION) public IOperatorService operatorGetService() { return new DefaultOperatorServiceImpl(); }@Bean @ConditionalOnMissingBean(ILogRecordService.class) @Role(BeanDefinition.ROLE_APPLICATION) public ILogRecordService recordService() { return new DefaultLogRecordServiceImpl(); } }

上一篇我已经完整的介绍了如何自定义 spring boot starter ,可去参考:
如何自定义 spring boot starter ?
5. 我们可以学到什么? 你可以拉取easy-log源码,用于学习,通过easy-log你可以学到:
  • 注解的定义及使用
  • AOP的应用
  • SpEL表达式的解析
  • 自定义 Spring boot starter
  • 设计模式
6. 源码
  • GitHub: https://github.com/flyhero/easy-log
  • Gitee: https://gitee.com/flyhero/easy-log

    推荐阅读