本文目录导读:

这是一个很经典的企业级开发问题,我先帮你梳理一下核心思路,然后给出两种主流实现方案的具体代码示例。
核心思路: “记录级注解”通常指的是在方法(Service层或Controller层)上添加一个自定义注解,通过AOP(面向切面编程) 拦截该方法执行,在方法执行前后,利用 SpEL(Spring表达式语言) 解析注解中的参数(如方法参数、返回值),动态拼装出日志内容,最后异步写入数据库。
基于 Spring AOP + SpEL(最推荐,通用性强)
这是最标准的方案,不侵入业务代码,支持动态获取方法参数。
自定义注解
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RecordLog {
// 操作模块(如:用户管理)
String module();
// 操作类型(如:新增、修改、删除)
String type();
// 日志内容描述(支持SpEL表达式)
// "新增了用户:{#user.name}"
String content();
}
AOP 切面实现(核心)
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class RecordLogAspect {
// 用来解析 SpEL 表达式
private ExpressionParser parser = new SpelExpressionParser();
@Around("@annotation(recordLog)")
public Object around(ProceedingJoinPoint joinPoint, RecordLog recordLog) throws Throwable {
Object result = null;
boolean success = true;
String errorMsg = null;
long startTime = System.currentTimeMillis();
try {
result = joinPoint.proceed(); // 执行原方法
} catch (Throwable e) {
success = false;
errorMsg = e.getMessage();
throw e;
} finally {
// 不管成功失败都要记录日志
try {
// 1. 解析 SpEL 并获得最终的日志内容
String content = parseContent(joinPoint, recordLog.content());
// 2. 组装日志对象(这里简化,实际用 Builder/构造方法)
OperationLog log = new OperationLog();
log.setModule(recordLog.module());
log.setType(recordLog.type());
log.setContent(content);
log.setSuccess(success);
log.setErrorMsg(errorMsg);
log.setDuration(System.currentTimeMillis() - startTime);
log.setCreateTime(new Date());
// 3. 异步写入数据库(这里模拟,建议使用 @Async 或 消息队列)
saveLogAsync(log);
} catch (Exception e) {
// 日志记录失败不能影响主业务,仅打印日志
System.err.println("记录操作日志失败: " + e.getMessage());
}
}
return result;
}
/**
* 解析 SpEL 表达式
*/
private String parseContent(ProceedingJoinPoint joinPoint, String expression) {
if (!expression.contains("#")) {
return expression; // 没有 SpEL 表达式,直接返回
}
// 获取方法参数名和参数值
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String[] paramNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
// 创建 SpEL 上下文
EvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
// 解析
return parser.parseExpression(expression).getValue(context, String.class);
}
// 这里简化,实际应该注入一个 Service 并异步调用
private void saveLogAsync(OperationLog log) {
System.out.println("保存日志: " + log);
}
}
使用示例
@Service
public class UserService {
@RecordLog(
module = "用户管理",
type = "新增",
content = "新增用户:{T(java.time.LocalDateTime).now()}, 用户名: {#user.name}"
)
public User createUser(User user) {
// 执行数据库操作...
return user;
}
@RecordLog(
module = "用户管理",
type = "修改",
content = "修改用户 ID: {#id}, 原姓名: {#oldName}, 新姓名: {#user.name}"
)
public User updateUser(Long id, String oldName, User user) {
// 执行更新
return user;
}
}
关键点:
- SpEL 支持
#参数名访问方法参数,甚至支持更复杂的表达式。 - 异步处理:日志写入不能阻塞主流程。
基于函数式接口 + 回调(更灵活,适用于复杂场景)
需要复杂的业务逻辑拼装(比如需要查询数据库、计算多个字段),简单的 SpEL 可能不够,这时可以用函数式接口作为注解的属性。
自定义注解 + 函数式接口
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RecordLogFunctional {
String module();
String type();
// 指定一个实现了 LogContentGenerator 接口的 Bean 名称
String contentGenerator();
}
// 函数式接口:日志内容生成器
@FunctionalInterface
public interface LogContentGenerator {
String generate(ProceedingJoinPoint joinPoint, Object result, Throwable exception);
}
切面实现
@Aspect
@Component
public class RecordLogFunctionalAspect {
@Autowired
private ApplicationContext applicationContext;
@Around("@annotation(recordLog)")
public Object around(ProceedingJoinPoint joinPoint, RecordLogFunctional recordLog) throws Throwable {
// 1. 获取内容生成器
LogContentGenerator generator = applicationContext.getBean(recordLog.contentGenerator(), LogContentGenerator.class);
Object result = null;
Throwable exception = null;
try {
result = joinPoint.proceed();
} catch (Throwable e) {
exception = e;
throw e;
} finally {
// 2. 调用生成器获取日志内容
String content = generator.generate(joinPoint, result, exception);
// 3. 保存日志(同上)
saveLog(recordLog, content, exception);
}
return result;
}
}
具体业务实现
@Component("userCreateLogGenerator")
public class UserCreateLogGenerator implements LogContentGenerator {
@Override
public String generate(ProceedingJoinPoint joinPoint, Object result, Throwable exception) {
// 假设第一个参数是 User
Object[] args = joinPoint.getArgs();
User user = (User) args[0];
// 可以在这里查询其他数据,做复杂拼装
return String.format("新增用户[%s]成功,角色为%s", user.getName(), "管理员");
}
}
使用
@Service
public class UserService {
@RecordLogFunctional(
module = "用户管理",
type = "新增",
contentGenerator = "userCreateLogGenerator"
)
public User createUser(User user) {
// ...
}
}
总结对比
| 特性 | AOP + SpEL | 函数式接口回调 |
|---|---|---|
| 复杂度 | 低,注解即可使用 | 中,需要额外实现 Bean |
| 灵活性 | 高,可动态拼接方法参数 | 极高,可执行任意 Java 代码 |
| 维护成本 | 低,SpEL 表达式写在注解里 | 中,需要维护多个 Generator 类 |
| 适用场景 | 80%的通用场景,CRUD | 复杂业务逻辑,比如需要读取缓存、计算多个摘要 |
- 推荐首选方案一(AOP + SpEL),它实现了“声明式编程”,开发人员只需在方法上加一行注解,即可自动记录日志,是最通用、最标准的实现方式。
- 在方案一无法满足(比如需要复杂计算)时,才考虑方案二作为补充。
进阶优化点(生产环境必看)
- 异步写入:日志保存绝对不能影响主流程,使用
@Async+ 线程池,或者发送到 MQ(消息队列),再由消费端批量入库。 - 避免循环依赖:AOP 切面里不要注入自己切面的 Service,否则会出现循环依赖。
- 性能开销:SpEL 解析有性能损耗,建议在 AOP 内部做结果缓存(如用
@Cacheable),或者对表达式编译结果进行本地缓存(CachedExpressionEvaluator)。 - 记录变更前数据:对于更新操作,如果还想记录修改前的值(将 A 改为 B”),可以在
@RecordLog中增加"#oldUser"参数,由调用方传入,或通过 AOP 方法执行前查询数据库获取。
如果你需要某个部分的详细代码(比如SpEL缓存优化、异步写入数据库的线程池配置),我可以继续补充。