设计模式 E03:默认行为和规则组合
Null Object 和 Specification 都不在 GoF 23 种模式里,但它们常出现在业务代码里。前者把“没有可选协作者”变成一个安全对象,后者把规则变成可组合对象。真正危险的地方也在这里:默认对象不能吞掉关键失败,规则组合不能把失败原因洗掉。
Null Object 只替代可选副作用
labs/E03 的 NullNotifier 什么都不做,用来替代“没有通知渠道”。这类协作者失败不会改变支付是否成功,默认对象是合理的。nullObjectCanOnlyReplaceAnOptionalSideEffect 用 Checkout 提交 100 分订单,通知器为空行为也返回通过。
关键依赖不能这么处理。支付网关、库存锁、风控结果这些东西缺失时,系统需要失败,而不是装作成功。Checkout 构造器对规则使用 Objects.requireNonNull,missingCriticalDependencyIsARealFailure 断言关键规则为 null 会抛 NullPointerException。
1 | |
Null Object 适合“没有动作也是合法动作”的位置;不适合“缺失就无法判断正确性”的位置。
Specification 要保留失败原因
本例的 Specification 返回 Decision,包含是否通过和原因列表。and 组合先执行左侧;左侧失败就短路返回,不再执行右侧。specificationCompositionKeepsReasonsAndShortCircuits 让金额为 0 且支付依赖不可用的订单先命中金额失败,通知列表仍为空。
这个选择不是唯一正确答案。若业务需要一次性展示所有错误,可以像 Alternative 那样用两个 if 收集所有原因;若后续规则有外部调用成本,短路更合适。Specification 的价值不在“把 if 改成对象”,而在规则需要命名、复用、组合和测试时,给它一个稳定结果模型。
flowchart LR
Checkout --> Rule[Specification.and]
Rule --> Amount[positiveAmount]
Amount -- 失败 --> Decision[返回金额原因]
Amount -- 通过 --> Payment[支付依赖规则]
Payment --> Decision
Checkout --> Notifier[可选NullNotifier]
| 需求 | Null Object | Specification | 直接 if |
|---|---|---|---|
| 可选通知 | 合适 | 不相关 | 合适 |
| 缺失关键支付依赖 | 不合适 | 可显式失败 | 可显式失败 |
| 规则要组合复用 | 不相关 | 合适 | 容易扩散 |
| 一次收集全部错误 | 不相关 | 需要组合策略 | 很直接 |
默认行为和规则组合都要先定义失败语义。没有失败语义的默认值,会把错误藏成“正常路径”。
验证与练习
运行 ./mvnw -B -ntp -pl labs/E03 -am test;NullObjectSpecificationTest 通过 3 个测试。原始日志在 examples/design-patterns/evidence/E03/targeted.stdout.txt。
- 新增“VIP 订单允许 0 元试用”,先写一个让现有
positiveAmount失败的测试,再决定把 VIP 规则组合在金额规则前还是改写金额规则本身。 - 新增短信通知失败。分别实现“通知失败不影响提交”和“通知失败导致提交失败”两套测试,说明 Null Object 在哪一套语义下可以保留。
参考资料:
- Martin Fowler:Special Case。PoEAA 使用 Special Case 这个名称讨论 Null Object 一类做法。
- Eric Evans 与 Martin Fowler:Specification。
- 本地实验:
examples/design-patterns/labs/E03/src/test/java/blog/examples/labe03/NullObjectSpecificationTest.java。
上一节:E02 record、sealed 类型与模式匹配;下一节:E04 模式不证明线程安全。





