适用读者:写过"一长串 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):
run()必须final,否则子类可以改流程顺序,"模板"就退化成"建议"。- 钩子(hook)只留必要数量。钩子越多,父类越难改;一般 ≤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。三个坑:
- 默认同步:一个监听器慢,发布方就慢。需要异步要显式
@Async+ 线程池。 - 事务内发布:监听器在事务提交前执行,事务回滚了事件却已经"发出去"(对进程内监听器而言)。正确做法是
@TransactionalEventListener(phase = AFTER_COMMIT)或走发件箱(outbox,见 J06 §4)。 - 异常传播:同步监听器抛异常会让发布方的事务回滚。这是"特性"还是"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. 自测
- 责任链里"命中即返回"和"收集全部错误"分别适合什么场景?
- 模板方法的
run()不加final会发生什么?举一个具体后果。 - 你的订单状态用 int + switch,改成状态模式能获得什么?成本是什么?
@Transactional方法里publishEvent,事务回滚后监听器还会执行吗?为什么?- 命令模式为什么能支持"撤销"?撤销栈里存的到底是什么?
- 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 文档口径(推断)。