优雅记录操作日志:从注解到 SpEL 的全链路实践与开源方案对比

假如你有一个 CRM 系统的需求,给客户资料编辑页面加上操作日志。一开始觉得小菜一碟,改个字段加一行 log.info 的事。结果一周后你就会对着代码陷入了沉思------

一个「编辑客户信息」的方法里,塞了七八行日志拼接代码,还因为同事交接时没写注释,你根本分不清某段文案是系统日志还是操作日志。更崩溃的是,产品说「改字段之前的值也要记上」,于是你不得不在方法签名上加了个参数,就为了传个旧值进来。

这种体验,我相信每一个写过业务系统的后端都不会陌生。


一、为什么操作日志总是写得很痛苦

先定义清楚:我们说的操作日志到底是什么?

操作日志,记录的是「某个人在某个时间对某个对象做了什么」。用户看到的物流信息(「2026-07-14 10:00 您的包裹已到达配送站」)、客服看到的工单处理记录(「张三 2026-07-14 09:30 将工单状态从「待处理」修改为「处理中」」),都是操作日志。

它和 系统日志 有本质区别。系统日志写给开发者看的,排查 NPE、SQL 慢查询用的,里面充斥着类名、行号、堆栈信息。操作日志是写给用户或运营看的,要求可读性高,一眼就能明白发生了什么。

一张对比表可以更清晰地看出区别:

操作日志(Operation Log):记录用户或系统对业务对象执行的操作,具备高可读性和可追溯性。你可以理解为系统的「黑匣子录音笔」------不光录下结果,还录下了谁、什么时候、怎么操作的。

但在实际代码里,它们往往混在一起,甚至写在同一行日志里。

我总结了三个最普遍的痛点:

痛点一:LogUtil 散落一地

java 复制代码
// 在业务方法里随手记一笔
log.info("订单已创建,订单号:{}", orderNo);

// 改地址的时候再拼一段
String template = "用户%s修改了配送地址:从"%s"修改到"%s"";
LogUtil.log(orderNo, String.format(template, userName, oldAddr, newAddr), userName);

这还算体面的。我见过更夸张的------一个方法里横跨了四五种日志记录方式,有的是 LoggerFactory.getLogger,有的是 LogUtil.log,还有的直接 System.out.println。新人进组看到这段代码直接自闭。

痛点二:文案永远说不清楚

可能有人会问:文案不就是个字符串吗,写清楚很难吗?

难在动态部分。比如「修改了配送员:从 10090 修改到 10099」------用户 ID 谁看得懂?用户想看的是「从张三修改到李四」。为了把 ID 翻译成姓名,你不得不在业务方法里再加一段查询和拼接逻辑。

痛点三:历史值从哪里拿

要记录「从 A 改到 B」,你得先拿到 A。但 A 是修改前的值,方法入参里只有新值。常见的解法是在方法开头多查一次数据库,但这样代码就变成了:

java 复制代码
public void updateAddress(UpdateRequest request) {
    String oldAddress = addressService.queryOld(request.getOrderNo()); // 为了日志多查一次
    doUpdate(request); // 真正的业务
    LogUtil.log(orderNo, "从" + oldAddress + "改到" + request.getNewAddress()); 
}

这段代码里,有两行跟业务逻辑毫无关系,纯粹是为了记录日志。而且这样的代码在每个需要记录日志的方法里都要重复一遍。

操作日志的核心困境,说白了就一句话:日志代码和业务代码严重耦合,而且动态文案的组装没有统一规范。


二、优雅操作日志的核心原理

既然问题找到了,那解决方案也就清晰了:把日志代码从业务方法里抽出去。

如果你写过 Spring,第一反应应该是 AOP(面向切面编程)------在方法执行前后统一处理日志,业务代码不需要知道日志的存在。

但光有 AOP 还不够,核心要解决两个问题:

1) 模板动态化:日志文案不是写死的,里面要嵌入方法参数、数据库查出来的旧值、甚至方法返回值。这就需要一个表达式引擎来解析模板。

2)上下文传递:有些变量不在方法参数里(比如旧值、当前用户),需要一种方式让业务代码「告诉」日志组件这些值。

于是业界达成了共识方案:注解 + SpEL + AOP

java 复制代码
@LogRecord(
    success = "用户{{#userName}}修改了订单的配送地址:从"{{#oldAddress}}"修改到"{{#request.address}}"",
    bizNo = "#request.deliveryOrderNo"
)
public void modifyAddress(UpdateRequest request) {
    LogRecordContext.putVariable("oldAddress", deliveryService.queryOld(request.getDeliveryOrderNo()));
    doUpdate(request);
}

这套方案的运行机制是:

  1. AOP 拦截器捕获方法上的 @LogRecord 注解
  2. 解析注解中的 SpEL 表达式,从方法参数和 LogRecordContext 中取值填充模板
  3. 将组装好的操作日志持久化(数据库 / ES / 文件)
  4. 整个过程出错不影响主业务(日志记录异常不会抛给调用方)

SpEL(Spring Expression Language) :Spring 框架自带的表达式语言,可以在运行时通过字符串表达式求值。你可以理解成一种「安全的字符串模板引擎」------不用拼代码,用 #paramName 就能引用方法参数。

这套模式的核心价值在于:让你的业务方法从「做业务 + 记日志」变成了只做业务。日志代码被注解和配置替代,可读性大幅提升,维护成本直线下降。


三、log-record:阿里程序员的注解日志实践

先说这个由阿里巴巴程序员开源的项目 log-record。目前 1.1k Star,支持 SpringBoot 1/2/3(JDK 8~21),稳定更新到 v1.7.x。

快速接入

三步走,极致简单:

第一步:引入 Maven 依赖

SpringBoot 2 引入:

xml 复制代码
<dependency>
    <groupId>cn.monitor4all</groupId>
    <artifactId>log-record-starter</artifactId>
    <version>1.7.1</version>
</dependency>

SpringBoot 3(JDK 17+)引入:

xml 复制代码
<dependency>
    <groupId>cn.monitor4all</groupId>
    <artifactId>log-record-springboot3-starter</artifactId>
    <version>1.7.1</version>
</dependency>

第二步:实现日志处理接口

java 复制代码
@Component
public class MyLogService implements IOperationLogGetService {
    @Override
    public boolean createLog(LogDTO logDTO) {
        // 这里写你的持久化逻辑:存数据库、写ES、发MQ...
        System.out.println(JSON.toJSONString(logDTO));
        return true;
    }
}

第三步:在方法上加注解

java 复制代码
@OperationLog(
    bizType = "'orderCreate'",
    bizId = "#request.orderNo",
    msg = "'用户' + #queryUserName(#request.userId) + '创建了订单'"
)
public Response<String> createOrder(CreateRequest request) {
    // 纯业务逻辑,不需要写任何日志代码
    return orderService.create(request);
}

基础功能拆解

SpEL 表达式 :注解中 bizTypebizIdmsg 等字段全部使用 SpEL 解析。注意字符串常量要用单引号包裹------'orderCreate',否则 SpEL 会当成方法名解析报错。

自定义上下文 :当需要的变量不在方法参数中时,用 LogRecordContext.putVariable() 手动传递:

java 复制代码
@OperationLog(
    bizType = "'followerChange'",
    bizId = "#request.orderId",
    msg = "'用户' + #userName + '修改了跟进人:从' + #oldFollower + '改到' + #request.newFollower"
)
public Response<String> changeFollower(ChangeRequest request) {
    LogRecordContext.putVariable("userName", queryUserName(request.getUserId()));
    LogRecordContext.putVariable("oldFollower", queryOldFollower(request.getOrderId()));
    return bizService.changeFollower(request);
}

自定义函数 :通过 @LogRecordFunc 注册静态方法,在 SpEL 中直接调用:

java 复制代码
@LogRecordFunc("UserHelper")
public class UserFunctions {
    @LogRecordFunc
    public static String queryName(Long userId) {
        return userService.getNameById(userId);
    }
}

然后在注解中使用:msg = "#UserHelper_queryName(#request.userId)"

实体类 Diff:这是 log-record 的高频功能之一。比较新旧两个对象,自动生成变更字段的对比文案:

java 复制代码
@OperationLog(
    bizType = "'userUpdate'",
    bizId = "'1'",
    msg = "#_DIFF(#oldUser, #newUser)"
)
public void updateUser(User newUser) {
    LogRecordContext.putVariable("oldUser", userService.getById(newUser.getId()));
    userService.update(newUser);
}

输出的日志内容示例:「用户工号」从【1】变成了【2】 「name」从【张三】变成了【李四】

可能有人会问:日志处理失败了会不会影响业务?

不会。log-record 的日志切面异常不会抛给调用方------日志记录出错,业务逻辑照常执行。它还提供了重试机制(log-record.retry.retry-times=5)和兜底 SPI 接口,确保日志尽可能不丢。

亮点特性

log-record 还有一些很实用的差异化功能:

  • 数据管道:支持 RabbitMQ / RocketMQ / SpringCloud Stream 直接推送日志,适合微服务架构的统一日志采集场景
  • 条件记录condition = "#result.code == 200" 满足条件才记录
  • 重复注解 :一个方法可以加多个 @OperationLog 记录多维度日志
  • 方法执行优化制executeBeforeFunc = true 可在方法执行前解析 SpEL

四、mzt-biz-log:美团程序员的亲儿子

mzt-biz-log 由美团程序员开发,正是美团技术博客那篇文章背后代码的直接开源版本。目前 2.8k Star,是操作日志领域 Star 数最高的项目之一。

快速接入

同样是三步走:

第一步:引入 Maven 依赖

xml 复制代码
<dependency>
    <groupId>io.github.mouzt</groupId>
    <artifactId>bizlog-sdk</artifactId>
    <version>3.0.7-SNAPSHOT</version>
</dependency>

第二步:在 SpringBoot 启动类上开启注解

java 复制代码
@SpringBootApplication
@EnableLogRecord(tenant = "com.myapp")
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

tenant 代表租户,同一个集团下的多服务可以共用同一个 tenant 标识来划分日志归属。

第三步:在方法上加注解

java 复制代码
@LogRecord(
    success = "{{#order.purchaseName}}下了一个订单,购买商品「{{#order.productName}}」",
    type = LogRecordType.ORDER, 
    bizNo = "{{#order.orderNo}}"
)
public boolean createOrder(Order order) {
    return orderService.create(order);
}

mzt-biz-log 使用 {{}} 双大括号来标识 SpEL 表达式,和 log-record 略有不同。

基础功能拆解

成功/失败双文案:方法执行成功和抛出异常时记录不同的文案:

java 复制代码
@LogRecord(
    success = "{{#order.purchaseName}}下了一个订单",
    fail = "创建订单失败,原因:「{{#_errorMsg}}」",
    type = LogRecordType.ORDER, 
    bizNo = "{{#order.orderNo}}"
)
public boolean createOrder(Order order) {
    // 如果抛出异常则记录 fail,否则记录 success
}

操作人获取:mzt-biz-log 提供了两种方案。注解上指定:

java 复制代码
@LogRecord(
    operator = "{{#currentUser}}",
    success = "...",
    type = LogRecordType.ORDER, 
    bizNo = "{{#order.orderNo}}"
)
public boolean createOrder(Order order, String currentUser) { }

或通过全局接口注入,这样每个注解都不用重复指定操作人:

java 复制代码
@Component
public class MyOperatorGetService implements IOperatorGetService {
    @Override
    public Operator getUser() {
        return new Operator(UserContext.getCurrentUser().getName(), 
                           UserContext.getCurrentUser().getId());
    }
}

自定义函数 :通过实现 IParseFunction 接口,把 ID 翻译成可读文案:

java 复制代码
@Component
public class OrderParseFunction implements IParseFunction {
    @Override
    public boolean executeBefore() { return true; }
    
    @Override
    public String functionName() { return "ORDER"; }
    
    @Override
    public String apply(Object value) {
        // value 是订单 ID,返回「商品名称(订单ID)」的格式
        Order order = orderService.getById(Long.valueOf(value.toString()));
        return order.getProductName() + "(" + value + ")";
    }
}

然后在注解中使用:success = "更新了订单{ORDER{#orderId}},更新内容为..."

对象 Diff:mzt-biz-log 的 Diff 功能非常强大,支持嵌套对象、集合、自定义字段注解:

java 复制代码
@LogRecord(
    success = "更新了订单{_DIFF{#oldOrder, #newOrder}}",
    type = LogRecordType.ORDER, 
    bizNo = "{{#newOrder.orderNo}}"
)
public boolean diff(Order oldOrder, Order newOrder) {
    return true;
}

配合 @DiffLogField@DiffLogAllFields 注解,可以精确控制 Diff 的字段名和翻译函数,输出结果类似:

复制代码
更新了订单【创建人的用户ID】从【9001】修改为【9002】;【订单号】从【MT0000011】修改为【MT0000099】

亮点特性

  • 事务集成@EnableLogRecord(joinTransaction = true) 让日志记录与业务事务绑定,日志落库失败则业务回滚
  • 性能监控 :提供了 ILogRecordPerformanceMonitor 接口,可监控日志记录的性能开销
  • 多注解支持 :同一个方法可以叠加多个 @LogRecord,记录不同维度的日志
  • 成功率表达式successCondition = "{{#result.code == 200}}" 精细化控制成功/失败的判断逻辑

五、log-record vs mzt-biz-log,怎么选

两个项目解决问题的方式高度一致,都是「注解 + SpEL + AOP」模式,但在细节取舍上各有侧重。

对比维度 log-record(qqxx6661) mzt-biz-log(mouzt)
Star 数 1.1k 2.8k
目前版本 v1.7.x v3.0.x
SpringBoot 版本 1.x / 2.x / 3.x 均支持 2.x / 3.x
JDK 兼容 JDK 8 ~ 21 JDK 8+
SpEL 表达式风格 标准 SpEL(字符串需加单引号) {{}} 双大括号包裹
数据管道 内置 RabbitMQ / RocketMQ / Stream 支持 需自行实现持久化
对象 Diff 通过 _DIFF 内置函数 通过 _DIFF + @DiffLogField 注解
事务回滚 不支持 支持(joinTransaction
性能监控 有(ILogRecordPerformanceMonitor
失败重试 + 兜底 支持(配置重试次数 + SPI) 不支持
在方法执行前解析 支持(executeBeforeFunc 支持(自定义函数加 executeBefore
非注解方式记录 支持 OperationLogUtil.log() 不支持

选型建议其实很简单:

选 log-record 的场景:

  • 你的团队用 SpringBoot 3 / JDK 17+,需要开箱即用的 MQ 管道
  • 对日志可靠性要求高,需要重试和兜底机制
  • 偏好标准的 SpEL 语法,不想学新的 {{}} 风格

选 mzt-biz-log 的场景:

  • 你的场景对日志的事务一致性要求高(日志写入失败业务也回滚)
  • 需要完善的 Diff 功能(嵌套对象、集合对比、字段级自定义)
  • 社区更活跃(2.8k Star),遇到问题更容易找到解决方案

一句话选型:想要消息管道和重试兜底 → log-record;想要对象 Diff 和事务集成 → mzt-biz-log。


六、总结

操作日志优雅化的本质,不是找到了什么黑科技,而是 用 AOP 解耦、用 SpEL 动态化、用注解替代重复代码。这三个技术单独拿出来都不稀奇,但组合在一起就解决了后端开发的一个高频痛点。

如果你现在正在被操作日志折磨,别硬扛了。找一个合适的开源项目,加一个注解,把时间省下来解决真正有价值的业务问题。


参考与说明


本文为作者原创,转载请注明出处。如果对你有帮助,欢迎点赞、收藏、转发。

相关推荐
战血石LoveYY3 小时前
Integer类型超限,导致数据异常
java·算法
饼干哥哥4 小时前
Vibe Coding 出海首月收割 100+用户爆赚美金,怎么做?
后端·架构·代码规范
大康4 小时前
macOS Homebrew 重装与国内镜像配置手册
后端
行者全栈架构师4 小时前
大模型批量生成代码的质量衰减:为什么 AI 越写越敷衍?根因分析与系统性解决方案
后端·架构·代码规范
乐观的Terry4 小时前
1、为什么要自己造发布系统
java·spring boot·后端·spring cloud·架构
用户EasyAdminBlazor4 小时前
EasyAdminBlazor 第十篇:数据导入导出——批量处理不再麻烦
前端·后端
兰令水4 小时前
hot100【acm版】【2026.7.13打卡-java版本】
java·开发语言·数据结构·算法·leetcode·面试
ikoala5 小时前
Codex 小白入门:从安装到插件、MCP、Skills,一篇把配置讲明白
前端·javascript·后端
雪隐5 小时前
用Flutter做背单词APP-04一个人,2周,从零撸出一个背单词App
前端·人工智能·后端