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

作者:袖梨 2026-07-15

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

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

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


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

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

img_6a576e0f65d8b30.webp

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

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

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

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

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

痛点一:LogUtil 散落一地

 复制代码// 在业务方法里随手记一笔
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 是修改前的值,方法入参里只有新值。常见的解法是在方法开头多查一次数据库,但这样代码就变成了:

 复制代码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

 复制代码@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. 整个过程出错不影响主业务(日志记录异常不会抛给调用方)

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


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

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

img_6a576e0f65d9131.webp

快速接入

三步走,极致简单:

第一步:引入 Maven 依赖

SpringBoot 2 引入:

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

SpringBoot 3(JDK 17+)引入:

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

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

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

第三步:在方法上加注解

 复制代码@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() 手动传递:

 复制代码@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 中直接调用:

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

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

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

 复制代码@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 还有一些很实用的差异化功能:

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

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

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

img_6a576e0f65d9332.webp

快速接入

同样是三步走:

第一步:引入 Maven 依赖

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

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

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

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

第三步:在方法上加注解

 复制代码@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 略有不同。

基础功能拆解

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

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

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

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

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

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

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

 复制代码@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 功能非常强大,支持嵌套对象、集合、自定义字段注解:

 复制代码@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.1k2.8k
目前版本v1.7.xv3.0.x
SpringBoot 版本1.x / 2.x / 3.x 均支持2.x / 3.x
JDK 兼容JDK 8 ~ 21JDK 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),遇到问题更容易找到解决方案

六、总结

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

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


参考与说明

  • 本文在操作日志主题上结合个人实践独立撰写
  • 部分背景信息参考:美团技术团队博客《如何优雅地记录操作日志?》,观点与表述均为作者本人
  • 涉及工具版本请以官方文档为准

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

相关文章

精彩推荐