责任链模式:Servlet Filter 和 Spring Interceptor 的实现对比
一句话结论(30s)
责任链模式的本质,是把「堆在一个方法里的一串处理/校验步骤」拆成可独立复用、按顺序串联、失败可逆序补偿的节点。我在个人项目「吃什么」本地生活点评平台里用它重构了消息发送链路——因为消息类型从 2 种扩展到 7 种后,所有前置校验堆进一个方法变成箭头型代码、圈复杂度冲到 23,改一个场景要动全局;重构后每个校验规则独立成一个 Handler,圈复杂度从 23 降到单节点 ≤3、整体约降 40%,单元测试覆盖率从 34% 提升到 92%。
背景诉求
- 业务增长:用户通知 + 业务消息两类消息,随着「吃什么」平台功能增多,扩展到 7 种消息类型,每种消息的校验规则都不一样。
- 痛点一:箭头型代码。所有前置校验堆在一个方法里,if/else 层层嵌套,代码一路向右缩进,形似箭头,可读性差。
- 痛点二:耦合高。改某一种消息的校验,要在同一个大方法里动刀,牵一发动全身,容易引入回归。
- 痛点三:扩展难。每加一种消息类型,就要再往方法里塞一段校验,方法越滚越长,圈复杂度一路飙到 23。
- 核心诉求:把「校验步骤」从「一个大方法」里拆出来,做到单点可测、顺序可编排、失败可补偿。
目标边界
做:
- 把消息发送的前置校验拆成一个个独立 Handler,按顺序组成链。
- 每个 Handler 只做一件事,圈复杂度压到 ≤3。
- 链执行支持「失败即停 + 逆序补偿」,保证已执行的步骤能被对称清理。
- 新增消息类型 = 新增/组合 Handler,而不是改旧代码。
不做:
- 不引入重量级流程引擎/规则引擎——一个轻量链,够用即止。
- 不做跨服务分布式编排,只解决单进程内校验链的复杂度问题。
- 不追求「纯责任链」的完全终止语义,采用「不纯责任链」(处理后继续传递 + 后置补偿)。
核心难点
- 节点怎么串联:不用递归(深链路有栈溢出风险),用迭代 + 索引(pos / interceptorIndex)驱动方法重入,链长安全无上限。
- 失败怎么处理:中间某节点失败后,后面的节点不再执行;关键是「已经执行过的节点怎么撤」——必须逆序补偿,让「做得越多、撤得越彻底」,与执行顺序对称。
- 顺序怎么保证:链的顺序即业务校验的先后(如先鉴权、再限流、再参数校验),顺序错了会导致依赖错误——后置步骤依赖前置步骤的产物。
- 补偿与主流程的对称性:每个 Handler 都要成对设计 handle()/compensate(),缺了补偿,异常路径就会漏清理,出现「回滚了一半」。
思考穿插:为什么链执行偏要用「迭代 + 索引」,而不直接递归? 递归的本质是「每个节点调用下一个节点」,节点不返回、栈帧就不弹,链一长调用栈里就压着一串未返回的栈帧,几十上百个节点可能先 StackOverflow。迭代 + 索引是把「推进到哪了」这个状态从调用栈搬到堆上的一个游标变量(pos / interceptorIndex),栈帧始终只有固定几层,链长就没有上限——这正是 Tomcat 放弃递归、改用迭代写 FilterChain 的底层原因。
关键取舍
责任链 vs 策略 vs 管道:
- 策略模式:解决「同一动作的多种算法可替换」,但一次只选一个,不解决「一串步骤按顺序叠加」的问题——校验是「都要过一遍」,不是「N 选 1」。
- 管道模式(Pipeline):本质和责任链接近,都能把步骤串起来;但管道强调「数据流经每一段被加工」,责任链更强调「每个节点可以决定是否放行/终止」,失败传播语义更贴合校验场景。
- 责任链:天然匹配「前置校验逐项放行、任一失败即停、可逆序补偿」的诉求,所以选中它。
- 为什么不直接用现成框架:Servlet Filter / Spring Interceptor 已经提供了责任链,但它们的粒度绑定在「请求生命周期」;消息发送是普通业务方法,复用框架的成本和耦合反而更高,自己写一个轻量链(十几行)更贴合场景。
思考穿插:责任链和策略模式到底什么区别? 一句判断锚点——调用方是要「选一个」还是「串一串」?策略解决「同一动作的 N 种算法里选一个执行」,一次只跑一个;责任链解决「N 个步骤按顺序都要过一遍、任一失败即停」,节点都会被依次触发。所以校验场景是「都要过」,天然该选责任链;而「支付方式 N 选 1」才是策略的主场。
个人行动
- 抽基类:定义
MessageHandler抽象类,两个方法handle(ctx)(正向处理,返回 boolean 决定是否放行)+compensate(ctx)(逆序补偿)。 - 写链执行器:
executeChain用游标executed记录已执行到哪,正向 for 循环逐个handle,失败即break;再逆序 for 循环从executed - 1往回compensate;最后返回「是否全部通过」。 - 迁移校验逻辑:把原大方法里的每段校验逐一拆成独立 Handler(鉴权、参数、频率、内容等),每个只保留自己的分支。
- 补测试:为每个 Handler 单独写单测(覆盖成功 / 失败 / 补偿三条路径),把覆盖率从 34% 拉到 92%。
- 对照学习:读 Tomcat
ApplicationFilterChain(迭代 + pos)和 SpringHandlerExecutionChain(interceptorIndex 逆序 afterCompletion),借鉴「迭代替代递归」「逆序补偿」两个设计点落到自己的链里。
结果与复盘
结果(数据):
- 圈复杂度:23 → 单节点 ≤3,整体约降 40%。
- 单元测试覆盖率:34% → 92%。
- 扩展成本:新增第 8 种消息 = 新增一个 Handler + 注册进链,旧代码零改动。
复盘(学到什么):
- 结论先行:这个优化的价值不在「用了设计模式」,而在「把复杂度从一个点摊薄成可独立测试的多个点」——因为单节点复杂度低,测试成本才降得下来。
- 补偿对称性是最容易被漏的点:主流程容易写,异常路径的逆序补偿才是责任链的精华,漏了就会出现「回滚了一半」。
- 不要为了模式而模式:先有 7 种消息、23 圈复杂度的真实痛点,再上责任链;如果只有 2 种消息硬上,反而徒增抽象成本。
三版本回答
30 秒版(一句话)
我用责任链模式重构了「吃什么」平台的消息发送链路——因为消息类型从 2 种扩展到 7 种后,前置校验堆成一个箭头型方法、圈复杂度冲到 23;重构后拆成独立 Handler 顺序串联、失败逆序补偿,圈复杂度降到单节点 ≤3、整体约降 40%,单测覆盖率从 34% 提到 92%。
2 分钟版(背景 → 难点 → 方案 → 结果)
背景:消息发送从 2 种类型扩到 7 种,每种校验不同,全堆一个方法,箭头型代码、耦合高、改一处动全局,圈复杂度 23。
难点:一是节点怎么串联,深链路用递归会栈溢出;二是中间节点失败,已执行过的节点怎么对称清理;三是顺序怎么保证。
方案:抽象 MessageHandler(handle + compensate),用迭代 + 游标驱动链执行,失败 break 后逆序 compensate,做到「做得越多、撤得越彻底」——这是从 Tomcat FilterChain(迭代 + pos 索引)和 Spring Interceptor(逆序 afterCompletion)借鉴的。
结果:圈复杂度 23 → 单节点 ≤3、整体约降 40%,单测覆盖率 34% → 92%,新增消息类型只加 Handler、不改旧代码。
5-10 分钟版(深度展开)
- 架构:责任链节点模型(
MessageHandler抽象类,handle/compensate 成对设计)+ 链执行器(executeChain游标 + 逆序补偿)+ 各校验 Handler 组合。 - 数据链路:消息进来 → 链按顺序逐个 handle → 任一失败 break → 逆序 compensate 已执行节点 → 返回是否全部通过 → 通过才真正发送。
- 方案取舍:为什么是责任链而不是策略(策略是 N 选 1,校验是都要过)、不是管道(责任链的「放行/终止」语义更贴合校验失败即停)、不是直接套 Servlet Filter(粒度绑定请求生命周期,普通业务方法复用成本高)。
- 异常路径:重点讲逆序补偿的对称性——A 先执行预操作、B 后执行,失败时先清 A 再清 B 会不对称,必须 C→B→A 逆序,保证「做得越多、撤得越彻底」。
- 源码对照:Tomcat
ApplicationFilterChain用迭代 + pos 索引替代递归(避免几十个 Filter 深链路栈溢出);SpringHandlerExecutionChain在 preHandle 失败时触发triggerAfterCompletion逆序补偿。 - 稳定性:每个 Handler 独立单测(成功/失败/补偿三条路径),覆盖率 92% 兜底;新增类型回归面只有新 Handler。
- 钩子:其中「逆序补偿的对称性」最有意思,要不要展开讲?
底层深入(技术细节)
纯 vs 不纯责任链
- 纯责任链:每个处理器要么完全处理请求并终止链,要么完全放行给下一个。处理后不再继续传递
- 不纯责任链:处理后仍继续传递。Servlet Filter、Spring Interceptor、Netty Pipeline 都是不纯责任链——前置处理(
preHandle/doFilter之前的代码)→ 传递 → 后置处理(afterCompletion/doFilter之后的代码)
源码说明:Servlet 不在 JDK 里
javax.servlet.Filter / FilterChain 不属于 JDK——它们是 Jakarta EE 的 Servlet API(由 Tomcat / Jetty 提供),不在本仓库 library/jdk 源码树里。所以这里用两处真实存在于源码树的等价实现来讲链式调用与逆序补偿:
- JDK 自带:
com.sun.net.httpserver.Filter(jdk/src/jdk.httpserver/share/classes/com/sun/net/httpserver/Filter.java)——和 Servlet 一样是Filter + Filter.Chain结构,Chain.doFilter负责链式调用。 - Spring:
HandlerInterceptor接口 +HandlerExecutionChain(library/spring-framework/spring-webmvc/src/main/java/org/springframework/web/servlet/)——演示「迭代 + interceptorIndex 逆序补偿」。
JDK 的 Filter + Chain:doFilter 链式调用
public abstract class Filter {
public static class Chain {
private ListIterator<Filter> iter;
private HttpHandler handler;
public Chain(List<Filter> filters, HttpHandler handler) {
iter = filters.listIterator();
this.handler = handler;
}
public void doFilter(HttpExchange exchange) throws IOException {
if (!iter.hasNext()) {
handler.handle(exchange);
} else {
Filter f = iter.next();
f.doFilter(exchange, this);
}
}
}
public abstract void doFilter(HttpExchange exchange, Chain chain)
throws IOException;
}
这就是「Filter 接口 + FilterChain 的 doFilter 链式调用」的原始结构,与 Servlet 的 Filter.doFilter(req, res, chain) / FilterChain.doFilter(req, res) 一一对应:Filter 只有一个抽象方法 doFilter(exchange, chain),处理器自己在方法体里决定「调 chain.doFilter(exchange) 放行」还是「不调、直接终止」。Chain 内部用 ListIterator 当游标,iter.next() 取出下一个 Filter 并把 this(链本身)传进去,游标耗尽(!iter.hasNext())时落到 handler.handle(exchange)——链尾就是真正的目标。
它同样是迭代驱动、而非显式递归:f.doFilter(exchange, this) 里的 this 是同一个 Chain,靠 iter 游标前进;每个 Filter 的 doFilter 栈帧要等 chain.doFilter 返回后才弹出,所以「doFilter 之前的前置代码」和「doFilter 之后的后置代码」天然对称。深链路下栈帧随 Filter 数量线性增长,这是 Servlet/Filter 链的通用形态。
Spring HandlerInterceptor:逆序 afterCompletion
真实接口(HandlerInterceptor.java)——三个钩子全是 default 方法,preHandle 默认放行、后两个默认空实现:
public interface HandlerInterceptor {
default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
return true;
}
default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
@Nullable ModelAndView modelAndView) throws Exception {
}
default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
@Nullable Exception ex) throws Exception {
}
}
真实链执行器(HandlerExecutionChain.java)——interceptorIndex 记录「最后一个 preHandle 成功」的下标,失败即逆序补偿:
private final List<HandlerInterceptor> interceptorList = new ArrayList<>();
private int interceptorIndex = -1;
boolean applyPreHandle(HttpServletRequest request, HttpServletResponse response) throws Exception {
for (int i = 0; i < this.interceptorList.size(); i++) {
HandlerInterceptor interceptor = this.interceptorList.get(i);
if (!interceptor.preHandle(request, response, this.handler)) {
triggerAfterCompletion(request, response, null);
return false;
}
this.interceptorIndex = i;
}
return true;
}
void triggerAfterCompletion(HttpServletRequest request, HttpServletResponse response, @Nullable Exception ex) {
for (int i = this.interceptorIndex; i >= 0; i--) {
HandlerInterceptor interceptor = this.interceptorList.get(i);
try {
interceptor.afterCompletion(request, response, this.handler, ex);
}
catch (Throwable ex2) {
logger.error("HandlerInterceptor.afterCompletion threw exception in interceptor [" + interceptor + "]", ex2);
}
}
}
逆序补偿的精髓在 interceptorIndex:正常路径 applyPreHandle 每过一个节点就 interceptorIndex = i 前进一位;一旦某个 preHandle 返回 false,triggerAfterCompletion 从 interceptorIndex 往回(i--)调 afterCompletion——只补偿「preHandle 已成功返回 true」的那些节点。Interceptor A、B、C 都成功、C 返回 false 时,interceptorIndex 停在 B(下标 1),于是逆序 B→A 补偿,而 C 自己没成功执行过 preHandle、不被补偿。
逆序补偿的理由:A 先执行 preHandle(做了预操作,如写审计日志),B 后执行。若失败时先清 A 再清 B——B 的预操作还在、A 已被「回滚」,不对称。逆序(后进先出的栈序)保证「做得越多、撤得越彻底」,与执行顺序完全对称。真实实现里 afterCompletion 的异常被 catch (Throwable) 兜底并 logger.error,不会因为某个节点的清理失败就中断整条补偿链——这也是生产级责任链的关键细节。
项目实战:消息发送责任链
在 WhatToEat 项目中,消息发送从 2 种扩展到 7 种类型。每种消息的校验逻辑不同,所有校验堆在一个方法里,圈复杂度达到 23。
重构为责任链:每个校验规则独立为一个 Handler:
abstract class MessageHandler {
abstract boolean handle(MessageContext ctx);
// 失败补偿
void compensate(MessageContext ctx) { }
}
// 链执行
boolean executeChain(List<MessageHandler> handlers, MessageContext ctx) {
int executed = 0;
for (; executed < handlers.size(); executed++) {
if (!handlers.get(executed).handle(ctx)) break;
}
// 逆序补偿
for (int i = executed - 1; i >= 0; i--) {
handlers.get(i).compensate(ctx);
}
return executed == handlers.size();
}
圈复杂度从 23 → 每个 Handler ≤ 3,单元测试覆盖率从 34% → 92%。
总结
| 实现 | 链结构 | 失败处理 |
|---|---|---|
| Tomcat Filter | 迭代 + pos 索引 | 直接返回,不调后续 Filter |
| Spring Interceptor | 迭代 + interceptorIndex | 逆序 afterCompletion |
| Netty Pipeline | 双向链表(头→尾入站,尾→头出站) | 异常沿链传递 |
章末提问
Q1:责任链和策略模式的区别? 结论先行:责任链解决「一串步骤按顺序叠加、任一失败即停」,策略解决「同一动作多种算法 N 选 1」。因为责任链里每个节点都会被依次触发、各自决定放行或终止,而策略一次只执行被选中的那一个算法,两者解决的维度完全不同。
Q2:为什么链执行不用递归,而用迭代 + 索引? 结论先行:为了把「推进到哪了」的状态从调用栈搬到堆上的游标变量,避免深链路栈溢出。因为递归时每个节点都要等下一个节点返回才弹栈,链越长栈帧压得越深;迭代 + pos/interceptorIndex 只占一个变量的空间,方法栈帧固定,链长无上限。
Q3:逆序补偿为什么必须逆序,正序清理错在哪? 结论先行:因为补偿必须与执行顺序严格对称(后进先出),否则会出现「回滚了一半」。A 先做预操作、B 后做,若失败时正序先清 A,B 的预操作还残留着,状态不对称;逆序 B→A 才能做到「做得越多、撤得越彻底」。
Q4:Spring 的 preHandle 返回 false 时,那个失败的 interceptor 自己会被补偿吗? 结论先行:不会,因为 interceptorIndex 只记录「最后一个 preHandle 成功」的下标。失败的节点没有成功执行过 preHandle,预操作并不存在,自然无需补偿;triggerAfterCompletion 从 interceptorIndex 往回,恰好只补偿已成功的那一批节点。
Q5:什么场景不适合硬上责任链? 结论先行:节点少、顺序固定、又没有补偿需求的场景,硬上责任链反而徒增抽象成本。因为责任链的价值在于「摊薄复杂度 + 逆序补偿」,只有两三种消息、三五步校验时,一个顺序方法更直观,抽象基类和执行器的成本反而高于收益。