Spring Boot实现AOP记录操作日志案例

wen java案例 3

Spring Boot实现AOP记录操作日志案例:从入门到生产级实践

目录导读

  1. 为什么需要AOP日志? —— 传统日志切面痛点与AOP优势
  2. 核心概念速览 —— 切点、通知、连接点、切面
  3. 环境准备 —— Spring Boot 3.x + Maven依赖配置
  4. 自定义注解设计 —— @OperationLog注解的完整定义
  5. 切面实现核心代码 —— @Around环绕通知详解
  6. 日志入库与异步优化 —— 防止日志拖垮主业务流程
  7. 常见问题FAQ —— 自调用失效、多线程、性能损耗等陷阱
  8. 生产级扩展建议 —— 结合SpEL表达式与ELK日志追踪

为什么需要AOP记录操作日志?

在电商、金融等系统中,每次用户操作(下单、退款、删除数据)都需要记录“谁在什么时间干了什么”,传统做法是在每个业务方法中手动添加日志代码:

Spring Boot实现AOP记录操作日志案例

public void deleteOrder(Long orderId) {
    // 手动写日志
    log.info("用户{}删除了订单{}", currentUser, orderId);
    // 业务逻辑...
}

这种方式的致命缺陷:日志代码侵入业务代码,重复且难以维护,当你想统一增加“操作IP”字段时,需要改动几十个方法。

AOP(面向切面编程)的解决方案:通过动态代理,在不修改原有业务代码的情况下,横切地添加日志记录逻辑,Spring Boot对AOP提供了开箱即用的支持,结合自定义注解,能实现优雅的“声明式日志”。


核心概念速览(必懂)

概念 说明 本例对应实现
连接点 程序执行的某个位置 目标方法执行时
切点(PoinCut) 匹配连接点的表达式 @annotation(operationLog)
通知(Advice) 在切点执行的动作 @Around环绕通知
切面(Aspect) 切点+通知的集合 OperationLogAspect类
织入(Weaving) 将切面应用到目标对象的过程 Spring启动时自动通过代理完成

关键理解:Spring AOP默认使用JDK动态代理(接口)或CGLIB代理(类),这决定了后续“自调用失效”问题的根源。


环境准备:Maven依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 根据需要引入MyBatis-Plus或JPA等 -->

自定义注解设计(高扩展性)

@Target(ElementType.METHOD)  // 作用于方法
@Retention(RetentionPolicy.RUNTIME) // 运行时保留,供反射读取
public @interface OperationLog {
    String module() default "";    // 模块名,如"订单模块"
    String operation() default ""; // 操作类型,如"删除"
    String detail() default "";    // 描述,支持Spring EL表达式
}

使用示例:

@OperationLog(module = "订单模块", operation = "删除", 
              detail = "#orderId")  // SpEL引用参数
public void deleteOrder(Long orderId) { ... }

切面实现核心代码(AOP灵魂)

@Aspect
@Component
@Slf4j
public class OperationLogAspect {
    @Autowired
    private OperationLogService logService; // 异步日志服务
    @Around("@annotation(operationLog)") // 绑定注解参数
    public Object around(ProceedingJoinPoint joinPoint, OperationLog operationLog) 
            throws Throwable {
        // 1. 前置记录:获取请求上下文
        HttpServletRequest request = 
            ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
            .getRequest();
        String ip = getIpAddress(request);
        String username = SecurityContextHolder.getContext()
            .getAuthentication() != null ? 
            SecurityContextHolder.getContext().getAuthentication().getName() : "anonymous";
        long startTime = System.currentTimeMillis();
        Object result = null;
        boolean success = true;
        String errorMsg = "";
        try {
            // 2. 执行原业务方法
            result = joinPoint.proceed();
            return result;
        } catch (Throwable e) {
            success = false;
            errorMsg = e.getMessage();
            throw e; // 继续向上抛出,事务正常回滚
        } finally {
            // 3. 后置日志组装(使用SpEL解析detail)
            OperationLogDTO dto = buildDTO(joinPoint, operationLog, 
                                           username, ip, success, errorMsg, 
                                           System.currentTimeMillis() - startTime);
            // 4. 异步保存,不阻塞主流程
            logService.saveLogAsync(dto);
        }
    }
    // 解析SpEL表达式
    private String resolveExpression(String expr, ProceedingJoinPoint pjp) {
        // 标准实现:StandardEvaluationContext + SpelExpressionParser
        // 具体代码略,核心是methodArgs映射参数名
    }
    private String getIpAddress(HttpServletRequest request) {
        String ip = request.getHeader("X-Forwarded-For");
        // ... 处理代理IP逻辑
        return ip != null ? ip.split(",")[0] : request.getRemoteAddr();
    }
}

为何使用@Around而不是@AfterReturning?
因为@Around可以同时捕获正常返回和异常,并且能够获取方法的返回值,让日志记录包含“操作结果”这一关键信息。


日志入库与异步优化——避免性能陷阱

如果同步执行数据库INSERT(每次日志消耗5ms),高并发下会拖垮接口响应,最佳实践:

@Service
@Slf4j
public class OperationLogService {
    @Async("logExecutor") // 自定义线程池
    public void saveLogAsync(OperationLogDTO dto) {
        // 1. 可先缓存到Redis List,批量落库
        // 2. 或直接使用MyBatis-Plus的save()
    }
    @Bean("logExecutor")
    public Executor executor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(4);
        executor.setQueueCapacity(1000); // 积压缓冲
        executor.initialize();
        return executor;
    }
}

注意:必须保证主方法执行完毕后日志保存完成(异步的“最终一致”),因此可在finally中先放入内存队列,再批量消费。


常见问题FAQ(90%开发者会踩坑)

Q1:在同一个类中,Controller调用Service的A方法,而A方法内部又调用了B方法,为什么B上的@OperationLog不生效?

A:Spring AOP基于代理,调用A方法是走代理,但A方法内部的this.B()调用的是原始对象的方法,绕过了代理,因此切面失效,解决方案:

  • 将B方法拆到另一个Service中(推荐)
  • 或使用AopContext.currentProxy()获取代理对象调用

Q2:异步日志的线程如何获取当前登录用户?

A:SecurityContextHolder默认使用ThreadLocal存储上下文,子线程无法获取,需要在主线程中取出用户信息,作为参数传入异步方法,切不可在异步线程中读取。

Q3:AOP日志导致接口性能下降50ms+,如何处理?

A:优化三连:

  1. 使用异步线程池(如上)
  2. 日志信息存Redis而不是直接写MySQL
  3. 增加采样率(比如每秒只记录20%的请求)

Q4:如何记录操作前的原始数据(比如修改前和修改后的值)?

A:可以在@Around中通过joinPoint.getArgs()获取参数,对于更新方法,查询数据库中的旧值,然后在proceed()后获取新值,组装差异,这适合审计需求。


生产级扩展建议(加分项)

  1. 结合SpEL表达式动态记录
    让用户自定义detail内容,比如detail = "订单号:#orderId, 金额:#order.amount",切面解析参数对象属性。

  2. 链路追踪ID整合
    在切面中提取MDC.get("traceId"),与日志框架集成,保证日志与业务流程的串联。

  3. 日志级别差异化
    操作成功使用INFO,失败使用ERROR并记录堆栈,便于告警。

  4. 绕过安全风险
    日志切面不应记录敏感信息(密码、支付二维码),可通过注解属性isSensitive过滤字段。

  5. 使用Spring AOP vs AspectJ
    若需要给(而非方法)加日志,或需要编译期织入,可迁移至AspectJ,但Spring Boot默认AOP已覆盖98%场景。


总结思维导图

@OperationLog注解 --> 定义模块、操作、SpEL详情
       ↓
OperationLogAspect切面
       ↓ @Around
前置:提取IP、用户、参数
执行:joinPoint.proceed()
后置:组装DTO(耗时、成功状态)
       ↓
异步服务(线程池) --> Redis队列 --> 批量入库

完整问答速查(面试必备)

:AOP通知类型有几个?分别场景?
:Before(权限检查)、After(资源释放)、AfterReturning(统计成功指标)、AfterThrowing(异常监控)、Around(最强大,可控制事务与缓存)。

:CGLIB和JDK动态代理如何选择?
:默认如果目标类有接口,则JDK;否则CGLIB,可在SpringBoot配置spring.aop.proxy-target-class=true强制CGLIB,这样所有类都支持切面,但会带来代理对象创建开销。

:日志写入失败会影响原业务吗?
:不会,异步日志在finally中执行,且内部有try-catch,保证日志失败只记录error,不抛出异常。


本文从实际项目出发,完整展示了Spring Boot集成AOP记录操作日志的每一步,从设计到优化,从常见坑到生产扩展,最后回到那句核心经验:“AOP不仅是一种技术,更是一种解耦思想”——它能帮你把横切关注点与核心业务彻底分离,让代码更加优雅可靠。

抱歉,评论功能暂时关闭!