Spring AOP 切面越写越多怎么办?8 类场景的一条有序处理器链
摘要: 当日志、参数校验、权限、幂等和异常处理同时进入 Spring AOP,难点会从“如何拦截”变成“如何协作”。本文拆解 MetaLite 的 8 类切面场景、三段生命周期、fail-fast 日志补偿及两阶段初始化。
日志、参数校验、权限、幂等、限流、加解密、异常转换……
这些逻辑都适合用 Spring AOP 吗?
单独看,每一个都适合。放到同一个系统里,问题就出现了:谁先执行,谁后执行;前面的校验失败以后,后面的日志还要不要执行;目标方法抛异常以后,是转换响应还是继续抛出?
如果每项能力都维护一个独立切面,真正困难的不是写 @Around,而是管理它们共同组成的执行协议。
MetaLite 的做法是:AOP 只负责识别调用场景,具体逻辑进入统一的 AspectHandlerChain,并通过 preHandle、postHandle、errorHandle 三段生命周期协作。
一、多个独立切面为什么会逐渐失控
假设一个 API 同时需要下面这些能力:
建立 TraceId
→ 记录请求日志
→ 参数校验
→ 权限校验
→ 防重复提交
→ 执行业务
→ 响应加密
→ 清理明文
→ 记录响应日志
全部写成独立 @Aspect 后,至少要处理四类问题:
@Order分散在多个类里,完整顺序不直观;- 每个切面都要重复编写 try/catch/finally;
- 校验失败时,其他切面是否仍然执行很难统一;
- API、定时任务、MQ 消费和 DAO 调用需要不同异常语义。
这类复杂度不会随着切面数量线性增长。每新增一个切面,都要重新检查它和已有切面的相对位置及失败行为。
因此,MetaLite 把“拦截调用”和“处理调用”拆成两部分。
二、AOP 只识别 8 类调用场景
当前源码中的 AspectTypeEnum 一共定义了 8 类场景,而不是文档旧描述中的 7 类:
| 分类 | 切面类型 | 作用 |
|---|---|---|
| 入口 | API_RECEIVE | 接收 HTTP API 请求 |
| 入口 | JOB_RUN | 执行定时任务 |
| 入口 | MQ_CONSUME | 消费消息 |
| 调用 | DAO_CALL | 访问数据库 |
| 调用 | API_CALL | 发起内部 HTTP 调用 |
| 调用 | MQ_PRODUCE | 生产消息 |
| 调用 | REDIS_CALL | 访问 Redis |
| 调用 | CAFFEINE_CALL | 访问本地缓存 |
这里最关键的不是数字,而是“入口”和“调用”的区分。
入口代表一次业务执行的边界,需要建立 TraceId、接收上游上下文,并在结束时完成清理。
调用发生在业务执行内部,应该沿用当前上下文,记录具体依赖调用,却不应该重新创建一条链路。
三、每种场景拥有自己的 Handler Chain
所有处理器实现同一个接口:
public interface AspectHandler {
AspectTypeEnum aspectType();
default Resp preHandle(AspectInfo aspectInfo) {
return Resp.ok();
}
default void postHandle(AspectInfo aspectInfo, Object result) {
}
default void errorHandle(AspectInfo aspectInfo, Throwable throwable) {
}
}
一个处理器先声明自己属于哪种切面类型,再按需实现三个生命周期方法。
AspectHandlerChain 启动时扫描全部 AspectHandler Bean,按照 aspectType() 分组,并读取类上的 @Order 升序排列。
最终得到的不是一条包含所有能力的超级链,而是 8 条彼此独立的处理器链:
API_RECEIVE → 参数、权限、幂等、日志……
JOB_RUN → 任务执行控制、日志……
MQ_CONSUME → 消费上下文、日志……
DAO_CALL → SQL 调用日志……
API_CALL → RPC 调用日志……
...
新增能力时,开发者实现一个 Handler,并明确它属于哪个生命周期,不需要再复制一套 AOP 环绕模板。
四、preHandle 为什么必须支持 fail-fast
前置阶段按 @Order 从小到大执行。一旦某个处理器返回失败响应,目标方法和后续普通处理器都不再执行:
for (AspectHandler handler : handlers) {
Resp resp = handler.preHandle(aspectInfo);
if (!resp.isOk()) {
return resp;
}
}
这适合参数校验、权限检查和重复提交控制。失败已经可以预期地表达为 Resp,没有必要再通过异常绕一圈。
但这里存在一个容易忽略的细节:失败请求仍然需要日志。
如果权限处理器提前返回,而日志处理器排在链尾,简单的 fail-fast 会让这次拒绝没有任何完整记录。MetaLite 在失败后会从链尾向前寻找 BaseAspectLogger,补充执行日志处理器的前置逻辑。
因此,它实现的是:
业务处理 fail-fast,观测能力不能一起被短路。
这个细节比“使用了责任链模式”更重要,因为生产问题往往恰好发生在被拒绝的请求上。
五、入口层与调用层为什么采用不同异常语义
BaseAspect 提供两套环绕执行模板。
入口层包括 API、Job 和 MQ 消费,是框架与外部触发源的边界。目标方法抛出异常时,入口层会调用 errorHandle,再将异常转换成统一的 Resp:
try {
result = pjp.proceed();
} catch (Throwable ex) {
aspectHandlerChain.applyErrorHandle(aspectInfo, ex);
result = Resp.error(ex);
} finally {
aspectHandlerChain.applyPostHandle(aspectInfo, result);
}
非入口调用层则不同。DAO、Redis、RPC 等调用失败后,异常必须继续向上抛,让业务事务和最外层入口看到真实失败:
try {
result = pjp.proceed();
} catch (Throwable ex) {
throw ex;
} finally {
aspectHandlerChain.applyPostHandle(aspectInfo, result);
}
如果 DAO 层过早把异常转换为一个普通响应,上层可能误以为方法已经正常返回,事务边界和失败语义都会变得模糊。
所以统一异常处理不等于“每一层都吞掉异常”。更合理的规则是:
- 内部调用保留异常语义;
- 到达系统入口后,再转换成外部可识别的结果。
六、postHandle 不是传统责任链的反向退出
很多责任链或过滤器模型会在退出阶段按相反顺序执行。
MetaLite 当前实现并非如此:postHandle 和 errorHandle 都按照与 preHandle 相同的升序遍历。
for (AspectHandler handler : handlers) {
handler.postHandle(aspectInfo, result);
}
这意味着 @Order 定义的是每个阶段各自的顺序,而不是“进入时升序、退出时降序”的栈模型。
编写响应加密、明文清理和响应日志等 Handler 时,必须基于这一事实设置顺序。不能只凭常见过滤器经验推断后置阶段会自动反转。
这是扩展该处理器链时最需要写进规范的一条边界。
七、为什么初始化要分成两个阶段
处理器链还有一个不太常见的工程细节。
业务 Bean 可能在 @PostConstruct 中调用 InternalServiceClient,RPC AOP 又会提前触发 AspectHandlerChain。此时 Spring 单例还没有全部创建完成,如果只在普通启动完成后扫描 Handler,早期调用可能拿到空链甚至出现空指针。
当前实现采用两阶段初始化:
ensureInitialized()在早期调用发生时做一次临时扫描,但不设置最终完成标记;SmartInitializingSingleton.afterSingletonsInstantiated()在所有单例就绪后再次扫描,补齐此前尚未创建的 Handler,并设置完成标记;- 使用 Bean 名称集合去重,避免同一个 Handler 注册两次。
这不是为了追求复杂技巧,而是处理 Spring 生命周期中的真实时序问题:
@PostConstruct 业务调用
→ AOP 提前触发
→ 临时扫描保证可用
→ 所有单例完成
→ 最终扫描补齐处理器
八、这套设计的扩展边界
处理器链降低了多个独立切面的协作成本,但它并不是没有约束。
当前扩展时至少要遵守以下规则:
- 每个 Handler 必须声明
@Order,当前排序代码会直接读取该注解; postHandle按升序执行,不会自动反转;AspectInfo是一次同步调用内共享的对象,不能默认安全地跨线程传播;- 入口层会转换异常,调用层继续抛出异常;
- 失败后的日志补偿只识别
BaseAspectLogger类型。
把这些规则公开出来,比把责任链包装成“万能扩展机制”更可靠。
九、真正要统一的是执行协议
Spring AOP 并不难,难的是当横切能力越来越多以后,系统仍能明确回答:
- 当前属于哪一种调用场景;
- 谁先执行、谁后执行;
- 哪些失败应该立即返回;
- 哪些异常必须继续传播;
- 失败请求怎样保留日志;
- Spring Bean 尚未全部就绪时怎样保证链可用。
MetaLite 的处理器链本质上是在统一这些执行协议。AOP 负责把调用交给正确的链,Handler 负责完成各自阶段的工作。
下一篇可以继续拆解其中最适合搜索排障的细节:为什么 @PostConstruct 中的一次 RPC 调用,会在所有 Handler Bean 就绪之前触发 AOP。
框架简介:元界 MetaLite — 下一代企业级 Java 微服务技术底座
作者简介:基于 Spring 体系 15 年企业级开发经验,专注于通过企业级生产环境落地的工程思维和架构思想打造下一代Java 微服务技术底座
完整文档与源码:Gitee 搜索 MetaLite (gitee.com/MetaLite)