1. 没写AOP之前,日志代码是怎么让人崩溃的
先说说我最初被 AOP 逼着去研究它的经历。那时候我在做一个内部管理系统,产品要求所有接口都记录调用日志:谁调的、传了什么参数、耗时多久、有没有报错。图省事,我直接在 controller 方法里复制粘贴,一个方法三行 log,二十个接口就是六十行几乎一模一样的代码。后来需求稍微变一下——"耗时超过 500ms 的要单独告警",我就得把所有接口翻一遍,漏改一个就是隐患。这种体验,我相信做后端的人多少都经历过。
这种"每个业务方法都要做,但跟业务逻辑本身又没关系"的操作,在工程上叫横切关注点(Cross-Cutting Concern)。日志、鉴权、限流、事务、参数校验、埋点统计,全是典型的横切关注点。用传统 OOP 的思路处理,无非三种:复制粘贴、写个基类让所有业务类继承、或者封装工具类然后手动调用。第一种污染代码,第二种强行绑架继承关系,第三种依然靠人自觉去调用,漏一次就是事故。
AOP(Aspect-Oriented Programming,面向切面编程)就是专门来解决这个问题的。思路其实很朴素:把"要在很多方法上重复执行的逻辑"单独抽出来写,再声明规则告诉框架"哪些方法需要套上这段逻辑"。框架在运行时创建代理对象,当你调用目标方法时自动把这段逻辑织入进去。这里面有几个术语你得先眼熟一下:被抽出来的逻辑类叫切面(Aspect),匹配规则的表达式叫切点(Pointcut),具体要执行的逻辑叫通知(Advice),代理对象的生成过程叫织入(Weaving)。后文我会反复用这些词。
在 Spring 生态里,SpringBoot 已经把 AOP 支持做得非常"开箱即用":引一个 starter、写一个切面类、配一条切点表达式,剩下的交给代理机制。需要注意,SpringBoot 里的 AOP 默认是指 Spring AOP(基于动态代理的运行时织入),不是完整的 AspectJ 编译期织入。引入 aspectjweaver 只是为了支持更丰富的切点表达式写法,这点先搞清楚,后面才不会概念混乱。这篇文章我就用一个最简单、能直接抄作业的例子,把环境配置、切面代码、切点表达式、执行顺序,以及我踩过的坑全部串一遍。
2. 搭环境:一个 starter 依赖就够了
2.1 pom.xml 里到底要加什么
我分别用 SpringBoot 2.7.x 和 3.2.x 验证过,AOP 部分的写法完全一致,依赖也一样:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>就这么一行。这个 starter 会传递引入 spring-aop 和 aspectjweaver。SpringBoot 的自动配置类 AopAutoConfiguration 会检测 classpath 下是否有 AspectJ 相关类,如果有,就自动注册一个关键组件:AnnotationAwareAspectJAutoProxyCreator。这个组件负责扫描带@Aspect注解的 Bean,并给匹配切点表达式的对象生成代理。
所以,正常项目里你不需要手动加@EnableAspectJAutoProxy。很多老教程里写这个东西,要么是历史遗留的 Spring 配置习惯,要么是为了打开exposeProxy = true这个开关(后文讲自调用问题时会提到)。如果你用的是 SpringBoot 1.x,或者是在纯 Spring 项目里手搓配置,那才需要考虑。
提示:如果项目里确实没引入任何 AOP 相关依赖,启动时通常不会立刻报错,而是等你开始调用被拦截的方法时,才发现没有任何效果。排查顺序里第一步永远是:确认 starter 有没有真的加进去。
2.2 最小工程结构
我习惯把切面类单独放在一个包下面,别混进 controller 或 service 包。
src/main/java/com/example/demo/ ├── DemoApplication.java ├── aspect/ │ └── LogAspect.java ├── controller/ │ └── UserController.java └── service/ └── UserService.java切面类必须放在能被@SpringBootApplication组件扫描到的包路径下,因为它本身要被注册成 Spring 管理的 Bean。另外建议类名简洁、职责单一,比如LogAspect、PermissionAspect,一个切面只干一类事,项目大了搜索起来也方便。
3. 手写第一个切面:接口入参、耗时、返回值的完整记录
3.1 先准备一个能被拦截的接口
为了演示,我建一个最普通的 controller 和 service,没有任何特殊处理:
@RestController @RequestMapping("/user") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping("/{id}") public User getUser(@PathVariable Long id) { return userService.getUserById(id); } }@Service public class UserService { public User getUserById(Long id) { return new User(id, "张三"); } }User 就是一个带 id 和 name 字段的普通 POJO,这里就不占篇幅了。现在,如果我在不动这两个类的前提下,统一给 controller 的所有方法加上日志,AOP 正好派上用场。
3.2 核心代码:LogAspect
package com.example.demo.aspect; import java.util.Arrays; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; @Aspect @Component public class LogAspect { private static final Logger log = LoggerFactory.getLogger(LogAspect.class); @Around("execution(* com.example.demo.controller..*.*(..))") public Object logController(ProceedingJoinPoint joinPoint) throws Throwable { String clazz = joinPoint.getTarget().getClass().getSimpleName(); String method = joinPoint.getSignature().getName(); Object[] args = joinPoint.getArgs(); long start = System.currentTimeMillis(); log.info("进入 {}.{},参数:{}", clazz, method, Arrays.toString(args)); try { Object result = joinPoint.proceed(); long cost = System.currentTimeMillis() - start; log.info("结束 {}.{},返回值:{},耗时:{} ms", clazz, method, result, cost); return result; } catch (Throwable ex) { long cost = System.currentTimeMillis() - start; log.error("异常 {}.{},原因:{},耗时:{} ms", clazz, method, ex.getMessage(), cost); throw ex; } } }这段代码是整个 AOP 最核心的骨架。几个关键点拆开说:
@Aspect标记这是一个切面类,@Component让它被 Spring 扫描到。@Around是通知类型,表示"全流程包裹",你要在方法执行前后做什么都可以。被包裹的方法引用叫ProceedingJoinPoint,注意必须带Proceeding前缀,它比普通的JoinPoint多一个proceed()方法。
joinPoint.proceed()是灵魂。只有执行到这一行,才会真正进入目标方法,相当于一个"放行"操作。你要是不调它,目标方法就永远不执行。我见过刚接触 AOP 的同学在@Around里忘了调 proceed,接口直接返回 null,排查半天。
日志里我同时拿到了类名、方法名、参数、返回值、耗时。joinPoint.getTarget()拿到的实际目标对象,getSignature()拿到的是方法签名,getArgs()是调用参数。这几个 API 是切面里最高频的,背下来不亏。
catch 到异常后我选择throw ex重新抛出去,这个习惯很重要。如果吞掉异常,当前接口虽然不会报错,但事务、上游调用方都会以为方法执行成功了,对日志审计类切面来说是严重误导。后文我会再单独展开。
3.3 跑起来验证效果
启动应用,请求curl http://localhost:8080/user/1,然后看控制台:
INFO 进入 UserController.getUser,参数:[1] INFO 结束 UserController.getUser,返回值:User(id=1, name=张三),耗时:3 ms到这一步,最小的 AOP 例子已经跑通了。你没动 controller 一行代码,却在所有接口上统一加上了日志能力。这种"不动业务代码,却给大量方法增加统一行为"的感觉,就是 AOP 的核心价值。
3.4 通知类型怎么选
@Around不是唯一的通知注解,Spring 提供了五种,我整理成一张表:
| 通知类型 | 注解 | 触发时机 | 适用场景 |
|---|---|---|---|
| 前置通知 | @Before | 目标方法执行前 | 参数校验、权限检查 |
| 后置通知 | @After | 目标方法执行后,无论成功失败 | 释放资源 |
| 返回通知 | @AfterReturning | 目标方法正常返回后 | 记录返回值、更新状态 |
| 异常通知 | @AfterThrowing | 目标方法抛出异常后 | 异常告警、兜底处理 |
| 环绕通知 | @Around | 全流程包裹 | 耗时统计、事务、限流等复杂逻辑 |
我的建议是:简单场景能用@Before、@AfterReturning就用简单的,代码更清晰;一旦你需要"方法前后关联起来做事"(比如计时),或者需要手动控制流程,直接上@Around。@Around能覆盖前四种的大部分场景,但也是最容易写错的,尤其是 proceed 调用和异常处理。
4. 切点表达式:execution、@annotation、自定义注解三种玩法
4.1 execution 表达式逐段拆解
刚才切面里的@Around("execution(* com.example.demo.controller..*.*(..))"),这一串字符的语法是:
execution( 修饰符? 返回类型 类路径.方法名(参数) 异常? )其中修饰符和异常部分可以省略。我们挨个拆:
*表示返回类型任意。com.example.demo.controller..*表示 controller 包及其子包下的所有类。两个点..在切点表达式里是"匹配子包"的意思。.*(..)最后的*表示任意方法名,(..)表示任意数量、任意类型的参数。
经常有人把controller..*和controller.*搞混。前者包含 controller 包和它的子包,后者只匹配 controller 包下的直接类。如果子包里的 controller 没被拦截到,先检查是不是这里少写了一个点。
常见的 execution 模式我列一下,方便 Copy:
| 表达式 | 含义 |
|---|---|
execution(public * *(..)) | 所有 public 方法 |
execution(* set*(..)) | 所有 set 开头的方法 |
execution(* com.example.demo.service.*.*(..)) | service 包下的所有方法,不含子包 |
execution(* com.example.demo.service..*.*(..)) | service 包及子包下的所有方法 |
execution(* com.example.demo.controller.*.save(..)) | controller 包下所有类的 save 方法 |
提示:切点表达式在 Spring AOP 里是基于代理的,只有通过 Spring 容器获取的代理对象的调用才能被拦截。
static、private、final方法不参与动态代理,这个我在第 6 节详细说。
4.2 @annotation:按注解匹配
execution 适合"按位置拦截",但很多时候你只想拦截"打了某个标记的方法",这时候用@annotation更精准:
@Around("@annotation(com.example.demo.annotation.OperationLog)") public Object record(ProceedingJoinPoint joinPoint) throws Throwable { // 只有标注了 @OperationLog 的方法才会进到这里 return joinPoint.proceed(); }这种写法不用关心方法在哪个包、叫什么名字,只认注解。它比 execution 的颗粒度细得多,也更符合"按需拦截"的直觉。
4.3 自定义注解玩法:把参数传进切面
@annotation最常见的进阶用法是配合自定义注解。比如我要记录"哪个模块、哪个操作产生了日志",定义注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface OperationLog { String module() default ""; String action() default ""; }然后在切面里通过方法签名拿到注解里的值:
@Aspect @Component public class OperationLogAspect { private static final Logger log = LoggerFactory.getLogger(OperationLogAspect.class); @Pointcut("@annotation(com.example.demo.annotation.OperationLog)") public void operationLogPointcut() { } @Around("operationLogPointcut()") public Object record(ProceedingJoinPoint joinPoint) throws Throwable { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); OperationLog annotation = signature.getMethod().getAnnotation(OperationLog.class); long start = System.currentTimeMillis(); try { Object result = joinPoint.proceed(); log.info("模块 [{}],动作 [{}],参数:{},耗时:{} ms", annotation.module(), annotation.action(), Arrays.toString(joinPoint.getArgs()), System.currentTimeMillis() - start); return result; } catch (Throwable throwable) { log.error("模块 [{}],动作 [{}] 执行失败:{}", annotation.module(), annotation.action(), throwable.getMessage()); throw throwable; } } }这里我用@Pointcut单独声明了一个公共切点方法,好处是:如果以后要新增多个通知共用一个切点,或者要和其他切面组合,只需要改这一处表达式。
业务方法上只需要一行注解:
@OperationLog(module = "用户模块", action = "查询用户") public User getUserById(Long id) { return new User(id, "张三"); }这种方式特别适合做审计日志、权限校验、幂等控制这类"选择性"横切逻辑。注解本身就是一种声明式配置,一眼就能看出方法具备什么额外能力,比在 xml 里写一堆正则清爽得多。
4.4 三种方式怎么选
我的经验是:
- 全局、无差别的横切逻辑(比如给所有 controller 统一计时、统一异常兜底)用
execution,表达式写一次,一劳永逸。 - 只针对特定方法、需要携带参数的横切逻辑(比如审计、操作记录)用自定义注解,代码可读性最好。
- 需要拦截外部库里的方法、或者你没法改源码的方法时,只能依赖
execution或within这类基于位置的表达式。
5. 多个切面叠加时,执行顺序怎么控制
5.1 @Order 控制切面的嵌套顺序
真实项目里不可能只有一个切面。我经常同时遇到:日志切面、权限校验切面、耗时统计切面、甚至事务切面。它们都作用在 controller 层方法上时,执行顺序就得说清楚。
Spring 的规则是:通过@Order注解或者Ordered接口指定优先级,数值越小优先级越高。多个@Around切面叠加时,优先级高的切面在外层,它的前置逻辑先执行,后置逻辑反而最后执行,就像一层层套娃一样。
@Aspect @Component @Order(1) public class PermissionAspect { @Around("execution(* com.example.demo.controller..*.*(..))") public Object checkPermission(ProceedingJoinPoint joinPoint) throws Throwable { System.out.println("第一步:权限校验开始"); return joinPoint.proceed(); } } @Aspect @Component @Order(2) public class PerformanceAspect { @Around("execution(* com.example.demo.controller..*.*(..))") public Object record(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); try { return joinPoint.proceed(); } finally { System.out.println("耗时:" + (System.currentTimeMillis() - start) + " ms"); } } }执行顺序是:
第一步:权限校验开始 目标方法执行... 耗时:XX ms如果把两个 Order 调换,输出顺序就反过来了。理解这个嵌套关系,能避免很多"为什么我日志顺序不对"的困惑。
注意:不写 @Order 的切面,默认优先级是 Ordered.LOWEST_PRECEDENCE,也就是 Integer.MAX_VALUE,在叠加时会跑到最内层。如果你发现某个切面莫名其妙的执行顺序不对,先检查它有没有被 @Order 约束。
5.2 事务切面和自定义切面的相对位置
很多人忽略的是,@Transactional本身也是通过 AOP 实现的。它的 order 默认也是 LOWEST_PRECEDENCE。当你自定义切面没有设置 @Order,或者设置了较大的值,理论上会影响切面和事务的相对顺序。
举个实际场景:如果自定义切面里要做"在数据库事务提交前记录一条日志",而事务切面在更内层,那你记录日志的时机可能比预期早。反过来,如果你希望切面在事务内部执行,常规做法是把自定义切面的 @Order 调小,让它作为外层先包住事务,然后在 @Around 里通过TransactionSynchronizationManager注册事务同步回调,在真正提交后执行逻辑。这个细节比较深,我提一句是想提醒:多切面叠加时,事务本身也是一个"看不见的切面",排顺序的时候别忽略它。
5.3 同一切面里多个通知不要依赖执行顺序
有一种很隐蔽的坑:同一个切面类里既写了@Before,又写了@Around、@After。官方文档明确说过,同一个切面内不同通知的执行顺序是没有严格保证的。我实测不同版本表现还不一样,有的先执行@Before,有的先执行@Around的前半段。
所以我的原则是:一个切面类只做一件事,通知之间不要有顺序假设。比如耗时统计就只放@Around,权限校验就只放@Before,不要混在一起,否则排查问题的时候会非常痛苦。
6. 实战踩坑:切面不生效、行为诡异,多半是这些问题
6.1 自调用导致切面被"绕过去"
这是 AOP 新手最容易踩的坑,没有之一。看这段代码:
@Service public class UserService { @OperationLog(module = "用户模块", action = "创建用户") public void createUser(String name) { // 业务逻辑... this.validateUser(name); } @OperationLog(module = "用户模块", action = "校验用户") public void validateUser(String name) { // 校验逻辑... } }外部其他类调用userService.createUser("张三"),切面生效。但createUser内部通过this.validateUser(name)自调用时,validateUser上的切面不会生效。
原因是 Spring AOP 的代理机制:外部拿到的userService是一个代理对象,调用方法时会先经过代理;但方法内部的this指向的是原始对象,不是代理。想绕过代理,直接执行了你写的原始方法,切面自然被跳过了。
解决思路有几种:
- 把
validateUser拆到另一个 Bean(比如ValidationService),通过注入的代理对象调用,这是最干净、最推荐的做法。 - 注入自身:把
UserService注入到自己字段里,通过selfService.validateUser()调用。 - 使用
AopContext.currentProxy(),注意这需要启动类上加@EnableAspectJAutoProxy(exposeProxy = true),并且有 ThreadLocal 开销,不推荐默认使用。
补充一句:SpringBoot 2.6 之后默认禁止循环依赖,自注入本质上也是一种特殊依赖注入,某些版本下会报错,所以拆 Bean 是长期最稳的选择。
6.2 JDK 动态代理和 CGLIB 的差异
Spring AOP 的底层有两种代理方式:
| 代理方式 | 原理 | 限制 |
|---|---|---|
| JDK 动态代理 | 基于接口,生成实现类 | 目标必须实现接口,只能代理接口方法 |
| CGLIB | 基于继承,生成子类 | final类、final方法无法代理 |
SpringBoot 2.x 之后默认spring.aop.proxy-target-class=true,也就是默认走 CGLIB。这意味着如果你的某些被拦截类或方法是final的,CGLIB 也无能为力,切面会静默失效。这种失效不会报错,只会让你怀疑人生。
我排查时有个习惯:临时打印一下注入对象的真实类型,看是不是代理类:
System.out.println(userService.getClass().getName());如果输出里带着$$EnhancerBySpringCGLIB$$,说明代理生效了,切面没触发就是表达式或方法可见性的问题;如果输出就是原始类名com.example.demo.service.UserService,说明整个代理压根没生成,直接检查 starter 依赖和组件扫描。
6.3 切面不生效的完整排查链路
遇到"我写了切面但就是不执行",按这个顺序查,90% 的问题能定位:
- 确认
spring-boot-starter-aop在 pom 里,并且依赖确实下载下来、没有 exclusions 把传递依赖排掉。 - 确认切面类上有
@Aspect和@Component,并且类所在包能被启动类扫描到。 - 确认切点表达式里的包路径、类名、方法名写得对。最常见的是
..少写一个点,或者包名写成了别人的包。 - 确认目标方法是
public且非static、非final。private方法永远无法被 Spring AOP 拦截。 - 确认调用方拿到的对象是代理对象,而不是通过
new直接创建的原始对象,也不是在目标类内部用this自调用。 - 如果以上都正常,把日志级别调大看代理生成细节:
logging: level: org.springframework.aop: debug com.example.demo.aspect: debug启动时观察日志里有没有类似 "Creating implicit filter for pointcut expression" 的字样,能帮助判断表达式有没有被识别。
6.4 切面里的坏味道:吞异常、干重活
最后说几个写切面时的原则:
第一,@Around里捕获到异常后,如果不是故意要吞掉(比如某些熔断降级场景),一定要重新抛出。否则上游感知不到失败,事务也可能误判为成功。我写过一版统计切面,catch 里忘了throw ex,结果有几天线上接口连续报错,日志里却是"调用结束",排查了很久才发现是切面把异常吞了。
第二,切面不要干重活。不要在里面做数据库查询、调用远程接口这类耗时操作,因为切面包裹的是所有匹配方法,这些额外开销会叠加到每个业务接口上。耗时统计切面本身是轻量的,但如果你在切面里去查库做"调用次数统计",接口性能会肉眼可见地下降。
第三,多个切面职责分离。日志归日志、权限归权限,不要写一个"万能切面"把所有横切逻辑堆在一起。切面的可维护性,靠的是职责单一和清晰命名。
第四,切点表达式适合"宁窄勿宽"。能精确匹配到目标方法,就别图省事写一个宽泛的*.*(..)。表达式太宽,可能把不该拦截的内部方法也套上切面,性能损耗和误伤都很难查。
最后分享一点体会
这套 AOP 配置,我在多个项目里用了好几年,从最早的纯 Spring 时代到现在的 SpringBoot 3.x,核心套路几乎没变过。每次有同事问我"为什么接口里什么都没写,却有日志、有权限校验",我都会带他过一遍切面代码。AOP 最妙的地方不是省了几行代码,而是改变了一种思维方式:以前你改一个横切需求,要动几十个业务类;现在你只需要改一个切面类,所有匹配的方法自动生效。当然,代价是代码的"显式性"变弱了——控制流不再直白地写在业务方法里,所以切面要写清楚、职责要单一,Debug 经验要积累。希望这篇实战记录能让你少走一点弯路。