Spring-AOP切面越写越多怎么办-8类场景的一条有序处理器链

0 阅读8分钟

Spring AOP 切面越写越多怎么办?8 类场景的一条有序处理器链

摘要: 当日志、参数校验、权限、幂等和异常处理同时进入 Spring AOP,难点会从“如何拦截”变成“如何协作”。本文拆解 MetaLite 的 8 类切面场景、三段生命周期、fail-fast 日志补偿及两阶段初始化。

日志、参数校验、权限、幂等、限流、加解密、异常转换……

这些逻辑都适合用 Spring AOP 吗?

单独看,每一个都适合。放到同一个系统里,问题就出现了:谁先执行,谁后执行;前面的校验失败以后,后面的日志还要不要执行;目标方法抛异常以后,是转换响应还是继续抛出?

如果每项能力都维护一个独立切面,真正困难的不是写 @Around,而是管理它们共同组成的执行协议。

MetaLite 的做法是:AOP 只负责识别调用场景,具体逻辑进入统一的 AspectHandlerChain,并通过 preHandlepostHandleerrorHandle 三段生命周期协作。

一、多个独立切面为什么会逐渐失控

假设一个 API 同时需要下面这些能力:

建立 TraceId
→ 记录请求日志
→ 参数校验
→ 权限校验
→ 防重复提交
→ 执行业务
→ 响应加密
→ 清理明文
→ 记录响应日志

全部写成独立 @Aspect 后,至少要处理四类问题:

  1. @Order 分散在多个类里,完整顺序不直观;
  2. 每个切面都要重复编写 try/catch/finally;
  3. 校验失败时,其他切面是否仍然执行很难统一;
  4. 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 当前实现并非如此:postHandleerrorHandle 都按照与 preHandle 相同的升序遍历。

for (AspectHandler handler : handlers) {
    handler.postHandle(aspectInfo, result);
}

这意味着 @Order 定义的是每个阶段各自的顺序,而不是“进入时升序、退出时降序”的栈模型。

编写响应加密、明文清理和响应日志等 Handler 时,必须基于这一事实设置顺序。不能只凭常见过滤器经验推断后置阶段会自动反转。

这是扩展该处理器链时最需要写进规范的一条边界。

七、为什么初始化要分成两个阶段

处理器链还有一个不太常见的工程细节。

业务 Bean 可能在 @PostConstruct 中调用 InternalServiceClient,RPC AOP 又会提前触发 AspectHandlerChain。此时 Spring 单例还没有全部创建完成,如果只在普通启动完成后扫描 Handler,早期调用可能拿到空链甚至出现空指针。

当前实现采用两阶段初始化:

  1. ensureInitialized() 在早期调用发生时做一次临时扫描,但不设置最终完成标记;
  2. SmartInitializingSingleton.afterSingletonsInstantiated() 在所有单例就绪后再次扫描,补齐此前尚未创建的 Handler,并设置完成标记;
  3. 使用 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)