news 2026/10/10 20:11:00

Spring Boot 环绕通知实战:接口耗时统计、幂等防重与验签

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot 环绕通知实战:接口耗时统计、幂等防重与验签

如果你在 Spring Boot 项目里做过接口耗时统计、防重复提交、第三方调用签名校验,大概率经历过同一个噩梦:Controller 里复制粘贴几十行一样的逻辑,改一个需求,就要全局搜索替换。Spring AOP 的环绕通知,就是用来终结这种“模板代码满天飞”的利器。它把横切逻辑集中到一个切面里,业务方法保持纯粹,后续要调整规则也只需改一处。

我用环绕通知处理过日志记录、幂等防重、开放接口验签、耗时告警这些典型场景,也踩过自调用失效、异常被吞、切面重复执行之类的坑。这篇文章就把 Spring Boot 中环绕通知的完整用法、业务落地代码和避坑经验一次性讲清楚,适合已经会用 Spring Boot 写接口、但想给代码做减法的人。

1. 环绕通知是什么,为什么实际业务里总是它

1.1 先理清 AOP 的五种通知类型

很多人能把“环绕通知”四个字说出来,但真要解释它和其他通知的区别,反而会含糊。Spring AOP 一共提供了五种通知,先拉个表对比一下:

通知类型注解执行时机实际痛点
前置通知@Before目标方法执行前拿不到方法返回值,异常时后续逻辑全断
后置通知@After目标方法执行后(不论是否异常)同样拿不到返回值
返回后通知@AfterReturning目标方法正常返回后能拿返回值,但异常场景下不执行
异常后通知@AfterThrowing目标方法抛出异常后只能看异常,不能处理返回值
环绕通知@Around目标方法执行前后全程所有时机自己掌控

前置、后置、返回后、异常后这四种通知,本质上都是“观察者”。它们像站在方法旁边的摄像机,只能记录和反应,不能改变事件本身。而环绕通知是“控制者”,它把目标方法的调用过程整个包裹起来,方法执不执行、执行时传什么参数、返回什么结果、抛出异常后怎么处理,全部由你决定。

在实际业务开发里,我百分之九十的场景都直接选环绕通知,因为它的能力覆盖了其他四种通知的合集,还能额外做到两点:

  • 修改目标方法的返回值,比如把敏感信息脱敏后返回,或者失败时返回一个统一的兜底结果。
  • 吞掉异常或者包装异常,让调用方不用面对底层异常细节。

1.2 环绕通知的设计思想:把方法执行权握在自己手里

环绕通知的底层思路是“代理模式 + 模板方法模式”。Spring 容器在启动时,会为匹配切点表达式的 Bean 生成代理对象。外部调用这个 Bean 时,真正执行顺序是:调用代理对象的通知方法,通知方法内部再通过ProceedingJoinPoint.proceed()触发原始方法。

你可以把它类比成高铁进站闸机:@Around是安检员,proceed()是放行闸门。安检员有权拦下乘客做额外检查,也可以放行让乘客上车,甚至可以把一个乘客的票改成另一个乘客的,只要规则允许。目标方法就是那个乘客,它根本感知不到安检员的存在,乘客只知道自己从进站到上车这段路变得顺畅了。

这种设计带来一个关键特性:Spring AOP 根本不需要修改业务类的源码,而是运行时生成代理,所以目标类完全无侵入。这也是它比“写一个公共工具类在业务方法里手动调用”更优雅的根本原因——业务方法里一行横切代码都不用写。

1.3 什么场景非环绕通知不可

一句话总结:只要你需要对“方法调用全过程”进行统一控制,就只能用环绕通知。

典型场景我列几个:

  1. 接口耗时统计:必须在方法执行前记录开始时间,执行后计算差值,异常分支也要算,这天然需要包裹整个过程。
  2. 接口幂等防重:要在方法执行前加锁、拦截,执行成功后再决定是否释放锁,只靠 @Before 和 @After 分开写很容易丢上下文。
  3. 统一异常处理与返回值包装:需要 catch 所有异常,然后统一封装成固定结构返回,只有环绕通知能拦截异常并替换返回值。
  4. 带重试机制的方法调用:proceed 失败后,业务代码自己决定是否再次调用 proceed,这也是环绕通知的独有玩法。
  5. 第三方开放接口签名校验:验签通过才把请求交给目标方法,验签失败直接返回错误码,不需要让业务代码参与。

你自己回顾一下项目里的那些“非业务逻辑”:性能日志、操作记录、权限校验、接口限流、幂等控制,哪一个不是包裹在方法调用前后?这正是环绕通知存在的价值。

2. Spring Boot 里最快落地的一版环绕通知

2.1 引入依赖与搭建环境

Spring Boot 默认不会给你装 AOP 相关依赖,需要手动加spring-boot-starter-aop。这个依赖会带着aspectjweaver和spring-aop一起进来,足够支撑注解方式使用切面。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>

如果你用的是 2.7.x 或 3.x 的 Spring Boot,这个依赖坐标不需要指定版本,交给父工程统一管理就行。从实践来看,Spring Boot 3.x 里这套 AOP 用法和 2.x 几乎没差别,下面的代码在两个大版本下都能直接跑。

这里有个容易误会的地方:Spring Boot 的 AOP 自动配置默认是开启的,正常情况下你不需要写@EnableAspectJAutoProxy。只有当你自己改了条件配置spring.aop.auto=false,或者引入的依赖组合比较特殊时,才需要显式加这个注解开启代理。开发环境用 IntelliJ IDEA 社区版、Eclipse 还是命令行 Maven 都完全不影响 AOP 功能,它纯粹是 Spring 容器层面的东西。

2.2 一个最小可运行的环绕通知切面

先给你一个打开就能跑的最小示例,目标是把controller包下所有方法执行耗时都打印出来。

package com.example.demo.aspect; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; @Slf4j @Aspect @Component public class TimeCostAspect { @Around("execution(* com.example.demo.controller..*.*(..))") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); try { return joinPoint.proceed(); } finally { long cost = System.currentTimeMillis() - start; log.info("[耗时切面] {}.{}() 耗时 {} ms", joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName(), cost); } } }

@Aspect告诉 Spring 这是个切面类,@Component把它注册成 Bean,@Around里的表达式决定了它拦截哪些方法。整个逻辑非常直观:先记录开始时间,调用proceed()真正执行目标方法,finally块保证不论正常返回还是抛出异常,耗时都能被算出来。

注意一个细节:around方法必须声明throws Throwable。因为ProceedingJoinPoint.proceed()会向上抛出目标方法的任何异常,你不允许编译器通过的话,代码都写不出来。

2.3 ProceedingJoinPoint 能给你什么信息

ProceedingJoinPoint是环绕通知里最核心的参数,千万别把它当成摆设。我梳理一下它常用方法的用途:

方法返回内容典型用途
proceed()Object触发目标方法执行,可以拿到返回值
proceed(Object[] args)Object用新的参数列表触发目标方法,适合做参数修改/重绑
getSignature()MethodSignature拿到方法签名,包括方法名、类全限定名、参数名
getArgs()Object[]拿到本次调用入参
getTarget()Object拿到目标对象实例,一般不建议直接用
getThis()Object拿到当前代理对象,可用它解决自调用问题

实战中我几乎都会用getSignature()打印“哪个类哪个方法被切了”。因为切面表达式可能匹配一个包下的几十个方法,没有这个信息,你排查问题时会疯掉。

有一个小坑提前说:joinPoint.getArgs()返回的是 Object 数组,如果方法没有参数,这个数组长度是 0,不是 null。取参数时不要直接args[0],先判断长度。

再补充一个带参调用proceed的场景。有时候我们需要修改入参再放行目标方法,比如统一给请求参数补一个公共字段,代码可以这样写:

Object[] args = joinPoint.getArgs(); if (args.length > 0 && args[0] instanceof CommonRequest) { CommonRequest request = (CommonRequest) args[0]; request.setTraceId(UUID.randomUUID().toString().replace("-", "")); } return joinPoint.proceed(args);

这个能力只有环绕通知有,其他通知想改参数基本要配合参数绑定,绕得很。我在处理老系统接口兼容时经常用这一招:老接口少了一个新字段,我不改业务代码,只在切面里给参数补上默认值。

2.4 切点表达式:控制你的影响范围

切点表达式是环绕通知的“瞄准镜”。表达式写得太宽,会把系统内部方法、定时任务、框架回调全都变成打击目标;写得太窄,业务方法又切不进来了。

常见表达式类型我放在一个表里对比:

切点类型示例适用场景
executionexecution(* com.example.service..*.*(..))按方法访问修饰符、类名、方法名匹配,最常用
annotation@annotation(com.example.annotation.Demo)只匹配加了指定注解的方法,精准投资
withinwithin(com.example.controller.*)按类类型匹配,粒度比 execution 粗
beanbean(orderService)按 Bean 名称匹配,适合快速限定单个服务
argsargs(com.example.dto.OrderDTO)按入参类型匹配

execution表达式里几个符号的含义要记牢:

  • *表示任意内容,放在方法返回值位置表示任意返回类型。
  • ..表示任意子包或任意数量参数,放在包名后缀或参数位置。
  • (..)中的两个点表示任意参数数量和类型。

举个例子:execution(* com.example.demo.service..OrderService.*(..))表示匹配service任意子包下 OrderService 类里的所有方法,不管返回值是什么、参数是什么。

在 Spring Boot 项目里,我建议优先用@annotation或者“包范围 + @annotation”的组合。直接execution(* com.example.controller..*.*(..))虽然简单,但容易把白名单、回调接口也切进去,后续你只想去掉某些方法时反而要重新写表达式。

3. 三个掉头就走的业务场景,直接抄作业

3.1 场景一:统一接口耗时与日志记录

这是环绕通知最经典的入门场景。需求是:项目里所有 Controller 的接口,都要自动打印“哪个接口被调用、入参是什么、耗时多少、返回结果是什么”。

我给它定的规范是:代码侵入为零,日志量可控,参数自动脱敏。千万注意,直接把参数对象打印出来可能踩大坑——对象里的密码、身份证号、手机号会原样进日志,这在安全审计节骨眼上是严重隐患。所以切面里我放了简单的脱敏工具方法。

package com.example.demo.aspect; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; @Slf4j @Aspect @Component public class ApiLogAspect { private final ObjectMapper objectMapper = new ObjectMapper(); @Around("execution(public * com.example.demo.controller..*.*(..))") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { String className = joinPoint.getSignature().getDeclaringTypeName(); String methodName = joinPoint.getSignature().getName(); long start = System.currentTimeMillis(); Object result; try { result = joinPoint.proceed(); log.info("[API入口] {}.{} 入参={} 返回={} 耗时={}ms", className, methodName, buildParamText(joinPoint.getArgs()), objectMapper.writeValueAsString(result), System.currentTimeMillis() - start); return result; } catch (Exception e) { log.error("[API异常] {}.{} 入参={} 异常={} 耗时={}ms", className, methodName, buildParamText(joinPoint.getArgs()), e.getMessage(), System.currentTimeMillis() - start); throw e; } } private String buildParamText(Object[] args) { if (args == null || args.length == 0) { return "无"; } try { return objectMapper.writeValueAsString(args); } catch (Exception e) { return "参数序列化失败"; } } }

这套切面里有个关键选择:异常分支我不吞,而是先打印完整现场日志,然后重新把异常抛出去。原因很简单:接口日志切面是“观察者”,不是“决策者”,异常最终怎么处理应由全局异常处理器统一搞定。如果你在这里捕获后直接返回一个错误响应体,后续想加全局异常处理、想统计错误率,都会变得混乱。

实际落地时还有几个细节值得注意:

  • ObjectMapper用局部实例没问题,但如果你项目里已经注入了 Spring 管理的 ObjectMapper,建议注入而不是 new,能继承全局配置(比如 Java 8 日期时间序列化配置)。
  • 打印响应结果时,如果返回值里有HttpServletResponse这类对象,直接序列化会报错,需要在代码里把javax.servlet.http.*类型的入参过滤掉。我这里的buildParamText没体现,你写的时候记得判断一下。
  • 日志切面自身不要执行业务逻辑,比如消息推送、远程调用,否则接口性能会被切面拖垮。

3.2 场景二:接口幂等防重复提交

防重提交是我个人觉着环绕通知“物超所值”的一个场景。以前没切面时,每个提交接口都要手写一段 Redis 判断代码,而且很容易漏掉。后来我用“自定义注解 + 环绕通知”做了一套通用防重,业务流程里只需一个注解。

先定义幂等注解:

package com.example.demo.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Idempotent { /** 幂等 key 的前缀,建议带上业务场景,例如 order:submit */ String prefix(); /** key 过期时间,单位秒,默认 10 秒 */ int expireSeconds() default 10; }

然后写切面。核心逻辑是利用 Redis 的SET NX EX语义实现“同一个 key 只能成功写入一次”。第二次请求带着相同 key 来时,setIfAbsent会返回 false,直接判定为重复提交。

package com.example.demo.aspect; import com.example.demo.annotation.Idempotent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import java.util.UUID; import java.util.concurrent.TimeUnit; @Slf4j @Aspect @Component @RequiredArgsConstructor public class IdempotentAspect { private final StringRedisTemplate stringRedisTemplate; /** 注解里配的前缀 + 请求方唯一 ID,拼成真正的 Redis key */ private static final String REQUEST_ID_HEADER = "X-Request-Id"; @Around("@annotation(idempotent)") public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable { String requestId = resolveRequestId(); if (requestId == null) { // 消息头里拿不到 requestId 时,按“强制要求”处理 throw new IllegalArgumentException("缺少幂等请求头 X-Request-Id"); } String key = idempotent.prefix() + ":" + requestId; Boolean success = stringRedisTemplate.opsForValue() .setIfAbsent(key, UUID.randomUUID().toString(), idempotent.expireSeconds(), TimeUnit.SECONDS); if (Boolean.TRUE.equals(success)) { try { return joinPoint.proceed(); } catch (Exception e) { // 业务失败时是否删除 key 是一个产品决策。默认保留到过期,避免用户快速重试把错误请求顶掉 throw e; } } log.warn("[幂等拦截] key={} 重复请求,已被拦截", key); // 这里抛异常还是返回兜底对象,取决于你定义的错误响应方式 throw new RuntimeException("重复提交,请稍后再试"); } private String resolveRequestId() { // 简化实现:从请求头获取,你的项目里可以直接引入 RequestContextHolder return "demo-request-id"; } }

这个切面真正“环绕”的体现在于:加锁动作在proceed()之前,锁的释放或不释放由切面决定,业务方法全程无感知。防重逻辑是典型的不适合拆成 @Before + @After 的场景——因为两次通知之间无法共享同一个局部变量 key。

很多团队做防重时有个纠结:什么时候删除 Redis key?我踩过的坑告诉你,别在执行成功后立刻删。假如你的业务方法执行成功了,但接口响应在网络传输时超时,客户端自动重试,这时 key 已经删了,重试请求会再次执行业务逻辑,造成数据重复。正确做法是让 key 自然过期,过期时间给一个稍微大于接口平均耗时的时间。

3.3 场景三:第三方开放接口签名校验

很多公司会遇到“对外开放接口”这件事。项目里的开放接口可能单独部署一个服务,也可能混在当前系统里。如果你问我是单放服务还是在原系统模块里,我的建议是:量大、需要隔离安全风险就单独开服务,但代码组织上两者都可以先用切面统一验签。这样一来,未来把接口模块拆成独立服务时,验签逻辑直接搬走,Controller 一行都不用改。

我设计开放接口时,给第三方调用方分配一个appId和appSecret。调用方请求时带上三个参数:appId、timestamp、sign。签名规则先约定为:MD5(appId + timestamp + appSecret)。

切面要做的事:

  1. 从请求里取这三个参数。
  2. 用appId查到对应的appSecret(实际项目里会放到配置中心或者缓存里)。
  3. 按同样规则计算签名,和请求里的sign比对。
  4. 校验timestamp是否在 5 分钟内,防止重放攻击。
package com.example.demo.aspect; import com.example.demo.annotation.OpenApiSign; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest; import java.util.concurrent.ConcurrentHashMap; @Slf4j @Aspect @Component @RequiredArgsConstructor public class OpenApiSignAspect { /** 实际项目应从配置中心/数据库读取,这里模拟 */ private static final ConcurrentHashMap<String, String> APP_SECRET_MAP = new ConcurrentHashMap<>(); static { APP_SECRET_MAP.put("1001", "abc-secret"); APP_SECRET_MAP.put("1002", "def-secret"); } @Around("@annotation(openApiSign)") public Object around(ProceedingJoinPoint joinPoint, OpenApiSign openApiSign) throws Throwable { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes == null) { throw new RuntimeException("非法请求上下文"); } HttpServletRequest request = attributes.getRequest(); String appId = request.getHeader("appId"); String timestamp = request.getHeader("timestamp"); String sign = request.getHeader("sign"); if (!StringUtils.hasText(appId) || !StringUtils.hasText(timestamp) || !StringUtils.hasText(sign)) { throw new RuntimeException("缺少签名参数"); } String secret = APP_SECRET_MAP.get(appId); if (secret == null) { throw new RuntimeException("未知的appId"); } String serverSideSign = md5(appId + timestamp + secret); if (!serverSideSign.equals(sign)) { log.warn("[开放接口验签失败] appId={}", appId); throw new RuntimeException("签名校验不通过"); } long diff = Math.abs(System.currentTimeMillis() - Long.parseLong(timestamp)); if (diff > 5 * 60 * 1000) { throw new RuntimeException("请求已过期"); } return joinPoint.proceed(); } private String md5(String input) { // 正常写一个工具方法,这里用 JDK 自带 MessageDigest + 十六进制转换即可 return String.valueOf(input.hashCode()); } }

这里我简化了 md5 实现,实际项目里用DigestUtils.md5DigestAsHex或者引入commons-codec都行。但环切的思路值得借鉴:所有开放接口的验签集中到一个切面,新增开放接口时什么都不用关心,只要加上@OpenApiSign注解。这个方案比在基类 Controller 里写 init 方法干净多了——有些团队会用继承 BaseController 来做验签,但 Java 是单继承,如果以后 Controller 想继承别的东西,就卡死了。AOP 没有这个约束。

你要注意验签逻辑不要放在业务代码里,否则其他人开发新开放接口时很容易漏掉验签,接口裸奔在公网上。用注解 + 环绕通知,从机制上逼迫开发者必须加上验签。

4. 高频错误与排查思路(踩坑实录)

4.1 切面不生效,九成是这五个原因

环绕通知写起来不难,难的是“我写了切面但就是不执行”。我把大大小小项目里遇到的切面失效案例汇总一下,按出现频率排序。

第一个:方法内部调用this.xxx(),切面不生效。Spring AOP 是基于代理实现的,当外部调用一个 Bean 的方法时,进入的是代理对象,所以环绕通知能生效。但当这个类内部调用它自己的另一个方法时,用的是this关键字,指向的是原始对象而不是代理对象,所以切面就绕过去了。

解决方式有三种:把内部调用改为通过注入自身的代理对象调用(配合@Lazy避免循环依赖);或者把需要被切面的方法抽到另一个 Spring Bean 里;再或者使用AopContext.currentProxy(),但前提是你开启了exposeProxy=true。我后面会再细讲。

第二个:切点表达式写错。最常见的是execution表达式里的包名大小写写错、..和*的位置不对。我排查这类问题时,会先写一个特别宽的表达式比如execution(* com.example.demo.controller..*.*(..)),确认能切进去后,再逐步收紧范围。如果最宽的都能匹配,但签名里又限制条件时,就要检查方法访问修饰符——execution默认只匹配 public 方法,除非把*放在第一位通配修饰符。

第三个:切面类没有被 Spring 扫描到。这个错误很隐蔽,因为团队里一旦有人把切面类放在业务包外面,而启动扫描又指定了scanBasePackages,切面类就可能完全不会被加载。验证方法很简单:启动日志里搜Aspect,或者直接在切面构造方法里打断点,一次没进来就说明没加载。

第四个:类或方法是 final 修饰的。Spring Boot 2.x 之后 AOP 默认走 CGLIB,而 CGLIB 是基于继承生成子类代理的,final 类无法被子类化。final 方法无法被重写。虽然 Spring Boot 2.x 默认已经使用 CGLIB,但遇到 final 定义,代理生成时同样会出问题,或者干脆不匹配。所以不要给被切方法加 final。

第五个:JDK 动态代理与 CGLIB 的差异。如果你手工配置了spring.aop.proxy-target-class=false,Spring 会使用 JDK 动态代理。此时代理只对接口生效,你如果注入的是一个具体类对象,对象类型不匹配时,代理对象也无法被强转,导致注入失败。Spring Boot 默认配置是 CGLIB,所以建议不要动这个开关。

查代理对象是否生成有个很实用的调试手段:在业务代码里注入 Spring 提供的AopUtils,打印isAopProxy()的结果。

boolean proxy = AopUtils.isAopProxy(orderService); log.info("orderService 是否代理对象: {}", proxy);

如果输出 false,说明根本没代理,切面表达式、启动加载这些方向一定有问题。

4.2 proceed() 在 try-catch 里必须小心的坑

环绕通知给了你异常处理的控制权,但这个控制权也是刺客。很多人写切面时,习惯性地用 try-catch 包住proceed(),然后 catch 里直接 return 一个兜底值。表面上看,接口不再报错了,实际上是把所有异常都吞了,调用方拿到一个“成功”的结果,业务风险被埋了起来。

看这个反面教材:

@Around("execution(* com.example.demo.controller..*.*(..))") public Object around(ProceedingJoinPoint joinPoint) { try { return joinPoint.proceed(); } catch (Exception e) { return "系统繁忙"; } }

如果你只是做接口兜底,这个写法也许能满足场景。但你要清楚,异常被吞掉后,外层的事务切面感知不到异常,@Transactional就不会触发回滚;监控系统拿到的也是正常返回,误报率会升高。我建议至少做两件事:第一,catch 里必须打 error 日志并带上上下文;第二,明确设计哪些异常要被吞、哪些要重新抛出。

拿到异常后的处理有一个标准范式,我比较推荐:

@Around("@annotation(demo)") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { try { return joinPoint.proceed(); } catch (BizException e) { throw e; } catch (Exception e) { log.error("捕获到未处理异常", e); // 你可以在这里把异常包装成自定义异常重新抛出 throw new BizException("系统内部异常", e); } }

原则就是:能识别的业务异常直接透传,未预期的异常包装后抛出,千万不要闷不做声地吞掉。

4.3 多切面执行顺序与事务纠缠的问题

当一个方法同时被事务切面、日志切面、权限切面环绕时,执行顺序是有讲究的。Spring AOP 通过@Order注解控制切面的执行顺序,数字越小,越早执行,并且最外层包裹。

顺序的一个指导原则是:想“看见”事务提交后的结果,就把对应切面放在事务内层;不关心事务状态的,可以放在外层。

举个例子,一个插入操作上有@Transactional,同时我又写了一个操作日志切面。如果日志切面的@Order在所有其他切面之前,也就是最外层,那么执行顺序是:日志切面开始 → 事务切面开启事务 → 目标方法执行 → 事务切面提交事务 → 日志切面记录日志。日志切面此时记录的是已经提交后的结果,如果它内部要发出消息或做异步通知,正好可以拿到稳定的数据。

反过来,如果日志切面在事务内层,执行顺序就会变成:事务切面开启事务 → 日志切面开始 → 目标方法执行 → 日志切面记录日志(这时代事务还没提交)→ 事务切面提交事务。如果日志切面里有耗时操作,事务提交会被延后,导致事务持有数据库连接的时间变长,并发压力下连接池容易被占满。

验证执行顺序有个土办法:给切面代码里log.info加标记,比如“第一个切面 start/第二个切面 start”,然后跑一次请求,看日志顺序就知道切面怎么嵌套了。

4.4 异步线程里拿不到 RequestContextHolder 的值

这个坑是我做日志链路时踩的。环绕通知里通过RequestContextHolder.getRequestAttributes()拿 HttpServletRequest,主线程内一切正常,但一旦业务流程里用了@Async异步方法,执行异步逻辑的子线程里RequestContextHolder就变成了 null。

原因是RequestContextHolder基于 ThreadLocal 实现,ThreadLocal 的数据是线程私有的,子线程默认不会继承父线程的副本。解决方式很多,我最后选了最优的一种:配置一个TaskDecorator,把主线程的请求上下文显式设置到异步线程里。

@Component public class ContextTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { RequestAttributes context = RequestContextHolder.getRequestAttributes(); return () -> { try { RequestContextHolder.setRequestAttributes(context); runnable.run(); } finally { RequestContextHolder.resetRequestAttributes(); } }; } }

在注入线程池的地方设置:

ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setTaskDecorator(new ContextTaskDecorator());

这样异步线程也能拿到请求上下文,切面里的验签、日志、请求信息获取就不会折在半路了。注意这个方案是在 Spring 线程池的场景下生效的,如果你用裸的new Thread()创建线程,ThreadLocal 一样不会传递。

5. 经验:这样写环绕通知更好维护

5.1 用自定义注解做精准切点,别全局扫射

直接写execution(* com.example.demo..*.*(..))虽然省事,但会让排查问题变得很痛苦。我见过一个项目,全局包匹配切面里堆了五种逻辑,任何地方加一个类都可能被扫到,后面的人根本不敢动那个切面。

更好的方案是给切面定义一个明确的“开关语义”,那就是自定义注解。你把它理解成打标记,业务方法主动声明“我要被哪个切面处理”。加注解方式的切点表达式是@annotation(...),没有任何“猜”的成分。

以下是一个实际用过的“操作审计日志”注解和切面:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface AuditLog { String action(); }

切面方法带有注解参数绑定:

@Around("@annotation(auditLog)") public Object around(ProceedingJoinPoint joinPoint, AuditLog auditLog) throws Throwable { log.info("操作类型={}, 方法={}", auditLog.action(), joinPoint.getSignature().getName()); return joinPoint.proceed(); }

当切面逻辑出错时,“哪些方法会被切”这个问题一目了然。你跟同事说“给方法加个 @AuditLog 即可”,比让他在 AOP 表达式里研究包路径靠谱多了。

5.2 在切面里拿全参数名和注解,让日志有生命力

环绕通知里除了能拿到参数值,还能拿到方法参数名。MethodSignature.getParameterNames()可以获取真实参数名,前提是编译时加了-parameters参数(Spring Boot 的 Maven 插件默认会加)。拿到参数名后,日志里可以直接输出“参数名=参数值”,排查问题时的可读性会好很多。

稍微改造一下日志切面的buildParamText:

MethodSignature signature = (MethodSignature) joinPoint.getSignature(); String[] paramNames = signature.getParameterNames(); Object[] paramValues = joinPoint.getArgs();

如果你还需要读取方法注解上的属性,直接用@annotation绑定注解实例是最快的,不用反射。但如果注解同时在类和方法上,且你想合并类级和方法级配置,可能需要手动从joinPoint.getTarget().getClass().getMethod(...)取注解,这种场景不多,了解即可。

5.3 给切面增加开关、排除清单和灰度策略

生产环境里,最怕的就是改一个切面,全站跟着遭殃。所以我的切面类都会配一个“开关”和“排除清单”。

开关用 Spring Boot 配置直接控制切面 Bean 是否加载:

@Component @ConditionalOnProperty(prefix = "app.aop", name = "enabled", havingValue = "true", matchIfMissing = true) public class ApiLogAspect { ... }

比如压测时想临时关掉接口耗时日志,不用改代码,改配置即可。

排除清单可以在切面代码里根据方法名判断:

@Around("within(com.example.demo.controller..*)") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { String methodName = joinPoint.getSignature().getName(); if (excludeMethods.contains(methodName)) { return joinPoint.proceed(); } // 正常切面逻辑 ... }

但这样会让代码变得冗长。更好的手段是用&& !bean(...)或&& !within(...)排除特定类,比如:

@Around("execution(public * com.example.demo.controller..*.*(..)) && !within(com.example.demo.controller.HealthController)")

灰度策略同样适用于环绕通知:通过配置中心动态下发“灰度比例”,切面里Math.random()判断是否走新逻辑。这个思路在一些需要重放请求或者双写的场景里尤其好用,比让业务代码写 if 判断干净得多。

5.4 性能与嵌套:环绕通知不是越多越好

我见过一个项目给一个方法叠了九个切面,调用链路又臭又长,出问题时翻日志翻到怀疑人生。每加一个环绕通知,方法调用链上就多一次代理转发和一次方法调用。虽然 Spring AOP 的性能损耗在大多数业务系统里可以忽略,但嵌套过多会影响以下几点:

  • 日志里链路变长,排查问题时 n 个切面“披着”同一方法,看哪个都像。
  • 切面之间可能互相影响,一个切面改了返回值,下一个切面拿到的结果就不是真实业务结果。
  • 异常处理链路复杂化,某层吞了异常,外层完全无感知。

我的习惯是做减法:能用自定义注解精准点名的,就不用包匹配;能合并的日志、耗时、监控逻辑就合并成一个切面;尽量控制在一个方法最多两三个环绕通知。

还有一点容易被忽略:环绕通知里千万不要做重的 IO 操作。例如每次方法调用都同步写日志表、同步调远程接口,这会把一个 5ms 的业务方法拖到 80ms。如果确实要丢数据到数据库,建议切面里只记录,另起一个线程或 MQ 异步消费。

最后聊几句实际感受

环绕通知是我用 Spring AOP 时最顺手、也是最有“掌控感”的工具。它就像一张织好的过滤网,把所有重复的横切逻辑拦在业务代码之外。用熟了之后你会慢慢形成一种习惯:遇到有统一规则的代码,先想想是不是应该写切面,而不是继续复制粘贴。

但我也要提醒一句,环绕通知的控制力越强,越要谨慎设计。尤其是那些会修改返回值、吞异常、改变参数的环绕通知,必须写清楚注释,否则几个月后你自己回头看代码都会疑惑“这里为什么把异常 catch 掉了”。给团队成员定的规矩可以简单一点:切面只做横切,不替代业务决策;异常要么透传,要么写日志后按统一策略抛。这套规矩我沿着项目用到现在,AOP 带来的维护成本控制在了很低的水平。

如果你刚开始接触,建议从接口耗时日志这个最安全的场景入手,跑通了再尝试幂等防重、签名校验。先小范围实验,别一上来就把整站包进一个大切面里。环绕通知本身是可以随时加随时撤的,找准时机用,它就是代码质量提升的加速器。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 20:10:15

SSM框架下的图书馆预约系统:从数据库设计到并发控制

1. 系统整体拆解&#xff1a;图书馆预约系统的需求与设计思路1.1 为什么选这个题目&#xff0c;以及它到底解决了什么问题每年毕业设计选题的时候&#xff0c;"图书馆预约管理系统"总是一个高频选项。很多同学觉得它"常规"&#xff0c;但恰恰是这种看似普通…

作者头像 李华
网站建设 2026/10/10 20:09:59

HarmonyOS Builder体系全解析:5种核心用法与性能优化实践

HarmonyOS 的 Builder 体系&#xff0c;是 ArkUI 声明式开发里最绕不开、也最容易让人迷糊的一环。很多人把Builder当成“模板函数”用&#xff0c;一遇到传参不刷新、局部更新失效、模板插槽不会写&#xff0c;就开始踩坑。我这些年做 HarmonyOS 应用&#xff0c;从 API 9 一路…

作者头像 李华
网站建设 2026/10/10 20:06:48

仓库工人YOLO数据集实战:623张双标签图像与训练避坑指南

简介&#xff1a;面向YOLO系列目标检测学习与实战的仓库工人数据集&#xff0c;主要由623张真实仓储场景图像构成&#xff0c;标注了工人位置及安全相关目标&#xff0c;可用于工人安全防护检测、人员活动监控等任务的模型训练。数据集已完成训练/验证/测试划分&#xff0c;适配…

作者头像 李华
网站建设 2026/10/10 20:06:44

YOLOv8玉米叶病害检测实战:数据集、权重与PyQt界面部署

简介&#xff1a;面向玉米叶病害智能检测与农业视觉应用&#xff0c;这份资源包整合了YOLOv8训练权重、PyQt图形界面及1500张带txt标签的玉米叶病害数据集&#xff0c;覆盖blight、common_rust、gray_leaf_spot、healthy四类目标&#xff0c;适合从事目标检测算法研究或农业智能…

作者头像 李华
网站建设 2026/10/10 20:06:17

CNN人脸识别实战:从卷积原理到特征向量部署的完整流程

简介&#xff1a;面向希望借助卷积神经网络完成人脸识别的深度学习者&#xff0c;这份配套代码来自CSDN一篇CNN实战教程&#xff0c;完整覆盖图像预处理、网络搭建、模型训练、参数保存与复用等关键环节。包内共4个文件&#xff0c;包括两个Python脚本&#xff0c;分别负责CNN训…

作者头像 李华
网站建设 2026/10/10 20:04:59

无人机航拍三维重建实战:SFM+MVS全流程解析与工程调优

简介&#xff1a;本资源是一套面向计算机相关专业学生与初学者的无人机航拍三维场景重建完整实践方案&#xff0c;适用于毕业设计、课程设计、大作业及科研入门等场景&#xff0c;聚焦于从真实航拍图像出发构建三维几何结构的核心任务。压缩包共54个文件&#xff0c;含41个Pyth…

作者头像 李华