1. 项目概述
在企业级应用开发中,操作日志记录是一个看似简单却极其重要的功能模块。传统的日志记录方式往往需要在每个业务方法中手动添加日志代码,这不仅导致大量重复劳动,更严重的是让业务逻辑与非功能性需求(如日志、监控)高度耦合。作为一名经历过多个企业级项目的开发者,我深刻体会到这种耦合带来的维护噩梦 - 当需要调整日志格式或增加日志字段时,往往需要修改数十个甚至上百个方法。
Spring AOP(面向切面编程)为解决这类问题提供了优雅的方案。通过AOP,我们可以将日志记录这样的横切关注点(Cross-Cutting Concern)从业务代码中彻底剥离出来,实现关注点分离。本文将基于Spring Boot环境,从零开始构建一个完整的操作日志系统,重点解析五种通知类型(Advice)的实际应用场景和实现细节。
2. 环境准备与基础配置
2.1 项目初始化
首先创建一个标准的Spring Boot项目,添加以下核心依赖:
<dependencies> <!-- Spring Boot Starter Web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Spring AOP支持 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> <!-- Lombok简化代码 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>提示:虽然Spring AOP已经内置在spring-boot-starter-aop中,但实际使用的是Spring对AspectJ的整合实现,而非原生的AspectJ。这种实现方式在功能上足够满足大多数企业应用场景,且无需额外的编译时织入(CTW)或加载时织入(LTW)配置。
2.2 日志框架配置
在application.properties中添加日志相关配置:
# 日志级别设置 logging.level.root=INFO logging.level.com.example.demo=DEBUG # 日志输出格式 logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n3. 自定义注解设计
3.1 注解定义
我们首先定义一个用于标记需要记录日志的方法的注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface OperationLog { /** * 业务操作描述 */ String value() default ""; /** * 操作类型(如: 新增、删除、修改、查询) */ OperationType type() default OperationType.QUERY; /** * 是否记录方法参数 */ boolean recordParams() default true; /** * 是否记录返回值 */ boolean recordResult() default true; } public enum OperationType { CREATE, DELETE, UPDATE, QUERY, OTHER }这个注解设计考虑了实际业务中的多种需求:
- 通过value属性可以描述具体的业务操作
- type属性用于分类统计不同类型的操作
- recordParams和recordResult提供了灵活的配置选项
3.2 注解使用示例
在Controller或Service方法上使用注解:
@RestController @RequestMapping("/user") public class UserController { @OperationLog(value = "用户登录", type = OperationType.OTHER) @PostMapping("/login") public Result<User> login(@RequestBody LoginDTO dto) { // 业务逻辑 } @OperationLog(value = "删除用户", type = OperationType.DELETE) @DeleteMapping("/{id}") public Result<Void> deleteUser(@PathVariable Long id) { // 业务逻辑 } }4. 切面实现详解
4.1 切面基础结构
创建切面类LogAspect:
@Aspect @Component @Slf4j public class LogAspect { /** * 定义切入点:所有带有@OperationLog注解的方法 */ @Pointcut("@annotation(com.example.demo.annotation.OperationLog)") public void operationLogPointcut() {} // 各种通知方法将在下面实现 }4.2 前置通知(Before Advice)
前置通知在目标方法执行前被调用,适合记录方法调用开始和参数信息:
@Before("operationLogPointcut() && @annotation(operationLog)") public void beforeMethod(JoinPoint joinPoint, OperationLog operationLog) { String className = joinPoint.getTarget().getClass().getName(); String methodName = joinPoint.getSignature().getName(); String operationDesc = operationLog.value(); // 构建日志上下文 LogContext context = LogContext.builder() .className(className) .methodName(methodName) .operationDesc(operationDesc) .operationType(operationLog.type()) .startTime(System.currentTimeMillis()) .build(); if (operationLog.recordParams()) { Object[] args = joinPoint.getArgs(); context.setParams(JsonUtils.toJson(args)); } // 将日志上下文存入ThreadLocal LogContextHolder.set(context); log.info("[操作开始] {}.{}, 操作类型: {}, 参数: {}", className, methodName, operationLog.type(), context.getParams()); }注意:这里使用了ThreadLocal来保存日志上下文,确保在多线程环境下各个通知之间可以共享数据。LogContextHolder是一个简单的工具类,内部维护了ThreadLocal变量。
4.3 返回通知(AfterReturning Advice)
返回通知在方法正常返回后执行,适合记录方法返回值:
@AfterReturning(pointcut = "operationLogPointcut()", returning = "result") public void afterReturning(JoinPoint joinPoint, Object result) { LogContext context = LogContextHolder.get(); if (context == null) return; String className = joinPoint.getTarget().getClass().getName(); String methodName = joinPoint.getSignature().getName(); if (context.isRecordResult()) { context.setResult(JsonUtils.toJson(result)); } context.setEndTime(System.currentTimeMillis()); context.setSuccess(true); log.info("[操作成功] {}.{}, 耗时: {}ms, 结果: {}", className, methodName, context.getEndTime() - context.getStartTime(), context.getResult()); // 异步保存完整日志到数据库 asyncSaveLog(context); // 清除ThreadLocal LogContextHolder.remove(); }4.4 异常通知(AfterThrowing Advice)
异常通知在方法抛出异常时执行,适合记录异常信息:
@AfterThrowing(pointcut = "operationLogPointcut()", throwing = "ex") public void afterThrowing(JoinPoint joinPoint, Exception ex) { LogContext context = LogContextHolder.get(); if (context == null) return; String className = joinPoint.getTarget().getClass().getName(); String methodName = joinPoint.getSignature().getName(); context.setEndTime(System.currentTimeMillis()); context.setSuccess(false); context.setErrorMsg(ex.getMessage()); log.error("[操作异常] {}.{}, 耗时: {}ms, 异常: {}", className, methodName, context.getEndTime() - context.getStartTime(), ex.getMessage(), ex); // 异步保存完整日志到数据库 asyncSaveLog(context); // 清除ThreadLocal LogContextHolder.remove(); }4.5 最终通知(After Advice)
最终通知在方法执行完成后(无论是否抛出异常)都会执行,适合做一些资源清理工作:
@After("operationLogPointcut()") public void afterMethod(JoinPoint joinPoint) { // 这里主要确保ThreadLocal被清理 LogContextHolder.remove(); }4.6 环绕通知(Around Advice)
环绕通知是最强大的通知类型,可以完全控制目标方法的执行:
@Around("operationLogPointcut() && @annotation(operationLog)") public Object aroundMethod(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable { // 前置处理 beforeMethod(joinPoint, operationLog); try { // 执行目标方法 Object result = joinPoint.proceed(); // 返回处理 afterReturning(joinPoint, result); return result; } catch (Exception ex) { // 异常处理 afterThrowing(joinPoint, ex); throw ex; } finally { // 最终处理 afterMethod(joinPoint); } }重要提示:环绕通知虽然功能强大,但通常不建议直接使用它替代其他通知的组合。只有在需要完全控制方法执行流程(如事务管理、缓存等场景)时才使用环绕通知。对于日志记录这种相对简单的横切关注点,组合使用其他通知类型通常更清晰。
5. 日志上下文与异步存储
5.1 日志上下文设计
@Data @Builder public class LogContext { private String className; private String methodName; private String operationDesc; private OperationType operationType; private String params; private String result; private boolean success; private String errorMsg; private long startTime; private long endTime; private boolean recordParams; private boolean recordResult; public long getCostTime() { return endTime - startTime; } }5.2 异步存储实现
为了避免日志记录影响主业务流程性能,我们采用异步方式存储日志:
@Async public void asyncSaveLog(LogContext context) { try { OperationLogEntity logEntity = convertToEntity(context); logRepository.save(logEntity); } catch (Exception e) { log.error("保存操作日志失败", e); } } private OperationLogEntity convertToEntity(LogContext context) { OperationLogEntity entity = new OperationLogEntity(); entity.setClassName(context.getClassName()); entity.setMethodName(context.getMethodName()); entity.setOperationDesc(context.getOperationDesc()); entity.setOperationType(context.getOperationType().name()); entity.setParams(context.getParams()); entity.setResult(context.getResult()); entity.setSuccess(context.isSuccess()); entity.setErrorMsg(context.getErrorMsg()); entity.setCostTime(context.getCostTime()); entity.setCreateTime(new Date()); // 可以从SecurityContext中获取当前用户信息 entity.setOperator(getCurrentUsername()); entity.setIp(getRequestIp()); return entity; }提示:要使用@Async注解,需要在Spring Boot启动类上添加@EnableAsync注解。同时建议配置自定义的线程池,而不是使用默认的SimpleAsyncTaskExecutor。
6. 高级功能与优化
6.1 敏感数据脱敏
在实际项目中,某些参数可能包含敏感信息(如密码、身份证号等),需要在记录日志时进行脱敏处理:
public String processSensitiveData(Object arg) { if (arg instanceof String) { String str = (String) arg; // 简单示例:对密码字段脱敏 if (str.toLowerCase().contains("password")) { return "******"; } } // 更复杂的脱敏规则可以在这里实现 return JsonUtils.toJson(arg); }6.2 日志内容过滤
有时我们可能希望忽略某些特定参数的记录:
@OperationLog(value = "更新用户信息", ignoreParams = {"password", "salt"}) public void updateUser(User user) { // 业务逻辑 }在切面中处理忽略参数:
private String filterIgnoredParams(Object[] args, String[] ignoreParams) { if (ignoreParams == null || ignoreParams.length == 0) { return JsonUtils.toJson(args); } try { ObjectMapper mapper = new ObjectMapper(); String json = mapper.writeValueAsString(args); JsonNode node = mapper.readTree(json); // 遍历所有参数,过滤掉需要忽略的字段 for (JsonNode paramNode : node) { if (paramNode.isObject()) { Iterator<Map.Entry<String, JsonNode>> fields = paramNode.fields(); while (fields.hasNext()) { Map.Entry<String, JsonNode> field = fields.next(); if (Arrays.asList(ignoreParams).contains(field.getKey())) { ((ObjectNode) paramNode).put(field.getKey(), "******"); } } } } return node.toString(); } catch (Exception e) { log.warn("参数过滤失败", e); return JsonUtils.toJson(args); } }6.3 性能监控集成
我们可以扩展日志切面,将方法执行时间等指标发送到监控系统:
private void sendMetrics(LogContext context) { Metrics.gauge("method.cost.time", context.getCostTime(), "class", context.getClassName(), "method", context.getMethodName()); if (!context.isSuccess()) { Metrics.counter("method.error.count", "class", context.getClassName(), "method", context.getMethodName(), "exception", context.getErrorMsg()); } }7. 生产环境注意事项
7.1 线程安全
在使用ThreadLocal时,必须确保在适当的时候清理数据,否则可能导致内存泄漏:
public class LogContextHolder { private static final ThreadLocal<LogContext> CONTEXT = new ThreadLocal<>(); public static void set(LogContext context) { CONTEXT.set(context); } public static LogContext get() { return CONTEXT.get(); } public static void remove() { CONTEXT.remove(); } }7.2 异常处理
在切面中处理异常时需要特别注意:
- 不要吞没业务异常
- 切面本身的异常不应该影响业务流程
- 记录足够的错误信息以便排查问题
@Around("@annotation(operationLog)") public Object aroundMethod(ProceedingJoinPoint joinPoint, OperationLog operationLog) { try { // 前置处理 beforeMethod(joinPoint, operationLog); // 执行目标方法 Object result = joinPoint.proceed(); // 返回处理 afterReturning(joinPoint, result); return result; } catch (Throwable ex) { // 异常处理 afterThrowing(joinPoint, ex); throw ex; } finally { try { // 最终处理 afterMethod(joinPoint); } catch (Exception e) { log.error("切面最终处理异常", e); } } }7.3 性能考量
- 日志序列化可能成为性能瓶颈,特别是当参数或返回值很大时
- 异步存储日志时要注意队列积压问题
- 在高并发场景下,可以考虑采样记录而非全量记录
// 采样率配置 @Value("${log.sample.rate:1}") private double sampleRate; private boolean shouldSample() { return Math.random() < sampleRate; } @Around("@annotation(operationLog)") public Object aroundMethod(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable { if (!shouldSample()) { return joinPoint.proceed(); } // 正常处理逻辑 }8. 测试与验证
8.1 单元测试
编写切面单元测试验证各种通知的执行情况:
@SpringBootTest public class LogAspectTest { @Autowired private TestService testService; @Test void testNormalOperation() { testService.normalMethod("test"); // 验证日志是否被正确记录 } @Test void testExceptionOperation() { assertThrows(RuntimeException.class, () -> { testService.exceptionMethod(); }); // 验证异常日志是否被记录 } } @Service class TestService { @OperationLog("正常方法") public String normalMethod(String param) { return "result"; } @OperationLog("异常方法") public void exceptionMethod() { throw new RuntimeException("测试异常"); } }8.2 集成测试
通过MockMvc测试Controller层的日志记录:
@SpringBootTest @AutoConfigureMockMvc public class ControllerLogTest { @Autowired private MockMvc mockMvc; @Test void testControllerLog() throws Exception { mockMvc.perform(post("/user/login") .contentType(MediaType.APPLICATION_JSON) .content("{\"username\":\"test\",\"password\":\"123456\"}")) .andExpect(status().isOk()); // 验证日志是否被记录 } }9. 扩展与演进
9.1 分布式追踪集成
在微服务架构中,可以将操作日志与分布式追踪系统(如SkyWalking、Zipkin)集成:
private void enrichTraceInfo(LogContext context) { try { context.setTraceId(Tracer.getTraceId()); context.setSpanId(Tracer.getSpanId()); } catch (Exception e) { log.warn("获取追踪信息失败", e); } }9.2 审计日志增强
对于需要严格审计的场景,可以增强日志记录:
@OperationLog(value = "敏感操作", audit = true) public void sensitiveOperation() { // 业务逻辑 }在切面中处理审计标记:
private void handleAuditLog(LogContext context) { if (context.isAudit()) { // 发送到专门的审计系统 auditLogService.sendAuditLog(convertToAuditLog(context)); } }9.3 日志分析平台对接
将日志发送到ELK等日志分析平台:
private void sendToELK(LogContext context) { Map<String, Object> logData = new HashMap<>(); logData.put("timestamp", new Date()); logData.put("className", context.getClassName()); logData.put("methodName", context.getMethodName()); logData.put("costTime", context.getCostTime()); // 其他字段... elkClient.index("operation-log", logData); }10. 总结与最佳实践
经过上述步骤,我们实现了一个完整的企业级操作日志系统。在实际项目中应用时,建议遵循以下最佳实践:
合理选择通知类型:对于简单的日志记录,使用@Before和@AfterReturning组合;需要完整控制时使用@Around
注意性能影响:日志序列化和存储应该异步执行,避免阻塞主业务流程
考虑安全性:敏感数据必须脱敏,必要时可以完全忽略某些参数的记录
保持简洁:切面逻辑应该专注于横切关注点,不要包含业务逻辑
完善的异常处理:确保切面自身的异常不会影响业务功能
合理的采样策略:在高并发场景下,可以考虑采样记录而非全量记录
与监控系统集成:将方法执行时间、成功率等指标发送到监控系统
这个日志系统不仅解决了传统日志记录方式的代码冗余问题,还提供了丰富的扩展点,可以根据实际需求灵活调整。通过AOP实现的解耦设计,使得日志策略的变更不会影响到业务代码,大大提高了系统的可维护性。