一个下单接口进来:校验登录态、校验参数完整、查风控名单、算价格、锁库存、验券可用性。半年后这段代码变成 240 行 if 嵌套,每个新需求都往中间插一段,谁也不敢删。真正的问题不是 if 太多,而是"这段校验要不要执行"的决定权散落在主流程手里——加一条规则就得改主流程。
责任链只解决一件事:把"是否继续"的决定权下放到环节内部。链表串、数组串、Spring List 注入串都是细节。
1. 判据:三个该拆信号,两个假信号
典型长链:
public OrderResult createOrder(CreateOrderCmd cmd) {
if (cmd.getUserId() == null) return OrderResult.fail("未登录");
if (cmd.getItems() == null || cmd.getItems().isEmpty()) return OrderResult.fail("购物车为空");
if (!riskClient.check(cmd.getUserId())) return OrderResult.fail("风控拦截");
if (!couponService.applicable(cmd.getCouponId(), cmd)) return OrderResult.fail("券不可用");
if (!stockService.lock(cmd.getItems())) return OrderResult.fail("库存不足");
// ... 后面还有 60 行
}
拆之前过四条判据:
| 判据 | 该拆的信号 | 不该拆的信号 |
|---|---|---|
| 分支数量 | 独立分支 ≥ 4,且每个分支都要碰外部资源 | 只有 2–3 个纯内存判断 |
| 分支是否正交 | 各分支互不依赖,删掉任何一条其他分支照常工作 | 后一条依赖前一条的中间结果("算完价格才知道要不要验券") |
| 是否会被独立开关 | 有配置开关、灰度名单、租户级差异 | 所有分支永远同生共死 |
| 失败语义是否一致 | 都是"拒绝并返回原因",同一个出口 | 每个分支失败后要做不同的事(回滚 A、补偿 B、转异步 C) |
满足三条才值得拆。只满足"分支多"是最常见的误判:高度耦合的分支拆完会得到 8 个互相要对方中间结果的 handler,调用方还得按固定顺序手动传参,比原来更难读。
2. 两种落地形态:链表式与数组式
2.1 链表式(经典 GoF)
public abstract class Handler {
protected Handler next;
public Handler setNext(Handler next) { this.next = next; return next; }
public abstract Result handle(Context ctx);
}
public class RiskHandler extends Handler {
private final RiskClient client;
public RiskHandler(RiskClient client) { this.client = client; }
@Override
public Result handle(Context ctx) {
if (!client.check(ctx.getUserId())) {
return Result.reject("风控拦截");
}
return next == null ? Result.pass() : next.handle(ctx);
}
}
适用:环节要"跳过后续全部"的语义,且数量编译期固定、启动构建一次。坑:next 可变 + setNext 流式返回,handler 一旦是单例,两个请求同时 setNext 就把链串乱。
2.2 数组式(有序处理)
public interface OrderRule {
/** 返回 null 表示通过并继续;返回非 null 表示拒绝并终止 */
RejectReason check(OrderContext ctx);
}
public class OrderRuleChain {
private final List<OrderRule> rules;
public OrderRuleChain(List<OrderRule> rules) {
this.rules = List.copyOf(rules); // 不可变快照,天然线程安全
}
public Optional<RejectReason> run(OrderContext ctx) {
for (OrderRule rule : rules) {
RejectReason r = rule.check(ctx);
if (r != null) return Optional.of(r);
}
return Optional.empty();
}
}
绝大多数业务代码该用数组式:无共享可变状态、顺序外部化、要收集全部错误只需去掉 break。
| 维度 | 链表式 | 数组式 |
|---|---|---|
| 顺序来源 | handler 内部 setNext |
外部列表装配 |
| 线程安全 | 依赖 handler 无状态 + 链不复用 | 列表不可变,天然安全 |
| 中途拦截 | 天然(不调 next 即断) | 需返回值或异常表达 |
| 动态增删 | 重建整条链 | 换列表(重建而非原地改) |
| 收集全部错误 | 要额外改写 | 去掉 break 即可 |
3. Spring 里用 @Order + List<Handler> 自动装配
public interface OrderRule {
RejectReason check(OrderContext ctx);
}
@Component
@Order(10)
public class LoginRule implements OrderRule {
public RejectReason check(OrderContext ctx) {
return ctx.getUserId() != null ? null : RejectReason.of("NO_LOGIN", "未登录");
}
}
@Component
@Order(20)
public class StockRule implements OrderRule { /* ... */ }
@Service
public class OrderService {
private final List<OrderRule> rules; // 注入即按 @Order 升序排好
public OrderService(List<OrderRule> rules) {
this.rules = rules;
}
}
几种注入写法的顺序行为不一样:
| 写法 | 顺序生效 | 说明 |
|---|---|---|
List<OrderRule> |
是 | Spring 用 AnnotationAwareOrderComparator 排序 |
OrderRule[] |
是 | 与 List 同机制 |
Map<String, OrderRule> |
否 | 按 bean 名字母序,与 @Order 无关 |
字段 @Autowired 注入 |
是 | 但不利测试,构造器注入优先 |
顺序要配置化时别指望 @Order——它是编译期常量,改序要发版。做法:@Order 只给默认序,另加 RulePlan 表按场景列 id,再按 id 过滤子集。
坑:List<OrderRule> 拿到的是全部实现类。有人加了个 @Component 的调试规则,线上就多跑一步。给接口加 boolean enabled() 或用 @Profile 隔离,比事后 grep 便宜。
4. 处理结果怎么传:三种写法
// A:void,靠异常中断
void handle(Context ctx); // 抛 RejectException 即终止,调用方 catch
// B:返回处理后的对象(可替换上下文)
Context handle(Context ctx); // 下一步拿到上一步的产物
// C:返回中断标记(数组式最常用)
Optional<RejectReason> check(Ctx ctx); // 显式表达"通过/拒绝"
| 写法 | 中断方式 | 适用 | 坑 |
|---|---|---|---|
| void + 异常 | 抛出 | 校验链,出口唯一 | 中途 try-catch 吞掉异常,链"静默继续" |
| 返回新对象 | 返回值 | 阶段化变换(见管道那篇) | 每步都 clone,深拷贝代价高 |
| 中断标记 | 显式返回 | 需区分"拒绝"与"不适用" | 标记语义混用,null/false/枚举三套并存 |
审批流里"拒绝"和"跳过"必须分开:若 null 表示通过、RejectReason 表示拒绝,"这条不适用(没券就不验券)"怎么表达?加 Skip 枚举值比复用 null 省事——让 null 承担两种语义是责任链最常见的隐性 bug。
5. 与装饰器、管道的区别
| 维度 | 责任链 | 装饰器 | 管道 |
|---|---|---|---|
| 是否包装同一接口 | 否,环节各自实现"处理" | 是,与被装饰者同接口 | 否,阶段可异构 Stage<I,O> |
| 是否可中断 | 可以,这是核心语义 | 不可以,必须调用内层 | 一般不中断,失败走分支 |
| 目的 | 决定"谁来处理/是否继续" | 在调用前后附加行为 | 阶段化变换,输入输出可不同构 |
| 顺序敏感度 | 高,语义依赖顺序 | 中,顺序等于嵌套层次 | 高,数据依赖强 |
| 典型实现 | Servlet Filter |
BufferedReader 包 FileReader |
lexer → parser → codegen |
装饰器必须调用被包装者,责任链可以不调用后继——这就是分水岭。
6. 三个真实场景
6.1 参数校验链
@Component @Order(1)
public class RequiredFieldRule implements OrderRule {
public RejectReason check(OrderContext ctx) {
if (ctx.getCmd().getItems().isEmpty()) return RejectReason.of("CART_EMPTY", "购物车为空");
return null;
}
}
@Component @Order(2)
public class AmountLimitRule implements OrderRule {
public RejectReason check(OrderContext ctx) {
if (ctx.getTotal().compareTo(ctx.getUserLimit()) > 0) return RejectReason.of("OVER_LIMIT", "超限额");
return null;
}
}
别自己写的情况:只有"非空、长度、正则"用 JSR-303 + @Valid 就够;自研链的价值在跨字段、查库、按租户变序三类,group 机制做这些很别扭。
6.2 审批流
审批流不是纯责任链——它要状态持久化、要回退、要知道"当前节点是谁"。正确分工:
| 职责 | 归属 |
|---|---|
| 当前节点能不能过 | 责任链(一个节点一个 handler) |
| 下一个节点是谁 | 状态机 / 流转表 |
| 历史与回退 | 持久化事件流 |
把三件事都塞进 handler 的 next,回退功能永远做不出来。
6.3 过滤器与拦截器
Servlet Filter 是链表的教科书实现:不调 chain.doFilter(req, resp) 即中断,调完还能写后置逻辑。Spring HandlerInterceptor.preHandle 返回 false 同样中断,但无包裹能力。要包装 request/响应体用 Filter,要 HandlerMethod 元信息用 Interceptor。
7. 排错:五个常见故障
| 现象 | 原因 | 处理 |
|---|---|---|
| handler 加了但没执行 | 缺 @Component、包不在扫描范围、接口泛型不匹配 |
启动日志打印 rules 的 class 列表 |
| 链断在半路却不知道谁拒的 | 返回值没带 handler id | RejectReason 增加 source 字段 |
| 顺序与预期不一致 | 用了 Map 注入、两个 handler 同 @Order |
同序时按 bean 名,不稳定;给不同序号 |
| 异常被吞,链继续跑 | 中间 handler catch 后未 rethrow |
只捕获自己声明要处理的异常类型 |
| 并发下偶尔串链 | 链表式 handler 是单例且 next 可变 |
改数组式,或每次请求重建链 |
定位第一步是把实际顺序打出来:log.debug("chain: {}", rules.stream().map(r -> r.getClass().getSimpleName()).toList())。
8. 边界:什么时候别上责任链
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 分支之间要共享中间计算结果 | 一个方法内顺序语句 | 链上传递会逼出"上帝上下文" |
| 分支只有 2–3 个且稳定 | 直接 if-else | 抽象成本大于收益 |
| 需要回退与状态恢复 | 状态机 / 工作流引擎 | 责任链是无状态单向的 |
| 需要并行执行各分支 | 数组式 + CompletableFuture |
别硬套串行链 |
| 只是按 key 选一个实现 | 策略模式 + Map | 责任链是"依次尝试",策略是"选一个" |
9. 自测
- 一段 240 行的
if长链,满足哪几条判据才值得拆成责任链? - 链表式 handler 在 Spring 单例下为什么容易串链?
List<Handler>注入为什么能拿到有序集合,Map注入为什么不行?- "通过"和"不适用"用同一个
null表示,会在什么场景出错? - 责任链与装饰器最关键的行为差异是哪一条?
- 审批流里哪些职责不该塞进 handler?