资讯动态

Spring AOP五种通知类型详解:执行顺序、代理机制与实战避坑

发布时间:2026/10/16 8:04:57 来源:尧图企业网站定制
1. 先弄清楚Spring AOP为什么值钱Spring AOP在Java后端开发里就像一个剪切点控制器。它解决的核心问题是把那些和业务无关、但又到处都要写的横切逻辑给抽出来比如日志记录、权限校验、事务管理、方法耗时统计、异常兜底。没有AOP之前这些逻辑只能散布在每一个业务方法里改一个日志格式可能要动几十个文件有了AOP之后你只需要写一个切面声明在哪些方法上生效剩下的交给Spring容器去织入。很多人学AOP只记了五个通知类型的名字和注解结果到了实际项目里一用就出问题为什么加了Around导致方法没有返回值为什么AfterReturning里的回调参数拿不到为什么两个切面嵌套执行顺序不是你想要的这些问题根子都在没有真正理解这五种通知类型各自的生命周期和触发时机。这篇内容适合这几类人看刚接触Spring AOP想系统搞懂底层机制的后端新人已经在用AOP但遇到过执行顺序、返回值修改、异常拦截问题的开发想给团队整理一份可落地的AOP最佳实践而不是照抄文档的中间层开发者。我下面的讲解不会只贴官方定义我会把每一种通知放到实际场景里讲配上可以运行的完整示例最后分享几个我在生产环境排查AOP问题时总结出来的经验。看完之后你可以直接照着搭一个自己的AOP示例工程顺便理解Spring在背后帮你做了什么。2. 先把AOP术语盘一遍不然下面全是废话在聊五种通知类型之前有几个词必须对齐口径。这些术语官方文档讲得很严谨但很多人第一遍读过去是懵的我换个方式说。连接点Join Point就是程序执行过程中的某个点。在Spring AOP里它通常就是一个方法的执行入口和出口。一个业务类里有多少个public方法在AOP眼里就有多少个候选连接点。注意Spring AOP只支持方法级别的连接点不像AspectJ那样能支持字段访问、构造器调用等更细粒度的位置这个限制后文我还会提。切点Pointcut就是一条“筛选规则”用来告诉AOP容器哪些连接点是我要拦截的。比如execution(* com.example.order.service.*.*(..))表示拦截order.service包下所有类的所有方法。连接点很多切点帮你圈定范围。切面Aspect就是“规则 行为”的组合体。一个切面类里既有切点定义也有通知方法。切面本身是一个Spring管理的Bean通常用Aspect和Component标记。通知Advice就是切面里真正要执行的逻辑方法。它决定了拦截到目标方法之后你的代码是插在方法前、方法后还是把整个方法包起来。织入Weaving就是把切面逻辑和目标方法组合起来形成代理对象的过程。Spring AOP是在运行时通过动态代理完成的没有专门的编译期织入。这个区别决定了它只能作用于Spring管理的Bean被new出来的对象是不会被AOP拦截的。把这些概念串起来就是Spring容器启动时扫描到切面解析切点表达式对匹配的Bean生成代理对象你调用Bean方法时实际调用的是代理对象代理对象在合适时机执行通知代码再反射调用真实的目标方法。3. 五种通知类型逐个拆解每种都配场景和代码这是本文的核心。我不会只罗列注解我会按“触发时机、适用场景、注意事项、示例代码”四个维度把每种通知讲透。3.1 Before 前置通知Before触发时机目标方法执行之前。Spring保证在进入目标方法体之前先执行你写的这个通知方法。适用场景前置校验、参数预处理、权限检查、请求日志记录。这些逻辑通常不需要关心方法最终是否执行成功只需要在调用前做一次判断或记录。比如一个下单接口在进入service层之前检查用户是否登录、商品库存参数是否合法。注意事项Before通知不能阻止目标方法继续执行。有些人误以为在Before里抛个异常就算“拦住了”确实异常可以中断调用链但那等于用异常控制流程并不优雅如果你希望完全控制方法的执行和返回应该用环绕通知。Before里拿不到方法的返回值因为方法还没跑呢但你可以在JoinPoint里拿到方法签名和参数列表。Aspect Component public class UserOperateAspect { private static final Logger log LoggerFactory.getLogger(UserOperateAspect.class); Before(execution(* com.example.demo.service.*.create(..))) public void beforeCreateOrder(JoinPoint joinPoint) { Object[] args joinPoint.getArgs(); // 参数可能是一个实体对象也可能是多个参数 log.info(准备创建订单参数列表长度{}, args.length); if (args.length 0 args[0] instanceof CreateOrderRequest) { CreateOrderRequest req (CreateOrderRequest) args[0]; // 这里可以做一些参数层面的快速校验 if (req.getUserId() null) { throw new IllegalArgumentException(用户ID不能为空); } } } }3.2 AfterReturning 返回后通知AfterReturning触发时机目标方法正常返回之后而且只在这个前提下触发。如果目标方法抛了异常AfterReturning不会执行。适用场景对成功返回的结果做二次加工。最典型的用法是包装统一返回结构比如service层返回一个领域对象你需要把它包装成API响应DTO或者记录成功调用日志、统计成功率。关键技巧这个通知可以在returning属性里声明一个参数Spring会把目标方法的返回值绑定到这个参数上。注意两点一是参数名要和returning的值一致二是返回值类型默认是Object如果你写成了具体类型那只有目标方法返回该类型或其子类型的时候才会触达这个通知。Aspect Component public class ResultWrapAspect { AfterReturning(value execution(* com.example.demo.service.*.query(..)), returning result) public void afterQuery(JoinPoint joinPoint, Object result) { // 此时方法已经正常返回result就是目标方法的结果 // 可以在这里做结果增强比如填充默认值、记录返回摘要 if (result instanceof List?) { log.info(查询数量{}耗时统计见环绕通知, ((List?) result).size()); } } }我特别提醒一点AfterReturning里修改参数result的值无法影响最终返回到调用方的结果。很多人踩过这个坑觉得在返回通知里把result改一下调用方拿到的就是改过的结果。这个想法是错误的。Spring AOP执行到afterReturning通知时目标方法的返回值已经确定了你在这个通知里对result这个引用动手脚改的是你手里的副本返回给调用方的还是原来的对象。除非你返回的是一个可变对象修改了对象内部状态调用方才会看到变化。如果你要把返回值整个替换掉只能用环绕通知。3.3 AfterThrowing 异常后通知AfterThrowing触发时机目标方法抛出异常并且异常传播出目标方法之后触发。适用场景统一异常处理。比如把底层异常转换成业务异常、发送告警消息、记录包含堆栈的完整错误日志。很多团队的请求入口过滤器和异常处理器负责的是Controller层面的兜底而在service层抛出的特定异常可能需要在这里转换一下让上层接口返回错误码而不是500状态。注意事项AfterThrowing里同样不能吞掉异常。你在这个通知里catch不了除非你自己再抛出一个新的异常否则目标方法抛出的异常还是会继续向上传播。如果你想要既能记录异常又不让异常打断流程不要用这个通知去环绕通知里处理。Aspect Component public class ExceptionNoticeAspect { AfterThrowing(value execution(* com.example.demo.service.*.*(..)), throwing ex) public void afterThrowing(JoinPoint joinPoint, Exception ex) { String methodName joinPoint.getSignature().getName(); // 在这里统一记录错误日志甚至可以发起一个异步告警动作 log.error(方法 {} 执行异常异常信息{}, methodName, ex.getMessage(), ex); // 注意这里不返回异常仍然会继续抛给上层调用方 } }3.4 After 后置通知After触发时机目标方法执行结束之后等价于finally块。不管目标方法是正常返回还是抛出异常After都会执行。适用场景资源清理、释放操作、无论如何都要做的收尾工作。比如释放ThreadLocal变量、重置上下文、记录方法执行结束的日志。注意事项After拿不到方法返回值也拿不到异常信息。它就是一个纯收尾动作。所以如果你需要返回值或者异常对象请用AfterReturning和AfterThrowing。我把After置于三种通知的中间位置讲是因为它的语义介于“成功后”和“异常后”之间更接近“最终”的语义所以在逻辑顺序上它永远是最后一个被触发的。Aspect Component public class CleanupAspect { After(execution(* com.example.demo.service.*.process(..))) public void afterProcess(JoinPoint joinPoint) { // 清理线程绑定的一些临时状态避免线程池复用时的数据污染 TraceContext.clear(); log.info(方法处理结束完成清理); } }3.5 Around 环绕通知Around触发时机环绕通知包裹了整个目标方法。它的持有者是ProceedingJoinPoint你自己决定什么时候调用proceed()放行目标方法调用前后都可以塞自己的逻辑还可以对返回值做替换对异常做捕获。适用场景这是五种通知里唯一能完整控制方法执行的类型。方法耗时统计、分布式锁、事务边界、限流、缓存查询几乎所有需要“既要在方法前做点什么又要在方法后做点什么”的场景都应该优先考虑用环绕通知。比如统计一个service方法执行耗时用Before记录开始时间、AfterReturning计算耗时不是不行但这样做把一次统计拆到了两个方法里还得用ThreadLocal传递变量很麻烦直接用一个Around干净利落。核心机制proceed()方法调用一次就相当于执行一次目标方法。要修改返回值就把proceed()的结果接住加工后返回。要捕获异常就用try-catch包住proceed()调用。要注意的是如果你在环绕通知里吞掉了异常并返回了一个默认值调用方不会感知到异常这是能力也是责任我在下文会专门讲这种模式的风险。Aspect Component public class ExecutionTimeAspect { Around(annotation(com.example.demo.annotation.MetricTime)) public Object recordTime(ProceedingJoinPoint pjp) throws Throwable { long start System.nanoTime(); try { Object result pjp.proceed(); return result; } catch (Exception ex) { log.error(方法 {} 执行异常, pjp.getSignature().getName(), ex); throw ex; } finally { long cost System.nanoTime() - start; double ms cost / 1_000_000.0; log.info(方法 {} 耗时 {} ms, pjp.getSignature().getName(), ms); } } }这里的annotation(com.example.demo.annotation.MetricTime)是一个自定义注解切点。给需要统计耗时的方法标注MetricTimeAOP就能精准命中比用execution匹配包路径要更精准也更适合团队约定。自定义注解配合Around是我在项目里最常用的组合。3.6 五种通知的执行顺序对比在实际运行的时候如果同一个切面里的多个通知作用在同一个连接点上它们的执行顺序是这样Around的proceed()之前代码最先生效然后是Before紧接着进入目标方法目标方法执行完成后先触发AfterReturning正常路径或AfterThrowing异常路径无论正常还是异常After都会在AfterReturning或AfterThrowing之后执行最后回到Around的proceed()之后执行环绕通知的后置代码。我把这些通知围绕目标方法的触发顺序和权限特性做成一张速查表方便你记通知类型注解触发时机能否拿到返回值能否拿到异常能否阻断方法典型场景前置通知Before方法执行前否否不能抛异常才能中断参数校验、日志返回通知AfterReturning方法正常返回后是否不能只能处理结果引用结果包装、成功日志异常通知AfterThrowing方法抛出异常后否是不能异常仍继续传播异常告警、异常转换最终通知After方法结束后相当于finally否否不能资源清理、兜底日志环绕通知Around包裹方法前后是可替换是可捕获能完全掌控耗时统计、事务、缓冲、锁4. 实操从零搭一个能跑起来的AOP示例工程光看概念记不牢我建议你亲手跑一遍。下面这个示例工程基于Spring Boot我会把关键步骤都写出来你照着建就能通。4.1 添加依赖和启动配置Spring Boot的spring-boot-starter-aop会自动引入AspectJ和Spring AOP所需依赖。只需要在pom.xml里加一行dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency如果你是在一个非Spring Boot的老式Spring项目里需要手动加spring-aop和aspectjweaver依赖并且要在XML配置里加一句aop:aspectj-autoproxy/。在Spring Boot中不需要自动配置已经帮你开启了AOP支持。4.2 定义一个业务服务我们的业务服务就是一个普通的订单模块包含三个方法一个成功创建订单一个抛出异常一个查询订单列表。这些方法之后会被AOP切面拦截。package com.example.demo.service; Service public class OrderService { public String createOrder(String userId, String productId) { // 模拟业务处理 return 订单创建成功用户 userId 商品 productId; } public String queryOrders(String userId) { return 用户 userId 的订单列表共 3 条; } public String cancelOrder(String orderId) { // 模拟一个业务异常 throw new IllegalStateException(订单状态不允许取消订单号 orderId); } }4.3 编写一个包含五种通知的切面这里我把五种通知放进同一个切面用一个通用的切点表达式。这样你运行一次测试就能在一个控制台输出里看到它们的完整触发链路。package com.example.demo.aspect; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; Aspect Component public class FiveAdviceAspect { private static final Logger log LoggerFactory.getLogger(FiveAdviceAspect.class); Pointcut(execution(* com.example.demo.service.OrderService.*(..))) public void orderPointcut() { } Before(orderPointcut()) public void beforeAdvice() { log.info(前置通知方法即将执行); } AfterReturning(value orderPointcut(), returning result) public void afterReturningAdvice(Object result) { log.info(返回通知方法已正常返回返回值{}, result); } AfterThrowing(value orderPointcut(), throwing ex) public void afterThrowingAdvice(Exception ex) { log.info(异常通知方法抛出了异常异常类型{}, ex.getClass().getSimpleName()); } After(orderPointcut()) public void afterAdvice() { log.info(最终通知方法已结束无论成功还是异常); } Around(orderPointcut()) public Object aroundAdvice(ProceedingJoinPoint pjp) throws Throwable { log.info(环绕通知前——开始时间{}, System.currentTimeMillis()); Object result; try { result pjp.proceed(); log.info(环绕通知后——目标方法返回返回值{}, result); return result; } catch (Throwable t) { log.info(环绕通知异常——异常信息{}, t.getMessage()); throw t; } finally { log.info(环绕通知finally回到外层); } } }注意我在切面里用了Pointcut定义了一个可复用的切点方法orderPointcut()。这样后续每个通知只需要引用orderPointcut()统一维护切点表达式不用在每个注解里写一遍。小项目里这也许看不出明显收益一旦切点表达式复杂起来复用价值就很明显。4.4 写一个测试验证五种场景写一个简单的Spring Boot启动器然后在启动时调用三种不同路径的方法观察控制台输出。package com.example.demo; import com.example.demo.service.OrderService; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication public class AopDemoApplication implements CommandLineRunner { private final OrderService orderService; public AopDemoApplication(OrderService orderService) { this.orderService orderService; } public static void main(String[] args) { SpringApplication.run(AopDemoApplication.class, args); } Override public void run(String... args) { System.out.println( 场景1正常返回的方法 ); orderService.createOrder(10001, P20241); System.out.println(\n 场景2抛出异常的方法 ); try { orderService.cancelOrder(OD00001); } catch (Exception ignore) { } } }场景1的控制台输出如下我把日志简化了些环绕通知前——开始时间1730000000000 前置通知方法即将执行 环绕通知后——目标方法返回返回值订单创建成功... 返回通知方法已正常返回返回值订单创建成功... 最终通知方法已结束无论成功还是异常 环绕通知finally回到外层场景2的输出会把路径切到异常分支环绕通知前——开始时间1730000000000 前置通知方法即将执行 环绕通知异常——异常信息订单状态不允许取消... 异常通知方法抛出了异常异常类型IllegalStateException 最终通知方法已结束无论成功还是异常 环绕通知finally回到外层这个执行顺序和前面表格里的说明完全对得上。你亲手跑一遍之后会比只看文档清晰很多因为你能直观看到Around通知如何把其他通知包裹在里面也明白为什么说Around是“最终兜底”的那个环绕者。5. 真正决定成败的细节执行顺序、代理机制、失效场景与排查技巧AOP的例子很容易跑通但生产环境里很多隐蔽问题都出在下面几个方面。5.1 多切面时的执行顺序是怎么定的当多个切面同时匹配同一个方法时存在一个“顺序”问题。默认情况下Spring AOP以切面类的优先级来决定执行顺序但优先级并没有一个明文的默认值这时候顺序往往取决于切面类的加载顺序可能不是你预期的那样。解决方案是显式指定顺序。最优雅的方式是通过Order注解数值越小优先级越高。也可以用Ordered接口效果一样。Aspect Component Order(1) public class LoggingAspect { // 低优先级数值先执行 } Aspect Component Order(2) public class PerformanceAspect { // 后执行但它会包裹前面的切面 }顺序的含义要搞清楚数值较小的切面它的环绕通知/前置通知会先执行然后在目标方法深处再进入数值较大的切面。换句话说最外层切面通常是Order数值最小优先级最高的那一个。如果你希望事务切面包裹日志切面让日志记录发生在事务提交之后就得仔细调这个顺序。5.2 为什么有时候AOP静悄悄失效了这是团队里出现频率最高的问题。AOP拦截失效绝大多数时候逃不出这三个原因第一Spring AOP只代理公开方法。如果目标方法是private方法或者通过this.method()的方式在类内部调用同一类的其他方法都不会被拦截。原因是Spring容器生成的是目标对象的代理对象类内部this引用指向的是原始对象不是代理对象所以AOP根本无从介入。我见到过有人在业务类里加了一个自调用方法切点写的是类内所有方法结果预期要拦截的方法没有被拦截排查到凌晨才发现是被自调用绕过了。解决办法把自调用改成注入自身的代理对象或者拆成两个Bean相互调用或者用AopContext.currentProxy()需要在启动类开启EnableAspectJAutoProxy(exposeProxy true)。第二目标对象不是Spring容器管理的Bean。你用new关键字创建的对象不属于容器Spring AOP是不会去代理它的。这一点前面提过遇到这种代码只能重构。第三方法没有匹配上切点表达式。这种问题经常藏在包名、类名拼写上。比如execution(* com.example.service.*.*(..))只匹配service包第一层不会匹配子包。如果你期望匹配某个包及其子包下的所有类需要用execution(* com.example.service..*.*(..))两个点号才是“任意层级子包”的含义。我给我自己的排查套路是这样先确认Bean是被Spring代理的在启动日志或者debug里看Bean的class是否带有$$EnhancerBySpringCGLIB之类的代理信息再用一个最简单的切点execution(* com.example.demo..*(..))来做排除看是否是完全不匹配还是匹配了但没拦截。这个套路可以快速缩小排查范围。5.3 环绕通知吞异常的利与弊Around是能力最强的通知类型但也因此最容易埋坑。很多人喜欢在环绕通知里catch异常之后返回一个默认Result这样做很危险因为你把异常吞掉之后调用方的故障感知被彻底阻断了。日志里也许打了error但业务调用方拿到的是一个正常的默认值会继续往下执行很可能造成数据不一致等更严重的问题。我建议遵循一个原则环绕通知捕获异常后除非你有明确的降级策略否则一律重新抛出。如果确实需要吞掉异常并返回默认值一定要在catch块里记录完整的异常堆栈还要打一个醒目的告警日志最好配合监控报警保证开发者不会在毫无感知的情况下丢失异常。5.4 执行顺序在新旧版本Spring里的差异有一个容易被误判的细节Spring 5.2.6和之前的版本里当目标和同一个切面里的多个通知同时命中时AfterReturning和AfterThrowing的顺序以及After的相对位置在不同版本里存在过细微差异。尤其是Spring框架演进过程中ReflectiveMethodInvocation对同一切面内通知的执行顺序做过调整。你不必深究每个历史版本的差异但你要知道这个知识如果你升级了Spring Boot版本之后发现日志里通知顺序和以前不一样不要怀疑自己的代码写错了先确认框架版本变更。从Spring 5.2.7开始同一切面内的通知执行顺序已经稳定下来正常情况下是先AfterReturning再After异常情况下是先AfterThrowing再AfterAround通知的后置代码最晚执行。我们当前主流使用的Spring Boot 2.x、3.x基本都是这个行为。5.5 连接点获取参数和返回值的边界编程里最常见的纠结是在某个通知里怎么才能拿到当前方法的参数。答案是每个切面的通知方法都可以声明一个JoinPoint参数Around里是ProceedingJoinPoint然后通过joinPoint.getArgs()拿到参数数组。但边界在哪里我给你一个判断工具只拿参数不考虑返回值/异常 → 用Before或Around拿返回值 → 用AfterReturning或Around拿异常 → 用AfterThrowing或Around拿参数、返回值、异常三者齐全同时要控制执行流程 → 只有Around这样选型就不容易出错。在同一个切面里混用多种通知当然可以但如果你发现一个切面里Before里记了参数、AfterReturning里记了返回、AfterThrowing里记了异常整个逻辑被拆散得到处都是建议直接全部合并成一个Around代码会紧凑得多。6. 通知类型选择建议一个可落地的决策框架看完五种通知类型你也许会问项目里到底该怎么选我给一个从实际项目中归纳出的选择框架。能用Around就不用其他几种也不是。虽然Around功能最全但它的代码结构天然比简单通知复杂。如果你的需求只是“方法前记个日志”用Before就够了代码更简单可读性更好。反过来如果需求是“统计耗时、记录参数、处理异常、修改返回值”那就别拆成四种通知直接用一个Around最清晰。我的决策顺序是先判断是否需要控制目标方法执行流程。需要控制如限流、加锁、事务边界、吞异常、替换返回值 → 用Around。不需要控制流程但要在方法结束后拿到成功返回值 → 用AfterReturning。只需要做资源清理不管成功与否 → 用After。需要在异常发生时做告警或转换 → 用AfterThrowing。只需要在方法前做参数检查或记录 → 用Before。把这个决策顺序反过来看其实你会发现越往后的需求越简单越不需要考虑控制流也就越没有必要动用Around。很多人一上来就写Around导致代码里全是try-finally这是不合理的。另外当切面需要作用于多个不同包、不同方法时建议优先使用自定义注解作为切点而不是写一大堆execution表达式。自定义注解的可读性和维护性都要好很多团队成员看到方法上标了MetricTime就明白它会有耗时统计切面不需要去解析晦涩的切点表达式。7. 我实际项目里踩过的三个坑提醒你别再踩最后分享几个真实环境里沉淀出来的经验不成体系但每一条都是代码之外才知道的事情。关于参数修改的坑。某个同事希望在Before里修改入参对象的某个字段让它带着默认值进入目标方法。他直接在通知里改了args[0]的引用结果目标方法拿到的还是原来的对象后来发现除非修改的对象内部状态替换引用是无效的。原因不复杂Spring AOP把参数数组绑定到通知方法后你在数组里替换引用不会传回目标方法。想要真正替换入参只能在Around里改变proceed(args)传入的参数数组。关于自调用失效的坑。我处理过一次比较隐蔽的失效某个Service里方法A调用了自身的缓存方法B切点匹配了B但B没有走代理。当时代码长这样public Result doSomething() { // do some logic return this.getFromCache(key); // 自调用AOP不拦截 }后来把getFromCache拆到另一个缓存Service里通过注入的Bean调用AOP就生效了。如果你不想拆类也可以注入自身但Spring的循环依赖会出现警告还是推荐拆类逻辑也更清晰。关于切点表达式写错范围的坑。我见过有人写execution(* com.example..*(..))这个表达式会匹配com.example包下所有类的所有方法但实际上这个写法把系统里所有Bean的public方法都切进来了包括一些配置类的初始化方法导致启动时出现大量预期之外的日志。查询池这种表达式虽然方便但必须配合包结构仔细设计宁可写得窄一点也不要奢望一个万能表达式全局生效。我个人在使用AOP时最深刻的一个体会是AOP越强大越考验开发者对代码流和代理机制的理解。通知类型只决定了“什么时候做”而真正决定项目质量的是你能不能在设计层面知道自己“要做什么、在哪做、能不能这么做”。先用好Around这一个类型解决80%的复杂场景再用简单通知去处理收尾和日志你的AOP代码会清晰很多。如果你今天把这五种通知类型和一个完整例子跑通了接下来可以试着扩展几个点自定义注解切点、多切面的Order顺序、用AOP实现一个简单的接口幂等校验。这几个方向都是AOP最实用的延伸场景跑通了就算真正入门了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑