2574 字
约 8 分钟
16
S18-X04 · 责任链模式:把 if-else 长链拆成可插拔环节

一个下单接口进来:校验登录态、校验参数完整、查风控名单、算价格、锁库存、验券可用性。半年后这段代码变成 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. 自测

  1. 一段 240 行的 if 长链,满足哪几条判据才值得拆成责任链?
  2. 链表式 handler 在 Spring 单例下为什么容易串链?
  3. List<Handler> 注入为什么能拿到有序集合,Map 注入为什么不行?
  4. "通过"和"不适用"用同一个 null 表示,会在什么场景出错?
  5. 责任链与装饰器最关键的行为差异是哪一条?
  6. 审批流里哪些职责不该塞进 handler?
S18-X04 · 责任链模式:把 if-else 长链拆成可插拔环节
https://www.clxhxhhr.top/posts/5719/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。