{"id":27488207,"url":"https://github.com/qqxx6661/log-record","last_synced_at":"2025-04-16T19:05:41.392Z","repository":{"id":40369297,"uuid":"425174106","full_name":"qqxx6661/log-record","owner":"qqxx6661","description":"使用注解优雅记录系统日志，操作日志，后端埋点等，支持SpEL表达式，自定义上下文，自定义函数，实体类DIFF等其他高阶处理。","archived":false,"fork":false,"pushed_at":"2024-06-01T14:47:01.000Z","size":4078,"stargazers_count":819,"open_issues_count":13,"forks_count":161,"subscribers_count":14,"default_branch":"master","last_synced_at":"2024-06-01T16:17:22.641Z","etag":null,"topics":["java","logging"],"latest_commit_sha":null,"homepage":"","language":"Java","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/qqxx6661.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2021-11-06T06:35:33.000Z","updated_at":"2024-06-01T14:43:40.000Z","dependencies_parsed_at":"2024-02-20T15:46:20.659Z","dependency_job_id":"ef2c459f-3696-4255-bf39-806ddd69b6b0","html_url":"https://github.com/qqxx6661/log-record","commit_stats":null,"previous_names":[],"tags_count":26,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/qqxx6661%2Flog-record","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/qqxx6661%2Flog-record/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/qqxx6661%2Flog-record/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/qqxx6661%2Flog-record/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/qqxx6661","download_url":"https://codeload.github.com/qqxx6661/log-record/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":249267897,"owners_count":21240874,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["java","logging"],"created_at":"2025-04-16T19:02:14.656Z","updated_at":"2025-04-16T19:05:41.381Z","avatar_url":"https://github.com/qqxx6661.png","language":"Java","funding_links":[],"categories":["日志库","Java"],"sub_categories":[],"readme":"# log-record\n\n[![](https://img.shields.io/github/actions/workflow/status/qqxx6661/log-record/ci.yml?branch=master\u0026logo=github\u0026logoColor=white)](https://github.com/qqxx6661/log-record/actions/workflows/ci.yml)\n[![](https://img.shields.io/codecov/c/github/qqxx6661/log-record?logo=codecov\u0026logoColor=white)](https://codecov.io/gh/qqxx6661/log-record/branch/master)\n[![](https://img.shields.io/maven-central/v/cn.monitor4all/log-record-starter?logo=apache-maven\u0026logoColor=white)](https://search.maven.org/artifact/cn.monitor4all/log-record-starter)\n[![](https://img.shields.io/github/license/qqxx6661/log-record?color=4D7A97\u0026logo=apache)](https://www.apache.org/licenses/LICENSE-2.0.html)  \n[![](https://img.shields.io/github/stars/qqxx6661/log-record)](https://github.com/qqxx6661/log-record/stargazers)\n[![](https://img.shields.io/github/issues/qqxx6661/log-record)](https://github.com/qqxx6661/log-record/issues)\n[![](https://img.shields.io/github/issues-closed/qqxx6661/log-record)](https://github.com/qqxx6661/log-record/issues?q=is%3Aissue+is%3Aclosed)\n[![](https://img.shields.io/github/issues-pr/qqxx6661/log-record)](https://github.com/qqxx6661/log-record/pulls)\n[![](https://img.shields.io/github/issues-pr-closed/qqxx6661/log-record)](https://github.com/qqxx6661/log-record/pulls?q=is%3Apr+is%3Aclosed)\n\n\u003e 注意：本仓库最初灵感来源于[美团技术博客](https://tech.meituan.com/2021/09/16/operational-logbook.html) ，若您需要寻找的是原文中作者的代码仓库，可以跳转[这里](https://github.com/mouzt/mzt-biz-log/) 。本仓库从零实现了原文中描述的大部分特性，并吸取大量生产环境实践和内外网用户反馈，随着持续稳定的维护和更新，期望给用户提供更多差异化的功能。\n\n通过`Java`注解优雅的记录操作日志，并支持`SpEL`表达式，自定义上下文，自定义函数，实体类`DIFF`等功能，最终日志可由用户自行采集并处理，或推送至预配置的消息队列，支持SpringBoot1\u00262\u00263（JDK8~JDK21）。\n\n采用`SpringBoot Starter`的方式，只需一个依赖，一句注解，日志轻松记录，不侵入业务逻辑：\n\n```java\n@OperationLog(bizType = \"'followerChange'\", bizId = \"#request.orderId\", msg = \"'用户' + #queryUserName(#request.userId) + '修改了订单的跟进人：从' + #queryOldFollower(#request.orderId) + '修改到' + #request.newFollower\")\npublic Response\u003cT\u003e function(Request request) {\n  // 业务执行逻辑\n}\n```\n\n\nSpringBoot1\u0026SpringBoot2(JDK8+)请引用：\n```xml\n\u003cdependency\u003e\n    \u003cgroupId\u003ecn.monitor4all\u003c/groupId\u003e\n    \u003cartifactId\u003elog-record-starter\u003c/artifactId\u003e\n    \u003cversion\u003e{最新版本号}\u003c/version\u003e\n\u003c/dependency\u003e\n```\n\nSpringBoot3(JDK17+)请引用：\n\n```xml\n\u003cdependency\u003e\n    \u003cgroupId\u003ecn.monitor4all\u003c/groupId\u003e\n    \u003cartifactId\u003elog-record-springboot3-starter\u003c/artifactId\u003e\n    \u003cversion\u003e{最新版本号}\u003c/version\u003e\n\u003c/dependency\u003e\n```\n\n\u003e 最新版本号请查阅[`Maven`公共仓库](https://mvnrepository.com/artifact/cn.monitor4all/log-record-starter)\n\n\n## 项目背景\n\n大家一定见过下图的操作日志：\n\n![](pic/sample1.png)\n\n![](pic/sample2.png)\n\n在代码层面，如何优雅的记录上面的日志呢？\n\n能想到最粗暴的方式，**封装一个操作日志记录类**，如下：\n\n```java\nString template = \"用户%s修改了订单的跟进人：从“%s”修改到“%s”\"\nLogUtil.log(orderNo, String.format(tempalte, \"张三\", \"李四\", \"王五\"),  \"张三\")\n```\n\n这种方式会导致业务代码被记录日志的代码侵入，**对于代码的可读性和可维护性来说是一个灾难。**\n\n这个方式显然不够优雅，让我们试试使用注解：\n\n```java\n@OperationLog(bizType = \"'followerChange'\", bizId = \"'20211102001'\", msg = \"'用户 张三 修改了订单的跟进人：从 李四 修改到 王五'\")\npublic Response\u003cT\u003e function(Request request) {\n  // 业务执行逻辑\n}\n```\n\n日志的记录被放到了注解，对业务代码没有侵入。\n\n但是新的问题来了，我们该如何把**订单ID、用户信息、数据库里的旧地址、函数入参的新地址传递给注解呢？**\n\n`Spring`的 [`SpEL`表达式（`Spring Expression Language`）](https://docs.spring.io/spring-framework/docs/3.0.x/reference/expressions.html) 可以帮助我们，通过引入`SpEL`表达式，我们可以获取函数的入参。这样我们就可以对上面的注解进行修改：\n\n- 订单ID：`#request.orderId`\n- 新地址\"王五\"：`#request.newFollower`\n\n```java\n@OperationLog(bizType = \"'followerChange'\", bizId = \"#request.orderId\", msg = \"'用户 张三 修改了订单的跟进人：从 李四 修改到' + #request.newFollower\")\npublic Response\u003cT\u003e function(Request request) {\n  // 业务执行逻辑\n}\n```\n\n如此一来，订单ID和地址的新值就可以通过解析入参动态获取了。\n\n问题还没有结束，通常我们的用户信息（`user`），以及老的跟进人（`oldFollower`），是需要在方法中查询后才能获取，**入参里一般不会包含这些数据。**\n\n解决方案也不是没有，我们创建一个可以保存上下文的`LogRecordContext`变量，**让用户手动传递代码中计算出来的值，再交给`SpEL`解析** ，代码如下\n\n```java\n@OperationLog(bizType = \"'followerChange'\", bizId = \"#request.orderId\", msg = \"'用户' + #userName + '修改了订单的跟进人：从' + #oldFollower + '修改到' + #request.newFollower\")\npublic Response\u003cT\u003e function(Request request) {\n  // 业务执行逻辑\n  ...\n  // 手动传递日志上下文：用户信息 地址旧值\n  LogRecordContext.putVariable(\"userName\", queryUserName(request.getUserId()));\n  LogRecordContext.putVariable(\"oldFollower\", queryOldFollower(request.getOrderId()));\n}\n```\n\n什么？你说这不就又侵入了业务逻辑了么？\n\n确实是的，不过这种方法足够便捷易懂，并不会有什么理解的困难。\n\n**但是对于有“强迫症”的同学，这样的实现还是不够优雅，我们可以用`SpEL`支持的自定义函数，解决这个问题。**\n\n`SpEL`支持在表达式中传入用户自定义函数，我们将`queryUserName`和`queryOldFollower`这两个函数提前放入`SpEL`的解析器中，`SpEL`在解析表达式时，会执行对应函数。\n\n最终，我们的注解变成了这样，并且最终记录了日志：\n\n```java\n@OperationLog(bizType = \"'followerChange'\", bizId = \"#request.orderId\", msg = \"'用户' + #queryUserName(#request.userId) + '修改了订单的跟进人：从' + #queryOldFollower(#request.orderId) + '修改到' + #request.newFollower\")\npublic Response\u003cT\u003e function(Request request) {\n  // 业务执行逻辑\n}\n```\n\n\u003e 用户 张三 修改了订单的跟进人：从 李四 修改到 王五\n\n**以上便是本库的大致实现原理。**\n\n## 项目介绍\n\n本库帮助你通过注解优雅地记录项目中的操作日志，对业务代码无侵入。\n\n本项目特点：\n\n- 快速接入：使用`Spring Boot Starter`实现，用户直接在`pom.xml`引入依赖即可使用\n- 业务无侵入：无需侵入业务代码，日志切面发生任何异常不会影响原方法执行\n- `SpEL`解析：支持`SpEL`表达式\n- 实体类`Diff`：支持相同甚至不同类对象的`Diff`\n- 条件注解：满足`Condition`条件后才记录日志，通过`SpEL`进行解析\n- 自定义上下文：支持手动传递键值对，通过`SpEL`进行解析\n- 自定义函数：支持注册自定义函数，通过`SpEL`进行解析\n- 全局操作人ID：自定义操作人ID获取逻辑\n- 指定日志数据管道：自定义操作日志处理逻辑（写数据库，`TLog`等..）\n- 支持重复注解：同一个方法上可以写多个操作日志注解\n- 支持自动重试和兜底处理：支持配置重试次数和处理失败兜底逻辑`SPI`\n- 支持控制切面执行时机（方法执行前后）\n- 支持自定义执行成功判断\n- 支持非注解方式手动记录日志\n- 自定义消息线程池\n- 更多特性等你来发掘...\n\n**日志实体(LogDTO)内包含：**\n\n```\nlogId：生成的UUID\nbizId：业务唯一ID\nbizType：业务类型\nexception：函数执行失败时写入异常信息\noperateDate：操作执行时间\nsuccess：函数是否执行成功\nmsg：日志内容\ntag：自定义标签\nreturnStr: 方法执行成功后的返回值（字符串或JSON化实体）\nexecutionTime：方法执行耗时（单位：毫秒）\nextra：额外信息\noperatorId：操作人ID\nList\u003cdiffDTO\u003e: 实体类对象Diff数据，包括变更的字段名，字段值，类名等\n```\n\n日志实体复杂示例：\n\n```json\n{\n  \"bizId\":\"1\",\n  \"bizType\":\"testObjectDiff\",\n  \"executionTime\":0,\n  \"extra\":\"【用户工号】从【1】变成了【2】 【name】从【张三】变成了【李四】\",\n  \"logId\":\"38f7f417-2cc3-40ed-8c98-2fe3ee057518\",\n  \"msg\":\"【用户工号】从【1】变成了【2】 【name】从【张三】变成了【李四】\",\n  \"operateDate\":1651116932299,\n  \"operatorId\":\"操作人\",\n  \"returnStr\":\"{\\\"id\\\":1,\\\"name\\\":\\\"张三\\\"}\",\n  \"success\":true,\n  \"exception\":null,\n  \"tag\":\"operation\",\n  \"diffDTOList\":[\n    {\n      \"diffFieldDTOList\":[\n        {\n          \"fieldName\":\"id\",\n          \"newFieldAlias\":\"用户工号\",\n          \"newValue\":2,\n          \"oldFieldAlias\":\"用户工号\",\n          \"oldValue\":1\n        },\n        {\n          \"fieldName\":\"name\",\n          \"newValue\":\"李四\",\n          \"oldValue\":\"张三\"\n        }],\n      \"newClassAlias\":\"用户信息实体\",\n      \"newClassName\":\"cn.monitor4all.logRecord.test.bean.TestUser\",\n      \"oldClassAlias\":\"用户信息实体\",\n      \"oldClassName\":\"cn.monitor4all.logRecord.test.bean.TestUser\"\n    },\n    {\n      \"diffFieldDTOList\":[\n        {\n          \"fieldName\":\"id\",\n          \"newFieldAlias\":\"用户工号\",\n          \"newValue\":2,\n          \"oldFieldAlias\":\"用户工号\",\n          \"oldValue\":1\n        },\n        {\n          \"fieldName\":\"name\",\n          \"newValue\":\"李四\",\n          \"oldValue\":\"张三\"\n        }],\n      \"newClassAlias\":\"用户信息实体\",\n      \"newClassName\":\"cn.monitor4all.logRecord.test.bean.TestUser\",\n      \"oldClassAlias\":\"用户信息实体\",\n      \"oldClassName\":\"cn.monitor4all.logRecord.test.bean.TestUser\"\n    }]\n}\n```\n\n\n## 使用方法\n\n**只需要简单的三步：**\n\n**第一步：** `SpringBoot`项目中引入依赖\n\nSpringBoot1\u0026SpringBoot2(JDK8+)请引用：\n```xml\n\u003cdependency\u003e\n    \u003cgroupId\u003ecn.monitor4all\u003c/groupId\u003e\n    \u003cartifactId\u003elog-record-starter\u003c/artifactId\u003e\n    \u003cversion\u003e{最新版本号}\u003c/version\u003e\n\u003c/dependency\u003e\n```\n\nSpringBoot3(JDK17+)请引用：\n\n```xml\n\u003cdependency\u003e\n    \u003cgroupId\u003ecn.monitor4all\u003c/groupId\u003e\n    \u003cartifactId\u003elog-record-springboot3-starter\u003c/artifactId\u003e\n    \u003cversion\u003e{最新版本号}\u003c/version\u003e\n\u003c/dependency\u003e\n```\n\n\n\u003e 最新版本号请查阅[`Maven`公共仓库](https://search.maven.org/artifact/cn.monitor4all/log-record-starter)\n\u003e \n\u003e 推荐使用 \u003e= 1.6.x版本\n\n\n**第二步：** 配置日志处理方式\n\n支持处理方式：\n\n1. **自定义采集处理**\n2. 直接发送至`RabbitMQ`\n3. 直接发送至`RocketMQ`\n4. 直接发送至`SpringCloud Stream`\n\n**1. 自定义采集处理**\n\n若只需要在同一应用内处理日志信息，只需要实现接口`IOperationLogGetService`，便可对日志进行处理。\n\n```java\n@Component\npublic class CustomFuncTestOperationLogGetService implements IOperationLogGetService {\n    @Override\n    public boolean createLog(LogDTO logDTO) {\n        log.info(\"logDTO: [{}]\", JSON.toJSONString(logDTO));\n        return true;\n    }\n}\n```\n\n\n\n**2. 直接发送至`RabbitMQ`**\n\n配置`RabbitMQ`参数\n\n```properties\nlog-record.data-pipeline=rabbitMq\nlog-record.rabbit-mq-properties.host=localhost\nlog-record.rabbit-mq-properties.port=5672\nlog-record.rabbit-mq-properties.username=admin\nlog-record.rabbit-mq-properties.password=xxxxxx\nlog-record.rabbit-mq-properties.queue-name=logRecord\nlog-record.rabbit-mq-properties.routing-key=\nlog-record.rabbit-mq-properties.exchange-name=logRecord\n```\n\n**3. 直接发送至`RocketMQ`**\n\n配置`RocketMQ`参数\n\n```properties\nlog-record.data-pipeline=rocketMq\nlog-record.rocket-mq-properties.topic=logRecord\nlog-record.rocket-mq-properties.tag=\nlog-record.rocket-mq-properties.group-name=logRecord\nlog-record.rocket-mq-properties.namesrv-addr=localhost:9876\n```\n\n**4. 直接发送至`SpringCloud Stream`**\n\n配置`SpringCloud Stream`参数\n\n```properties\nlog-record.data-pipeline=stream\nlog-record.stream.destination=logRecord\nlog-record.stream.group=logRecord\n# 为空时 默认为spring.cloud.stream.default-binder指定的Binder\nlog-record.stream.binder=\n# rocketmq binder例子\nspring.cloud.stream.rocketmq.binder.name-server=127.0.0.1:9876\nspring.cloud.stream.rocketmq.binder.enable-msg-trace=false\n```\n\n**第三步：** 在需要记录系统操作的方法上，添加注解\n\n```java\n@OperationLog(bizType = \"'followerChange'\", bizId = \"#request.orderId\", msg = \"'用户 张三 修改了订单的跟进人：从 李四 修改到' + #request.newFollower\")\npublic Response\u003cT\u003e function(Request request) {\n  // 业务执行逻辑\n}\n```\n\n## 进阶特性\n\n- [`SpEL`的使用](#SpEL的使用)\n- [自定义`SpEL`解析顺序](#自定义SpEL解析顺序)\n- [内置自定义函数和自定义参数](#内置自定义函数和自定义参数)\n- [根据条件记录日志](#根据条件记录日志)\n- [全局操作人信息获取](#全局操作人信息获取)\n- [自定义上下文](#自定义上下文)\n- [自定义函数](#自定义函数)\n- [自定义原方法是否执行成功](#自定义原方法是否执行成功)\n- [实体类`Diff`](#实体类Diff)\n- [日志处理重试次数及兜底函数配置](#日志处理重试次数及兜底函数配置)\n- [重复注解](#重复注解)\n- [自定义消息线程池](#自定义消息线程池)\n- [函数返回值记录开关](#函数返回值记录开关)\n- [非注解方式手动记录日志](#非注解方式)\n- [操作日志数据表结构推荐](#操作日志数据表结构推荐)\n- [让注解支持`IDEA`自动补全](#让注解支持IDEA自动补全)\n\n### SpEL的使用\n\n`SpEL`是`Spring`实现的标准的表达式语言，具体的使用可以学习官方文档或者自行搜索资料，入门非常的简单，推荐几篇文章：\n\n- http://itmyhome.com/spring/expressions.html\n- https://docs.spring.io/spring-framework/docs/3.0.x/reference/expressions.html\n\n需要注意的是，`@OperationLog`注解中，除了`executeBeforeFunc`和`recordReturnValue`两个`boolean`类型的参数，**其他的参数均需要严格遵循`SpEL`表达式语法。**\n`\n\n举例来说，`bizType`中我们经常会填入常量，例如订单创建`orderCreate`, 订单修改`orderModify`。\n\n在`SpEL`表达式中，若传入`bizType=\"orderCreate\"`，SpEL会解析失败，因为纯字符串会被认为是一个方法名，导致`SpEL`找不到方法而报错，需要使用`bizType=\"'orderCreate'\"`，才能被正确解析。\n\n有时，我们会用枚举值和常量值来规范`bizType`等参数，合理写法如下：\n\n```java\n@Getter\n@AllArgsConstructor\npublic enum TestEnum {\n\n    TYPE1(\"type1\", \"枚举1\"),\n    TYPE2(\"type2\", \"枚举2\");\n\n    private final String key;\n    private final String name;\n\n}\n```\n\n```java\npublic class TestConstant {\n\n    public static final String TYPE1 = \"type1\";\n    public static final String TYPE2 = \"type2\";\n\n}\n```\n\n```java\n@OperationLog(bizId = \"'1'\", bizType = \"T(cn.monitor4all.logRecord.test.bean.TestConstant).TYPE1\")\n@OperationLog(bizId = \"'2'\", bizType = \"T(cn.monitor4all.logRecord.test.bean.TestEnum).TYPE1\")\n@OperationLog(bizId = \"'3'\", bizType = \"T(cn.monitor4all.logRecord.test.bean.TestEnum).TYPE1.key\")\n@OperationLog(bizId = \"'4'\", bizType = \"T(cn.monitor4all.logRecord.test.bean.TestEnum).TYPE1.name\")\n```\n\n\n**注意：`bizType`和`tag`参数在 \u003e= 1.2.0版本以后才要求严格遵循`SpEL`表达式，\u003c= 1.1.x以下版本均为直接填写字符串，不支持`SpEL`解析。**\n\n\n### 自定义`SpEL`解析顺序\n\n在默认配置下，注解切面的逻辑在方法执行之后才会执行，这样会带来一个问题，如果在方法内部修改了方法参数，`SpEL`解析后取值就变成了改变后的值。\n\n可以使用`LogRecordContext`写入旧值，避免这个问题，只是有一定代码侵入性。\n\n为了满足一些特殊需求，注解中提供`boolean`参数`executeBeforeFunc`，**若设置为`true`，则会在方法执行前先解析`SpEL`参数。 这样也会带来负作用，方法内写入的数值，比如自定义上下文，就不再参与`SpEL`解析了。**\n\n方法上加上注解：\n\n```java\n@OperationLog(bizId = \"#keyInBiz\", bizType = \"'testExecuteBeforeFunc1'\", executeBeforeFunc = true)\n@OperationLog(bizId = \"#keyInBiz\", bizType = \"'testExecuteAfterFunc'\")\n@OperationLog(bizId = \"#keyInBiz\", bizType = \"'testExecuteBeforeFunc2'\", executeBeforeFunc = true)\npublic void testExecuteBeforeFunc() {\n    LogRecordContext.putVariable(\"keyInBiz\", \"valueInBiz\");\n}\n```\n\n调用方法：\n\n```java\ntestService.testExecuteBeforeFunc();\n```\n\n得到结果：\n\n```json\n[{\"bizId\":null, \"bizType\":\"testExecuteBeforeFunc1\",\"diffDTOList\":[],\"executionTime\":0,\"extra\":\"\",\"logId\":\"8cbed2fc-bb2d-48a7-b9ec-f28e99773151\",\"msg\":\"\",\"operateDate\":1651144119444,\"operatorId\":\"操作人\",\"returnStr\":\"null\",\"success\":true,\"tag\":\"operation\"}]\n[{\"bizId\":null, \"bizType\":\"testExecuteBeforeFunc2\",\"diffDTOList\":[],\"executionTime\":0,\"extra\":\"\",\"logId\":\"a130b60c-791c-4c6f-812e-0475de4b38d2\",\"msg\":\"\",\"operateDate\":1651144119444,\"operatorId\":\"操作人\",\"returnStr\":\"null\",\"success\":true,\"tag\":\"operation\"}]\n[{\"bizId\":\"valueInBiz\",\"bizType\":\"testExecuteAfterFunc\",\"diffDTOList\":[],\"executionTime\":0,\"extra\":\"\",\"logId\":\"80af92f5-8e4a-489e-a626-83f2a696fe71\",\"msg\":\"\",\"operateDate\":1651144119444,\"operatorId\":\"操作人\",\"returnStr\":\"null\",\"success\":true,\"tag\":\"operation\"}]\n```\n\n\n\n### 内置自定义函数和自定义参数\n\n1. 可以直接使用的自定义参数：\n\n- `_return`：原方法的返回值\n- `_errorMsg`：原方法的异常信息（`throwable.getMessage()`）\n\n使用示例：\n\n```java\n@OperationLog(bizId = \"'1'\", bizType = \"'testDefaultParamReturn'\", msg = \"#_return\")\n```\n\n**注意：`_return`和`_errorMsg`均为方法执行后才赋值的参数，所以若`executeBeforeFunc=true`（设置为方法执行前执行日志切面），则这两个值为`null`。**\n\n2. 可以直接使用的自定义函数：\n\n- `_DIFF`：详见下方 **实体类`Diff`** 小节\n\n\n### 根据条件记录日志\n\n`@OperationLog`注解拥有字段`condition`，用户可以使用SpEL表达式来决定该条日志是否记录。\n\n方法上加上注解：\n\n```java\n@OperationLog(bizId = \"'1'\", bizType = \"'testCondition1'\", condition = \"#testUser != null\")\n@OperationLog(bizId = \"'2'\", bizType = \"'testCondition2'\", condition = \"#testUser.id == 1\")\n@OperationLog(bizId = \"'3'\", bizType = \"'testCondition3'\", condition = \"#testUser.id == 2\")\npublic void testCondition(TestUser testUser) {\n}\n```\n\n调用方法：\n\n```java\ntestService.testCondition(new TestUser(1, \"张三\"));\n```\n\n上述注解中，只有前两条注解满足`condition`条件，会输出日志。\n\n### 全局操作人信息获取\n\n大部分情况下，操作人ID往往不会在方法参数中传递，更多会是查询集团内`BUC`信息、查询外部服务、查表等获取。所以开放了`SPI`，只需要实现接口`IOperationLogGetService`，便可以统一注入操作人ID。\n\n```java\n@Component\npublic class IOperatorIdGetServiceImpl implements IOperatorIdGetService {\n\n    @Override\n    public String getOperatorId() {\n        // 查询操作人信息\n        return \"张三\";\n    }\n}\n```\n\n**注意：若实现了接口后仍在注解手动传入`OperatorID`，则以传入的`OperatorID`优先。**\n\n### 自定义上下文\n\n直接引入类`LogRecordContext`，放入键值对。\n\n```java\n@OperationLog(bizType = \"'followerChange'\", bizId = \"#request.orderId\", msg = \"'用户' + #userName + '修改了订单的跟进人：从' + #oldFollower + '修改到' + #request.newFollower\")\npublic Response\u003cT\u003e function(Request request) {\n  // 业务执行逻辑\n  ...\n  // 手动传递日志上下文：用户信息 地址旧值\n  LogRecordContext.putVariable(\"userName\", queryUserName(request.getUserId()));\n  LogRecordContext.putVariable(\"oldFollower\", queryOldFollower(request.getOrderId()));\n}\n```\n\nLogRecordContext内部使用TransmittableThreadLocal实现与主线程的ThreadLocal传递。\n\n### 自定义函数\n\n将`@LogRecordFunc`注解申明在需要注册到`SpEL`的自定义函数上，参与`SpEL`表达式的运算。\n\n注意，需要在类上也声明`@LogRecordFunc`，否则无法找到该函数。\n\n`@LogRecordFunc`可以添加参数`value`，实现自定义方法别名，若不添加，则默认不需要写前缀。\n\n静态自定义方法：\n\n`SpEL`天生支持，写法如下：\n\n```java\n@LogRecordFunc(\"CustomFunctionStatic\")\npublic class CustomFunctionStatic {\n\n    @LogRecordFunc(\"testStaticMethodWithCustomName\")\n    public static String testStaticMethodWithCustomName(){\n        return \"testStaticMethodWithCustomName\";\n    }\n\n    @LogRecordFunc\n    public static String testStaticMethodWithoutCustomName(){\n        return \"testStaticMethodWithoutCustomName\";\n    }\n\n}\n```\n\n上述代码中，注册的自定义函数名为`CustomFunctionStatic_testStaticMethodWithoutCustomName`和`CustomFunctionStatic_testStaticMethodWithoutCustomName`，若类上的注解更改为`@LogRecordFunc(\"test\")`，则注册的自定义函数名为`testStaticMethodWithCustomName`和`testStaticMethodWithoutCustomName`\n\n非静态自定义方法：\n\n~~原理主要是依靠我们框架内部转换，将非静态方法需要包装为静态方法再传给`SpEL`。原理详见[#PR25](https://github.com/qqxx6661/log-record/pull/25)~~\n\n在1.6.x版本之前，部分版本(1.5.x)支持非静态自定义函数，但由于其大量使用反射，写法较为Hack，兼容性不佳（在JDk11+后反射限制更加严格），在1.6.x+ 版本后删除，仅支持静态方法。\n\n\n\n注意：所有自定义函数可在应用启动时的日志中找到\n\n```\n2022-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]\n2022-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]\n2022-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]\n```\n\n注解中使用：\n\n```java\n@OperationLog(bizId = \"#CustomFunctionStatic_testStaticMethodWithCustomName()\", bizType = \"'testStaticMethodWithCustomName'\")\n@OperationLog(bizId = \"#CustomFunctionStatic_testStaticMethodWithoutCustomName()\", bizType = \"'testStaticMethodWithoutCustomName'\")\npublic void testCustomFunc() {\n}\n```\n\n### 自定义原方法是否执行成功\n\n`@OperationLog`注解中有`success`参数，用于根据返回体或其他情况下自定义日志实体中的`success`字段。\n\n默认情况下，方法是否执行成功取决于是否抛出异常，若未抛出异常，默认为方法执行成功。\n\n但很多时候，我们的方法执行成功可能取决于方法内部调用的接口的返回值，如下所示：\n\n```java\n@OperationLog(\n        success = \"#isSuccess\",\n        bizId = \"#request.trade.id\",\n        bizType = \"'createOrder'\",\n    )\n@Override\npublic Result\u003cVoid\u003e createOrder(Request request) {\n    try {\n        Response response = tradeCreateService.create(request);\n        LogRecordContext.putVariable(\"isSuccess\", response.getIsSuccess());\n        return Result.ofSuccess();\n    } catch (Exception e) {\n        return Result.ofSysError();\n    }\n}\n```\n\n可以通过接口返回的`response.getIsSuccess()`来表名该创建订单方法是否执行成功。\n\n### 实体类`Diff`\n\n支持两个对象（相同或者不同的类对象皆可）对象的`Diff`。\n\n有如下注解：\n\n- `@LogRecordDiffField`：在字段上申明`@LogRecordDiffField(alias = \"用户工号\", ignored = true)`，`alias`别名为可选字段。 `ignored`为可选字段，默认为`false`，若为`true`，则该字段不参与`DIFF`。\n- `@LogRecordDiffObject`：在类上允许可以申明`@LogRecordDiffObject(alias = \"用户信息实体\")`，`alias`别名为可选字段，默认类下所有字段会进行`DIFF`，可通过`enableAllFields`手动关闭，关闭后等于该注解只用于获取类别名。\n\n类对象使用示例：\n\n```java\n@LogRecordDiffObject(alias = \"用户信息实体\")\npublic class TestUser {\n    private Integer id;\n    private String name;\n    private String job;\n}\n```\n\n或者单独为类中的字段DIFF：\n\n```java\npublic class TestUser {\n    @LogRecordDiffField(alias = \"用户工号\")\n    private Integer id;\n    @LogRecordDiffField(alias = \"用户工号\", ignored = true)\n    private String name;\n}\n```\n\n在`@OperationLog`注解上，可以通过调用内置实现的自定义函数`_DIFF`，传入两个对象即可拿到`Diff`结果。\n\n\n```java\n@OperationLog(bizId = \"'1'\", bizType = \"'testObjectDiff'\", msg = \"#_DIFF(#oldObject, #testUser)\", extra = \"#_DIFF(#oldObject, #testUser)\")\npublic void testObjectDiff(TestUser testUser) {\n    LogRecordContext.putVariable(\"oldObject\", new TestUser(1, \"张三\"));\n}\n```\n\n比较完成后的结果在日志实体中以`diffDTO`实体呈现。\n\n```java\n{\n  \"diffFieldDTOList\":[\n    {\n      \"fieldName\":\"id\",\n      \"newFieldAlias\":\"用户工号\",\n      \"newValue\":2,\n      \"oldFieldAlias\":\"用户工号\",\n      \"oldValue\":1\n    },\n    {\n      \"fieldName\":\"name\",\n      \"newValue\":\"李四\",\n      \"oldValue\":\"张三\"\n    }],\n  \"newClassAlias\":\"用户信息实体\",\n  \"newClassName\":\"cn.monitor4all.logRecord.test.bean.TestUser\",\n  \"oldClassAlias\":\"用户信息实体\",\n  \"oldClassName\":\"cn.monitor4all.logRecord.test.bean.TestUser\"\n}\n```\n\n调用方法：\n\n```java\ntestService.testObjectDiff(new TestUser(2, \"李四\"));\n```\n\n\n最终得到的日志消息实体`logDTO`：\n\n```json\n{\n  \"bizId\":\"1\",\n  \"bizType\":\"testObjectDiff\",\n  \"executionTime\":0,\n  \"extra\":\"【用户工号】从【1】变成了【2】 【name】从【张三】变成了【李四】\",\n  \"logId\":\"38f7f417-2cc3-40ed-8c98-2fe3ee057518\",\n  \"msg\":\"【用户工号】从【1】变成了【2】 【name】从【张三】变成了【李四】\",\n  \"operateDate\":1651116932299,\n  \"operatorId\":\"操作人\",\n  \"returnStr\":\"{\\\"id\\\":1,\\\"name\\\":\\\"张三\\\"}\",\n  \"success\":true,\n  \"exception\":null,\n  \"tag\":\"operation\",\n  \"diffDTOList\":[\n    {\n      \"diffFieldDTOList\":[\n        {\n          \"fieldName\":\"id\",\n          \"newFieldAlias\":\"用户工号\",\n          \"newValue\":2,\n          \"oldFieldAlias\":\"用户工号\",\n          \"oldValue\":1\n        },\n        {\n          \"fieldName\":\"name\",\n          \"newValue\":\"李四\",\n          \"oldValue\":\"张三\"\n        }],\n      \"newClassAlias\":\"用户信息实体\",\n      \"newClassName\":\"cn.monitor4all.logRecord.test.bean.TestUser\",\n      \"oldClassAlias\":\"用户信息实体\",\n      \"oldClassName\":\"cn.monitor4all.logRecord.test.bean.TestUser\"\n    },\n    {\n      \"diffFieldDTOList\":[\n        {\n          \"fieldName\":\"id\",\n          \"newFieldAlias\":\"用户工号\",\n          \"newValue\":2,\n          \"oldFieldAlias\":\"用户工号\",\n          \"oldValue\":1\n        },\n        {\n          \"fieldName\":\"name\",\n          \"newValue\":\"李四\",\n          \"oldValue\":\"张三\"\n        }],\n      \"newClassAlias\":\"用户信息实体\",\n      \"newClassName\":\"cn.monitor4all.logRecord.test.bean.TestUser\",\n      \"oldClassAlias\":\"用户信息实体\",\n      \"oldClassName\":\"cn.monitor4all.logRecord.test.bean.TestUser\"\n    }]\n}\n```\n\n可以通过`Spring`配置，忽略对比的新旧对象中值为null的字段，形如：\n\n```properties\nlog-record.diff-ignore-new-object-null-value=true # 忽略新对象中null值字段，默认为false\nlog-record.diff-ignore-old-object-null-value=true # 忽略旧对象中null值字段，默认为false\n```\n\n此外，可以通过`Spring`配置自定义`DIFF`的标准输出格式，形如：\n\n```properties\nlog-record.diff-msg-format=（默认值为【${_fieldName}】从【${_oldValue}】变成了【${_newValue}】）\nlog-record.diff-msg-separator=（默认值为\" \"空格）\n```\n\n还支持同一个注解中多次调用`_DIFF`, 如下：\n\n```java\n/**\n * 测试实体类DIFF：使用多个_DIFF\n */\n@OperationLog(bizId = \"'1'\", bizType = \"'testMultipleDiff'\", msg = \"'第一个DIFF：' + #_DIFF(#oldObject1, #testUser) + '第二个DIFF' + #_DIFF(#oldObject2, #testUser)\")\npublic void testMultipleDiff(TestUser testUser) {\n    LogRecordContext.putVariable(\"oldObject1\", new TestUser(1, \"张三\"));\n    LogRecordContext.putVariable(\"oldObject2\", new TestUser(3, \"王五\"));\n}\n```\n\n**注意：目前`DIFF`功能支持完全不同的类之间进行`DIFF`，对于同名的基础类型，进行`equals`对比，对于同名的非基础类型，则借用`fastjson`的`toJSON`能力，转为`JSONObject`进行对比，本质上是将对象映射为`map`进行`map.equals`。**\n\n### 日志处理重试次数及兜底函数配置\n\n无论是本地处理日志，或者发送到消息管道处理日志，都会存在处理异常需要重试的场景。可以通过`properties`配置：\n\n```properties\nlog-record.retry.retry-times=5  # 默认为0次重试，即日志处理方法只执行1次\n```\n\n配置后框架会重新执行`createLog`直至达到最大重试次数。\n\n若超过了重试次数，可以通过实现`SPI`接口 `cn.monitor4all.logRecord.service.LogRecordErrorHandlerService` 来进行兜底逻辑处理，这里将本地日志处理和消息管道兜底处理分开了。\n\n```java\n@Component\npublic class LogRecordErrorHandlerServiceImpl implements LogRecordErrorHandlerService {\n\n    @Override\n    public void operationLogGetErrorHandler() {\n        log.error(\"operation log get service error reached max retryTimes!\");\n    }\n\n    @Override\n    public void dataPipelineErrorHandler() {\n        log.error(\"data pipeline send log error reached max retryTimes!\");\n    }\n}\n```\n\n### 重复注解\n\n```java\n@OperationLog(bizId = \"#testClass.testId\", bizType = \"'testType1'\", msg = \"#testFunc(#testClass.testId)\")\n@OperationLog(bizId = \"#testClass.testId\", bizType = \"'testType2'\", msg = \"#testFunc(#testClass.testId)\")\n@OperationLog(bizId = \"#testClass.testId\", bizType = \"'testType3'\", msg = \"'用户将旧值' + #old + '更改为新值' + #testClass.testStr\")\n```\n\n我们还加上了重复注解的支持，可以在一个方法上同时加多个`@OperationLog`，**会保证按照`@OperationLog`从上到下的顺序输出日志**。\n\n### 自定义消息线程池\n\nstarter提供了如下配置：\n\n```properties\nlog-record.thread-pool.pool-size=4（线程池核心线程大小 默认为4）\nlog-record.thread-pool.enabled=true（线程池开关 默认为开启 若关闭则使用业务线程进行消息处理发送）\n```\n\n在组装好`logDTO`后，默认会使用线程池对消息进行处理，发送至本地监听函数或者消息队列发送者，也可以通过配置关闭线程池，让主线程执行全部消息处理逻辑。\n\n**注意：`logDTO`的组装逻辑在切面中，该切面仍然在函数执行的线程中运行。**\n\n默认线程池配置如下（拒绝策略为丢弃）：\n\n```java\nreturn new ThreadPoolExecutor(poolSize, poolSize, 0L, TimeUnit.SECONDS, new LinkedBlockingQueue\u003c\u003e(1024), THREAD_FACTORY, new ThreadPoolExecutor.AbortPolicy());\n```\n\n此外，还提供了用户传入自定义线程池的方式，用户可自行实现cn.monitor4all.logRecord.thread.ThreadPoolProvider，传入线程池。\n\n示例：\n\n```java\npublic class CustomThreadPoolProvider implements ThreadPoolProvider {\n\n    private static ThreadPoolExecutor EXECUTOR;\n\n    private static final ThreadFactory THREAD_FACTORY = new CustomizableThreadFactory(\"custom-log-record-\");\n\n\n    private CustomThreadPoolProvider() {\n        log.info(\"CustomThreadPoolProvider init\");\n        EXECUTOR = new ThreadPoolExecutor(3, 3, 0L, TimeUnit.SECONDS, new LinkedBlockingQueue\u003c\u003e(100), THREAD_FACTORY, new ThreadPoolExecutor.AbortPolicy());\n    }\n\n    @Override\n    public ThreadPoolExecutor buildLogRecordThreadPool() {\n        return EXECUTOR;\n    }\n}\n```\n\n\n### 函数返回值记录开关\n\n`@OperationLog`注解提供布尔值`recordReturnValue()`用于是否开启记录函数返回值，默认关闭，防止返回值实体过大，造成序列化时性能消耗过多。\n\n### 非注解方式\n\n在实际业务场景中，很多时候由于注解的限制，无法很好的使用注解记录日志，此时可以使用纯手动的方式进行日志记录。\n\n框架提供了手动记录日志的方法：\n\ncn.monitor4all.logRecord.util.OperationLogUtil\n\n```java\nLogRequest logRequest = LogRequest.builder()\n        .bizId(\"testBizId\")\n        .bizType(\"testBuildLogRequest\")\n        .success(true)\n        .msg(\"testMsg\")\n        .tag(\"testTag\")\n        .returnStr(\"testReturnStr\")\n        .extra(\"testExtra\")\n        // 其他字段\n        .build();\nOperationLogUtil.log(logRequest);\n```\n\n使用该方式记录日志，注解带来的相关功能则无法使用，如SpEL表达式，自定义函数等。\n\n\n### 操作日志数据表结构推荐\n\n以MySQL表为例：\n\n```\nCREATE TABLE `operation_log` (\n  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',\n  `gmt_create` datetime NOT NULL COMMENT '创建时间',\n  `gmt_modified` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',\n  `biz_id` varchar(128) NOT NULL COMMENT '业务ID',\n  `biz_type` varchar(64) DEFAULT NULL COMMENT '业务类型',\n  `tag` varchar(64) DEFAULT NULL COMMENT '标签',\n  `operation_date` datetime DEFAULT NULL COMMENT '操作执行时间',\n  `msg` varchar(512) DEFAULT NULL COMMENT '操作内容',\n  `extra` varchar(512) DEFAULT NULL COMMENT '附加信息',\n  `operation_status` tinyint(4) DEFAULT NULL COMMENT '操作结果状态',\n  `operation_time` int(11) DEFAULT NULL COMMENT '操作耗时',\n  `content_return` varchar(512) COMMENT '方法返回内容',\n  `content_exception` varchar(512) COMMENT '方法异常内容',\n  `operator_id` varchar(32) DEFAULT NULL COMMENT '操作人ID',\n  `operator_name` varchar(32) DEFAULT NULL COMMENT '操作人姓名',\n  PRIMARY KEY (`id`),\n  KEY `idx_biz_id` (`biz_id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';\n\n```\n\n### 让注解支持`IDEA`自动补全\n\n在自定义注解想实现类似`@Cacheable`的自动补全，其实是`IDEA`等`IDE`自己的支持，可以在配置中将本二方库的注解添加上去，从而支持自动补全和`SpEL`表达式校验。\n\n![](pic/IDEA_SpEL.png)\n\n## SpringBoot3(JDK17+)版本与SpringBoot1\u0026SpringBoot2(JDK8+)版本使用差异\n\n本框架尽可能在不同SpringBoot版本下提供统一的功能和特性，但由于JDk兼容等问题，在使用上仍有一些差异。\n\n在这里列举需要本框架使用者注意的差异：\n\n### SpringBoot3无法获取函数入参\n\n由于JDK11+以上收紧了对反射的使用，导致SpringBoot3无法获取函数入参，所以在SpringBoot3版本下，无法使用参数名获取函数入参。\n\n例如在SpringBoot1\u0026SpringBoot2中可以这样做：\n\n```java\n@OperationLog(bizId = \"#bizId\", bizType = \"'testBizIdWithSpEL'\")\npublic void testBizIdWithSpEL(String bizId) {\n}\n```\n\n但是SpringBoot3中，只能使用`p0`、`p1`等参数名获取函数入参（参数的绝对位置下标），如下：\n\n```java\n@OperationLog(bizId = \"#p0\", bizType = \"'testBizIdWithSpEL'\")\npublic void testBizIdWithSpEL(String bizId) {\n}\n```\n\n## 应用场景\n\n以下罗列了一些实际的应用场景，包括我业务中实际使用，并且已经上线使用的场景。\n\n### 操作日志\n\n`CRM`系统，在用户进行了编辑操作后，拿到用户操作的数据，执行日志写入。\n\n### 系统日志\n\n操作日志是主要的功能，当然也可以兼顾一些系统日志记录的操作，比如只是想简单记录方法执行时间，出入参等，也可以通过该库轻松做到。\n\n### 后端埋点\n\n与系统日志类似，可以记录一些用户操作埋点。\n\n### 通知\n\n应用之间通过关键操作的日志消息，互相通知。\n\n## Demo\n\n当你觉得用法不熟悉，可以查看单元测试用例，里面有最为详细且最全的使用示例。\n\n另外提供完整SpringBoot2\u00263 Demo项目:\n\nhttps://github.com/qqxx6661/systemLog\n\n## Release Note\n\n[Release](https://github.com/qqxx6661/log-record/releases)\n\n## 附录\n\n### 编译注意\n\n由于拆分了父子模块，在不同JDK下，请重新编译log-record-core，再编译对应版本的log-record-starter，否则会导致编译失败（单元测试异常）。\n\n### 发布版本注意\n\n请将log-record-core, log-record-starter, log-record-springboot3-starter都编译打包发布到Maven公共仓库。\n\n### 配套教程文章\n\n- 如何使用注解优雅的记录操作日志  \n  https://mp.weixin.qq.com/s/q2qmffH8t-ou2apOa6BiPQ\n- 如何提交自己的项目到Maven公共仓库  \n  https://mp.weixin.qq.com/s/B9LA6be_cPAKACbZot_Nrg\n\n### 关注我\n\n公众号：后端技术漫谈\n\n全网博客名：蛮三刀酱\n\n如果觉得该项目对你有用，请点个star，谢谢！\n\n### Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=qqxx6661/log-record\u0026type=Date)](https://star-history.com/#qqxx6661/log-record\u0026Date)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fqqxx6661%2Flog-record","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fqqxx6661%2Flog-record","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fqqxx6661%2Flog-record/lists"}