3316 字
约 11 分钟
8
S18-J04 · 行为型模式:谁通知谁、按什么顺序

适用读者:写过"一长串 if-else 决定下一步"、也写过"事件发出去没人处理"的人。
前置:S18-J01(判据)、S18-J03(结构型)。集合与函数式接口见 S04。
覆盖旧文:A-2379(模板方法/中介者/命令/责任链/策略/迭代器/观察者/备忘录/访问者/状态/解释器段)、A-2609(模板 108 + 策略 121 + 观察者 139)、A-2326(责任链 150 + 策略 55 + 模板 92)、A-379(模板 212)、A-380(责任链 253)——合计 826 点。
扩展点:E-S18-005(责任链的顺序与终止)、E-S18-006(模板方法与钩子)、E-S18-007(策略 / 状态 / 命令)。
上一篇:S18-J03 · 结构型模式:对象怎么拼
下一篇:S18-J05 · 设计原则与质量属性:从"模式"回到"度量"。


1. 一句话结论

行为型模式解决的是"控制流归谁":把执行顺序(责任链、模板方法)、行为选择(策略、状态、命令)、通知关系(观察者、中介者)、遍历与快照(迭代器、访问者、备忘录)从调用方搬进对象里。 搬家的唯一收益是"这段控制流可以被替换、被测试、被复用";代价是"读代码的人要跳一次"。

11 种行为型容易混,按四个问题分组就不混了:

问题 模式 一句话
谁先处理、什么时候停? 责任链 一串处理者,按顺序试到有人接手
流程骨架固定、步骤可变? 模板方法 父类定顺序,子类填步骤
同一个动作换算法 / 换状态 / 换请求? 策略 / 状态 / 命令 把"行为"变成对象
一件事要通知多方 / 多方互相通信? 观察者 / 中介者 一对多广播 / 星形收敛
怎么遍历、怎么在不改类的前提下加操作、怎么撤销? 迭代器 / 访问者 / 备忘录 把遍历、操作、快照外置

2. 责任链:顺序、终止与短路

interface Rule { Optional<String> check(Order o, long limit); }

static final class AmountRule implements Rule {
    public Optional<String> check(Order o, long limit) {
        return o.amount > limit ? Optional.of("amount_over:" + o.amount) : Optional.empty();
    }
}
static final class ItemCountRule implements Rule {
    public Optional<String> check(Order o, long limit) {
        return o.items.size() > 5 ? Optional.of("too_many_items") : Optional.empty();
    }
}
static Optional<String> runChain(List<Rule> chain, Order o, long limit) {
    for (Rule r : chain) {
        var hit = r.check(o, limit);
        if (hit.isPresent()) return hit;   // 短路:第一个命中即返回
    }
    return Optional.empty();
}

实测(Java 17.0.20,2026-11-06):

[chain] Optional[amount_over:2000]
[chain] Optional.empty

同一个订单(2000 分、2 个条目):限额 1000 时命中"金额超限",限额 5000 时全部通过。这两个输出就是责任链的全部价值——顺序可编排、命中即停、失败原因可携带。

三个必须显式设计的点(E-S18-005):

设计点 必须回答 不回答的后果
顺序 校验、幂等、限流、风控谁先? 先扣库存再校验 → 脏数据落地
终止 命中后是"返回"还是"继续收集"? 全部跑完再报错 → 副作用已发生
异常 某个处理者抛异常,链继续还是中断? 一个规则 NPE 静默吞掉整条链

责任链 vs 拦截器 vs 管道:三者形状相同(一串处理者),差别在"数据是否被改写"。责任链通常只做判断(返回命中/不命中),拦截器/管道通常改写上下文(加头、加日志、换请求体)。业务校验用责任链,横切处理用拦截器——混用会让"谁改了请求"变得不可追。

真实反例:把 20 条风控规则串成一条链,每条都要查一次用户画像 → 一次请求 20 次 RPC。正确做法是先批量取数,再链式判断(链只负责判断,不负责取数)。这条经验在 S18-A04 的"故障域"一节还会用到。


3. 模板方法:骨架固定、步骤可变

abstract static class ImportJob {
    final String run(String src) {                 // final:骨架不许子类改
        String raw = fetch(src);
        String clean = clean(raw);
        return verify(clean) ? load(clean) : "reject:" + clean;
    }
    abstract String fetch(String src);             // 抽象步骤
    String clean(String raw) { return raw.trim().toLowerCase(); }   // 钩子
    boolean verify(String s) { return !s.isEmpty(); }               // 钩子
    String load(String s) { return "loaded:" + s; }
}

实测输出:[template] loaded:a,b(" A,B " 被 trim + 小写后通过校验并落库)。

三点设计纪律(E-S18-006):

  1. run() 必须 final,否则子类可以改流程顺序,"模板"就退化成"建议"。
  2. 钩子(hook)只留必要数量。钩子越多,父类越难改;一般 ≤3 个。
  3. 好莱坞原则:"Don't call us, we'll call you"——子类不主动调父类,只等父类调自己。这条原则是模板方法与"子类里写 main 流程"的分水岭。

Java 17 的替代方案:如果流程只有 2–3 步且无共享状态,用函数参数比继承更轻:

String importJob(String src, Function<String,String> fetch, Predicate<String> verify) { ... }

继承的成本是编译期耦合(父类一改,所有子类重编译/重测试);函数参数的成本是可读性(参数一多就难读)。判据:步骤 >4 或需要复用父类字段 → 继承;步骤 ≤3 且无状态 → 函数。


4. 策略、状态与命令:三种"行为可变"

这三个是最常被混用的组合,因为代码形状都是"接口 + N 个实现 + 一个持有者"。

维度 策略 Strategy 状态 State 命令 Command
变的是什么 算法(同一件事的不同做法) 对象自身的行为随内部状态变 一个请求(可排队、可撤销、可记录)
谁决定用哪个 调用方(外部选择) 对象内部(状态迁移) 调用方(构造一个命令对象)
典型接口 pay(amount) handle() + next() execute() / undo()
是否保存历史 否 否(只存当前状态) 是(可入队、可回滚)
场景 支付渠道、折扣计算 订单状态机、连接状态 任务队列、事务日志、撤销栈

实测(状态迁移):

[state] CREATED -> PAID -> SHIPPED

非法迁移会抛异常(cannot ship in CREATED),这是状态模式相对"用 int 存状态 + switch"的核心收益:非法状态转移在编译/运行期就有唯一出口。

策略在 Java 17 的最小形态,其实是一个 Map<String, Function>:

Map<String, Function<Long, Long>> fee = Map.of(
    "alipay", c -> (long)(c * 0.006),
    "card",   c -> (long)(c * 0.012) + 30);

需要"每个策略还有生命周期/依赖注入/多方法"时才升格为接口 + 实现类。策略的价值不在"有接口",在"新增一种算法不改调用方"。

命令模式的真实用途往往被低估:它把"做什么"变成数据。一旦变成数据,就能入队(异步)、能持久化(事务日志)、能重放(补偿)、能撤销(undo 栈)。S17 的消息模型、S18-A04 的"长任务异步化"本质都是命令模式。


5. 观察者、中介者与命令:谁通知谁

模式 拓扑 优点 代价
观察者 一对多广播 发布者不认识订阅者 顺序不保证;异常隔离要自己做
中介者 星形(都连中间人) N×N 变 N×1 中介者容易变成上帝对象
命令 + 消息队列 生产者→队列→消费者 解耦 + 削峰 + 可重放 最终一致、幂等、顺序问题

Spring 的 ApplicationEventPublisher 就是观察者:publishEvent() 同步调用所有 @EventListener。三个坑:

  1. 默认同步:一个监听器慢,发布方就慢。需要异步要显式 @Async + 线程池。
  2. 事务内发布:监听器在事务提交前执行,事务回滚了事件却已经"发出去"(对进程内监听器而言)。正确做法是 @TransactionalEventListener(phase = AFTER_COMMIT) 或走发件箱(outbox,见 J06 §4)。
  3. 异常传播:同步监听器抛异常会让发布方的事务回滚。这是"特性"还是"bug"取决于业务,但必须显式决定。

事件设计的三个必答问题(发布前问自己):

问题 选错的表现 正确做法
事件是"事实"还是"命令"? 用 PleasePay 这种祈使句 事实用过去时(OrderPaid),命令用独立的消息类型
消费者要"幂等"还是"去重"? 靠"应该不会重复" 消费端用业务键去重,别赌投递语义
顺序重要吗? 假设 MQ 保序 需要顺序就按聚合 ID 分区,否则显式设计乱序处理

中介者为什么会变成上帝对象:因为它天然知道所有参与者的协议。判据:中介者里出现业务规则(折扣、状态判断)就错了,它只该做路由和协议转换。真正的业务协调应交给领域服务或流程引擎。


6. 迭代器、访问者、备忘录、解释器

模式 解决的问题 现代替代 还用吗
迭代器 统一遍历,不暴露内部结构 Iterable / Stream 写集合类时用;业务代码几乎不用
访问者 不改类的前提下加操作(双分派) sealed interface + switch 模式匹配(Java 17+) AST/编译器场景仍用;普通业务不用
备忘录 保存/恢复对象状态 快照对象 + 版本表(DB) 编辑器撤销、流程回滚
解释器 用类表达一套文法 DSL / 表达式引擎(SpEL、Aviator) 规则引擎内部;业务别自己写

访问者在 Java 17 之后要重新评估:sealed interface + switch 的模式匹配已经能做到"新增操作不改数据类型",而访问者的 accept(Visitor) 侵入性更强(每个类都要加方法)。判据:类型集合封闭(sealed)→ 用 switch;类型集合开放(插件)→ 用访问者。

解释器是最容易被自造的轮子。业务里出现"配置一段表达式让运营配规则"的需求时,先看表达式引擎(SpEL/Aviator/QLExpress),别自己写 AST。自己写的解释器通常在第 6 个月开始缺"括号优先级、短路、类型转换"。


7. 常见误区与判据

误区 真相
"策略和状态差不多" 策略由外部选,状态由内部迁移;状态有"非法转移"约束,策略没有
"责任链越长越健壮" 长度 = 延迟 × 失败面;20 条规则串行查库是性能事故
"模板方法里钩子越多越灵活" 钩子 >3 个,父类改一次要重测所有子类
"观察者是异步的" Spring 默认同步;异步要显式配置,且异常/顺序语义都会变
"命令模式就是 if-else 换名字" 命令把"做什么"变成数据,因此可入队、可持久化、可撤销
"访问者是最佳扩展方式" Java 17 的 sealed + switch 更轻;访问者适合开放类型集

选型顺序(建议照这个顺序问):① 是不是"顺序/骨架"问题(责任链/模板)→ ② 是不是"行为要换"(策略/状态/命令)→ ③ 是不是"通知关系"问题(观察者/中介者)→ ④ 都不是,就别用行为型。


8. 自测

  1. 责任链里"命中即返回"和"收集全部错误"分别适合什么场景?
  2. 模板方法的 run() 不加 final 会发生什么?举一个具体后果。
  3. 你的订单状态用 int + switch,改成状态模式能获得什么?成本是什么?
  4. @Transactional 方法里 publishEvent,事务回滚后监听器还会执行吗?为什么?
  5. 命令模式为什么能支持"撤销"?撤销栈里存的到底是什么?
  6. sealed interface + switch 能替代访问者的条件是什么?

下一篇

S18-J05 · 设计原则与质量属性:从"模式"回到"度量"

模式讲完了,下一篇回到更底层:七个原则之间的关系与优先级、耦合度量(CBO、扇入扇出、抽象度、不稳定性)怎么算、质量属性场景六要素怎么写,以及三个真实取舍案例(缓存指纹、外部依赖、失败语义)。


本篇属于系列《S18 · 架构方法与领域建模》。旧文来源:A-2379/A-2609/A-2326/A-379/A-380;扩展点 E-S18-005/006/007。责任链、模板方法、状态迁移输出均为 Java 17.0.20 实测;Spring 事件与事务语义按 Spring Framework 6.x 文档口径(推断)。

S18-J04 · 行为型模式:谁通知谁、按什么顺序
https://www.clxhxhhr.top/posts/5438/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。