AOP 思想入门:横切关注点与代理模式
一句话讲完 AOP:把「每个方法都要顺便做一遍」的代码,从每个方法里抽出来,集中写一份,让容器在运行期偷偷帮你贴回去。被抽走的东西通常是日志、事务、权限、限流;贴回去的执行者叫代理对象——一个和你写的类同名同姓、却多干了点活的替身。
动态代理到底代理了什么?答案是什么都没改。就像给手机套壳:壳子有按键孔、有充电口,手感跟原来一模一样,你按下电源键还是手机在关机——但壳子顺手加了一层防摔缓冲。手机本身没变,变的是「别人递给你的是什么」:容器递给你的从来不是那台裸机,而是套好壳的那台。你的业务代码(手机)一行都不用动,日志/事务这些额外能力全在壳上。
通知链像洋葱,也像闯关。一次调用要一层层往里穿:第一关查门禁(权限)、第二关打卡计时(日志)、第三关领工牌(开事务),走到最里面才是真正干活的肉(目标方法);出来时反过来,最里面的关先退——先交工牌提交事务,再补打卡记录耗时。所以「先进后出、后进先出」是理解所有通知执行顺序的钥匙。

学完这节,你要能回答三个问题:
- 「横切关注点」到底指什么,为什么它不该长在业务方法里?
- 切面 / 切点 / 连接点 / 通知 / 织入这五个词,各自对应现实里的哪个动作?
- 为什么说 Spring AOP 只是「方法级的代理」,它的三个失效场景是什么?
镜头拉到一次代码评审。需求很普通:所有 Service 方法记录入参、出参和耗时;关键的写操作要包事务;后台接口要校验权限。三句话能讲完,落到代码上却是这样:
@Servicepublic class OrderService { private static final Logger log = LoggerFactory.getLogger(OrderService.class); public Order createOrder(Long userId, OrderDTO dto) { long start = System.currentTimeMillis(); log.info("createOrder 入参: userId={}, dto={}", userId, dto); // ↓↓↓ 真正有价值的业务逻辑,只有这几行 ↓↓↓ Order order = new Order(userId, dto); orderRepository.save(order); // ↑↑↑ 真正有价值的业务逻辑 ↑↑↑ log.info("createOrder 出参: orderId={}, 耗时={}ms", order.getId(), System.currentTimeMillis() - start); return order; } public void cancelOrder(Long orderId) { long start = System.currentTimeMillis(); log.info("cancelOrder 入参: orderId={}", orderId); Order order = orderRepository.findById(orderId).orElseThrow(); order.cancel(); orderRepository.save(order); log.info("cancelOrder 出参: 耗时={}ms", System.currentTimeMillis() - start); }}- 每个方法开头都要取时间戳、打一条入参日志
- 每个方法结尾都要打一条出参日志和耗时
- 中间那几行才是方法的「本职工作」,其余都是为了日志而做的复制粘贴
一个模块 10 个 Service、平均每个 12 个方法,就是 120 次复制。改一次日志格式,要改 120 处——这正是横切逻辑最难受的地方:它跟业务几乎不沾边,却和业务一样频繁地被修改。
判断一段代码是不是「横切关注点」,看一个信号就够了——它是否出现在很多个方法里、且内容高度雷同。日志、事务、权限、限流、缓存、监控全都符合这个特征。
把「继续复制粘贴」和「抽成一个切面」并排摆出来,收益与代价一目了然:

那 120 段雷同代码到底是怎么「搬」出业务方法的?六帧动画走完整个收拢过程,注意第 ⑤ 帧——搬家不是改源码,而是运行期多套了一层壳:

把这堆代码按「方向」分个类,问题就清楚了:

- 核心关注点(纵向):Controller → Service → Repository,一层层回答「这个业务的流程是什么」,它沿着调用链竖着走
- 横切关注点(横向):日志、事务、权限……它们不属于某个具体业务,却要「横着」穿过几乎每一个方法
典型的横切关注点以及不做它们的代价:
| 横切关注点 | 在做什么 | 缺失的后果 |
|---|---|---|
| 日志 | 记录入参、出参、耗时、异常 | 线上出问题无从追溯 |
| 事务 | 一组操作要么全成功要么全回滚 | 数据不一致 |
| 权限 | 判断当前用户能否执行该操作 | 越权访问 |
| 限流 | 控制单位时间的请求量 | 被突发流量打垮 |
| 缓存 | 复用结果,减少重复计算 | 性能差、数据库压力大 |
| 监控 | 埋点、指标、链路追踪 | 系统没有可观测性 |
一句话收束:核心关注点决定「做什么业务」,横切关注点决定「顺便还要做什么」。前者天然长在方法里,后者天然想被「抽走」——AOP 就是为「抽走」而生的技术。
很多人觉得 AOP 难,是被一堆术语先劝退了。其实只要抓住一条主线:切点负责筛选,通知负责执行,切面把两者打包,代理负责落地。
| 术语 | 一句话解释 | Spring 里的对应物 |
|---|---|---|
| Aspect 切面 | 横切逻辑的封装体 | 加了 @Aspect 的类 |
| JoinPoint 连接点 | 程序执行中可以插入逻辑的点 | 每一个可被拦截的方法执行 |
| Pointcut 切点 | 用表达式筛出要拦哪些连接点 | @Pointcut("execution(...)") |
| Advice 通知 | 在连接点上真正执行的代码 | @Before / @After / @Around…… |
| Target 目标对象 | 被代理的原始对象 | 你的 UserServiceImpl |
| Proxy 代理 | 目标对象 + 通知组成的替身 | JDK Proxy / CGLIB 生成的对象 |
| Weaving 织入 | 把切面应用到目标上的过程 | 创建代理对象的那一步 |
把它们串成一句话:切面用切点从连接点里筛出一批方法,在这些方法上挂通知;运行期由代理包住目标对象,完成织入。记住这条链,术语就不再是零散的名词。
「织入」听着玄,其实就是「把通知塞进目标方法的执行流程」那一下。它可以在三个时机发生:
| 时机 | 谁来做 | 特点 | 代表 |
|---|---|---|---|
| 编译期 | 编译时把通知编进字节码 | 无运行时代理,性能最好 | AspectJ 编译期织入 |
| 类加载期(LTW) | 类被加载时改造字节码 | 需要 agent / aop.xml 配合 | AspectJ Load-Time Weaving |
| 运行期 | 运行中动态生成代理对象 | 接入零成本,无侵入 | Spring AOP 动态代理 |
Spring AOP 走的是第三条:运行期生成代理对象。这一点也直接决定了它的能力边界——代理对象本质上是个「长得一样」的替身,只能拦得住经过它发起的调用。
要理解 Spring AOP,最快的办法是先手写一个「最笨的代理」。假设有一个用户服务接口:
public interface UserService { User findById(Long id); void save(User user);}手写一个静态代理,把日志硬塞进去:
public class UserServiceLogProxy implements UserService { private final UserService target; // 真实的目标对象 private static final Logger log = LoggerFactory.getLogger(UserServiceLogProxy.class); public UserServiceLogProxy(UserService target) { this.target = target; } @Override public User findById(Long id) { log.info("findById 开始, id={}", id); User result = target.findById(id); // 转发给真实目标 log.info("findById 结束, 结果={}", result); return result; } @Override public void save(User user) { log.info("save 开始, user={}", user); target.save(user); log.info("save 结束"); }}UserServiceLogProxy与UserServiceImpl实现同一个接口,从外面看「长得一模一样」- 每个方法都是同一套套路:打前日志 → 调
target的同名方法 → 打后日志 - 真正干活的仍然是
target,代理只是「套了个壳」,但它成功把日志从业务代码里挪了出来
坑:静态代理的死穴不是「难写」,而是「写不完」。接口有 N 个方法就要写 N 个代理方法;接口一加方法,所有代理类都得跟着改。这还只是日志一个切面——再加事务、权限,代理类就要按组合数量爆炸。
代理模式的关键收获是:调用方持有的其实是代理的引用,代理在「转发」前后偷偷做了手脚。Spring AOP 只是把「手写代理」换成了「运行时自动生成代理」,动画演示的正是这条调用链:

面试官爱问:「AOP 是不是要取代 OOP?」标准答案是:不是取代,是补充。
- OOP 用继承和组合表达「是什么(is-a)」「有什么(has-a)」这类纵向的结构关系
- AOP 表达的是「在哪些地方、额外还要做点什么」这类横向的行为关系
- OOP 把业务切成一个个类;AOP 再把散落在这些类里的横切逻辑收拢回一处
换个比方:OOP 把公司画成了部门树,AOP 相当于给「所有部门都要盖章的流程」装了一台统一的印章机——部门结构(OOP)没有被推翻,只是多了一种跨部门的表达方式。
AOP 是 OOP 的补充。它解决的正是 OOP 的表达短板——有些逻辑天然属于「多个类共同的行为」,硬塞进任何一个类都不合适。
理解了「代理」这个本质,Spring AOP 的能力边界就顺理成章了。它不是「无所不能的魔法」,而是一层方法级的代理:
| 能力 | 是否支持 | 说明 |
|---|---|---|
| 方法执行拦截 | 支持 | 这是 Spring AOP 唯一的连接点类型 |
| 字段访问拦截 | 不支持 | 需要 AspectJ 编译期织入 |
| 构造器拦截 | 不支持 | Spring AOP 做不到 |
| 容器内的 Bean | 支持 | 只有被容器管理的对象才有代理 |
new 出来的对象 | 不支持 | 不在容器里,自然不会被增强 |
| 方法内部自调用 | 不支持 | 绕过代理,通知直接失效 |
三条边界要特别记住:
- 只支持方法级:字段读写、构造器调用都拦不住,那是 AspectJ 编译期织入的地盘
- 只管容器内的 Bean:
new出来的对象没有代理,自然没有通知 - 自调用会失效:
this.method()直接走原对象,绕过了代理——这是最常见的「切面不生效」原因
下面这个演示把「代理对象在调用前后插入通知」的过程可视化出来。切换 JDK / CGLIB / 自调用三种模式,能直观看到通知链在什么时候生效、什么时候直接断掉:
建议重点看「自调用」模式:目标方法里的 this.xxx() 根本不经过代理,通知自然一条都不会触发。
四个实验按顺序跑,每个 30 秒。第一个最有冲击力——同一个方法,没有切面 vs 织入切面,调用栈长得完全不一样:
上一节的手写静态代理你已经读懂了,现在看容器自动生成代理时通知链怎么跑。切到「同类自调用」,通知会一条都不打印——这就是第七节那条边界的现场证据:
再回答一个动机问题:为什么非 Spring 不可? 手写 12 步(自己管对象生命周期、自己拼装依赖)和交给容器的代价差在哪,cost 参数会把「省下的代码」和「换来的复杂度」一起摊给你看:
最后一层往下挖:代理到底是谁、在什么时刻给套上去的?答案是自动代理创建器——它是注册在容器里的一个 BeanPostProcessor(容器在造每个 Bean 前后插进来的钩子),在每个 Bean 初始化后置阶段问一句「你匹配某个切点吗?」,匹配就当场生成代理:
术语和壳都讲完了,剩下那句「通知链先进后出」才是真正的坎。下面这个实验把五种通知同时挂在同一个方法上,先把 normal 那一档的日志顺序抄下来——它会推翻很多人的第一直觉:@Around 的前半其实跑在 @Before 之前:
光看日志还是容易糊,因为日志是「一维」的,而通知链是「嵌套」的。把它摊成一次单步执行:左边是那八行,右边同步刷新此刻的变量与调用栈,连点「下一步」,盯住第 ④ 步——目标方法只执行那一次,其余七步全在它外面转:
orderService.create(dto); // ① 调用打到代理,进入拦截器链[@Around] long start = System.nanoTime(); // ② 环绕的前半,其实是第一个[@Before] log.info(「in」); // ③ 前置通知[target] orderRepository.save(order); // ④ 唯一干业务活的一行[@AfterReturning] counter.add(result); // ⑤ 只有正常返回才跑[@After] MDC.remove(「op」); // ⑥ finally 语义,正常异常都跑[@Around] pjp.proceed() 返回,打印耗时 // ⑦ 环绕的后半,倒数第二return order; // ⑧ 洋葱退完,调用方才拿到值| orderService | OrderServiceImpl$$SpringCGLIB$$0 |
| 调用方线程 | main |
AopDemo.mainproxy.create顺手把「洋葱」这条动图对照着通知链再看一遍,进关与退关的顺序就刻住了:

上面那八步是「一个切面」的情形。真到了生产上往往是两三个切面叠在一起,谁进谁出就容易背糊了——来玩一局:左边点注解,右边点它真实的触发时机,配错当场给解释。
两条最阴的路径值得各开一次实验。第一条:@Around 忘了 proceed():
第二条:目标方法抛异常时,哪几条通知会跑、哪几条不会——@AfterReturning 一条都不响,这就是很多「失败埋点永远没数据」的原因:
实验做到这里,可以换成命令行自己敲了。下面这台控制台连着浏览器里的同一个容器,回答全部由内核算出来——先 boot 起容器,beans 看哪些 Bean 被套了壳,再逐条 lab:
「切面不生效」的原因有五个开关,逐个拨一遍比背清单快得多。这张沙盘刻意只定义了有决定性差异的组合:只要失效原因已经确定(比如对象压根不在容器里),再叠加别的问题也不会产生新现象,所以未列出的混搭会归到最接近的一格结论。
[log] before save ...# 权限 → 日志 → 事务 → 目标方法 → 提交 → 打印耗时return: ok结论:唯一完全生效的组合
用法建议:先把四个开关全拨到最「正」的那一格(第一行输出)当作基准,然后每次只改一个开关,看少了哪一步。这样你会自己总结出「代理三要素」:调用要经过代理、方法得能被改写、对象得在容器里。
AOP 最阴的地方在于:多数失败一声不响。以下表格按「现象 → 原因 → 自救」排。
先记住一条排查铁律:切面不生效时,第一件事不是改切点,而是打印 getClass().getName()——如果打出来是 com.example.UserServiceImpl 而不是 ...$$EnhancerBySpringCGLIB$$... 或 $Proxy...,说明代理根本没生成,后面查什么都白费。
| 报错原文(片段) | 真实原因 | 30 秒自救 | 深挖看第几篇 |
|---|---|---|---|
| 无异常、无日志,切面就是不执行(最常见) | ① 纯 Spring 项目忘 @EnableAspectJAutoProxy;② Bean 不在容器里(手动 new);③ 方法不是 public | 先打 getClass().getName() 看有没有代理;再确认目标类带 @Service/@Component 且在扫描路径内;最后把方法改 public | 本篇第七节 + 第 14 篇 |
java.lang.NoClassDefFoundError: org/aspectj/lang/annotation/Aspect | 引了 @Aspect 的注解却没加 AspectJ 运行时依赖 | 补 spring-boot-starter-aop(它同时带来 aspectjweaver) | 第 13 篇 |
BeanCreationException: Failed to instantiate [com.example.TimingAspect] + Caused by: java.lang.IllegalArgumentException: error The near term's relationship is malformed; a valid WildcardType is required | 切点表达式语法写错,容器创建切面 Bean 时就炸(异常来自 AspectJ 的表达式解析器);常见于包名拼错、少写 ..、引用了不存在的类型 | 逐段核对 execution(...) 的返回类型/包名/参数部分,先用 within(com.example..*) 粗匹配验证链路通不通 | 第 13 篇 |
@Transactional 标的方法里抛异常却提交了 | 默认只对 RuntimeException/Error 回滚,受检异常不回滚 | 用 @Transactional(rollbackFor = Exception.class);或在切面里显式包装 | 第 31 篇 |
Cannot proxy target class because CGLIB-Proxies are disabled 或 final 类报 IllegalArgumentException: Cannot subclass final class | 目标类是 final,CGLIB 无法生成子类 | 去掉 final;或让该类实现接口改走 JDK 代理 | 第 12 篇 |
| 同类方法互调后通知丢失,调试时栈里没有代理帧 | this.method() 根本没经过代理对象 | 抽到另一个 Bean;或注入自身代理;或 ((MyService) AopContext.currentProxy()).method() 配合 exposeProxy = true | 本篇第七节 + 第 15 篇 |
org.aspectj.weaver.reflect.ReflectionWorld$ReflectionWorldException: warning can't determine annotations of missing type on class path | 切点范围太宽,AspectJ 反射世界扫到了编译期不在 classpath 上的类型 | 给切点加 within(com.example..*) 收窄范围,或把缺失依赖补进 classpath | 第 13 篇 |
| 切面在测试里生效、线上不生效(或反之) | 两处走的代理类型不同:一边有接口(JDK),一边强制 CGLIB | 统一 spring.aop.proxy-target-class;按接口注入而不是实现类 | 第 12 篇第六节 |
spring.aop.proxy-target-class=true(Boot 默认)会让所有 Bean 都走 CGLIB,好处是按实现类注入也不会炸,代价是 final 类必须处理。
上面表格里那条 Cannot subclass final class 是最值得练的一次——它是 AOP 里少数「启动就炸、栈还很短」的报错,凶手通常一眼能指出来。先别看答案,点出你认为的那一帧:
刚加了一个记耗时的 @Aspect,什么都没改业务,应用启动直接失败。
先写一个「裸」Service,再加一个切面,用日志行数证明切面真的生效。完整可跑代码:
package com.example.aop;// 1) 业务接口与实现:里面一行日志都没有public interface OrderService { String create(String sku);}@Servicepublic class OrderServiceImpl implements OrderService { @Override public String create(String sku) { return "order-" + sku; // 只做正事 }}package com.example.aop;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.Around;import org.aspectj.lang.annotation.Aspect;import org.aspectj.lang.annotation.Pointcut;import org.springframework.stereotype.Component;@Aspect // 告诉容器:这是个切面@Component // 让它成为 Bean —— 少了这句切面不会被发现public class TimingAspect { @Pointcut("execution(* com.example.aop.OrderService.*(..))") public void orderApi() {} // 切点:连接点的「筛选条件」 @Around("orderApi()") public Object timeIt(ProceedingJoinPoint pjp) throws Throwable { long start = System.nanoTime(); System.out.println("[around] in -> " + pjp.getSignature().getName()); Object result = pjp.proceed(); // 往洋葱里走,最终调到目标方法 System.out.println("[around] out -> " + pjp.getSignature().getName() + " result=" + result + " cost=" + (System.nanoTime() - start) / 1000 + "us"); return result; }}package com.example.aop;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.context.ConfigurableApplicationContext;@SpringBootApplicationpublic class AopDemo { public static void main(String[] args) { try (ConfigurableApplicationContext ctx = SpringApplication.run(AopDemo.class, args)) { OrderService svc = ctx.getBean(OrderService.class); System.out.println("[class] " + svc.getClass().getName()); System.out.println("[call ] " + svc.create("A1001")); } }}预期日志(关键四行):
[class] com.sun.proxy.$Proxy78 # 或 ...OrderServiceImpl$$SpringCGLIB$$0[around] in -> create[around] out -> create result=order-A1001 cost=xxxus[call ] order-A1001看到 [class] 那行是个你不认识的类名,就说明「容器递给你的是替身」;in/out 两行成对出现,说明通知链真的把目标方法包在了中间。
目标:不加任何新切面,只改调用方式,让上面那两行 [around] 消失。
提示:在 OrderServiceImpl 里加一个方法 public String createTwice(String sku) { return create(sku) + "/" + create(sku); },然后在 main 里改调 svc.createTwice("A1001")。
你会观察到:createTwice 被拦到了(一对 [around]),但它内部的两次 create(...) 没有再打印任何 [around],最终结果却是 order-A1001/order-A1001。这就是自调用失效的铁证——把这一组日志贴进笔记,比背十遍结论有用。
做一个「方法级三合一」切面:对所有 com.example..service..* 的 public 方法同时做到 ① 打印入参与耗时 ② 记录异常并原样抛出 ③ 用一个 ConcurrentHashMap 统计每个方法的调用次数,并提供一个只读接口返回这份统计。
验收清单:
- [ ] 只用一个
@Aspect类完成三件事,业务代码里没有任何 log/try-catch/计数器 - [ ]
@AfterThrowing里能拿到异常对象,且异常仍能正常冒泡到调用方(不许吞掉) - [ ] 故意制造一次自调用,说明统计次数为什么会「少算」,并给出修复方案
- [ ] 用
within与execution各写一版切点,写下两者的可读性差异 - [ ] 能说清:这份统计如果要变成生产指标,为什么不该塞在切面里而该交给 Micrometer
能否用「手机套壳」讲清动态代理到底代理了什么,并说出一句「手机本身没变,递给你的那台变了」?
切面、切点、连接点、通知、织入这五个词,能不能各配一个生活角色(规则手册/筛选条件/每一个门/具体动作/装壳这道工序)?
Spring AOP 的三个失效场景是什么?(自调用、非 public/final/static、对象不在容器里)
通知链的进出顺序能不能口述?(先进后出,最内层的通知最先退出)
切面不生效时,你的第一步动作是什么?(打印 getClass().getName() 确认代理是否生成)
切点选门、通知定动作、切面打包、代理落地;自调用不走门,通知一概不响。
AOP 要解决的是「横切关注点被复制进每个方法」的问题。理解它抓住三层就够了——概念上,它是 OOP 的补充,负责横向的行为;机制上,它靠在运行期生成代理对象来「织入」通知;边界上,它只能拦方法级、且只对容器内的 Bean 生效,自调用会失效。下一节我们把代理对象本身拆开看:JDK 和 CGLIB 到底是怎么造出这个替身的。