☰
Spring AOP @Around环绕通知:核心原理、实战写法与避坑指南
2026/10/8 20:30:55 网站建设 项目流程

一、先说一个让很多人在实战里栽跟头的点:环绕通知不是"前通知加后通知"

接触过Spring AOP的朋友,基本都知道有五种通知类型:@Before、@After、@AfterReturning、@AfterThrowing、@Around。很多人看教程的时候觉得前面四种好理解,@Around无非就是"在目标方法前后都塞一段逻辑",也就是前四种功能的拼装。我第一次在真实项目里用@Around时也是这么理解的,结果写出来的拦截器出了怪问题:方法确实被拦截了,入参也打印了,但目标业务方法压根没执行,页面直接返回了null。排查了半天才发现,问题出在环绕通知里漏了那一行关键代码。

其实环绕通知和其他通知有一个本质差别,不能简单理解成"执行顺序上的组合"。

  • @Before:在连接点之前执行,但你没有办法控制连接点方法本身是否继续执行。
  • @After:无论方法正常返回还是抛异常,都会执行,但同样无法阻止方法执行。
  • @AfterReturning:只有方法正常返回后才走这里。
  • @AfterThrowing:只有方法抛异常后才走这里。
  • @Around:它是唯一一个"把目标方法执行权交到你手里"的通知类型,目标方法执行不执行、什么时候执行、用什么参数执行、执行完之后返回什么、抛出的异常怎么处理,全部由你在环绕通知方法里决定。

用一句大白话讲:@Around就是"你拿着遥控器,目标方法按不按播放键由你说了算"。其他四种通知都只是旁观者,只能在旁边看,或者在某些节点上插一脚,但改变不了主线剧情。

这个能力在单次拦截里可能看不出多大优势,但在复杂场景里就是杀手锏。比如你想给某个方法加"失败重试",用@Before和@AfterReturning是拼不出来的,因为目标方法一旦抛出异常,@AfterReturning压根不会触发。只有@Around能让你在catch到异常之后,再调一次ProceedingJoinPoint.proceed(),实现第二次、第三次执行。再比如你想做"方法内部耗时统计",@Around可以在方法执行前后分别取时间戳,毫秒级监控,别的通知类型想做到这个得靠ThreadLocal或者AOP上下文绕路。可以说,环绕通知就是Spring AOP里最灵活、也最值得认真啃下来的一个点。

这篇文章就围绕@Around这个具体的技术点,把我自己在Spring Boot项目里实际用到的写法、原理、以及掉过的坑一次性讲清楚。内容适合正在学Spring AOP的同学,也适合已经写过一些切面但遇到"为什么没生效""为什么重复执行""为什么事务丢了"这类问题的朋友。

二、@Around的核心运行机制:ProceedingJoinPoint.proceed() 到底在做什么

先看一个最基础的环绕通知长什么样:

@Slf4j @Component @Aspect public class SimpleAroundAspect { @Around("execution(* com.example.order.service.*.*(..))") public Object doAround(ProceedingJoinPoint pjp) throws Throwable { // 目标方法执行之前的逻辑 log.info("进入环绕通知,目标方法:{}", pjp.getSignature()); // 调用目标方法 Object result = pjp.proceed(); // 目标方法执行之后的逻辑 log.info("目标方法返回:{}", result); return result; } }

这段代码是环绕通知的最小骨架。里面最关键的,就是那句pjp.proceed()。下面把它拆开讲透。

2.1 proveed 方法:目标方法调用的"总开关"

ProceedingJoinPoint 是JoinPoint的子接口,专门给环绕通知用。它比普通的JoinPoint多了两个方法:

  • Object proceed() throws Throwable:执行目标方法,返回目标方法的结果。
  • Object proceed(Object[] args) throws Throwable:用改造后的参数执行目标方法。

注意proceed()这个名字,它来自英文"继续进行"。什么意思?当Spring AOP把控制权交给你之后,程序执行到这个方法,才会真正进入你拦截的那个业务方法。如果环绕通知方法里不写pjp.proceed(),那么恭喜你,目标方法会直接跳过,整个调用链就以你的环绕通知方法返回值为终点返回。

举一个在实际业务里踩过坑的例子。我们有个订单查询接口,开发同学想加一个缓存切面:

@Around("execution(* com.example.order.service.OrderQueryService.getOrder(..))") public Object cacheAround(ProceedingJoinPoint pjp) throws Throwable { String key = "order:" + pjp.getArgs()[0]; // 先查缓存 Object cachedValue = redisTemplate.opsForValue().get(key); if (cachedValue != null) { return cachedValue; } // 缓存不命中,执行目标方法 Object result = pjp.proceed(); redisTemplate.opsForValue().set(key, result); return result; }

这段逻辑看起来没毛病:缓存命中就直接返回,不用执行DB查询;缓存没命中才执行方法。但如果你把pjp.proceed()漏了,走到else分支里调用目标方法时就会拿到一个null或者空的返回,而且接口不报错,让人误以为是被拦截的方法返回了空数据。这种问题非常隐蔽,因为编译器不会提示,IDE也不会标红,只有对环绕通知机制足够敏感的人才能一眼定位。

2.2 proceed() 方法的返回值与 void 方法的处理

还有一个容易忽略的细节:pjp.proceed()返回的是Object,哪怕被拦截的目标方法返回类型是void,它也会返回一个值,只是这个值永远是null。

所以环绕通知方法的返回类型必须写成Object,最终把这个Object返回给调用方。Spring AOP会做一层适配:

  • 目标方法返回String,环绕通知返回String,调用方拿到的就是方法原本的结果。
  • 目标方法是void,环绕通知可以返回null,调用方拿到的就是null,看起来一切正常。
  • 如果目标方法返回基本类型(比如int),环绕通知返回了null,那么调用方在拆箱的时候就会报NullPointerException。

这里我建议一个习惯:在环绕通知结尾,统一把pjp.proceed()的结果用变量接着,然后return result;,不要直接写return pjp.proceed();,因为后面你要做后置处理时,没有变量引用就没法操作中间结果了。

2.3 proceed(Object[] args) 与参数修改的威力

环绕通知另一个独有能力,是可以在目标方法执行前修改参数。

@Around("execution(* com.example.user.service.UserService.updateUser(..))") public Object userAuditAround(ProceedingJoinPoint pjp) throws Throwable { Object[] args = pjp.getArgs(); if (args != null && args.length > 0 && args[0] instanceof UserDO user) { user.setUpdateTime(LocalDateTime.now()); user.setOperator("system"); args[0] = user; } return pjp.proceed(args); }

这个能力在"统一填充公共字段"场景里非常有用。比如系统里所有更新操作都需要带操作人、操作时间,你不可能在每个Service方法里都写一遍,用环绕通知统一处理干净。

这里有一个个重要的底层细节:pjp.getArgs()返回的是Object数组,这个数组是目标方法参数的一份快照引用。你直接改数组里的元素,如果元素本身是引用类型,会影响到目标方法看到的对象状态。这就是为什么例子中能通过args[0] = user实现"把修改后的对象传给目标方法"。但对基本类型参数,直接改数组里的整形是没用的,因为基本类型走的是值传递。这一点在实战中要注意区分。

三、为什么项目里要选环绕通知而不是四种"简单通知":场景驱动型选择

刚学AOP的时候,可能觉得@Before、@After这种简单通知更容易理解,那为什么我在项目里越来越倾向于用@Around?不是因为@Before不好,而是因为真实业务里的横切需求,往往同时涉及"前置处理、异常兜底、后置清理、返回值改造"多个环节,用简单通知就要拆到多个切面方法里,代码分散,调用顺序还得靠脑子记。

拿登录校验举例。用@Before做权限校验时,你可以在校验失败时抛一个异常让目标方法不执行,效果上没问题,但代码的"意图表达"不够直接。用环绕通知写权限校验,语义就非常清晰:校验通过才调proceed(),不通过直接返回错误响应,目标方法从头到尾都不会被唤醒。

下面用表格把我的选型经验总结一下,方便对照:

场景@Before@Around选Around的理由
单纯打日志可以可以两者都行,但Around能拿到执行结果和耗时
权限校验、参数校验可以推荐Around可以在校验失败时直接短路,同时控制返回值
失败重试不行必须只有Around能catch异常后再次proceed()
记录方法执行耗时麻烦推荐start/end都在同一个方法内,不用ThreadLocal
修改目标方法入参不行必须只有Around能用proceed(args)传入新参数
吞掉异常并降级不行必须Around可以在catch里返回兜底数据
缓存查询更新部分可以推荐Around命中缓存直接返回,天然适合"旁路缓存"模式

我在多个项目里观察到的规律是:如果一个切面里你同时写了@Before、@AfterReturning、@AfterThrowing三个注解来处理同一件事,那大概率可以用一个@Around替代,而且逻辑更紧凑、调用顺序更可控。不是说简单通知没有价值,而是@Around本身是它们的超集,工程实践中过度拆分反而增加了理解成本。

四、跟我一步步写一个完整的环绕通知切面:日志加耗时监控的实战

这里给出一个可以在Spring Boot项目里直接套用的完整示例。先准备一个接口和实现:

public interface OrderService { OrderVO getOrder(Long orderId); void updateOrder(OrderUpdateDTO dto); } @Service public class OrderServiceImpl implements OrderService { @Override public OrderVO getOrder(Long orderId) { // 模拟耗时操作 try { Thread.sleep(30); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return new OrderVO(orderId, "订单" + orderId); } @Override public void updateOrder(OrderUpdateDTO dto) { // 模拟更新 System.out.println("更新订单:" + dto.getOrderId()); } }

4.1 定义切面与切点表达式

@Slf4j @Component @Aspect public class AroundLogAspect { @Around("execution(* com.example.order.service..*.*(..))") public Object logAround(ProceedingJoinPoint pjp) throws Throwable { // 1. 获取方法签名与参数 String methodName = pjp.getSignature().toShortString(); Object[] args = pjp.getArgs(); // 2. 前置处理:打印入参 log.info("[{}] 开始执行,入参:{}", methodName, Arrays.toString(args)); // 3. 调用目标方法 long startTime = System.currentTimeMillis(); Object result; Throwable error = null; try { result = pjp.proceed(); return result; } catch (Throwable t) { error = t; throw t; } finally { long cost = System.currentTimeMillis() - startTime; if (error != null) { log.error("[{}] 执行异常,异常信息:{},耗时:{}ms", methodName, error.getMessage(), cost); } else { log.info("[{}] 执行完成,返回结果:{},耗时:{}ms", methodName, result, cost); } } } }

这里有一个非常关键的处理:把pjp.proceed()放到try-finally块里。为什么?因为如果目标方法抛异常了,你直接写后面的日志打印代码是执行不到的,日志就丢了。用try-finally可以保证"无论正常还是异常,耗时的统计都能落地"。而先把异常catch住再throw t,是为了在日志里拿到异常信息,同时又不把原始异常吞掉,让上层调用方依然能够感知到错误。

4.2 用自定义注解做细粒度控制

execution表达式适合全局横切,比如拦截service包下所有方法。但业务里经常只想对某些特定方法做日志,或者想在注解里传业务参数。这时候我习惯用"自定义注解加@Around"的组合。

先定义一个注解:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface OperationLog { String businessType() default "OPERATION"; String description() default ""; }

然后在service方法上打上注解:

@Service public class OrderServiceImpl implements OrderService { @OperationLog(businessType = "ORDER_QUERY", description = "查询订单") @Override public OrderVO getOrder(Long orderId) { // 业务代码... return new OrderVO(orderId, "订单" + orderId); } }

切面里通过@annotation()绑定注解参数:

@Slf4j @Component @Aspect public class OperationLogAspect { @Around("@annotation(operationLog)") public Object operateAround(ProceedingJoinPoint pjp, OperationLog operationLog) throws Throwable { String businessType = operationLog.businessType(); String description = operationLog.description(); log.info("[操作日志] 业务类型: {}, 描述: {}, 方法: {}", businessType, description, pjp.getSignature()); long start = System.currentTimeMillis(); Object result = pjp.proceed(); long cost = System.currentTimeMillis() - start; log.info("[操作日志] 业务类型: {},执行时长: {}ms", businessType, cost); return result; } }

注意,只有这个注解真正标注在方法上,且方法被Spring的代理对象调用时,这个环绕通知才会生效。注解如果标在类上,这种写法是匹配不到的,需要用@within或者类级别的切点表达式。这个细节很容易把人绕晕。

五、Spring AOP代理机制与环绕通知的"三个看不见的坑"

5.1 为什么同类内部调用会绕过环绕通知

先说结论:Spring AOP基于代理,环绕通知只有在调用方持有代理对象时才会生效。如果方法内部直接this.xxx()来调用另一个方法,这个this是原始目标对象,不是代理对象,环绕通知直接失效。

看个具体例子:

@Service public class OrderServiceImpl implements OrderService { @Override public OrderVO getOrder(Long orderId) { // 直接调用本类另一个方法 this.checkPermission(orderId); return new OrderVO(orderId, "订单" + orderId); } @OperationLog(businessType = "ORDER_CHECK", description = "校验权限") public void checkPermission(Long orderId) { // 权限校验逻辑 } }

如果先调getOrder,再看日志,会发现checkPermission上的@OperationLog环绕通知完全不打印。因为this.checkPermission()执行的是原始对象的方法,走不进代理对象,切面自然"被跳过"。

这个问题和Spring事务注解不生效的原理完全一致,网上讨论"spring三级缓存原理"经常和这个问题一起出现。三级缓存在Bean创建早期把早期引用暴露出去,核心目的是解决代理对象和循环依赖的冲突,但不管三级缓存怎么运作,基于JDK动态代理或CGLIB生成的代理对象,都只能在外面包一层。内部自调用永远是绕过代理的。

解决方式有三种:

第一种,把方法拆分到另一个Service里,通过注入的代理对象调用。我最推荐这种,职责也更清晰。

第二种,从Spring容器里重新拿到代理对象。用@Autowired注入自身:

@Service public class OrderServiceImpl implements OrderService { @Autowired private OrderServiceImpl self; @Override public OrderVO getOrder(Long orderId) { self.checkPermission(orderId); return new OrderVO(orderId, "订单" + orderId); } @OperationLog(...) public void checkPermission(Long orderId) { // 权限校验 } }

但要小心循环依赖,如果构造器注入自身,Spring Boot 2.6之后默认禁止循环依赖,直接启动失败。所以要么用@Lazy延迟注入,要么干脆拆类。

第三种,使用AopContext.currentProxy(),前提是启动类或配置类上加了@EnableAspectJAutoProxy(exposeProxy = true):

@Override public OrderVO getOrder(Long orderId) { OrderService proxy = (OrderService) AopContext.currentProxy(); proxy.checkPermission(orderId); return new OrderVO(orderId, "订单" + orderId); }

这种方式代码最简洁,但AopContext内部用的是ThreadLocal,意味着你必须在切面代理的调用链内使用,拿到代理对象后不要另起线程去用,否则会丢。

5.2 多环绕通知的执行顺序:@Order 与嵌套调用

当项目里有多个环绕通知同时切到同一个方法时,它们的执行顺序不是你代码里写的先后顺序,而是由各自切面的@Order值决定,数值越小越靠外。

例如有两个切面,一个做权限校验,一个做操作日志:

@Aspect @Component @Order(1) public class SecurityAspect { @Around("execution(* com.example.order.service.*.*(..))") public Object securityAround(ProceedingJoinPoint pjp) throws Throwable { log.info("权限切面进入"); Object result = pjp.proceed(); log.info("权限切面退出"); return result; } } @Aspect @Component @Order(2) public class LogAspect { @Around("execution(* com.example.order.service.*.*(..))") public Object logAround(ProceedingJoinPoint pjp) throws Throwable { log.info("日志切面进入"); Object result = pjp.proceed(); log.info("日志切面退出"); return result; } }

执行顺序是:权限切面进入 -> 日志切面进入 -> 目标方法执行 -> 日志切面退出 -> 权限切面退出。

也就是说,@Order值越小越先进入,也越晚退出,看起来像一层层洋葱。这在实战里非常重要。比如你希望"如果权限校验不通过,日志里就不要有这次调用记录",那权限切面的@Order必须比日志切面小(靠外),这样权限一旦短路,日志切面的proceed()就不会执行。

5.3 环绕通知与事务切面的交互:异常传播不能断

在Spring Boot项目里,事务通常也是用AOP实现的。当你自己写了一个环绕通知,需要格外小心异常处理对事务的影响。

看这段有问题的代码:

@Around("execution(* com.example.order.service.*.*(..))") public Object noRollbackAround(ProceedingJoinPoint pjp) throws Throwable { try { return pjp.proceed(); } catch (Exception e) { log.error("捕获异常,但不抛出", e); return null; // 把异常吞掉了 } }

如果目标方法里抛了一个RuntimeException,这个环绕通知把异常接住后直接返回null,事务切面看到的返回值是"正常完成",于是不会回滚。数据就处于一种"事务层以为成功了,实际业务方法根本没执行完"的中间状态。

所以,环绕通知里如果需要捕获异常做降级,务必要想清楚事务边界。要么你明确知道自己降级后的数据是一致的,要么在catch块里把异常重新抛出去:

@Around("execution(* com.example.order.service.*.*(..))") public Object correctAround(ProceedingJoinPoint pjp) throws Throwable { try { return pjp.proceed(); } catch (Exception e) { log.error("记录日志后重新抛出", e); throw e; } }

这是我在多个项目里反复强调的一条铁律:环绕通知尽量不要吞异常,除非你非常清楚自己在做什么。

5.4 切面方法自身的递归问题

还有一个容易遇到的现象:环绕通知里如果还调用了同类的其他被切方法,而切点表达式范围比较大,可能触发切面方法再次执行。比如:

@Around("execution(* com.example.order.service.*.*(..))") public Object around(ProceedingJoinPoint pjp) throws Throwable { Object result = pjp.proceed(); otherService.doSomething(); // 这个方法也可能被切到 return result; }

如果otherService.doSomething()也匹配切点,那么环绕通知会再执行一次,形成嵌套。如果是串行依赖还好,要是互相调用,可能栈溢出。解决办法就是设计切点范围时尽量精确,别一杆子把所有service方法都圈进来,或者在做后置逻辑时避免调用会命中同一切点的其他方法。

六、环绕通知在真实业务里的四个典型套用模板

6.1 模板一:接口幂等与重复提交拦截

秒杀、下单这类接口最容易出现用户重复点击。用环绕通知可以做一个相对粗糙但非常有效的幂等拦截:

@Aspect @Component public class IdempotentAspect { @Around("@annotation(idempotent)") public Object idempotentAround(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable { // 生成幂等键:简单示例用"方法名+第一个参数" String key = pjp.getSignature().toShortString() + ":" + Arrays.toString(pjp.getArgs()); boolean firstRequest = tryLock(key); if (!firstRequest) { // 直接返回重复提交的提示 return Result.fail("请勿重复提交"); } try { return pjp.proceed(); } finally { // 注意:如果是长时间执行的异步任务,这里就不能立即释放锁,需要根据业务调整 releaseLock(key); } } }

这里的核心价值就是环绕通知独有的"短路"能力。请求第一次进来,加锁然后执行;第二次请求还没等目标方法执行,环绕通知就把它拦在外面了。

6.2 模板二:失败重试

调用外部RPC接口或者操作数据库偶尔会碰到瞬时抖动,在允许范围内做几次重试,是提高系统鲁棒性的常见手段。这个需求用其他通知类型写不出来,只有环绕通知能做:

@Aspect @Component public class RetryAspect { @Around("@annotation(retryable)") public Object retryAround(ProceedingJoinPoint pjp, Retryable retryable) throws Throwable { int maxAttempts = retryable.attempts(); Throwable lastError = null; for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { return pjp.proceed(); } catch (Throwable t) { lastError = t; log.warn("第{}次执行失败,错误:{}", attempt, t.getMessage()); // 最后一次不再休眠 if (attempt < maxAttempts) { Thread.sleep(retryable.intervalMillis()); } } } throw lastError; } }

注意重试要设置上限,并且对非幂等操作要格外谨慎。不是所有接口都适合自动重试,比如"扣款"这类操作一旦重复执行就是事故。这里只是提供一个模板,具体业务要自己评估重试风险。

6.3 模板三:耗时监控与慢接口告警

这个模板我几乎在每个Spring Boot项目里都会用。不需要引入额外的监控SDK,一个环绕通知就能把慢接口统计出来:

@Aspect @Component public class SlowMethodMonitorAspect { private static final long SLOW_THRESHOLD = 500L; @Around("execution(* com.example.order.controller..*.*(..))") public Object monitorAround(ProceedingJoinPoint pjp) throws Throwable { long start = System.currentTimeMillis(); Object result = pjp.proceed(); long cost = System.currentTimeMillis() - start; if (cost > SLOW_THRESHOLD) { String method = pjp.getSignature().toLongString(); log.warn("[慢接口] {} 耗时 {}ms,入参 {}", method, cost, Arrays.toString(pjp.getArgs())); } return result; } }

比网上很多到处埋点统计耗时的方案要干净得多。而且因为环绕通知能拿到入参,慢接口排查时可以直接从日志里看到是哪个参数组合导致性能劣化。

6.4 模板四:统一异常兜底返回

在对外提供接口的服务里,最常见的需求就是"不要直接把异常堆栈抛给前端"。环绕通知可以把异常捕住并转为统一的结果结构:

@Aspect @Component public class ExceptionTranslatorAspect { @Around("@within(org.springframework.web.bind.annotation.RestController)") public Object exceptionAround(ProceedingJoinPoint pjp) throws Throwable { try { return pjp.proceed(); } catch (BizException e) { return Result.fail(e.getCode(), e.getMessage()); } catch (Exception e) { log.error("系统异常", e); return Result.fail(500, "系统繁忙"); } } }

这个用@within(RestController)来匹配类上标注了@RestController的所有Controller方法,比一个个方法加try-catch干净多了。但要注意,如果Controller里已经有自己的try-catch,它们会先于环绕通知处理异常,所以兜底逻辑要根据项目实际整理边界。

七、环绕通知里你不得不注意的细节清单

写到这里,把我在实战中积累的关于环绕通知的经验整理成一个清单,都是踩过坑或者帮别人排过坑才总结出来的。

7.1 方法签名必须抛 Throwable

ProceedingJoinPoint.proceed()声明抛出的异常类型是Throwable,所以环绕通知的方法签名必须写throws Throwable。如果只写throws Exception,编译就直接报错。我见过有人为了省事把异常catch掉再包装成RuntimeException,其实没必要,直接抛Throwable就完事了。目标方法如果抛出受检异常,你这里不抛Throwable也没法如实把异常传递出去。

7.2 环绕通知方法返回值类型一定是 Object

因为被拦截的方法返回值类型五花八门,统一用Object接收,再原样返回,Spring AOP会自动做适配。如果你图省事把环绕通知方法声明成void,那问题大了:目标方法的返回值会直接丢失,调用方拿到一个null。这个坑在项目代码review时经常遇到,非常隐蔽。

7.3 入参记录要防止序列化问题

很多切面在打日志时直接把Arrays.toString(pjp.getArgs())拼出来。如果入参对象里嵌套了数据库连接、IO流或者循环引用,toString可能触发巨大的输出甚至StackOverflow。我在实际项目中遇到过一次:一个对象内部有List<File>,打日志时直接把整个字节流打印出来了,日志文件瞬间膨胀。后来统一改成了"只打印参数的简短摘要",比如对象只打印主键字段和关键业务字段,或者用Jackson的toString限制深度。

7.4 环绕通知方法不要设成 private

环绕通知方法是给Spring AOP框架调用的,框架需要能访问到它。如果被切面的方法是private,运行时会出现访问异常。很多经验不足的同学把切面方法按习惯写成private,结果启动时一切正常,一触发切面就报错。实际上Spring会尝试用反射调用你的切面方法,私有方法虽然有setAccessible处理,但不同版本的Spring行为不完全一致,最稳妥就是写成public。

7.5 自己写切面时,切点表达式尽量"窄"一点

很多人一上来就是execution(* com.example..*.*(..)),这种超大范围切点会把所有方法都圈进来,轻则日志爆炸,重则陷入循环依赖或递归调用。我建议优先用@annotation(自定义注解)或者execution(* com.xxx.service..*.*(..))这种有边界的写法。不是说大范围不能用,而是切点越宽,你要考虑的副作用越多,从工程角度讲,窄切点更好维护。

7.6 环绕通知与@Transactional的先后顺序

多个切面的执行顺序用@Order控制。如果你希望"开启事务之后再执行自己的业务增强",那么事务切面的@Order要小于你的环绕通知。如果你希望"先做权限校验,再进入事务",那权限切面的@Order要小于事务切面。这个问题没有标准答案,完全取决于你项目的分层约定,但在Spring Boot里默认不带@Order的切面执行的顺序是不确定的,需要显示设置。

八、从运行原理角度再深入一层:JDK动态代理与CGLIB对环绕通知的影响

最后补一个偏原理但实战经常遇见的细节。Spring AOP默认对接口使用JDK动态代理,对类使用CGLIB代理。这两种代理方式对环绕通知的表现没有任何差别,因为都是通过过代理对象拦截方法调用,但有一个区分点需要记住:

  • 如果你的Service类只实现了接口,但切点表达式写的是类名,比如execution(* com.example.order.service.OrderServiceImpl.getOrder(..)),在JDK动态代理模式下,这个切点可能匹配不到,因为代理对象是接口实现,不是类本身。
  • 在Spring Boot 2.x及以上版本,spring.aop.proxy-target-class默认是true,也就是优先使用CGLIB。CGLIB基于继承生成子类,对类方法做拦截,所以大多数情况下你按类名写切点也能生效。

但CGLIB也有自己的限制,被代理的类不能是final的,被代理的方法不能是final或static的。如果项目中有人把Service类定义成final,那么环绕通知会莫名其妙地失效,而且启动时不一定有明确报错。遇到这种问题,先看类修饰符,再看方法修饰符,基本能排查掉一大半。

另外,当我们在一台机器上运行多个Spring Boot应用,AOP相关的问题往往很难从表象判断。我的习惯是启动时加上-Dspring.aop.proxy-target-class=true显式指定代理方式,然后在配置里打印代理对象类型,确认代理是否生成正确。加一行:

@PostConstruct public void checkProxy() { log.info("OrderService proxy type: {}", orderService.getClass().getName()); }

如果看到类名里带$$EnhancerBySpringCGLIB或者$Proxy,说明代理生效;如果打印出来是普通实现类,说明切面根本没挂上去,这时候优先检查切面类有没有被Spring扫描到,有没有加@Aspect和@Component注解。

九、最后再分享一个环绕通知的调试技巧

可能很多人不知道,环绕通知里那个ProceedingJoinPoint对象在调试时能给你很多信息。它不只是有proceed()方法,还有:

  • getSignature():拿到方法签名,里面有方法名、参数类型、返回类型、声明类。
  • getTarget():拿目标对象(原始对象,不是代理对象)。
  • getThis():拿代理对象本身。
  • getArgs():拿参数列表。
  • getKind():返回连接点类型,通常是"method-execution"。

如果用断点调试,看这几个字段就能快速判断当前拦截的是哪个类的哪个方法,参数是什么,目标对象和代理对象是否正常。这比在代码里加无数log效率高得多。

我还习惯在公共切面里加一个内部开关:

if (enabled) { return pjp.proceed(); }

这个开关用配置中心控制,线上如果切面出问题,不用重新发布就能临时关闭。第一次在真实项目里用这个开关时,正好碰上日志量激增,团队有惊无险地隔离开关,事后定位到是某个请求把大头参数打进了日志。这个经验后来我写进了团队规范里:所有生产环境的切面,默认都要有开关注入。

环绕通知这个技术点,说难不难,说简单也不简单。难在它的机制灵活,所以对"你替框架做了决定"这件事,每一步都要想清楚后果;简单在它只有一句proceed(),把这一句吃透了,后面的花活都围绕"什么时候调、调几次、传什么参数进去、返回值怎么处理"展开。希望这篇文章能帮你把Spring AOP最锋利的一把刀用得顺手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询