
统一操作日志
统一操作日志记录
几乎所有重要的业务系统模块都需要记录操作日志,以便追踪数据变更。这些日志通常是面向系统用户显示的,因此需要具备一定的文案格式要求,使日志内容易于理解。
操作日志与系统日志与请求日志不同,它是针对业务操作的记录,通常包含操作人、操作时间、操作内容等信息。通常的操作日志文案格式是固定但是内容是动态的。
例如:
「
坤坤」于 「2019-06-09 12:23:23」 完成了个人练习生学业,练习时长为「两年半」,毕业作品为 「基泥钛镁」,喜欢「唱、跳、rap、篮球」。「
凡凡」于 「2021-11-25 12:13:14」 使缝纫机转动了: 「6699」次「
如花(69)」修改了个人信息 「家庭住址」从 「广州市天河区棠下村」 修改为 「广州天河区珠江新城保利中心」「
系统管理员」新增了一个员工(工号): 「小蓬鱼彦(666)」
其中「」当中的内容都是动态的,主要是包括人、时间、动作等。其他的内容可以抽象为模板,同样类似的操作只是换了不同的人在做。
例如,假如你是练习生,针对系统上练习生的毕业总结日志,在系统中经过管理员确认通过后,文案就如上述格式那样,系统一视同仁。坤坤是这样,你也是这样,所以你 = 坤坤 ,你和坤坤并没有区别,都有美好的明天(⭐️)。
将上述内容抽象成模板,大概就是这样:
「currentUser」于 「time」 完成了个人练习生学业,练习时长为「costTime」,毕业作品为 「creations」,喜欢「hobbies」。
其他的情况也类似,但要组装出上述类似的操作日志文案,需要在业务代码中进行大量的变量拼接,日志逻辑与业务代码混杂在一起,显得不够纯粹。
针对这类需求,oplog 使用 AOP + SpEL 的组合来提供注解驱动的统一处理,使业务代码更加纯净。
特点:
工作方式
快速开始
导入依赖
提示
2.1.3版本开始支持
implementation(commonLibs.blade.oplog.spring.boot.starter)implementation("team.aikero.blade:blade-oplog-spring-boot-starter:${latestVersion}")
<dependency>
<groupId>team.aikero.blade</groupId>
<artifactId>blade-oplog-spring-boot-starter</artifactId>
<version>${latestVersion}</version>
</dependency>配置项
在 application.yml 中配置
一般情况下的配置,如下即可
blade:
oplog:
header-source: # 开启解析请求头中的source信息
enabled: true
unwrap-response: true # 解包响应体包装类 在controller层打注解需要
persistence:
rabbitmq:
enabled: true #内置了发送到MQ消费记录日志也可以引用单独配置好的文件: oplog.yml,
使用
在Controller或Service的目标方法上使用@OpLog注解,填写必要属性,系统会在方法执行前后自动记录操作日志。
// 简单模式 四个必填属性
@OpLog(
success = "'下单成功订单号:' + #_return.data.orderNo+' 订单ID('+ #_return.data.id + ')'",
type = OpType.ADD,
category = "订单",
bizId = "#_return.data.id",
)
@PostMapping("/add-success-with-opened-unwrap-response")
fun add(@RequestBody req: OrderInfoAddReq): DataResponse<@FetchBy("SIMPLE") OrderInfo> =
ok(orderService.save(req.toEntity()))或者 使用全局类别标签
@RestController
@RequestMapping("/orders")
@OpLogTag(category = ORDER_CATEGORY) // 全局类别标签
class OrderController(private val orderService: OrderService) {
@OpLog(
success = "'下单成功订单号:' + #_return.data.orderNo+' 订单ID('+ #_return.data.id + ')'",
type = OpType.ADD,
bizId = "#_return.data.id",
)
@PostMapping("/add-success-with-opened-unwrap-response")
fun add(@RequestBody req: OrderInfoAddReq): DataResponse<@FetchBy("SIMPLE") OrderInfo> =
ok(orderService.save(req.toEntity()))
}详细说明
类图说明
核心图
整个组件的应用逻辑核心围绕注解OpLog在 OpLogPointcutAdvisor、OpLogPointcut、OpLogInterceptor当中处理,以下是作用说明:
- OpLogPointcutAdvisor:结合了切点和通知的组织点,定义哪些方法需要被拦截(匹配注解)以及在拦截时需要执行哪些逻辑。
- OpLogPointcut:定义了拦截点的切点,包含特定的匹配规则用以识别目标方法。
- OpLogInterceptor:具体的拦截器,定义了方法拦截时需要执行的逻辑,实现横切关注点的实际业务逻辑
细化图
详细说明可以往下查看了解
注解内容
所有功能的扩展和定义都是通过注解 @OpLog 实现的。这是一个非常重要的注解,它有很多属性,但大多数都是可选的。请参照以下表格,根据自己的需要进行配置。
重要
请注意以下表格的描述,有一些属性虽然非必填,但对于日志完善性上来说是必须的,可以通过拓展点形式提供,所以在使用时必须要有一个赋值。
| 属性 | 描述 | 是否必填 | 支持SpEL | 默认值 |
|---|---|---|---|---|
| success | 执行成功后的日志模版 | Yes | Yes | None |
| failure | 执行失败后的日志模版 | No | Yes | '执行失败: ' + #_errorMsg |
| category | 日志的分类,用于查询时的分类检索。主要的日志分类,比如:订单日志、商品日志、用户日志等 优先级高于[OpLogTag]的category 非必填 但是@OpLogTag注解与该注解赋值必须要有一个 | 条件下必填 | No | None |
| operator | 日志的操作人 优先级高于接口OperatorProvider 非必填 但是接口实现与注解赋值必须要有一个 | 条件下必填 | Yes | None |
| type | 日志的操作类型,如修改、删除、新增等。 | Yes | No | None |
| source | 日志的来源,区分日志来源的客户端 比如APP只能看到APP产生的日志 或者运营只能看到客户客户端的日志 但是后台运营可以看到所有的日志 优先级高于[SourceProvider] 非必填 但是接口实现与注解赋值必须要有一个 | 条件下必填 | Yes | None |
| entry | 日志的入口,区别于来源 source。入口可以是客户端的具体某个页面的某个按钮 适用于需要精细化记录操作入口的场景 | No | Yes | None |
| bizNo | 业务单据号,用于标识业务的唯一标识。优先级高于OpLogProperties.forceBizNo全局开关 若关闭了全局仍可在注解上填写单独生效 | No | Yes | None |
| bizId | 业务单据标识 ID,非常重要! 例如订单ID 后续查询订单操作日志的重要依据 标识数据的归属 | 条件下必填 | Yes | None |
| extra | 日志的额外信息 有需求则填写 | No | Yes | None |
| condition | 是否记录日志,优先级最高 若条件不成立 则不记录日志 | No | Yes | true |
| successCondition | 记录成功日志的条件,若为空不解析,代表不抛异常为成功。这里一般默认判断包装返回类就好,如果有特殊需求可以自定义 controller层有包装统一返回结构时候需要 优先级高于ResponseUnWrapper | No | Yes | None |
| successConditionFailMsg | 如果successCondition为false时的错误消息 | No | Yes | None |
| diffDataMethod | 用于比较时查询的方法,打开全局或enabledDiff后 必须有值 更新操作时候对比前后数据版本的差异 | 条件下必填 | Yes | None |
| diff | 自定义 diff 执行的方法,支持 SpEL。若为空默认使用默认的 _DIFF 自定义函数。 | No | Yes | None |
| enabledDiff | 是否开启 diff 功能,默认关闭。只有在 UPDATE 操作类型有效并且优先级高于 OpLogProperties.enabledDiff。 | No | No | false |
关于有条件必填字段的说明:这些字段在代码层面非必填,但在特定场景下必须赋值,组件会根据以下场景情况来进行校验。以下是详细解释:
category:若
@OpLogTag注解未被标记,那么category就是必填的,否则可以不填operator:若未提供
OperatorProvider的实现,那么operator就是必填的,否则可以不填source:若未提供
SourceProvider的实现,那么source就是必填的,否则可以不填bizId: 对于一些操作数据的日志类型记录,
bizId是必填的,因为这是操作日志的唯一标识,后续查询操作日志的重要依据 操作数据的操作类似:ADD、UPDATE、DELETE这些操作是针对某一条数据的操作必须要指定操作数据归属,来确保回显端能够根据bizId来检索日志列表 与之相反的例如EXPORT、IMPORT甚至QUERY这类泛性操作无法绑定到某一条数据上的,bizId则可为空diffDataMethod:若打开了全局diff开关(
blade.oplog.enabled-diff=true)或者在注解上指定enabledDiff = true,那么diffDataMethod就是必填的,否则可以不填
操作类型说明
具体支持的操作见 OpType 枚举类,这里对于一些操作的静默行为作说明 基于对数据的影响性质,操作类型分为以下几种
增加
新增操作一般是操作日志的第一条,也是确定一条有效数据的开始,针对新增操作的类型 如果新增失败,不会记录日志,只有新增成功才会记录日志
如果是开启解包,并且解包结果是false的也不记录日志。
修改
对于修改的操作,才会有 Diff的功能,其余的操作不会有Diff功能,因为新增和删除没有对比的对象。
删除/导出/查询/导入
暂时没有特殊行为
注
操作类型暂不提供拓展
拓展点
因为希望尽可能的把注解驱动的操作日志记录的足够灵活和丰富,提供了很多配置属性,但是如果要把这些属性都放在注解上,会显得很臃肿,就像这样
//整个注解内容占据很大篇幅
@OpLog(
success = "'下单成功订单号:' + #_return.data.orderNo+' 订单ID('+ #_return.data.id + ')'",
fail = "'下单失败订单号:' + #_return.data.orderNo+' 订单ID('+ #_return.data.id + ')'",
category = "订单",
condition = "#_return?.code == 0",
operator = "'操作人:' + #_return.data.operatorName",
type = OpType.ADD,
source = "前端",
entry = "订单下单页面",
bizNo = "#_return.data.orderNo",
bizId = "#_return.data.id",
extra = "#_return.data",
successCondition = BLADE_RESPONSE_SUCCESS,
successConditionFailMsg = BLADE_RESPONSE_ERROR_MSG,
diffDataMethod = "diffData",
diff = "#_return.data",
enabledDiff = false
) //到这里才完成
@PostMapping("/add-success")
fun addOrderSuccess(@RequestBody req: OrderInfoAddReq): DataResponse<@FetchBy("SIMPLE") OrderInfo> =
ok(orderService.save(req.toEntity()))原本很优雅的注解驱动因为加入大量配置属性而显得不再优雅,所以提供了扩展点,允许通过实现接口的方式来提供更多配置属性。因为一些配置在一个生态系统下基本都是统一的,所以这种方式更为合理。
注意
接口形式的统一扩展点的优先级低于注解上的同名配置,如果注解上有配置,会覆盖接口的配置。
操作人提供器
一般来说,记录操作日志通常与当前登录用户有关,否则也无法操作系统。一个系统也都会有统一处理登录用户的地方,可以通过实现 OperatorProvider 接口来提供操作人。
interface OperatorProvider {
/**
* 获取当前登录用户,并转换成Operator返回
*
* @return 当前登录用户的Operator对象
*/
fun get(): Operator
}例如在 fashion 团队内部,使用了 CurrentUserContentHolder 来提供操作人,因此可以这样实现。
class CurrentUserOperatorProvider : OperatorProvider {
override fun get(): Operator = CurrentUserContentHolder.get().let {
Operator(it.currentUserId.toString(), it.currentUserName)
}
}如果有别的统一方式的话,也可以实现这个接口,配置为Spring Bean即可
自动配置大致如下:
@Bean
fun customOperatorProvider(): OperatorProvider = CustomUserOperatorProvider()注解上的配置
如果有特殊的操作人提供,可以使用注解上的配置,优先级高于接口提供的配置
在注解上使用 可以有以下方式配置方式:
- 已定义在上下文的方法直接使用
#(使用自定义函数也一样) - 静态方法
${}包裹起来的表达式 - 静态方法
T()包裹起来的表达式
已定义在上下文的方法直接使用#
@OpLog(
success = "'新建订单add-service-error订单号:' + #_return.data.orderNo+' 订单ID('+ #_return.data.id + ')'",
type = OpType.ADD,
bizId = "#_return.data.id",
operator = "#contextOperator" // 必须是已经定义在上下文的方法才可以使用 #
)
@PostMapping(ADD_WITH_CONTEXT_OPERATOR)
fun addWithOperatorNormal(@RequestBody req: OrderInfoAddReq): DataResponse<@FetchBy("SIMPLE") OrderInfo> {
OpLogContext.put("contextOperator", Operator("888", "context User")) // 这里将内容放入上下文
return ok(orderService.save(req.toEntity()))
}针对静态方法的方式,需要自定义函数
定义一个静态方法,返回一个 Operator 对象,后续需要在注解上使用
object CustomOperatorProvider {
@JvmStatic
fun get(): Operator = Operator(id = "123", name = "Custom User")
}使用${}包裹起来的表达式
例如:
"#{T(team.aikero.blade.oplog.service.provider.operator.CustomOperatorProvider).get()}"
必须是全类路径
@OpLog(
//忽略其他配置
operator = "#{T(team.aikero.blade.oplog.service.provider.operator.CustomOperatorProvider).get()}"
)
@PostMapping(ADD_WITH_OPERATOR)
fun addWithOperator(@RequestBody req: OrderInfoAddReq): DataResponse<@FetchBy("SIMPLE") OrderInfo> =
ok(orderService.save(req.toEntity()))使用T()包裹起来的表达式
例如:
"T(team.aikero.blade.oplog.service.provider.operator.CustomOperatorProvider).get()"
必须是全类路径
@OpLog(
//忽略其他配置
operator = "T(team.aikero.blade.oplog.service.provider.operator.CustomOperatorProvider).get()"
)
@PostMapping(ADD_WITH_OPERATOR_SIMPLE)
fun addWithOperatorSimple(@RequestBody req: OrderInfoAddReq): DataResponse<@FetchBy("SIMPLE") OrderInfo> =
ok(orderService.save(req.toEntity()))来源提供器
与操作人提供器类似,来源提供器也应该是一个统一配置,可以通过实现 SourceProvider 接口来提供来源。
/**
* 日志来源提供器
*
* 通常来标识 日志的产生来源
*
* 客户端标识,如:前端、后端、APP等
*
*
*/
interface SourceProvider {
/**
* 获取当前来源
*
* @return 来源信息
*/
fun get(request: Any): String
}一般情况下,我们规定客户端来源由客户端传递,通常藏在 header 里面, 如果你的系统通过 header 传递,组件默认已经提供了一个实现,默认的 header 名字是 Client-Id。如果 header 不一致,可以通过配置文件进行修改。
blade:
oplog:
header-source:
enabled: true
header-name: XXXX # 这里是你的自定义header名字如果有不同的但是是统一获取的方式,可以自行实现这个接口,配置为Spring Bean即可。
包装结构解包器
如果目标方法是进行过一层包装为统一结构的(一般发生在controller层) ,有时候可能把异常包装了并未抛出,那么仅仅判断目标方法是否抛出异常来作为目标方法执行是否成功的条件会不精准,这时候可以通过实现 ResponseUnWrapper 接口来提供解包的逻辑。
/**
* 响应解析器接口
*
* 用于定义解析业务方法返回的响应实体的接口规范。
* 实现此接口的类应提供具体解析逻辑,将原始响应转换为 `ParsedResponse` 对象。
*/
interface ResponseUnWrapper {
/**
* 是否匹配 要执行unwrap操作
* @param method 方法
* @param targetClass 目标类
* true: 执行unwrap操作 false: 不执行unwrap操作
*/
fun matches(method: Method, targetClass: Class<*>): Boolean = false
/**
* 解析响应实体
*
* 接受一个任意类型的响应对象,将其解析为 `ParsedResponse`。
* 如果无法解析,应该返回 `null`。
*
* @param response 原始响应对象
* @return 解析后的 `ParsedResponse` 对象
*/
fun unwrap(response: Any): ResponseExecResult
}一般情况下一个系统应该只会有一个统一的包装结构,所以只提供一个实现类即可。
就像我们团队的 DataResponseUnWrapper 一样,包装结构只存在Controller层,Service层不需要包装结构!
实现ResponseUnWrapper
class DataResponseUnWrapper : ResponseUnWrapper {
override fun matches(method: Method, targetClass: Class<*>): Boolean =
targetClass.isAnnotationPresent(RestController::class.java) || targetClass.isAnnotationPresent(Controller::class.java)
override fun unwrap(response: Any): ResponseExecResult {
if (response is DataResponse<*>) {
return ResponseExecResult(
successful = response.successful && response.code == 200,
message = response.message,
)
}
throw UnsupportedOperationException("The returned structure is not a [team.aikero.blade.core.protocol.DataResponse] type, cannot be parsed, please configure the correct parser.")
}
}如果有不同的但是是统一获取的方式,可以自行实现这个接口配置为Spring Ban即可。
记得要打开配置,默认是关闭的
blade:
oplog:
unwrap-response: true统一分类标签注解
一般来说,针对一个Controller 或者针对一个Service实现类,在操作上面的分类通常都可以是同一个,例如 订单操作日志、商品操作日志、用户操作日志,大多数情况下相关接口都写在一个类下面,所以可以通过实现 OpLogTag 注解来提供统一的分类标签。 来减少 @OpLog 注解的上的配置。让其更加简洁一些。
只需要在Controller 或者 Service实现类上打上 OpLogTag 注解即可。填写 name的值即可。
@RestController
@RequestMapping("/orders")
@OpLogTag(category = ORDER_CATEGORY) // 这里是你的分类名字
class OrderController(private val orderService: OrderService)分类标签校验器
与操作人提供器类似,分类标签校验器也应该是一个统一配置,可以通过实现 CategoryCheckProvider 接口来提供分类校验。
/**
* 日志分类提供器
*
* 用于标明生成的是什么日志 例如 订单操作日志 商品操作日志
*/
interface CategoryCheckProvider {
/**
* 获取合法的日志分类
*
* @return 分类集合
*/
fun get(): Set<String> =
throw UnsupportedOperationException("CategoryCheckProvider not implemented, If you want to use the CategoryCheckProvider interface as the categoryCheck provider, please implement the CategoryCheckProvider interface.")
/**
* 校验日志分类的合法性
*
* @param category 日志分类
* @return true 有效 false 无效
*/
fun check(category: String): Boolean =
throw UnsupportedOperationException("CategoryCheckProvider not implemented, If you want to use the CategoryCheckProvider interface as the categoryCheck provider, please implement the CategoryCheckProvider interface.")
}一般情况下,日志分类是需要进行有效性的校验,不然会产生很多脏数据
日志分类的校验是有默认的实现 -- DictCategoryCheckProvider
可以通过如下配置开启
blade:
oplog:
category-check:
dict:
enabled: true
url: ${domain.nest-api}/sys-admin/inner/dict/tree如开启了默认的日志分类校验,则日志分类需要手动维护到字典里面
如下:


使用:

如果有不同的但是是统一获取的方式,可以自行实现这个接口,配置为Spring Bean即可。
操作日志持久化
组件默认提供了控制台打印与RabbitMQ的持久化方式,RabbitMQ的方式默认关闭。
默认的组件只会提供控制台输出,如果需要持久化到数据库或者ES或者其他存储中间件等,可以自行通过实现 OpLogPersistenceHandler 接口来提供持久化的逻辑。
ConsoleOpLogPersistenceHandler 做了@ConditionalOnMissingBean 如果有其他的实现类会覆盖掉
如果选择RabbitMQ的方式组件已经内置,可以根据需要修改配置文件:
blade:
oplog:
persistence:
rabbitmq:
enabled: true # 启用
queue-name: q.op_log # 队列名称 若不填默认q.op_log
exchange-name: e.op_log # 交换机名称 若不填默认e.op_log有自定义的持久化库,监听此类消息即可,默认的消费库在drawer库里。 表结构参考:
点击展开
CREATE TABLE `op_log` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id',
`tenant` varchar(63) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '租户标识',
`type` varchar(63) COLLATE utf8mb4_general_ci NOT NULL COMMENT '保存的操作日志的类型,比如:订单类型、商品类型',
`category` varchar(255) COLLATE utf8mb4_general_ci NOT NULL COMMENT '操作分类 当提供给多个服务使用的时候需要区分例如 员工操作日志 员工信息修改日志',
`subcategory` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '子类别',
`source` varchar(70) COLLATE utf8mb4_general_ci NOT NULL COMMENT '数据来自哪里?例如来自后台、APP、web 我们这里可以来源clientId',
`biz_no` varchar(63) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '日志绑定的业务标识',
`biz_id` varchar(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '日志绑定的业务标识ID',
`content` varchar(1023) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '日志内容',
`exec_state` varchar(30) COLLATE utf8mb4_general_ci NOT NULL COMMENT '登录状态 SUCCESS:成功 和 FAILURE:失败',
`extra` varchar(2000) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '扩展信息',
`method` varchar(100) COLLATE utf8mb4_general_ci NOT NULL COMMENT '请求方式',
`class_path` varchar(255) COLLATE utf8mb4_general_ci NOT NULL COMMENT '类路径',
`url` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '请求地址',
`params` longtext COLLATE utf8mb4_general_ci COMMENT '请求参数',
`result` longtext COLLATE utf8mb4_general_ci COMMENT '返回值',
`err_msg` longtext COLLATE utf8mb4_general_ci COMMENT '异常描述',
`os` varchar(130) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '操作系统',
`os_version` varchar(30) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '系统版本',
`device` varchar(130) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '设备',
`browser` varchar(130) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '浏览器类型',
`browser_version` varchar(30) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '浏览器版本',
`user_agent` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '原始UA信息',
`ip` varchar(45) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '操作IP地址',
`ip_location` varchar(90) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '操作IP地址归属',
`cos_time` bigint NOT NULL COMMENT '消耗时间 单位毫秒',
`diff_content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '差异内容',
`operator_id` varchar(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '操作人',
`operator_name` varchar(63) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '操作人名称',
`created_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7209840117085646862 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;若是自己实现的持久化方式,只需要实现 OpLogPersistenceHandler 接口并且设置成Spring Bean即可。
/**
* 持久化操作日志接口
* 提供抽象持久化操作日志的方法
*/
interface OpLogPersistenceHandler {
/**
* 保存log
*
* @param OpLogPayload 日志实体
*/
fun save(OpLogPayload: OpLogPayload)
}组件会统一收集所有的持久化处理器Bean,然后在最后的持久化步骤中依次调用。
提示
所有的解析结果都会反映在 OpLogPayload 对象中,这个对象是持久化的最终DTO对象,可以根据自己的需求来获取转换保存。
切面异常告警
如果切面逻辑自身发生错误,除了不记录日志、不影响目标方法的执行外,还可以提供一些简单的告警配置,帮助开发者快速识别异常。 组件默认提供了飞书机器人群内告警的功能,可以通过配置文件进行配置开启。
提示
- 目标方法的执行异常不会发送告警,业务异常非常直观,不需要告警。
- 切面内部发生的异常并且抛出会触发异常告警发送,静默不记录日志的条件成立的则不会触发
会在以下情况给予告警(切面内部发生异常抛出):
- 获取执行器抛出异常
- 解析注解内容抛出异常
- 前置执行抛出异常
- 后置执行抛出异常
#nacos 已包含 oplog-conf,服务也可以直接引用
blade:
oplog:
alert:
feishu-robot:
enabled: true # 默认是关闭的
url: https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxx # 机器人的webhook地址提示内容如下:

如果默认的告警功能不满足需求,可以自行实现 AlertNotifier 接口,然后配置为Spring Bean即可。
interface AlertNotifier {
/**
* 使用提供的错误负载发送警报。
* @param errPayload 需要发送警报的错误事件负载。
*/
fun alert(errPayload: OpLogAlertEvent) {}
}同样的,组件会收集所有的AlertNotifier进行批量触发。
API创建日志
如果注解驱动的方式暂时无法满足个性化的日志记录需求,也可以通过API的方式来创建日志。这将绕过AOP的逻辑,需要在自己的业务代码中构造 OpLogPayload 对象, 然后调用OpLogClient的log方法来创建日志。
因为保存日志是一个服务,所以需要引用相应的SDK,然后注入OpLogClient
依赖引用
// 可在自己项目里定义
implementation(libs.fashion.drawer.sdk)implementation("team.aikero.fashion:drawer-sdk:${latestVersion}")
<dependency>
<groupId>team.aikero.fashion</groupId>
<artifactId>drawer-sdk</artifactId>
<version>${latestVersion}</version>
</dependency>然后注入使用
class HiLog(private val opLogClient:OpLogClient){
opLogClient.log(oplogPayload)
}注意
一般情况下,都不需要使用这种方式来创建日志,因为注解驱动的方式已经足够灵活,如果声明式的功能仍然不足以支持相应场景需求,可以利用上下文 OpLogContext.put("key",value) 来装载任何你想装载的内容, 只有在特殊情况下才需要使用。
完整的配置文件
结合以上拓展点,完整的配置如下:
# 以下都是默认值
blade:
oplog:
base-packages: # 自定义函数扫描包路径 缩小扫描范围 默认会带上team.aikero.blade 可不填 不填扫描当前启动类包路径 + 默认包路径
- team.aikero.blade
unwrap-response: false # 是否解包响应体包装类
join-transaction: false # 是否加入事务 加入事务 事务报错后不记录日志
enabled-diff: false # 是否启用数据差异对比 适用于更新操作
header-source: # 请求头中获取source信息
enabled: false # 是否启用
header-name: Client-Id # 请求头中的key
persistence: # 持久化配置
console: # 控制台输出 默认行为 会输出到控制台
enabled: true # 是否启用 在没有其他持久化配置的情况下会输出到控制台
rabbitmq: # rabbitmq持久化
enabled: false # 是否启用
queue-name: q.op_log # 队列名称
exchange-name: e.op_log # 交换机名称
advisor: # 切面配置
order: 2147483647 # 切面顺序
force-biz-no: false # 是否强制使用业务编号解析
only-log-success: false # 只记录成功操作日志DIFF对比
重要
OpType为UPDATE的操作有效
在数据更新场景中,有时候需要对同修改的内容进行详细的对比并且留有日志,这时候就需要使用到DIFF对比功能。将数据实体的前后数据进行对比之后留存变更记录到日志中。
例如:
- 「
如花(69)」修改了个人信息 「家庭住址」从 「广州市天河区棠下村修改」 为 「广州天河区珠江新城保利中心」
像这种场景就需要对比前后数据,然后记录变更字段。也就是要知道旧数据,还要知道保存后的新数据。 流程如下:
内置DIFF函数使用
oplog 目前内置了基于javers的diff自定义函数,利用SpEL表达式来调用。
此功能依赖三个注解参数一个配置:
blade.oplog.enabled-diff: 默认关闭 true为全局开启enabledDiff: 是否开启diff功能,默认关闭。优先级高于配置文件全局开关diff: 自定义diff执行的方法,支持 SpEL。若为空默认使用默认的 _DIFF 自定义函数。diffDataMethod: 是一个查询方法,用于查询旧数据与新数据,用于后续对比的数据源。
一般来说只需要在注解上配置 diffDataMethod 属性和开启diff记录即可。
diffDataMethod 的配置有两种调用方式
- Bean调用(
@) - 静态调用(
#)
Bean调用
一般来说我们肯定有一个相关的Service实现了我们想要的查询实体bean的方法了,并且上下文当中已经注入了 BeanResolver ,可以直接使用SpEL的原生语法 @来调用bean方法 例如:@orderService.getEntryById(#req.id)
注意
Bean调用获取的是bean的name作为bean的标识,所以在调用的时候需要注意bean的名称, 例如上面的orderService不是接口OrderService 实际类型是OrderServiceImpl。
@Service("orderService") //注意这里做了别名
class OrderServiceImpl : OrderService {}静态调用
也可以实现自定义函数来进行调用,只需要标记类和方法是自定义函数即可,不过这种方法只支持静态方法的调用,不支持实例方法的调用。而我们的数据都要存在数据库的,所以获取数据的话,其实绕不开bean的,本质上还是bean调用。 可以使用类似SpringContextHolder.getBean()的方式来获取bean,然后调用相应的获取方法。 这种方式的好处是可以更加方便管理与SpEL相关的类和方法,
定义函数
@OpLoFunction(name = "customOrder")
class OrderSpelFunction {
companion object { //使用伴生对象 也可以使用object类
@JvmStatic // 必须是静态调用
@OpLogFunctionMethod
fun getEntryById(id: Long): OrderInfoEntry {
val orderService = SpringContextHolder.getBean(OrderService::class.java) //静态获取Bean
return orderService.getEntryById(id)
}
}
}使用
@OpLog(
success = "'静态调用修改订单号:' + @orderService.getOrderNoById(#req.id)+' 订单ID(' + #req.id + ')'",
type = OpType.UPDATE,
bizId = "#req.id",
diffDataMethod = "#customOrder_getEntryById(#req.id)", //静态调用使用#
enabledDiff = true
)
@PostMapping(UPDATE_BY_ID_AND_DIFF_STATIC)
fun updateWithDiffStaticFunc(@RequestBody req: OrderInfoUpdateReq): DataResponse<OrderInfo> {
log.info { "update OrderInfo: $req" }
return ok(orderService.save(req.toEntity()))
}DIFF内容格式
实体如果需要使用中文,可以使用 @PropertyName 注解来标记中文名,这样在diff对比的时候会显示中文名。
@PropertyName(value = "描述")
var remark: String? = nulldiffContent的内容显示如下:
描述 由 32ewewqe 修改为 32ewewqe-修改备注
客户端名称 由 oplog测试应用3 修改为 oplog测试应用3-修改
每一个字段的变动作为一行 接着换行显示下一个字段的变动
如果想要修改自定义的diff内容格式可以实现org.javers.core.changelog.AbstractTextChangeLog来自定义输出格式。 目前内置diff不支持拓展自定义格式,如果需要自定义,需要自己实现自定义的diff函数。
override fun onValueChange(valueChange: ValueChange) {
appendln(
(valueChange.propertyName + " 由 " +
defaultInstance.format(valueChange.left).ifEmpty { "空" } +
" 修改为 " +
defaultInstance.format(valueChange.right))
)
}开关配置
- 全局未开启diff
blade.oplog.enabled-diff=false
@OpLog(
// 忽略其他配置
diffDataMethod = "@orderService.getEntryById(#req.id)",
enabledDiff = true
)
override fun updateByIdAndDiff(req: OrderInfoUpdateReq): OrderInfo =
orderInfoRepository.save(req.toEntity())- 全局开启diff
blade.oplog.enabled-diff=true
@OpLog(
// 忽略其他配置
diffDataMethod = "@orderService.getEntryById(#req.id)",
)
override fun updateByIdAndDiff(req: OrderInfoUpdateReq): OrderInfo =
orderInfoRepository.save(req.toEntity())注意
- SpEL自定义只支持静态方法的调用,不支持实例方法的调用。
- 默认的diff函数只支持同类对象的对比,如果需要对比不同类的对象,需要自定义diff函数。
自定义实现DIFF功能函数
- 实现Diff函数
因为都是要求是静态方法,所以无法实现接口,只能是普通的类,在类上打上 @OpLoFunction 注解,在方法上打上 @OpLogFunctionMethod 注解,这样就可以在SpEL表达式中调用了。
/**
* 基于javers的Diff函数
*/
@Slf4j
@OpLoFunction("Custom") //函数前缀取名
object CustomDiffFunction {
@JvmStatic// 一定要是静态方法
@OpLogFunctionMethod("DIFF") // 函数名
fun compare(old: Any, new: Any): String{ // 这里一般都是这么写 参数是两个
// 你的自定义逻辑
}
}自定义实现
因为DIFF都要有两个参数入参并且是固定形式的,在SpEL表达式来说需要全部写全才能调用,但是在日志上这么写会有点长,所以组件简化了一下调用,自定义 diff 函数的定义可以使用${}来进行包裹只定义一个函数名即可, 组件会自动拼接成完整的函数表达式
例如:定义了一个 Custom_DIFF 函数,那么在@OpLog的diff属性可以直接这样定义: diff = ${Custom_DIFF} ,并配置好diffDataMethod即可。
最终的调用表达式是 Custom_DIFF(#_diffOldContent,#diffDataMethod(req.id))
@OpLog(
//省略其他属性配置
diffDataMethod = "@orderService.getEntryById(#req.id)", // 这里是你的查询方法 一定要写
diff = "${Custom_DIFF}" // 这里是你的自定义函数名
enabledDiff = true
)
@Transactional(rollbackFor = [Exception::class])
override fun updateByIdAndDiff(req: OrderInfoUpdateReq): OrderInfo {
log.info("update OrderInfo: $req")
return orderInfoRepository.save(req.toEntity())
}如果你想填写完整的表达式,也是可以的
@OpLog(
//省略其他属性配置
diff = "#DIY_DIFF(#_diffOldContent, #customOrder_getById(#req.id))", // 这里是你的自定义函数
diffDataMethod = "#customOrder_getById(#req.id)",
enabledDiff = true
)进阶了解
上下文
OpLogContext中保存了一些上下文内容,目前基于SpEL表达式的MethodBasedEvaluationContext拓展的,主要是为了解决SpEL表达式执行所需要的内容。 进入切面时,会初始化上下文,然后将解析的内容放入上下文当中,然后在后续的SpEL表达式解析中,会从上下文中获取相应的内容。 切面线程执行完成或者发生异常后 上下文会被清理掉,所以上下文只能是单次线程共享的对象。
上下文初始化后会在自动放置一些需要的内容,可以直接使用#来调用
目前会自动放置到上下文的内容有:
- 目标方法参数(参数名为方法形参名) ----
#req(假设形参名为req) - 目标方法调用成功的内容(若目标方法执行成功) ----
#_return - 目标方法调用失败的内容(若目标方法执行失败) ----
#_errorMsg - DIFF的默认函数 ----
#_DIFF - DIFF所需要的老对象数据(若开启DIFF功能) ----
#_diffOldContent
提示
你可以在目标方法的任何地方调用 OpLogContext.put(xxx,yyy) 方法来放入有关的内容,然后在SpEL表达式中使用,这意味着注解上非必填的内容都可以通过自定义上下文来存放需要的函数,供于解析调用。
嵌套调用
当一个方法被多个AOP注解标记的时候,会形成一个同心圆,各自处理各自的逻辑,最后返回到最初的方法。

与AOP的处理逻辑类似,注解支持嵌套调用,可以在目标方法当中调用注解了@OpLog的方法,各自处理各自的逻辑。
这时上下文维护一个堆栈,各自的上下文是独立的,不会相互影响。
private val CONTEXT_THREAD_LOCAL = ThreadLocal<Stack<MethodBasedEvaluationContext>>()注意
- 在Java中对于注解,默认情况下,一个方法上不能有同样名称的注解重复使用,kotlin也遵循Java的标准,这意味着组件不支持在同一方法上同时标记多个
@OpLog注解。 注解,从职责与维护性上来考虑,一个方法也应该只保留有一个操作日志。多个动态日志记录的需求应该通过拆分调用来实现。毕竟职责单一就更易于维护。 - 虽然Java 8引入了
@Repeatable注解,使得单个注解可以被声明多次,但组件暂不考虑支持,原因如上。
//不支持多次使用注解标记📌 This annotation is not repeatable
@OpLog(
success = "'上边Controller下单成功订单号:' + #_return.data.orderNo +' 订单ID('+ #_return.data.id + ')'",
category = ORDER_OP,
type = OpType.ADD,
bizNo = "#_return.data.orderNo",
bizId = "#_return.data.id",
extra = "#req",
operator = "#{T(team.aikero.blade.oplog.service.provider.operator.CustomOperatorProvider).get()}"
)
@OpLog(
success = "'下边Controller下单成功订单号:' + #_return.data.orderNo +' 订单ID('+ #_return.data.id + ')'",
category = ORDER_OP,
type = OpType.ADD,
bizNo = "#_return.data.orderNo",
bizId = "#_return.data.id",
extra = "#req",
operator = "#{T(team.aikero.blade.oplog.service.provider.operator.CustomOperatorProvider).get()}"
)
@PostMapping(ADD_SELF_DOUBLE)
fun addDouble(@RequestBody req: OrderInfoAddReq): DataResponse<OrderInfo> = ok(orderService.save(req.toEntity()))自定义函数
SpEL 支持自定义函数来扩展其功能,在运行时计算属性、调用方法、访问对象等等。
重要
SpEL只支持调用静态方法。这是因为在表达式解析期间,SpEL 无法自动实例化非静态方法所属的类。因此,自定义函数必须是静态方法。
编写自定义函数
必须是静态方法
为了方便在OpLog组件当中能够使用这些自定义函数,需要在有效的在类上打上 @OpLoFunction 注解,在方法上打上 @OpLogFunctionMethod 注解,这样就可以将自定义函数自动注册到上下文当中供于直接调用了。
函数名:@OpLoFunction的name属性 + _ + @OpLogFunctionMethod的name属性
如下所示:
/**
* 自定义函数
*/
@Slf4j
@OpLogFunction("Custom") //函数前缀取名
object CustomFunction {
@JvmStatic// 一定要是静态方法
@OpLogFunctionMethod("EXEC") // 函数名
fun exec(): String{ // 这里一般都是这么写 参数是两个
// 你的自定义逻辑
}
}则自定义函数名为:Custom_EXEC
注意
- 虽然连接符可以随意指定,但推荐
_来作为连接符,统一好管理, 默认的.是作为静态调用或者Bean调用的统一调用符了 - 不要混用,否则会识别为对象或静态调用,运行时会报错。
使用函数
只需要在SpEL表达式中调用即可
例如:
" '我是一个字符串' + #Custom_EXEC(#req) "" '我是一个字符串' + #Custom_EXEC('我是字符串参数') "
提示
自定义函数里若需要参数,参数也可以是SpEL表达式,只需要在函数的参数中写上即可,一般来说参数都是动态的,也就是一般都是SpEL。
函数注册到上下文
在类上打上 @OpLoFunction注解,在方法上打上 @OpLogFunctionMethod 注解,只要是这类的方法并且在扫描包范围内都会被自动注册到上下文当中供于直接调用。 自动注册是通过OpLogFunctionManager来实现的,会在启动时扫描相应包下的类,然后注册到上下文当中。
主要是两个步骤:
- 扫描缓存
- 切面初始化&注册上下文
扫描缓存
应用启动扫描注解标记的类与方法,将自定义函数缓存起来
提示
默认的包路径是team.aikero.blade,如果需要扩展包路径,可以在配置文件中配置blade.oplog.base-packages属性。
切面注册上下文
当切面执行时,将缓存的自定义函数注册到SpEL上下文中,以便于SpEL表达式调用
具体代码:
/**
* 将注册的函数添加到给定的 SpEL 上下文中
*
* @param context SpEL 上下文
*/
fun registerToSpelContext(context: StandardEvaluationContext) =
functions.forEach { (func, funcMethod) -> context.registerFunction(func, funcMethod) }你可以打开debug查看类似日志,确认自定义函数是否注册成功
2024-06-24 17:46:04.305 DEBUG 21669 --- [ main] c.z.b.o.function.OpLogFunctionManager : [ Registered OpLog Function ] [DIY_DIFF] from class [team.aikero.blade.oplog.service.function.DiyDiffFunction]内置函数
在SpEL上下文当中,内置了一些函数,可以直接调用
xxx:目标方法的参数 取决于目标方法的参数名称_return:目标方法执行成功的返回信息_errorMsg:目标方法直接失败的返回值_diffOldContent:开启DIFF对比时候的老对象数据_DIFF:默认的DIFF自定义函数
日志查询
由于是统一的操作日志收集服务,查询需要做数据过滤,以免错乱。
比如 一个订单操作日志,如果要在一个订单列表或者订单详情查询一条订单的操作日志,那么就需要在查询的时候带上订单的业务编号或者订单的ID,但是有时候 id 生成可能会在不同的业务当中重复,所以category至关重要,必须要加上这样才能查询到对应的日志。
除开状态外,bizId + category 是查询一条特性归属的日志的最小联合条件。
提示
一般对于业务性质的操作日志都是查询 成功的日志,所以 state = SUCCESS
在组件当中,就需要:bizId + category + state 作为查询数据归属依据。
如果还想要做一些过滤,比如不同的客户端需要过滤不同的数据,那么就需要在查询的时候带上来源信息,这样就可以根据来源信息来过滤数据。 也就是 bizId + category + source + state
如果还想进一步的过滤精确到入口,那么就需要在查询的时候带上入口信息,这样就可以根据入口信息来过滤数据。 也就是 bizId + category + source + entry + state
详细的查询组合根据自己的需求以及注解支持的记录信息来判断即可。
任何的数据输入都是请求方自定义的,需要请求方制定好相关的填充规则,以免后续数据查不出来、查不全、或者查不准确。
注意事项
事务问题
一般来说,框架的AOP与用户自定义的AOP的执行顺序是框架的AOP先执行,用户自定义的AOP后执行。所以如果在事务中使用AOP的时候,需要注意事务执行的问题。
如果要在声明式事务之前执行AOP,可以通过配置调整。
blade:
oplog:
advisor:
order: xxx # Int类型的数值即可 数值越小优先级越高如果由于奇奇怪怪的问题发现自动的事务与AOP顺序错乱,默认事务先执行,但是组件切面反而先执行了,可以通过配置文件修改优先级,将事务的优先级提高+1。
blade:
oplog:
join-transaction: true # 默认关闭 一般来说不需要调整 只有发现奇奇怪怪的问题可以考虑调整service方法若内部有自我调用注意切面的使用
AOP的老问题了,调用使用工具类获取代理类
AopContext.currentProxy()。启动提示警告
[xxx] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)类似的 BeanPostProcessorChecker : Bean 'xxx' of type [xxx] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
直接忽略即可
请求参数一定要把名字写对
接口参数的命名一定要和spel的参数一致才能执行获取到参数的值

使用Jimmer时注意动态实体的转换问题
由于Jimmer是接口形式的动态实体并且受接口的限制,无法使用普通的注解写法作用在字段上 也就是无法直接这样标记属性
@PropertyName("dsadasd")
jimmer_entity_do_not_set_javers_anno.png 需要使用类似
@get:PropertyName、@set:PropertyName、@filed:PropertyName注解 jimmer推荐使用val修饰属性,所以不能使用@set,@filed注解不能作用于接口属性,所以不能使用,所以只剩下@get:PropertyName可以标记在Jimmer实体接口属性上。
但是因为Jimmer的动态性会有隐藏的字段属性 例如字段为name 会有一个隐藏属性为 _name,当使用@get的时候获取的是_name 的值,所以无法翻译为中文, 目前的做法是需要自定义一个POJO并使用javers的注解指定名称 然后照常标记注解:
class SomeEntry{
@PropertyName(value = "描述")
var name: String
}注意:提供获取此Entry响应的自定义函数或者Bean方法即可,配置在@Oplog需要的地方,例如:
@OpLog(
bizId = "#req.id",
success = "更新客户端信息",
type = OpType.UPDATE,
diffDataMethod = "@clientServiceImpl.getEntryById(#req.id)", //获取实体内容
enabledDiff = true
)参考资料
- 如何优雅地记录操作日志? 强烈建议阅读
- SpEL译文
- SpEL官方文档
