Null Object 和 Specification 都不在 GoF 23 种模式里,但它们常出现在业务代码里。前者把“没有可选协作者”变成一个安全对象,后者把规则变成可组合对象。真正危险的地方也在这里:默认对象不能吞掉关键失败,规则组合不能把失败原因洗掉。

Null Object 只替代可选副作用

labs/E03 的 NullNotifier 什么都不做,用来替代“没有通知渠道”。这类协作者失败不会改变支付是否成功,默认对象是合理的。nullObjectCanOnlyReplaceAnOptionalSideEffect 用 Checkout 提交 100 分订单,通知器为空行为也返回通过。

关键依赖不能这么处理。支付网关、库存锁、风控结果这些东西缺失时,系统需要失败,而不是装作成功。Checkout 构造器对规则使用 Objects.requireNonNull,missingCriticalDependencyIsARealFailure 断言关键规则为 null 会抛 NullPointerException。

1
2
3
4
5
6
public record Checkout(Specification rule, Notifier notifier) {
public Checkout {
rule = Objects.requireNonNull(rule, "rule");
notifier = Objects.requireNonNull(notifier, "notifier");
}
}

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。

  1. 新增“VIP 订单允许 0 元试用”,先写一个让现有 positiveAmount 失败的测试,再决定把 VIP 规则组合在金额规则前还是改写金额规则本身。
  2. 新增短信通知失败。分别实现“通知失败不影响提交”和“通知失败导致提交失败”两套测试,说明 Null Object 在哪一套语义下可以保留。

参考资料:

上一节:E02 record、sealed 类型与模式匹配;下一节:E04 模式不证明线程安全。