news 2026/9/9 17:47:45

Spring AOP从入门到实战:面向切面编程、动态代理与注解限流全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AOP从入门到实战:面向切面编程、动态代理与注解限流全解析

如果你写过几个稍微像样点的 Spring Boot 项目,大概率见过这种场景:一个 Controller 里十几个接口,每个接口都要校验登录状态,方法里要统计耗时日志,出异常了还得统一记一条 error 日志。第一版还好,写多了就会发现这些代码跟业务逻辑缠在一起,改一次权限规则要动十几个方法,光复制粘贴都嫌烦。

Spring AOP 解决的就是这个问题。它可以把一类跟业务无关但又必须做的事(日志、鉴权、限流、事务、性能监控)抽出来,统一加在所有需要的地方。你不用改原来的方法,切面会自动在目标方法执行前后插入逻辑。这就是 AOP——面向切面编程。这篇博文我会从最基础的概念讲起,带你在 Spring Boot 里跑通第一个切面,再做一个基于注解的接口限流实战,最后说清楚它背后的动态代理原理和最容易踩的坑,适合刚接触 Spring AOP 的开发者,也适合想把这部分知识用在项目里的同学。

1. 从一段“脏乱差”的业务代码说起

1.1 业务代码里最让人烦躁的几种重复逻辑

随便打开一个真实项目的 Service 实现类,通常都能看到类似的代码结构。

public Order createOrder(OrderDTO dto) { // 记录开始时间 long start = System.currentTimeMillis(); try { // 权限校验 this.checkPermission("order:create"); // 参数校验逻辑 if (dto.getAmount() == null) { throw new IllegalArgumentException("金额不能为空"); } // 核心业务 Order order = new Order(); order.setUserId(dto.getUserId()); order.setAmount(dto.getAmount()); orderMapper.insert(order); // 操作日志 log.info("创建订单成功,orderId={}", order.getId()); return order; } catch (Exception e) { // 异常日志 log.error("创建订单失败,userId={}", dto.getUserId(), e); throw e; } finally { // 耗时统计 long cost = System.currentTimeMillis() - start; log.info("创建订单耗时:{}ms", cost); } }

这只是单个方法。如果项目里有二十个、五十个这样的方法,每个方法都粘一段权限校验、日志、耗时统计,代码的重复率会高到让人崩溃。更要命的是,有一天产品经理说“所有写操作都要加操作人记录”,你得一个方法一个方法去改,漏改一个就出线上事故。

这种散落在各个业务方法中、跟核心业务无关的代码,在 AOP 的语境里叫“横切关注点”。横切这个词很形象——它横着切过了很多个业务模块,每个模块里都有它的身影。AOP 的思路就是把这些横切关注点集中到一个地方统一管理,让业务方法回归业务的纯粹性。

1.2 AOP 解决问题的思路:召之即来挥之即去

AOP 本质上是一种编程范式,它的目标是把这些重复的横切逻辑抽取出来,在你不需要的时候完全不出现,在你需要的时候自动插入到目标方法周围。

用生活里的场景来类比:商场里的每个店铺都有收银台,收银台除了收钱,还要做三件事——验钞、开发票、记录流水。如果不统一处理,每个店员都要自己记三遍流程,漏掉一步就要出问题。后来商场引入了统一的收银系统,店员只需要操作收银系统,验钞、开发票、记流水这些事由系统在背后自动完成。AOP 就扮演了这套“收银系统”的角色。

在 Spring 体系里,AOP 的实现非常成熟,你只要定义好“在哪些方法上做增强”(切点)以及“增强什么逻辑”(通知),Spring 会在运行时帮你生成一个代理对象,目标方法执行时自动插入增强逻辑。这个机制,就是 Spring AOP 的核心。

1.3 使用 AOP 后的代码长什么样

还是刚才那个创建订单的方法,引入 AOP 之后,业务代码会变成这样:

public Order createOrder(OrderDTO dto) { // 参数合法性的基础校验 if (dto.getAmount() == null) { throw new IllegalArgumentException("金额不能为空"); } // 核心业务,只做它该做的事 Order order = new Order(); order.setUserId(dto.getUserId()); order.setAmount(dto.getAmount()); orderMapper.insert(order); return order; }

权限校验、操作日志、异常记录、耗时统计,全都被切面接管了。方法里只剩两段内容:参数基础校验和核心业务逻辑。代码的阅读成本、维护成本显著下降,出问题的概率也随之变小。

看到这里,你应该能理解为什么 AOP 在 Spring 中的地位这么高了。Spring 自己的事务管理、Spring Security 的鉴权、Spring Cache 的缓存逻辑,底层全部基于 AOP 实现。搞懂了 AOP,你就等于拿到了打开 Spring 框架深层机制的一把钥匙。

2. AOP 的四个核心术语,用大白话讲清楚

很多人一上来就背概念:切面、切点、通知、连接点……背完就忘,因为概念和实际代码对应不上。这里我换一种讲法,用一套“快递包裹安检”的类比把这些术语串起来。

2.1 切面、切点、通知、连接点的真实身份

假设你经营一家快递中转站,所有包裹在发出前都要经过安检。这个“安检流程”就是通知(Advice),因为它是你希望在某个时刻额外执行的一段逻辑。“哪些包裹要安检”就是切点(Pointcut)。在 Spring AOP 里,切点通常是一个表达式,用来匹配哪些方法需要被增强。比如“所有 com.example.service 包下以 Order 结尾的类的所有 public 方法”,这就是一个切点表达式描述。

每个包裹过安检时,安检员会执行完整套安检流程,这个具体的“执行时刻”就是连接点(JoinPoint)。每一个被切点匹配到的方法,都是一个连接点。理论上程序运行中任何一个方法都可以是连接点,但 Spring AOP 只支持方法级别的连接点,因为 Spring AOP 基于动态代理实现,代理只能拦截方法调用。

“切面”(Aspect)则是这一切的集合:你有一个类,里面定义了切点表达式,也定义了通知方法,这个类上标注了@Aspect注解,它就是切面。切面 = 切点 + 通知。

2.2 五种通知类型,各自在什么时候执行

Spring AOP 提供了五种通知注解,对应不同的执行时机。我整理了一张表,后面写代码时照着这份对应关系来:

注解执行时机典型用途
@Before目标方法执行前权限校验、参数前置检查
@After目标方法执行后(无论正常还是异常)释放资源、清理上下文
@AfterReturning目标方法正常返回后记录成功日志、组装返回结果
@AfterThrowing目标方法抛出异常后异常通知、告警
@Around环绕目标方法执行(最强大)耗时统计、限流、事务、分布式锁

@Around是五种通知里最灵活的一个,因为它可以完全控制目标方法什么时候执行、要不要执行、返回值如何修改。但它也是最容易写错的一个,后面实战环节我会展示它的正确写法。

2.3 切点表达式的基本语法

切点表达式是 AOP 的门面,你至少要能读懂、能写最常用的几种。最常见的写法是execution表达式:

@Pointcut("execution(public * com.example.service.*.*(..))") public void servicePointcut() {}

这段表达式的意思是:匹配com.example.service包下所有类的所有 public 方法,参数任意。逐个拆解:

  • 第一个*:返回值类型任意。
  • com.example.service.*:指定包下的所有类。
  • 第二个*:所有方法名。
  • (..):参数类型和个数任意。两个点表示任意个数的参数。

除了execution,还有几种常用的切点指示符:

  • within(com.example.service..*):匹配包及其子包中的所有方法。
  • @annotation(com.example.annotation.RateLimit):匹配标注了指定注解的方法。这个在实战中非常有用,我下文做接口限流时就会用到。
  • bean(orderServiceImpl):匹配指定名称的 Bean。

切点是 AOP 里最需要经验的环节,表达式写宽了会误拦截不该管的方法,写窄了该增强的又漏掉。我的建议是:新手期优先使用@annotationexecution组合的方式,通过自定义注解来精确控制需要增强的范围,而不是直接用通配符扫一个大包,这样可控性高很多。

3. 跑通第一个 Spring Boot AOP Demo:从零到能看见切面在工作

光看概念总是虚的。这一节我带你完整跑通一个 Spring Boot AOP Demo,整个过程大概十分钟,你就能在自己的项目里实际看到切面是怎么“插入”到业务方法中去的。

3.1 在 pom.xml 中引入 AOP 依赖

如果你从spring-boot-starter-web起步,AOP 依赖需要单独引入。在pom.xml里加上:

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

这个 starter 会自动引入 Spring AOP 以及 AspectJ 的相关支持(Spring Boot 2.7+ 默认的 aspectjweaver 版本已足够日常使用)。引入之后不需要额外配置切面开关,Spring Boot 的自动配置会自动识别标注了@Aspect的类并注册为切面。

提示:Spring Boot 3.x 基于 Spring Framework 6,底层用 AspectJ 的注解模型实现,使用方式和 2.x 完全一致,不用纠结版本问题。

3.2 定义一个最简业务 Service

我们模拟一个用户查询接口,后续所有切面都在这个 Service 上验证效果。

@Service public class UserService { public User getUserById(Long id) { // 模拟业务耗时 try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return new User(id, "张三" + id, 25); } public void deleteUser(Long id) { if (id <= 0) { throw new IllegalArgumentException("用户id不合法"); } } }

注意deleteUser方法里我故意留了一个抛异常的分支,后面验证异常通知时用得上。

3.3 定义第一个切面类

现在我们定义一个最基础的切面:在UserService的所有方法执行前打印一行日志。

@Aspect @Component public class LogAspect { private static final Logger log = LoggerFactory.getLogger(LogAspect.class); // 定义切点:匹配 UserService 中的所有方法 @Pointcut("execution(public * com.example.demo.service.UserService.*(..))") public void userServicePointcut() {} // 前置通知 @Before("userServicePointcut()") public void beforeAdvice(JoinPoint joinPoint) { String methodName = joinPoint.getSignature().getName(); Object[] args = joinPoint.getArgs(); log.info("【前置通知】方法 {} 即将执行,参数:{}", methodName, Arrays.toString(args)); } // 后置通知 @AfterReturning(pointcut = "userServicePointcut()", returning = "result") public void afterReturningAdvice(JoinPoint joinPoint, Object result) { String methodName = joinPoint.getSignature().getName(); log.info("【返回通知】方法 {} 执行完成,返回值:{}", methodName, result); } // 异常通知 @AfterThrowing(pointcut = "userServicePointcut()", throwing = "ex") public void afterThrowingAdvice(JoinPoint joinPoint, Exception ex) { String methodName = joinPoint.getSignature().getName(); log.info("【异常通知】方法 {} 抛出异常,异常信息:{}", methodName, ex.getMessage()); } // 环绕通知 @Around("userServicePointcut()") public Object aroundAdvice(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); try { Object result = joinPoint.proceed(); log.info("【环绕通知】方法 {} 执行成功,耗时 {} ms", joinPoint.getSignature().getName(), System.currentTimeMillis() - start); return result; } catch (Throwable e) { log.info("【环绕通知】方法 {} 抛出异常,耗时 {} ms", joinPoint.getSignature().getName(), System.currentTimeMillis() - start); throw e; } } }

这里有个非常重要的细节:@Around通知方法必须接收一个ProceedingJoinPoint类型的参数,并且要手动调用proceed()方法,目标方法才会执行。如果你在环绕通知里忘了调proceed(),目标方法就会被“吞掉”,业务逻辑直接不执行了,而且 Spring 不会报任何错。我第一次写 AOP 时就踩过这个坑,排了半天才发现是环绕通知少调了一行。

多个通知同时生效时,执行顺序是:@Around开始(proceed 之前的部分)→@Before→ 目标方法 →@AfterReturning@AfterThrowing@Around结束(proceed 之后的部分)。

3.4 编写测试接口验证切面

写一个最简单的 Controller 来验证效果:

@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); } @DeleteMapping("/{id}") public String deleteUser(@PathVariable Long id) { userService.deleteUser(id); return "ok"; } }

启动项目,访问/user/1,控制台会输出类似下面的日志:

【前置通知】方法 getUserById 即将执行,参数:[1] 【环绕通知】方法 getUserById 执行成功,耗时 102 ms 【返回通知】方法 getUserById 执行完成,返回值:User(id=1, name=张三1, age=25)

访问/user/0触发异常,输出会变为:

【前置通知】方法 deleteUser 即将执行,参数:[0] 【环绕通知】方法 deleteUser 抛出异常,耗时 1 ms 【异常通知】方法 deleteUser 抛出异常,异常信息:用户id不合法

从输出顺序可以看到通知的执行顺序完全符合上一小节说的规则。到这里,你的第一个 Spring AOP 切面已经成功跑通了。这段代码里隐藏了大量你可以实际去调整的细节:比如调整切点表达式、增加多个切面观察它们的执行顺序,强烈建议动手改一改再运行,比只看文章效果好得多。

4. 高价值的实战:用自定义注解+AOP 给接口加限流

理解了基础用法后,我们做一个实际项目中高频使用的功能:基于注解的接口限流。这个场景最常见的需求是对某些敏感接口(如短信验证码发送、支付接口)做调用频率限制,防止被刷。用 AOP + 自定义注解实现,业务代码保持零侵入,非常优雅。

4.1 自定义注解的设计

先定义一个@RateLimit注解:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RateLimit { // 限流时间窗口,单位秒 int windowSeconds() default 60; // 窗口内允许的最大请求次数 int maxCount() default 10; // 限流提示信息 String message() default "请求过于频繁,请稍后再试"; }

简单解释一下这两个元注解:@Target(ElementType.METHOD)表示这个注解只能标在方法上;@Retention(RetentionPolicy.RUNTIME)表示注解在运行时保留,这样 Spring AOP 才能在运行时通过反射读取注解的值。这两个缺一不可,少了 RUNTIME 会导致切面在运行时读不到注解信息。

4.2 限流切面的实现

核心思路:使用ConcurrentHashMap保存每个方法签名对应的访问记录,记录里保存时间窗口的起始时间和窗口内的访问次数。每次请求进来时检查当前时间是否超出窗口,如果超出则重置窗口,否则判断当前窗口内的访问次数是否超过上限。

@Aspect @Component public class RateLimitAspect { private static final Logger log = LoggerFactory.getLogger(RateLimitAspect.class); // 方法签名 -> 访问记录 private final Map<String, AccessRecord> recordMap = new ConcurrentHashMap<>(); @Around("@annotation(rateLimit)") public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { String methodKey = buildMethodKey(joinPoint); AccessRecord record = recordMap.computeIfAbsent(methodKey, k -> new AccessRecord()); synchronized (record) { long now = System.currentTimeMillis(); // 当前时间超出窗口起始时间 + 窗口长度,说明窗口已过期,重置 if (now - record.windowStart >= (long) rateLimit.windowSeconds() * 1000) { record.windowStart = now; record.count = 0; } if (record.count >= rateLimit.maxCount()) { log.warn("接口限流触发:{},{}", methodKey, rateLimit.message()); throw new RuntimeException(rateLimit.message()); } record.count++; } return joinPoint.proceed(); } private String buildMethodKey(ProceedingJoinPoint joinPoint) { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); return signature.getDeclaringTypeName() + "#" + signature.getName(); } private static class AccessRecord { private long windowStart = System.currentTimeMillis(); private int count; } }

这个实现里有几个值得注意的细节:

第一,使用synchronized (record)对同一个方法签名的访问做串行化,避免并发场景下计数不准。对于每个接口方法,锁的粒度只限于该方法本身的计数器,多个不同接口之间不互相阻塞,性能损耗很小。

第二,computeIfAbsent保证每个方法签名只创建一个记录对象,避免不必要的对象创建。

第三,我在计数完成后把结果加一,然后才放行目标方法。这么做的意思是,即使目标方法抛异常,这次请求也已经消费了一次调用次数,可以防止有人用异常请求绕过限流。

4.3 在业务方法上使用限流注解

使用方式非常简洁:

@RestController @RequestMapping("/sms") public class SmsController { @RateLimit(windowSeconds = 60, maxCount = 5, message = "短信发送太频繁,请一分钟后再试") @PostMapping("/send") public String sendSms(@RequestParam String phone) { // 真正的短信发送逻辑 return "发送成功"; } }

这样/sms/send接口在 60 秒内最多只能被调用 5 次,超过之后直接抛出异常,由全局异常处理器统一转换为友好的提示返回给前端。

这里展示的是一个完整可用的限流方案,但它只是一个基础版本,适合中小流量的项目使用。如果你的接口流量较大、又有多实例部署,就需要考虑用 Redis 做分布式限流了。思路是一样的:把计数逻辑从本地内存迁移到 Redis 的 INCR + EXPIRE 命令,AOP 切面的骨架完全不用改,这就是注解 + AOP 模式的灵活之处。

4.4 为什么用 @annotation 切点而不是 execution

我在实现里使用的是@annotation(rateLimit)切点,而不是execution表达式。这里解释一下设计原因:

如果用execution(public * com.example.demo.controller..*.*(..)),那么所有 Controller 方法都会被切面拦截。但很多接口不需要限流,切面必须在运行时反射检查每个方法有没有标注@RateLimit注解,没有就放行。这样做两个问题:一是所有接口的调用都会多一次反射检查,浪费性能;二是容易误拦截,未来新增的 Controller 方法也可能被意外增强。

@annotation(rateLimit)只匹配标注了指定注解的方法,Spring 在匹配阶段就把无关方法排除了,只有标注了@RateLimit的方法才会进入切面逻辑。同时,注解的值还能直接作为切面方法的参数注入进来,省去了反射读取注解的步骤。所以注解 + AOP 这种组合是现代 Spring 项目中最推崇的落地方式,也是 Spring Security、Spring Cache 等框架内部使用的方案。

5. 揭开 AOP 底牌:动态代理是如何让“外挂”生效的

学习 AOP 绝对不能只停留在会用注解,不理解底层就很难解释清楚一些玄学问题,比如:为什么同类的内部方法调用不走切面?为什么循环依赖下 AOP 处理会不一样?这些都绕不开动态代理这个核心机制。

5.1 代理模式是 AOP 的基石

AOP 能实现“不修改原代码就增强方法”,靠的是代理模式。它的本质是创建一个目标对象的代理,外部调用的其实是代理对象,代理对象在调用目标方法之前和之后插入增强逻辑。

Spring AOP 默认在运行期为目标对象动态生成代理,有两种生成方式:

维度JDK 动态代理CGLIB 动态代理
原理通过接口生成实现类通过继承目标类生成子类
要求目标类必须实现接口目标类不能是 final,方法不能是 final
性能生成代理快,调用稍慢生成代理慢,调用较快
Spring 默认策略早期版本默认Spring Boot 2.x 起默认

JDK 动态代理通过Proxy.newProxyInstance生成一个实现目标接口的代理类,调用代理方法时,InvocationHandler.invoke方法会被触发。CGLIB 则在字节码层面生成目标类的子类,子类覆写目标方法并插入增强逻辑。

Spring 之所以从默认 JDK 动态代理改为 CGLIB,最重要的原因是太多项目里 Bean 只写了实现类、没抽接口,JDK 动态代理面对这种没有接口的类无能为力。Spring Boot 2.x 开始默认将spring.aop.proxy-target-class设置为 true,也就是无接口也强制使用 CGLIB,省去了一些让人困惑的“为什么没有切面生效”的问题。

5.2 代理对象的实际存在感

用一段代码来感受代理对象的存在。给之前的UserService增加一个测试方法,打印它的运行类型:

@GetMapping("/proxy-type") public String proxyType() { return userService.getClass().getName(); }

如果你使用的是 Spring Boot 2.x 及以上版本,返回的结果大概率是:

com.example.demo.service.UserService$$EnhancerBySpringCGLIB$$xxxxx

看到EnhancerBySpringCGLIB这几个字符,就证明这个 Bean 在容器中已经被替换成了 CGLIB 生成的代理对象。你注入的UserService表面上是个 Service,实际运行时是个代理子类。

5.3 AOP 与循环依赖:三级缓存里的特殊情况

在 Spring 中,AOP 和循环依赖(比如 A 依赖 B、B 又依赖 A)放在一起时,处理逻辑非常微妙,也是面试高频考点。要理解这个,得知道 Spring 解决循环依赖用到的三级缓存:

  • 一级缓存:存储已完全初始化的单例 Bean。
  • 二级缓存:存储早期暴露的 Bean,但还没完成属性填充。
  • 三级缓存:存储ObjectFactory工厂,它可以在必要时提前生成代理对象。

为什么需要第三级缓存而不是直接用二级缓存存未代理的早期 Bean?根本原因就是 AOP:如果 Bean A 和 Bean B 互相依赖,且 A 需要被 AOP 代理,那么在 B 注入 A 的时候,A 还没走到初始化最后的代理生成阶段。如果没有三级缓存,B 拿到的就是未经代理的原始 A 对象,AOP 就直接失效了。

三级缓存的处理流程是:A 实例化后,把“获取 A 引用的工厂”(ObjectFactory)放入三级缓存。当 B 需要引用 A 时,从三级缓存拿到工厂,此时提前执行工厂,工厂会判断 A 是否需要代理。如果需要,就生成 A 的代理对象,放入二级缓存并返回给 B。等到 A 真正走完初始化流程时,如果发现二级缓存里已经有了提前生成的代理对象,就直接用代理对象完成最终的单例暴露,保证容器中全局只有一个 A 的代理对象。

这个机制的精妙之处在于:AOP 的代理生成被推迟到“有人真正需要 A 的引用”时才触发,如果整个初始化流程没人提前引用 A,就不需要提前生成代理,避免无谓的性能开销。这也是为什么“三级缓存”而非“二级缓存”的原因——二级缓存无法在最早阶段判断是否需要代理。

5.4 结合源码简单看通知的调用链

如果你有时间,可以跟着JdkDynamicAopProxy.invoke的源码走一遍调用链。它整体的执行顺序是:

  1. AdvisedSupport中拿到配置好的切面列表。
  2. 创建一个MethodInvocation,内部封装目标方法、参数、拦截器链(也就是各个通知)。
  3. 调用proceed()依次执行拦截器链中的每个通知。
  4. 最后一个拦截器执行完,才真正调用目标方法。

所以从某种意义上说,AOP 的执行可以理解为一个洋葱模型:每个通知包一层,最里层才是真正的业务代码。这也是为什么面试里经常有人问“多个切面时执行顺序怎么保证”,答案就是通过@Order注解指定切面的优先级,数值越小越靠外、越先执行。

6. 最容易踩的 AOP 坑:失效场景与排查思路

AOP 用起来不难,但真正让人头疼的是那些“该生效却不生效”的情况。沉默失败是最可怕的,因为业务代码没有报错,但日志、权限、限流全都静默失效。下面这类问题我在项目里排查过很多次,也帮同事定位过不少,总结了几个最常见的坑,你对照自查基本能解决大部分问题。

6.1 同类内部方法调用导致切面失效

这是最常见的问题,没有之一。考虑下面的代码:

@Service public class OrderService { public void createOrder(OrderDTO dto) { this.checkStock(dto); // 内部直接调用 orderMapper.insert(dto); } @RateLimit(windowSeconds = 60, maxCount = 10) public void checkStock(OrderDTO dto) { // 校验库存 } }

你期待调用createOrder时,checkStock的限流会生效。但实际上不会。原因从 5.1 节的代理原理就能推出来:外部注入到其他类中的是 OrderService 的代理对象,但this.checkStock()里的this指向的是目标原始对象,不是代理对象,所以 Spring 根本没有机会拦截这次调用。

解决方案有三种:

  • 把需要增强的方法抽到另一个 Service 类中,通过注入代理对象调用。
  • ApplicationContext中获取代理对象再调用。
  • 在类内部通过AopContext.currentProxy()获取当前代理(需要配置@EnableAspectJAutoProxy(exposeProxy = true)),实现相对优雅但侵入性强。

其中最简单也最推荐的是第一个方案:把一个类拆成两个职责更单一的类,既解决了 AOP 失效问题,也让代码结构更清晰。

6.2 切点表达式写错导致静默失效

切点表达式写错不会报错,只会默默不匹配任何方法。新手最容易犯的错误是路径写错。比如你写的包是com.example.demo.service,但 Service 类实际在com.example.demo.service.impl,用com.example.demo.service.*做匹配时,*只匹配一层包,不会匹配到impl子包下的类。解决办法是用com.example.demo.service..*,两个点代表包含所有子包。

表达式匹配没问题,但切面还是没生效,还有一种可能:切面类没有被 Spring 管理。如果你把切面类定义在了@SpringBootApplication默认扫描不到的位置,或者忘了加@Component注解,那么@Aspect就只是一个标注,Spring 不会实例化它,切面自然形同虚设。

排查技巧:在切面类的@Before方法里临时加一行输出,如果调用目标方法时没有任何日志,先查 Spring 容器里有没有这个切面 Bean(Idea 的 actuator 接口或者断点都可以确认),再确认切点表达式能否匹配。

6.3 事务注解和 AOP 同时使用时的顺序问题

@Transactional本身也是通过 AOP 实现的。如果你的项目里既有自定义切面又有事务需求,它们两个的执行顺序是有讲究的。默认情况下,事务切面的优先级更高,包裹在自定义切面内部(也就是事务先开启、自定义切面后执行,事务提交在最后一个切面执行完之后)。

这在某些业务场景下会造成困惑。比如你在@AfterReturning通知里发了一个消息,由于自定义切面在事务内部,消息发送时事务可能还没提交,如果此时消费方查数据库,会看到数据不存在。

解决办法是给自定义切面加@Order注解调整优先级。@Order(1)比事务的默认顺序更靠前,会在事务开启前就执行;@Order(Ordered.LOWEST_PRECEDENCE)则在最内层执行。具体选择取决于你的业务需求:比如做耗时统计的切面放在最外层,做业务数据校验的切面放在贴近业务方法的位置。

注意:@Order的数值越小优先级越高,多个切面同时作用于一个方法时,优先级高的切面先进入、后退出。类似洋葱模型的穿层顺序。

6.4 目标类是 final 或方法为 final 导致无法代理

如果你用了 CGLIB 动态代理,目标类不能是 final 类,目标方法不能是 final 方法。因为 CGLIB 靠继承目标类并重写方法来实现增强,这两个修饰符会直接阻止代理生成或方法覆写。

这个坑在接老项目改造时特别常见。有些老代码为了避免继承滥用,把类标记成了 final,引入 AOP 后项目还能启动,但切面就是不生效。遇到这种情况,先检查目标类和方法有没有 final 修饰,有的话去掉即可。

另外顺带提醒:private方法也不会被 AOP 拦截,因为代理类无法覆写 private 方法,这个问题在内部调用场景下和 6.1 类似,但表现略有不同——即使你从外部调用了私有方法所在的公有方法,私有方法内部的逻辑也不会被增强。

6.5 一个实用的排查链路

如果你遇到“AOP 没生效”的问题,我一般按下面这套链路排查,效率很高:

  1. 确认切面类是否被 Spring 管理:检查是否标注了@Component或注册成了 Bean。没有 Bean 一切都是空谈。
  2. 确认切点表达式是否匹配目标方法:在@Before@Around中临时打日志,调用目标方法后看日志有没有输出。没有输出就是表达式匹配不上。
  3. 确认是外部调用还是内部调用:看目标方法是不是被this.方式调用。内部调用走的是原始对象,切面不会触发。
  4. 确认目标类是否可被代理:检查类和方法有没有 final 修饰。
  5. 确认切面类自身的注解属性:@Aspect注解要保留在类上,方法上如果是@Around,确保参数里有ProceedingJoinPoint并调用了proceed()

大多数情况下,问题出在前三步。尤其是第一步,很多人把@Aspect放在 Controller 上,Controller 本身也被代理了,却忘了给切面类加@Component,导致切面类完全没被实例化。

7. 写在最后的几句实用提醒

这篇文章从一个具体的代码痛点切入,把 Spring AOP 从概念、Demo、实战到原理再到踩坑全部走了一遍。最后分享几条实操体会。

第一,AOP 用得好是利器,用不好是灾难。不要在业务代码里过度依赖 AOP,把所有逻辑都塞进切面里。我见过一些项目,整个方法调用链上五六个自定义切面,出了问题根本看不清执行顺序。合理的 AOP 使用边界是:横跨大量方法的通用逻辑,比如日志、限流、鉴权、幂等、分布式锁。单个方法专属的特殊逻辑,老老实实写在方法里。

第二,面向切面编程要求你养成一个习惯:在给方法加注解(比如@RateLimit@LogRecord)之前,先想清楚这个方法的调用来源。如果它会被同类内部this调用,那么注解大概率不会生效,这是最容易忽视的隐患。

第三,建议新手做一次手动写动态代理的实验:不借助 Spring,直接使用Proxy.newProxyInstance写一个 JDK 动态代理,再使用 CGLIB 写一个子类代理,亲手调用一下,体会代理对象的调用过程。这个实验做完,AOP 的原理你就会理解得非常透彻,排查问题也不再虚。

第四,调试 AOP 问题不要瞎试,善用断点。在代理类的invoke或者切面方法的入口打断点,观察argsjoinPoint.getSignature()就能快速确认切面是否被触发、参数是否正确。Spring Boot 的 actuator 提供的 Bean 信息也能帮你确认目标对象是否已经被代理。

Spring AOP 属于那种“知道的人觉得简单,不知道的人觉得神秘”的技术。一旦你把代理机制想通了,再看 Spring Security 的过滤器链、Spring Cache 的注解缓存、事务传播行为,都会有一种豁然开朗的感觉。希望这篇入门文章能帮你打开这扇门。

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

Android MediaPlayer.getDuration全链路解析:从Java到Native

1. 先搞清楚一个卑微的getDuration在整条链路里的位置 在Android开发里&#xff0c;MediaPlayer.getDuration()大概是看起来最人畜无害的API之一了。入行半年的人都会像这样写&#xff1a; mediaPlayer.setOnPreparedListener {val duration mediaPlayer.getDuration()textV…

作者头像 李华
网站建设 2026/9/9 17:43:35

红队必备:五款浏览器插件让漏洞挖掘效率翻倍

1. 扒开浏览器的“外衣”&#xff1a;为什么漏洞挖掘离不开插件做漏洞挖掘的人&#xff0c;尤其是红队和 SRC 选手&#xff0c;一天里有大半时间都泡在浏览器里。目标系统的前端逻辑、接口调用、鉴权绕过、DOM 类漏洞&#xff0c;几乎都要通过浏览器去观察和验证。可浏览器本身…

作者头像 李华
网站建设 2026/9/9 17:43:33

教育发布会虚拟演播选型:发丝级抠像与3D合成实战指南

去年帮一家教育集团做新产品发布会直播&#xff0c;校长站到绿幕前讲了不到十分钟&#xff0c;画面上头发边缘全是毛刺&#xff0c;像戴了一顶完全不合头型的假发。宣传部门在群里连发消息问能不能先切掉特写镜头&#xff0c;直播间评论区也有观众直接留言说画面边缘发灰。那场…

作者头像 李华
网站建设 2026/9/9 17:34:35

十分钟跑通 CCR 接入 DeepSeek:Claude Code 模型路由完整指南

十分钟跑通 CCR 接入 DeepSeek&#xff1a;Claude Code 模型路由完整指南 【免费下载链接】claude-code-router One local control plane for every AI agent: route across models, fuse new capabilities, orchestrate tools, and stay fully in control. 项目地址: https:…

作者头像 李华