Skip to content

使用注解优雅记录系统日志,操作日志,后端埋点等,支持SpEL表达式,自定义上下文,自定义函数,实体类DIFF等其他高阶处理。

License

Notifications You must be signed in to change notification settings

qqxx6661/log-record

Folders and files

NameName
Last commit message
Last commit date

Latest commit

57ba684 · Jun 1, 2024
May 15, 2024
Jun 1, 2024
Jun 1, 2024
Jun 1, 2024
Aug 9, 2022
Aug 9, 2022
Aug 9, 2022
Feb 12, 2022
Jun 1, 2024

Repository files navigation

log-record


注意:本仓库最初灵感来源于美团技术博客 ,若您需要寻找的是原文中作者的代码仓库,可以跳转这里 。本仓库从零实现了原文中描述的大部分特性,并吸取大量生产环境实践和内外网用户反馈,随着持续稳定的维护和更新,期望给用户提供更多差异化的功能。

通过Java注解优雅的记录操作日志,并支持SpEL表达式,自定义上下文,自定义函数,实体类DIFF等功能,最终日志可由用户自行采集并处理,或推送至预配置的消息队列,支持SpringBoot1&2&3(JDK8~JDK21)。

采用SpringBoot Starter的方式,只需一个依赖,一句注解,日志轻松记录,不侵入业务逻辑:

@OperationLog(bizType = "'followerChange'", bizId = "#request.orderId", msg = "'用户' + #queryUserName(#request.userId) + '修改了订单的跟进人:从' + #queryOldFollower(#request.orderId) + '修改到' + #request.newFollower")
public Response<T> function(Request request) {
  // 业务执行逻辑
}

SpringBoot1&SpringBoot2(JDK8+)请引用:

<dependency>
    <groupId>cn.monitor4all</groupId>
    <artifactId>log-record-starter</artifactId>
    <version>{最新版本号}</version>
</dependency>

SpringBoot3(JDK17+)请引用:

<dependency>
    <groupId>cn.monitor4all</groupId>
    <artifactId>log-record-springboot3-starter</artifactId>
    <version>{最新版本号}</version>
</dependency>

最新版本号请查阅Maven公共仓库

项目背景

大家一定见过下图的操作日志:

在代码层面,如何优雅的记录上面的日志呢?

能想到最粗暴的方式,封装一个操作日志记录类,如下:

String template = "用户%s修改了订单的跟进人:从“%s”修改到“%s”"
LogUtil.log(orderNo, String.format(tempalte, "张三", "李四", "王五"),  "张三")

这种方式会导致业务代码被记录日志的代码侵入,对于代码的可读性和可维护性来说是一个灾难。

这个方式显然不够优雅,让我们试试使用注解:

@OperationLog(bizType = "'followerChange'", bizId = "'20211102001'", msg = "'用户 张三 修改了订单的跟进人:从 李四 修改到 王五'")
public Response<T> function(Request request) {
  // 业务执行逻辑
}

日志的记录被放到了注解,对业务代码没有侵入。

但是新的问题来了,我们该如何把订单ID、用户信息、数据库里的旧地址、函数入参的新地址传递给注解呢?

SpringSpEL表达式(Spring Expression Language 可以帮助我们,通过引入SpEL表达式,我们可以获取函数的入参。这样我们就可以对上面的注解进行修改:

  • 订单ID:#request.orderId
  • 新地址"王五":#request.newFollower
@OperationLog(bizType = "'followerChange'", bizId = "#request.orderId", msg = "'用户 张三 修改了订单的跟进人:从 李四 修改到' + #request.newFollower")
public Response<T> function(Request request) {
  // 业务执行逻辑
}

如此一来,订单ID和地址的新值就可以通过解析入参动态获取了。

问题还没有结束,通常我们的用户信息(user),以及老的跟进人(oldFollower),是需要在方法中查询后才能获取,入参里一般不会包含这些数据。

解决方案也不是没有,我们创建一个可以保存上下文的LogRecordContext变量,让用户手动传递代码中计算出来的值,再交给SpEL解析 ,代码如下

@OperationLog(bizType = "'followerChange'", bizId = "#request.orderId", msg = "'用户' + #userName + '修改了订单的跟进人:从' + #oldFollower + '修改到' + #request.newFollower")
public Response<T> function(Request request) {
  // 业务执行逻辑
  ...
  // 手动传递日志上下文:用户信息 地址旧值
  LogRecordContext.putVariable("userName", queryUserName(request.getUserId()));
  LogRecordContext.putVariable("oldFollower", queryOldFollower(request.getOrderId()));
}

什么?你说这不就又侵入了业务逻辑了么?

确实是的,不过这种方法足够便捷易懂,并不会有什么理解的困难。

但是对于有“强迫症”的同学,这样的实现还是不够优雅,我们可以用SpEL支持的自定义函数,解决这个问题。

SpEL支持在表达式中传入用户自定义函数,我们将queryUserNamequeryOldFollower这两个函数提前放入SpEL的解析器中,SpEL在解析表达式时,会执行对应函数。

最终,我们的注解变成了这样,并且最终记录了日志:

@OperationLog(bizType = "'followerChange'", bizId = "#request.orderId", msg = "'用户' + #queryUserName(#request.userId) + '修改了订单的跟进人:从' + #queryOldFollower(#request.orderId) + '修改到' + #request.newFollower")
public Response<T> function(Request request) {
  // 业务执行逻辑
}

用户 张三 修改了订单的跟进人:从 李四 修改到 王五

以上便是本库的大致实现原理。

项目介绍

本库帮助你通过注解优雅地记录项目中的操作日志,对业务代码无侵入。

本项目特点:

  • 快速接入:使用Spring Boot Starter实现,用户直接在pom.xml引入依赖即可使用
  • 业务无侵入:无需侵入业务代码,日志切面发生任何异常不会影响原方法执行
  • SpEL解析:支持SpEL表达式
  • 实体类Diff:支持相同甚至不同类对象的Diff
  • 条件注解:满足Condition条件后才记录日志,通过SpEL进行解析
  • 自定义上下文:支持手动传递键值对,通过SpEL进行解析
  • 自定义函数:支持注册自定义函数,通过SpEL进行解析
  • 全局操作人ID:自定义操作人ID获取逻辑
  • 指定日志数据管道:自定义操作日志处理逻辑(写数据库,TLog等..)
  • 支持重复注解:同一个方法上可以写多个操作日志注解
  • 支持自动重试和兜底处理:支持配置重试次数和处理失败兜底逻辑SPI
  • 支持控制切面执行时机(方法执行前后)
  • 支持自定义执行成功判断
  • 支持非注解方式手动记录日志
  • 自定义消息线程池
  • 更多特性等你来发掘...

日志实体(LogDTO)内包含:

logId:生成的UUID
bizId:业务唯一ID
bizType:业务类型
exception:函数执行失败时写入异常信息
operateDate:操作执行时间
success:函数是否执行成功
msg:日志内容
tag:自定义标签
returnStr: 方法执行成功后的返回值(字符串或JSON化实体)
executionTime:方法执行耗时(单位:毫秒)
extra:额外信息
operatorId:操作人ID
List<diffDTO>: 实体类对象Diff数据,包括变更的字段名,字段值,类名等

日志实体复杂示例:

{
  "bizId":"1",
  "bizType":"testObjectDiff",
  "executionTime":0,
  "extra":"【用户工号】从【1】变成了【2】 【name】从【张三】变成了【李四】",
  "logId":"38f7f417-2cc3-40ed-8c98-2fe3ee057518",
  "msg":"【用户工号】从【1】变成了【2】 【name】从【张三】变成了【李四】",
  "operateDate":1651116932299,
  "operatorId":"操作人",
  "returnStr":"{\"id\":1,\"name\":\"张三\"}",
  "success":true,
  "exception":null,
  "tag":"operation",
  "diffDTOList":[
    {
      "diffFieldDTOList":[
        {
          "fieldName":"id",
          "newFieldAlias":"用户工号",
          "newValue":2,
          "oldFieldAlias":"用户工号",
          "oldValue":1
        },
        {
          "fieldName":"name",
          "newValue":"李四",
          "oldValue":"张三"
        }],
      "newClassAlias":"用户信息实体",
      "newClassName":"cn.monitor4all.logRecord.test.bean.TestUser",
      "oldClassAlias":"用户信息实体",
      "oldClassName":"cn.monitor4all.logRecord.test.bean.TestUser"
    },
    {
      "diffFieldDTOList":[
        {
          "fieldName":"id",
          "newFieldAlias":"用户工号",
          "newValue":2,
          "oldFieldAlias":"用户工号",
          "oldValue":1
        },
        {
          "fieldName":"name",
          "newValue":"李四",
          "oldValue":"张三"
        }],
      "newClassAlias":"用户信息实体",
      "newClassName":"cn.monitor4all.logRecord.test.bean.TestUser",
      "oldClassAlias":"用户信息实体",
      "oldClassName":"cn.monitor4all.logRecord.test.bean.TestUser"
    }]
}

使用方法

只需要简单的三步:

第一步: SpringBoot项目中引入依赖

SpringBoot1&SpringBoot2(JDK8+)请引用:

<dependency>
    <groupId>cn.monitor4all</groupId>
    <artifactId>log-record-starter</artifactId>
    <version>{最新版本号}</version>
</dependency>

SpringBoot3(JDK17+)请引用:

<dependency>
    <groupId>cn.monitor4all</groupId>
    <artifactId>log-record-springboot3-starter</artifactId>
    <version>{最新版本号}</version>
</dependency>

最新版本号请查阅Maven公共仓库

推荐使用 >= 1.6.x版本

第二步: 配置日志处理方式

支持处理方式:

  1. 自定义采集处理
  2. 直接发送至RabbitMQ
  3. 直接发送至RocketMQ
  4. 直接发送至SpringCloud Stream

1. 自定义采集处理

若只需要在同一应用内处理日志信息,只需要实现接口IOperationLogGetService,便可对日志进行处理。

@Component
public class CustomFuncTestOperationLogGetService implements IOperationLogGetService {
    @Override
    public boolean createLog(LogDTO logDTO) {
        log.info("logDTO: [{}]", JSON.toJSONString(logDTO));
        return true;
    }
}

2. 直接发送至RabbitMQ

配置RabbitMQ参数

log-record.data-pipeline=rabbitMq
log-record.rabbit-mq-properties.host=localhost
log-record.rabbit-mq-properties.port=5672
log-record.rabbit-mq-properties.username=admin
log-record.rabbit-mq-properties.password=xxxxxx
log-record.rabbit-mq-properties.queue-name=logRecord
log-record.rabbit-mq-properties.routing-key=
log-record.rabbit-mq-properties.exchange-name=logRecord

3. 直接发送至RocketMQ

配置RocketMQ参数

log-record.data-pipeline=rocketMq
log-record.rocket-mq-properties.topic=logRecord
log-record.rocket-mq-properties.tag=
log-record.rocket-mq-properties.group-name=logRecord
log-record.rocket-mq-properties.namesrv-addr=localhost:9876

4. 直接发送至SpringCloud Stream

配置SpringCloud Stream参数

log-record.data-pipeline=stream
log-record.stream.destination=logRecord
log-record.stream.group=logRecord
# 为空时 默认为spring.cloud.stream.default-binder指定的Binder
log-record.stream.binder=
# rocketmq binder例子
spring.cloud.stream.rocketmq.binder.name-server=127.0.0.1:9876
spring.cloud.stream.rocketmq.binder.enable-msg-trace=false

第三步: 在需要记录系统操作的方法上,添加注解

@OperationLog(bizType = "'followerChange'", bizId = "#request.orderId", msg = "'用户 张三 修改了订单的跟进人:从 李四 修改到' + #request.newFollower")
public Response<T> function(Request request) {
  // 业务执行逻辑
}

进阶特性

SpEL的使用

SpELSpring实现的标准的表达式语言,具体的使用可以学习官方文档或者自行搜索资料,入门非常的简单,推荐几篇文章:

需要注意的是,@OperationLog注解中,除了executeBeforeFuncrecordReturnValue两个boolean类型的参数,其他的参数均需要严格遵循SpEL表达式语法。 `

举例来说,bizType中我们经常会填入常量,例如订单创建orderCreate, 订单修改orderModify

SpEL表达式中,若传入bizType="orderCreate",SpEL会解析失败,因为纯字符串会被认为是一个方法名,导致SpEL找不到方法而报错,需要使用bizType="'orderCreate'",才能被正确解析。

有时,我们会用枚举值和常量值来规范bizType等参数,合理写法如下:

@Getter
@AllArgsConstructor
public enum TestEnum {

    TYPE1("type1", "枚举1"),
    TYPE2("type2", "枚举2");

    private final String key;
    private final String name;

}
public class TestConstant {

    public static final String TYPE1 = "type1";
    public static final String TYPE2 = "type2";

}
@OperationLog(bizId = "'1'", bizType = "T(cn.monitor4all.logRecord.test.bean.TestConstant).TYPE1")
@OperationLog(bizId = "'2'", bizType = "T(cn.monitor4all.logRecord.test.bean.TestEnum).TYPE1")
@OperationLog(bizId = "'3'", bizType = "T(cn.monitor4all.logRecord.test.bean.TestEnum).TYPE1.key")
@OperationLog(bizId = "'4'", bizType = "T(cn.monitor4all.logRecord.test.bean.TestEnum).TYPE1.name")

注意:bizTypetag参数在 >= 1.2.0版本以后才要求严格遵循SpEL表达式,<= 1.1.x以下版本均为直接填写字符串,不支持SpEL解析。

自定义SpEL解析顺序

在默认配置下,注解切面的逻辑在方法执行之后才会执行,这样会带来一个问题,如果在方法内部修改了方法参数,SpEL解析后取值就变成了改变后的值。

可以使用LogRecordContext写入旧值,避免这个问题,只是有一定代码侵入性。

为了满足一些特殊需求,注解中提供boolean参数executeBeforeFunc若设置为true,则会在方法执行前先解析SpEL参数。 这样也会带来负作用,方法内写入的数值,比如自定义上下文,就不再参与SpEL解析了。

方法上加上注解:

@OperationLog(bizId = "#keyInBiz", bizType = "'testExecuteBeforeFunc1'", executeBeforeFunc = true)
@OperationLog(bizId = "#keyInBiz", bizType = "'testExecuteAfterFunc'")
@OperationLog(bizId = "#keyInBiz", bizType = "'testExecuteBeforeFunc2'", executeBeforeFunc = true)
public void testExecuteBeforeFunc() {
    LogRecordContext.putVariable("keyInBiz", "valueInBiz");
}

调用方法:

testService.testExecuteBeforeFunc();

得到结果:

[{"bizId":null, "bizType":"testExecuteBeforeFunc1","diffDTOList":[],"executionTime":0,"extra":"","logId":"8cbed2fc-bb2d-48a7-b9ec-f28e99773151","msg":"","operateDate":1651144119444,"operatorId":"操作人","returnStr":"null","success":true,"tag":"operation"}]
[{"bizId":null, "bizType":"testExecuteBeforeFunc2","diffDTOList":[],"executionTime":0,"extra":"","logId":"a130b60c-791c-4c6f-812e-0475de4b38d2","msg":"","operateDate":1651144119444,"operatorId":"操作人","returnStr":"null","success":true,"tag":"operation"}]
[{"bizId":"valueInBiz","bizType":"testExecuteAfterFunc","diffDTOList":[],"executionTime":0,"extra":"","logId":"80af92f5-8e4a-489e-a626-83f2a696fe71","msg":"","operateDate":1651144119444,"operatorId":"操作人","returnStr":"null","success":true,"tag":"operation"}]

内置自定义函数和自定义参数

  1. 可以直接使用的自定义参数:
  • _return:原方法的返回值
  • _errorMsg:原方法的异常信息(throwable.getMessage()

使用示例:

@OperationLog(bizId = "'1'", bizType = "'testDefaultParamReturn'", msg = "#_return")

注意:_return_errorMsg均为方法执行后才赋值的参数,所以若executeBeforeFunc=true(设置为方法执行前执行日志切面),则这两个值为null

  1. 可以直接使用的自定义函数:
  • _DIFF:详见下方 实体类Diff 小节

根据条件记录日志

@OperationLog注解拥有字段condition,用户可以使用SpEL表达式来决定该条日志是否记录。

方法上加上注解:

@OperationLog(bizId = "'1'", bizType = "'testCondition1'", condition = "#testUser != null")
@OperationLog(bizId = "'2'", bizType = "'testCondition2'", condition = "#testUser.id == 1")
@OperationLog(bizId = "'3'", bizType = "'testCondition3'", condition = "#testUser.id == 2")
public void testCondition(TestUser testUser) {
}

调用方法:

testService.testCondition(new TestUser(1, "张三"));

上述注解中,只有前两条注解满足condition条件,会输出日志。

全局操作人信息获取

大部分情况下,操作人ID往往不会在方法参数中传递,更多会是查询集团内BUC信息、查询外部服务、查表等获取。所以开放了SPI,只需要实现接口IOperationLogGetService,便可以统一注入操作人ID。

@Component
public class IOperatorIdGetServiceImpl implements IOperatorIdGetService {

    @Override
    public String getOperatorId() {
        // 查询操作人信息
        return "张三";
    }
}

注意:若实现了接口后仍在注解手动传入OperatorID,则以传入的OperatorID优先。

自定义上下文

直接引入类LogRecordContext,放入键值对。

@OperationLog(bizType = "'followerChange'", bizId = "#request.orderId", msg = "'用户' + #userName + '修改了订单的跟进人:从' + #oldFollower + '修改到' + #request.newFollower")
public Response<T> function(Request request) {
  // 业务执行逻辑
  ...
  // 手动传递日志上下文:用户信息 地址旧值
  LogRecordContext.putVariable("userName", queryUserName(request.getUserId()));
  LogRecordContext.putVariable("oldFollower", queryOldFollower(request.getOrderId()));
}

LogRecordContext内部使用TransmittableThreadLocal实现与主线程的ThreadLocal传递。

自定义函数

@LogRecordFunc注解申明在需要注册到SpEL的自定义函数上,参与SpEL表达式的运算。

注意,需要在类上也声明@LogRecordFunc,否则无法找到该函数。

@LogRecordFunc可以添加参数value,实现自定义方法别名,若不添加,则默认不需要写前缀。

静态自定义方法:

SpEL天生支持,写法如下:

@LogRecordFunc("CustomFunctionStatic")
public class CustomFunctionStatic {

    @LogRecordFunc("testStaticMethodWithCustomName")
    public static String testStaticMethodWithCustomName(){
        return "testStaticMethodWithCustomName";
    }

    @LogRecordFunc
    public static String testStaticMethodWithoutCustomName(){
        return "testStaticMethodWithoutCustomName";
    }

}

上述代码中,注册的自定义函数名为CustomFunctionStatic_testStaticMethodWithoutCustomNameCustomFunctionStatic_testStaticMethodWithoutCustomName,若类上的注解更改为@LogRecordFunc("test"),则注册的自定义函数名为testStaticMethodWithCustomNametestStaticMethodWithoutCustomName

非静态自定义方法:

原理主要是依靠我们框架内部转换,将非静态方法需要包装为静态方法再传给SpEL。原理详见#PR25

在1.6.x版本之前,部分版本(1.5.x)支持非静态自定义函数,但由于其大量使用反射,写法较为Hack,兼容性不佳(在JDk11+后反射限制更加严格),在1.6.x+ 版本后删除,仅支持静态方法。

注意:所有自定义函数可在应用启动时的日志中找到

2022-06-09 11:35:18.672  INFO 73757 --- [           main] c.a.i.l.f.CustomFunctionRegistrar        : LogRecord register custom function [public static java.lang.String cn.monitor4all.logRecord.test.service.CustomFunctionStaticService.testStaticMethodWithCustomName()] as name [CustomFunctionStatic_testStaticMethodWithoutCustomName]
2022-06-09 11:35:18.672  INFO 73757 --- [           main] c.a.i.l.f.CustomFunctionRegistrar        : LogRecord register custom function [public static java.lang.String cn.monitor4all.logRecord.test.service.CustomFunctionStaticService.testStaticMethodWithoutCustomName()] as name [CustomFunctionStatic_testStaticMethodWithoutCustomName]
2022-06-09 11:35:18.672  INFO 73757 --- [           main] c.a.i.l.f.CustomFunctionRegistrar        : LogRecord register custom function [public static java.lang.String cn.monitor4all.logRecord.function.CustomFunctionObjectDiff.objectDiff(java.lang.Object,java.lang.Object)] as name [_DIFF]

注解中使用:

@OperationLog(bizId = "#CustomFunctionStatic_testStaticMethodWithCustomName()", bizType = "'testStaticMethodWithCustomName'")
@OperationLog(bizId = "#CustomFunctionStatic_testStaticMethodWithoutCustomName()", bizType = "'testStaticMethodWithoutCustomName'")
public void testCustomFunc() {
}

自定义原方法是否执行成功

@OperationLog注解中有success参数,用于根据返回体或其他情况下自定义日志实体中的success字段。

默认情况下,方法是否执行成功取决于是否抛出异常,若未抛出异常,默认为方法执行成功。

但很多时候,我们的方法执行成功可能取决于方法内部调用的接口的返回值,如下所示:

@OperationLog(
        success = "#isSuccess",
        bizId = "#request.trade.id",
        bizType = "'createOrder'",
    )
@Override
public Result<Void> createOrder(Request request) {
    try {
        Response response = tradeCreateService.create(request);
        LogRecordContext.putVariable("isSuccess", response.getIsSuccess());
        return Result.ofSuccess();
    } catch (Exception e) {
        return Result.ofSysError();
    }
}

可以通过接口返回的response.getIsSuccess()来表名该创建订单方法是否执行成功。

实体类Diff

支持两个对象(相同或者不同的类对象皆可)对象的Diff

有如下注解:

  • @LogRecordDiffField:在字段上申明@LogRecordDiffField(alias = "用户工号", ignored = true)alias别名为可选字段。 ignored为可选字段,默认为false,若为true,则该字段不参与DIFF
  • @LogRecordDiffObject:在类上允许可以申明@LogRecordDiffObject(alias = "用户信息实体")alias别名为可选字段,默认类下所有字段会进行DIFF,可通过enableAllFields手动关闭,关闭后等于该注解只用于获取类别名。

类对象使用示例:

@LogRecordDiffObject(alias = "用户信息实体")
public class TestUser {
    private Integer id;
    private String name;
    private String job;
}

或者单独为类中的字段DIFF:

public class TestUser {
    @LogRecordDiffField(alias = "用户工号")
    private Integer id;
    @LogRecordDiffField(alias = "用户工号", ignored = true)
    private String name;
}

@OperationLog注解上,可以通过调用内置实现的自定义函数_DIFF,传入两个对象即可拿到Diff结果。

@OperationLog(bizId = "'1'", bizType = "'testObjectDiff'", msg = "#_DIFF(#oldObject, #testUser)", extra = "#_DIFF(#oldObject, #testUser)")
public void testObjectDiff(TestUser testUser) {
    LogRecordContext.putVariable("oldObject", new TestUser(1, "张三"));
}

比较完成后的结果在日志实体中以diffDTO实体呈现。

{
  "diffFieldDTOList":[
    {
      "fieldName":"id",
      "newFieldAlias":"用户工号",
      "newValue":2,
      "oldFieldAlias":"用户工号",
      "oldValue":1
    },
    {
      "fieldName":"name",
      "newValue":"李四",
      "oldValue":"张三"
    }],
  "newClassAlias":"用户信息实体",
  "newClassName":"cn.monitor4all.logRecord.test.bean.TestUser",
  "oldClassAlias":"用户信息实体",
  "oldClassName":"cn.monitor4all.logRecord.test.bean.TestUser"
}

调用方法:

testService.testObjectDiff(new TestUser(2, "李四"));

最终得到的日志消息实体logDTO

{
  "bizId":"1",
  "bizType":"testObjectDiff",
  "executionTime":0,
  "extra":"【用户工号】从【1】变成了【2】 【name】从【张三】变成了【李四】",
  "logId":"38f7f417-2cc3-40ed-8c98-2fe3ee057518",
  "msg":"【用户工号】从【1】变成了【2】 【name】从【张三】变成了【李四】",
  "operateDate":1651116932299,
  "operatorId":"操作人",
  "returnStr":"{\"id\":1,\"name\":\"张三\"}",
  "success":true,
  "exception":null,
  "tag":"operation",
  "diffDTOList":[
    {
      "diffFieldDTOList":[
        {
          "fieldName":"id",
          "newFieldAlias":"用户工号",
          "newValue":2,
          "oldFieldAlias":"用户工号",
          "oldValue":1
        },
        {
          "fieldName":"name",
          "newValue":"李四",
          "oldValue":"张三"
        }],
      "newClassAlias":"用户信息实体",
      "newClassName":"cn.monitor4all.logRecord.test.bean.TestUser",
      "oldClassAlias":"用户信息实体",
      "oldClassName":"cn.monitor4all.logRecord.test.bean.TestUser"
    },
    {
      "diffFieldDTOList":[
        {
          "fieldName":"id",
          "newFieldAlias":"用户工号",
          "newValue":2,
          "oldFieldAlias":"用户工号",
          "oldValue":1
        },
        {
          "fieldName":"name",
          "newValue":"李四",
          "oldValue":"张三"
        }],
      "newClassAlias":"用户信息实体",
      "newClassName":"cn.monitor4all.logRecord.test.bean.TestUser",
      "oldClassAlias":"用户信息实体",
      "oldClassName":"cn.monitor4all.logRecord.test.bean.TestUser"
    }]
}

可以通过Spring配置,忽略对比的新旧对象中值为null的字段,形如:

log-record.diff-ignore-new-object-null-value=true # 忽略新对象中null值字段,默认为false
log-record.diff-ignore-old-object-null-value=true # 忽略旧对象中null值字段,默认为false

此外,可以通过Spring配置自定义DIFF的标准输出格式,形如:

log-record.diff-msg-format=(默认值为【${_fieldName}】从【${_oldValue}】变成了【${_newValue}】)
log-record.diff-msg-separator=(默认值为" "空格)

还支持同一个注解中多次调用_DIFF, 如下:

/**
 * 测试实体类DIFF:使用多个_DIFF
 */
@OperationLog(bizId = "'1'", bizType = "'testMultipleDiff'", msg = "'第一个DIFF:' + #_DIFF(#oldObject1, #testUser) + '第二个DIFF' + #_DIFF(#oldObject2, #testUser)")
public void testMultipleDiff(TestUser testUser) {
    LogRecordContext.putVariable("oldObject1", new TestUser(1, "张三"));
    LogRecordContext.putVariable("oldObject2", new TestUser(3, "王五"));
}

注意:目前DIFF功能支持完全不同的类之间进行DIFF,对于同名的基础类型,进行equals对比,对于同名的非基础类型,则借用fastjsontoJSON能力,转为JSONObject进行对比,本质上是将对象映射为map进行map.equals

日志处理重试次数及兜底函数配置

无论是本地处理日志,或者发送到消息管道处理日志,都会存在处理异常需要重试的场景。可以通过properties配置:

log-record.retry.retry-times=5  # 默认为0次重试,即日志处理方法只执行1次

配置后框架会重新执行createLog直至达到最大重试次数。

若超过了重试次数,可以通过实现SPI接口 cn.monitor4all.logRecord.service.LogRecordErrorHandlerService 来进行兜底逻辑处理,这里将本地日志处理和消息管道兜底处理分开了。

@Component
public class LogRecordErrorHandlerServiceImpl implements LogRecordErrorHandlerService {

    @Override
    public void operationLogGetErrorHandler() {
        log.error("operation log get service error reached max retryTimes!");
    }

    @Override
    public void dataPipelineErrorHandler() {
        log.error("data pipeline send log error reached max retryTimes!");
    }
}

重复注解

@OperationLog(bizId = "#testClass.testId", bizType = "'testType1'", msg = "#testFunc(#testClass.testId)")
@OperationLog(bizId = "#testClass.testId", bizType = "'testType2'", msg = "#testFunc(#testClass.testId)")
@OperationLog(bizId = "#testClass.testId", bizType = "'testType3'", msg = "'用户将旧值' + #old + '更改为新值' + #testClass.testStr")

我们还加上了重复注解的支持,可以在一个方法上同时加多个@OperationLog会保证按照@OperationLog从上到下的顺序输出日志

自定义消息线程池

starter提供了如下配置:

log-record.thread-pool.pool-size=4(线程池核心线程大小 默认为4)
log-record.thread-pool.enabled=true(线程池开关 默认为开启 若关闭则使用业务线程进行消息处理发送)

在组装好logDTO后,默认会使用线程池对消息进行处理,发送至本地监听函数或者消息队列发送者,也可以通过配置关闭线程池,让主线程执行全部消息处理逻辑。

注意:logDTO的组装逻辑在切面中,该切面仍然在函数执行的线程中运行。

默认线程池配置如下(拒绝策略为丢弃):

return new ThreadPoolExecutor(poolSize, poolSize, 0L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1024), THREAD_FACTORY, new ThreadPoolExecutor.AbortPolicy());

此外,还提供了用户传入自定义线程池的方式,用户可自行实现cn.monitor4all.logRecord.thread.ThreadPoolProvider,传入线程池。

示例:

public class CustomThreadPoolProvider implements ThreadPoolProvider {

    private static ThreadPoolExecutor EXECUTOR;

    private static final ThreadFactory THREAD_FACTORY = new CustomizableThreadFactory("custom-log-record-");


    private CustomThreadPoolProvider() {
        log.info("CustomThreadPoolProvider init");
        EXECUTOR = new ThreadPoolExecutor(3, 3, 0L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(100), THREAD_FACTORY, new ThreadPoolExecutor.AbortPolicy());
    }

    @Override
    public ThreadPoolExecutor buildLogRecordThreadPool() {
        return EXECUTOR;
    }
}

函数返回值记录开关

@OperationLog注解提供布尔值recordReturnValue()用于是否开启记录函数返回值,默认关闭,防止返回值实体过大,造成序列化时性能消耗过多。

非注解方式

在实际业务场景中,很多时候由于注解的限制,无法很好的使用注解记录日志,此时可以使用纯手动的方式进行日志记录。

框架提供了手动记录日志的方法:

cn.monitor4all.logRecord.util.OperationLogUtil

LogRequest logRequest = LogRequest.builder()
        .bizId("testBizId")
        .bizType("testBuildLogRequest")
        .success(true)
        .msg("testMsg")
        .tag("testTag")
        .returnStr("testReturnStr")
        .extra("testExtra")
        // 其他字段
        .build();
OperationLogUtil.log(logRequest);

使用该方式记录日志,注解带来的相关功能则无法使用,如SpEL表达式,自定义函数等。

操作日志数据表结构推荐

以MySQL表为例:

CREATE TABLE `operation_log` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `gmt_create` datetime NOT NULL COMMENT '创建时间',
  `gmt_modified` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  `biz_id` varchar(128) NOT NULL COMMENT '业务ID',
  `biz_type` varchar(64) DEFAULT NULL COMMENT '业务类型',
  `tag` varchar(64) DEFAULT NULL COMMENT '标签',
  `operation_date` datetime DEFAULT NULL COMMENT '操作执行时间',
  `msg` varchar(512) DEFAULT NULL COMMENT '操作内容',
  `extra` varchar(512) DEFAULT NULL COMMENT '附加信息',
  `operation_status` tinyint(4) DEFAULT NULL COMMENT '操作结果状态',
  `operation_time` int(11) DEFAULT NULL COMMENT '操作耗时',
  `content_return` varchar(512) COMMENT '方法返回内容',
  `content_exception` varchar(512) COMMENT '方法异常内容',
  `operator_id` varchar(32) DEFAULT NULL COMMENT '操作人ID',
  `operator_name` varchar(32) DEFAULT NULL COMMENT '操作人姓名',
  PRIMARY KEY (`id`),
  KEY `idx_biz_id` (`biz_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';

让注解支持IDEA自动补全

在自定义注解想实现类似@Cacheable的自动补全,其实是IDEAIDE自己的支持,可以在配置中将本二方库的注解添加上去,从而支持自动补全和SpEL表达式校验。

SpringBoot3(JDK17+)版本与SpringBoot1&SpringBoot2(JDK8+)版本使用差异

本框架尽可能在不同SpringBoot版本下提供统一的功能和特性,但由于JDk兼容等问题,在使用上仍有一些差异。

在这里列举需要本框架使用者注意的差异:

SpringBoot3无法获取函数入参

由于JDK11+以上收紧了对反射的使用,导致SpringBoot3无法获取函数入参,所以在SpringBoot3版本下,无法使用参数名获取函数入参。

例如在SpringBoot1&SpringBoot2中可以这样做:

@OperationLog(bizId = "#bizId", bizType = "'testBizIdWithSpEL'")
public void testBizIdWithSpEL(String bizId) {
}

但是SpringBoot3中,只能使用p0p1等参数名获取函数入参(参数的绝对位置下标),如下:

@OperationLog(bizId = "#p0", bizType = "'testBizIdWithSpEL'")
public void testBizIdWithSpEL(String bizId) {
}

应用场景

以下罗列了一些实际的应用场景,包括我业务中实际使用,并且已经上线使用的场景。

操作日志

CRM系统,在用户进行了编辑操作后,拿到用户操作的数据,执行日志写入。

系统日志

操作日志是主要的功能,当然也可以兼顾一些系统日志记录的操作,比如只是想简单记录方法执行时间,出入参等,也可以通过该库轻松做到。

后端埋点

与系统日志类似,可以记录一些用户操作埋点。

通知

应用之间通过关键操作的日志消息,互相通知。

Demo

当你觉得用法不熟悉,可以查看单元测试用例,里面有最为详细且最全的使用示例。

另外提供完整SpringBoot2&3 Demo项目:

https://github.com/qqxx6661/systemLog

Release Note

Release

附录

编译注意

由于拆分了父子模块,在不同JDK下,请重新编译log-record-core,再编译对应版本的log-record-starter,否则会导致编译失败(单元测试异常)。

发布版本注意

请将log-record-core, log-record-starter, log-record-springboot3-starter都编译打包发布到Maven公共仓库。

配套教程文章

关注我

公众号:后端技术漫谈

全网博客名:蛮三刀酱

如果觉得该项目对你有用,请点个star,谢谢!

Star History

Star History Chart

About

使用注解优雅记录系统日志,操作日志,后端埋点等,支持SpEL表达式,自定义上下文,自定义函数,实体类DIFF等其他高阶处理。

Topics

Resources

License

Stars

Watchers

Forks

Languages