AOP 思想入门:横切关注点与代理模式

bee2026-10-0841 分钟0 次阅读
每个方法都要打日志、加事务、查权限——重复代码是怎么被抽走的?从横切关注点讲到代理模式,把 AOP 的全部术语用一张图和一段动画讲明白。
1 / 116
小节
〇、30 秒看懂
2 / 116

一句话讲完 AOP:把「每个方法都要顺便做一遍」的代码,从每个方法里抽出来,集中写一份,让容器在运行期偷偷帮你贴回去。被抽走的东西通常是日志、事务、权限、限流;贴回去的执行者叫代理对象——一个和你写的类同名同姓、却多干了点活的替身。

3 / 116
类比

动态代理到底代理了什么?答案是什么都没改。就像给手机套壳:壳子有按键孔、有充电口,手感跟原来一模一样,你按下电源键还是手机在关机——但壳子顺手加了一层防摔缓冲。手机本身没变,变的是「别人递给你的是什么」:容器递给你的从来不是那台裸机,而是套好壳的那台。你的业务代码(手机)一行都不用动,日志/事务这些额外能力全在壳上。

4 / 116
类比

通知链像洋葱,也像闯关。一次调用要一层层往里穿:第一关查门禁(权限)、第二关打卡计时(日志)、第三关领工牌(开事务),走到最里面才是真正干活的肉(目标方法);出来时反过来,最里面的关先退——先交工牌提交事务,再补打卡记录耗时。所以「先进后出、后进先出」是理解所有通知执行顺序的钥匙。

5 / 116
架构图
图 · AOP 知识地图
图 · AOP 知识地图
6 / 116

学完这节,你要能回答三个问题:

7 / 116
  1. 「横切关注点」到底指什么,为什么它不该长在业务方法里?
  2. 切面 / 切点 / 连接点 / 通知 / 织入这五个词,各自对应现实里的哪个动作?
  3. 为什么说 Spring AOP 只是「方法级的代理」,它的三个失效场景是什么?
8 / 116
小节
一、先看现场:被样板代码淹没的 Service
9 / 116

镜头拉到一次代码评审。需求很普通:所有 Service 方法记录入参、出参和耗时;关键的写操作要包事务;后台接口要校验权限。三句话能讲完,落到代码上却是这样:

10 / 116
代码对照
代码java
@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);    }}
解读
  • 每个方法开头都要取时间戳、打一条入参日志
  • 每个方法结尾都要打一条出参日志和耗时
  • 中间那几行才是方法的「本职工作」,其余都是为了日志而做的复制粘贴
11 / 116

一个模块 10 个 Service、平均每个 12 个方法,就是 120 次复制。改一次日志格式,要改 120 处——这正是横切逻辑最难受的地方:它跟业务几乎不沾边,却和业务一样频繁地被修改。

12 / 116
提示

判断一段代码是不是「横切关注点」,看一个信号就够了——它是否出现在很多个方法里、且内容高度雷同。日志、事务、权限、限流、缓存、监控全都符合这个特征。

13 / 116

把「继续复制粘贴」和「抽成一个切面」并排摆出来,收益与代价一目了然:

14 / 116
架构图
图 · 复制粘贴 try-catch+log vs 一个切面
图 · 复制粘贴 try-catch+log vs 一个切面
15 / 116

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

16 / 116
原理动画
动图 · 120 段复制粘贴收拢成一个切面
动图 · 120 段复制粘贴收拢成一个切面
17 / 116
小节
二、核心关注点 vs 横切关注点
18 / 116

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

19 / 116
架构图
图 1 · 纵向业务 vs 横向切面
图 1 · 纵向业务 vs 横向切面
20 / 116
  • 核心关注点(纵向):Controller → Service → Repository,一层层回答「这个业务的流程是什么」,它沿着调用链竖着走
  • 横切关注点(横向):日志、事务、权限……它们不属于某个具体业务,却要「横着」穿过几乎每一个方法
21 / 116

典型的横切关注点以及不做它们的代价:

22 / 116
对照表
横切关注点在做什么缺失的后果
日志记录入参、出参、耗时、异常线上出问题无从追溯
事务一组操作要么全成功要么全回滚数据不一致
权限判断当前用户能否执行该操作越权访问
限流控制单位时间的请求量被突发流量打垮
缓存复用结果,减少重复计算性能差、数据库压力大
监控埋点、指标、链路追踪系统没有可观测性
23 / 116

一句话收束:核心关注点决定「做什么业务」,横切关注点决定「顺便还要做什么」。前者天然长在方法里,后者天然想被「抽走」——AOP 就是为「抽走」而生的技术。

24 / 116
小节
三、AOP 术语一口气讲完
25 / 116

很多人觉得 AOP 难,是被一堆术语先劝退了。其实只要抓住一条主线:切点负责筛选,通知负责执行,切面把两者打包,代理负责落地。

26 / 116
对照表
术语一句话解释Spring 里的对应物
Aspect 切面横切逻辑的封装体加了 @Aspect 的类
JoinPoint 连接点程序执行中可以插入逻辑的点每一个可被拦截的方法执行
Pointcut 切点用表达式筛出要拦哪些连接点@Pointcut("execution(...)")
Advice 通知在连接点上真正执行的代码@Before / @After / @Around……
Target 目标对象被代理的原始对象你的 UserServiceImpl
Proxy 代理目标对象 + 通知组成的替身JDK Proxy / CGLIB 生成的对象
Weaving 织入把切面应用到目标上的过程创建代理对象的那一步
27 / 116

把它们串成一句话:切面用切点从连接点里筛出一批方法,在这些方法上挂通知;运行期由代理包住目标对象,完成织入。记住这条链,术语就不再是零散的名词。

28 / 116
小节
四、织入的三种时机
29 / 116

「织入」听着玄,其实就是「把通知塞进目标方法的执行流程」那一下。它可以在三个时机发生:

30 / 116
对照表
时机谁来做特点代表
编译期编译时把通知编进字节码无运行时代理,性能最好AspectJ 编译期织入
类加载期(LTW)类被加载时改造字节码需要 agent / aop.xml 配合AspectJ Load-Time Weaving
运行期运行中动态生成代理对象接入零成本,无侵入Spring AOP 动态代理
31 / 116

Spring AOP 走的是第三条:运行期生成代理对象。这一点也直接决定了它的能力边界——代理对象本质上是个「长得一样」的替身,只能拦得住经过它发起的调用。

32 / 116
小节
五、代理模式:静态代理先手写一遍
33 / 116

要理解 Spring AOP,最快的办法是先手写一个「最笨的代理」。假设有一个用户服务接口:

34 / 116
java
public interface UserService {    User findById(Long id);    void save(User user);}
35 / 116

手写一个静态代理,把日志硬塞进去:

36 / 116
代码对照
代码java
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 个代理方法;接口一加方法,所有代理类都得跟着改。这还只是日志一个切面——再加事务、权限,代理类就要按组合数量爆炸。

37 / 116

代理模式的关键收获是:调用方持有的其实是代理的引用,代理在「转发」前后偷偷做了手脚。Spring AOP 只是把「手写代理」换成了「运行时自动生成代理」,动画演示的正是这条调用链:

38 / 116
原理动画
动图 · 代理模式:调用如何被拦截
动图 · 代理模式:调用如何被拦截
39 / 116
小节
六、AOP 与 OOP 的关系(一道高频面试题)
40 / 116

面试官爱问:「AOP 是不是要取代 OOP?」标准答案是:不是取代,是补充。

41 / 116
  • OOP 用继承和组合表达「是什么(is-a)」「有什么(has-a)」这类纵向的结构关系
  • AOP 表达的是「在哪些地方、额外还要做点什么」这类横向的行为关系
  • OOP 把业务切成一个个类;AOP 再把散落在这些类里的横切逻辑收拢回一处
42 / 116

换个比方:OOP 把公司画成了部门树,AOP 相当于给「所有部门都要盖章的流程」装了一台统一的印章机——部门结构(OOP)没有被推翻,只是多了一种跨部门的表达方式。

43 / 116
要点

AOP 是 OOP 的补充。它解决的正是 OOP 的表达短板——有些逻辑天然属于「多个类共同的行为」,硬塞进任何一个类都不合适。

44 / 116
小节
七、Spring AOP 的能力边界
45 / 116

理解了「代理」这个本质,Spring AOP 的能力边界就顺理成章了。它不是「无所不能的魔法」,而是一层方法级的代理:

46 / 116
对照表
能力是否支持说明
方法执行拦截支持这是 Spring AOP 唯一的连接点类型
字段访问拦截不支持需要 AspectJ 编译期织入
构造器拦截不支持Spring AOP 做不到
容器内的 Bean支持只有被容器管理的对象才有代理
new 出来的对象不支持不在容器里,自然不会被增强
方法内部自调用不支持绕过代理,通知直接失效
47 / 116

三条边界要特别记住:

48 / 116
  • 只支持方法级:字段读写、构造器调用都拦不住,那是 AspectJ 编译期织入的地盘
  • 只管容器内的 Bean:new 出来的对象没有代理,自然没有通知
  • 自调用会失效:this.method() 直接走原对象,绕过了代理——这是最常见的「切面不生效」原因
49 / 116
小节
八、动手体验:看通知链怎么挂上去
50 / 116

下面这个演示把「代理对象在调用前后插入通知」的过程可视化出来。切换 JDK / CGLIB / 自调用三种模式,能直观看到通知链在什么时候生效、什么时候直接断掉:

51 / 116
内核实验
TeaVM走进代理:一次调用的完整通知链未启动
切换 JDK / CGLIB / 自调用,看通知链什么时候会失效
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
52 / 116

建议重点看「自调用」模式:目标方法里的 this.xxx() 根本不经过代理,通知自然一条都不会触发。

53 / 116
小节
九、决策:什么时候该用 AOP
54 / 116
决策
决策你需要在 8 个 Service、共 40 多个 public 方法上统一加「执行耗时日志」,而且还会不断增加新方法。怎么做?
55 / 116
小节
十、上手实验:把「织入前后」摆在一起看
56 / 116

四个实验按顺序跑,每个 30 秒。第一个最有冲击力——同一个方法,没有切面 vs 织入切面,调用栈长得完全不一样:

57 / 116
内核实验
TeaVM切面织入前后对比未启动
先看 plain 的干净栈,再看 aspect 多出来的层,最后用 chain 钻进通知链内部
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
58 / 116

上一节的手写静态代理你已经读懂了,现在看容器自动生成代理时通知链怎么跑。切到「同类自调用」,通知会一条都不打印——这就是第七节那条边界的现场证据:

59 / 116
内核实验
TeaVMAOP 代理 · 通知链未启动
依次试 jdk / cglib / self / error,重点观察 self 下通知全部消失
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
60 / 116

再回答一个动机问题:为什么非 Spring 不可? 手写 12 步(自己管对象生命周期、自己拼装依赖)和交给容器的代价差在哪,cost 参数会把「省下的代码」和「换来的复杂度」一起摊给你看:

61 / 116
内核实验
TeaVM不用 Spring vs 用 Spring未启动
raw → spring → boot 走一遍,最后用 cost 看代价与收益
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
62 / 116

最后一层往下挖:代理到底是谁、在什么时刻给套上去的?答案是自动代理创建器——它是注册在容器里的一个 BeanPostProcessor(容器在造每个 Bean 前后插进来的钩子),在每个 Bean 初始化后置阶段问一句「你匹配某个切点吗?」,匹配就当场生成代理:

63 / 116
内核实验
TeaVM自动代理创建器未启动
bpp 看拦截位置,candidate 看判定条件,factory 看代理工厂怎么配
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
64 / 116

术语和壳都讲完了,剩下那句「通知链先进后出」才是真正的坎。下面这个实验把五种通知同时挂在同一个方法上,先把 normal 那一档的日志顺序抄下来——它会推翻很多人的第一直觉:@Around 的前半其实跑在 @Before 之前:

65 / 116
内核实验
TeaVM五种通知的真实执行顺序未启动
选 normal,按日志顺序数一遍;再切 order 看两个切面怎么一层层穿插
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
66 / 116

光看日志还是容易糊,因为日志是「一维」的,而通知链是「嵌套」的。把它摊成一次单步执行:左边是那八行,右边同步刷新此刻的变量与调用栈,连点「下一步」,盯住第 ④ 步——目标方法只执行那一次,其余七步全在它外面转:

67 / 116
单步调试台
单步台单步走完洋葱:五种通知的真实落点1 / 8
按 ①→⑧ 连点下一步,注意第 ② 步比第 ③ 步先跑,第 ⑦ 步比第 ⑥ 步晚出栈
被调试的代码
1orderService.create(dto); // ① 调用打到代理,进入拦截器链
2[@Around] long start = System.nanoTime(); // ② 环绕的前半,其实是第一个
3[@Before] log.info(「in」); // ③ 前置通知
4[target] orderRepository.save(order); // ④ 唯一干业务活的一行
5[@AfterReturning] counter.add(result); // ⑤ 只有正常返回才跑
6[@After] MDC.remove(「op」); // ⑥ finally 语义,正常异常都跑
7[@Around] pjp.proceed() 返回,打印耗时 // ⑦ 环绕的后半,倒数第二
8return order; // ⑧ 洋葱退完,调用方才拿到值
此刻的变量
orderServiceOrderServiceImpl$$SpringCGLIB$$0
调用方线程main
调用栈
1AopDemo.main
2proxy.create
1这一步确认前提:注入进来的不是你自己 new 的对象,而是容器造的替身。类名里带 SpringCGLIB 或 $Proxy,才说明后面所有通知都有机会跑。
68 / 116

顺手把「洋葱」这条动图对照着通知链再看一遍,进关与退关的顺序就刻住了:

69 / 116
原理动画
动图 · 通知链像洋葱:一层层进、一层层出
动图 · 通知链像洋葱:一层层进、一层层出
70 / 116

上面那八步是「一个切面」的情形。真到了生产上往往是两三个切面叠在一起,谁进谁出就容易背糊了——来玩一局:左边点注解,右边点它真实的触发时机,配错当场给解释。

71 / 116
配对闯关
闯关通知类型 ↔ 它到底什么时候跑已配对 0/6 · 配错 0
左边六个是注解与写法,右边是它们真实的行为;这一局配完,第八节的日志顺序就不用背了
先点左边一个
72 / 116

两条最阴的路径值得各开一次实验。第一条:@Around 忘了 proceed():

73 / 116
内核实验
TeaVM@Around 不调 proceed():目标被整个吃掉未启动
选 around,看日志里业务那行为什么不见了,返回值还被人换掉了
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
74 / 116

第二条:目标方法抛异常时,哪几条通知会跑、哪几条不会——@AfterReturning 一条都不响,这就是很多「失败埋点永远没数据」的原因:

75 / 116
内核实验
TeaVM抛异常时哪些通知会跑未启动
选 error,对照上一条:异常路径只走 @AfterThrowing 与 @After
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
76 / 116

实验做到这里,可以换成命令行自己敲了。下面这台控制台连着浏览器里的同一个容器,回答全部由内核算出来——先 boot 起容器,beans 看哪些 Bean 被套了壳,再逐条 lab:

77 / 116
内核控制台
78 / 116
小节
十一、沙盘:这个方法到底会不会被增强
79 / 116

「切面不生效」的原因有五个开关,逐个拨一遍比背清单快得多。这张沙盘刻意只定义了有决定性差异的组合:只要失效原因已经确定(比如对象压根不在容器里),再叠加别的问题也不会产生新现象,所以未列出的混搭会归到最接近的一格结论。

80 / 116
沙盘
沙盘这次调用会被通知拦住吗
运行结果
[log] before save ...
# 权限 → 日志 → 事务 → 目标方法 → 提交 → 打印耗时
return: ok
结论:唯一完全生效的组合
这是基准线:其余三种组合都在它之上少做点什么
81 / 116

用法建议:先把四个开关全拨到最「正」的那一格(第一行输出)当作基准,然后每次只改一个开关,看少了哪一步。这样你会自己总结出「代理三要素」:调用要经过代理、方法得能被改写、对象得在容器里。

82 / 116
小节
十二、常见报错速查
83 / 116

AOP 最阴的地方在于:多数失败一声不响。以下表格按「现象 → 原因 → 自救」排。

84 / 116

先记住一条排查铁律:切面不生效时,第一件事不是改切点,而是打印 getClass().getName()——如果打出来是 com.example.UserServiceImpl 而不是 ...$$EnhancerBySpringCGLIB$$... 或 $Proxy...,说明代理根本没生成,后面查什么都白费。

85 / 116
对照表
报错原文(片段)真实原因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 篇第六节
86 / 116
提示

spring.aop.proxy-target-class=true(Boot 默认)会让所有 Bean 都走 CGLIB,好处是按实现类注入也不会炸,代价是 final 类必须处理。

87 / 116

上面表格里那条 Cannot subclass final class 是最值得练的一次——它是 AOP 里少数「启动就炸、栈还很短」的报错,凶手通常一眼能指出来。先别看答案,点出你认为的那一帧:

88 / 116
报错急救
报错急救IllegalArgumentException: Cannot subclass final class
给 final 类套 CGLIB 壳的报错现场

刚加了一个记耗时的 @Aspect,什么都没改业务,应用启动直接失败。

APPLICATION FAILED TO START
java.lang.IllegalArgumentException: Cannot subclass final class com.bee.order.service.OrderService
at org.springframework.cglib.proxy.Enhancer.generateClass(Enhancer.java:667)
at org.springframework.cglib.proxy.Enhancer.create(Enhancer.java:522)
at org.springframework.aop.framework.CglibAopProxy.getProxy(CglibAopProxy.java:208)
at org.springframework.aop.framework.ProxyFactory.getProxy(ProxyFactory.java:110)
at org.springframework.aop.framework.autoproxy.AbstractAutoProxyCreator.createProxy(AbstractAutoProxyCreator.java:483)
at org.springframework.aop.framework.autoproxy.AbstractAutoProxyCreator.wrapIfNecessary(AbstractAutoProxyCreator.java:352)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBeanAndFindTargetSource(AbstractAutowireCapableBeanFactory.java:2971)
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
89 / 116
小节
十三、随堂自测
90 / 116
随堂自测
随堂自测下面哪种写法能让同类里的 a() 调用 b() 时,b() 上的通知照常生效?
先自己选一个,选中立刻告诉你对不对
91 / 116
随堂自测
随堂自测「动态代理到底代理了什么?」最准确的说法是?
先自己选一个,选中立刻告诉你对不对
92 / 116
小节
十四、动手练习
93 / 116
小节
第一档 · 照做
94 / 116

先写一个「裸」Service,再加一个切面,用日志行数证明切面真的生效。完整可跑代码:

95 / 116
java
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;          // 只做正事    }}
96 / 116
java
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;    }}
97 / 116
java
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"));        }    }}
98 / 116

预期日志(关键四行):

99 / 116
text
[class] com.sun.proxy.$Proxy78          # 或 ...OrderServiceImpl$$SpringCGLIB$$0[around] in  -> create[around] out -> create result=order-A1001 cost=xxxus[call ] order-A1001
100 / 116

看到 [class] 那行是个你不认识的类名,就说明「容器递给你的是替身」;in/out 两行成对出现,说明通知链真的把目标方法包在了中间。

101 / 116
小节
第二档 · 变体
102 / 116

目标:不加任何新切面,只改调用方式,让上面那两行 [around] 消失。

103 / 116

提示:在 OrderServiceImpl 里加一个方法 public String createTwice(String sku) { return create(sku) + "/" + create(sku); },然后在 main 里改调 svc.createTwice("A1001")。

104 / 116

你会观察到:createTwice 被拦到了(一对 [around]),但它内部的两次 create(...) 没有再打印任何 [around],最终结果却是 order-A1001/order-A1001。这就是自调用失效的铁证——把这一组日志贴进笔记,比背十遍结论有用。

105 / 116
小节
第三档 · 造一个
106 / 116

做一个「方法级三合一」切面:对所有 com.example..service..* 的 public 方法同时做到 ① 打印入参与耗时 ② 记录异常并原样抛出 ③ 用一个 ConcurrentHashMap 统计每个方法的调用次数,并提供一个只读接口返回这份统计。

107 / 116

验收清单:

108 / 116
  • [ ] 只用一个 @Aspect 类完成三件事,业务代码里没有任何 log/try-catch/计数器
  • [ ] @AfterThrowing 里能拿到异常对象,且异常仍能正常冒泡到调用方(不许吞掉)
  • [ ] 故意制造一次自调用,说明统计次数为什么会「少算」,并给出修复方案
  • [ ] 用 within 与 execution 各写一版切点,写下两者的可读性差异
  • [ ] 能说清:这份统计如果要变成生产指标,为什么不该塞在切面里而该交给 Micrometer
109 / 116
小节
十五、要点自查
110 / 116
自检

能否用「手机套壳」讲清动态代理到底代理了什么,并说出一句「手机本身没变,递给你的那台变了」?

111 / 116
自检

切面、切点、连接点、通知、织入这五个词,能不能各配一个生活角色(规则手册/筛选条件/每一个门/具体动作/装壳这道工序)?

112 / 116
自检

Spring AOP 的三个失效场景是什么?(自调用、非 public/final/static、对象不在容器里)

113 / 116
自检

通知链的进出顺序能不能口述?(先进后出,最内层的通知最先退出)

114 / 116
自检

切面不生效时,你的第一步动作是什么?(打印 getClass().getName() 确认代理是否生成)

115 / 116
口诀

切点选门、通知定动作、切面打包、代理落地;自调用不走门,通知一概不响。

116 / 116
总结

AOP 要解决的是「横切关注点被复制进每个方法」的问题。理解它抓住三层就够了——概念上,它是 OOP 的补充,负责横向的行为;机制上,它靠在运行期生成代理对象来「织入」通知;边界上,它只能拦方法级、且只对容器内的 Bean 生效,自调用会失效。下一节我们把代理对象本身拆开看:JDK 和 CGLIB 到底是怎么造出这个替身的。