普通订单只要商品清单;礼品订单还要收件人。调用方在可写请求上先勾选 gift=true,忘记填写收件人后仍把它交给下游。收件人缺失究竟应该在数据库报错时才发现,还是在“构造完成”的边界被拒绝?

分步收集,最后一次检查

Before 公共字段可以任意顺序被修改;测试特意构造 gift=true, recipient=null 的错误对象。After 用 giftFor 收集可选项,build() 把参数交给不可变的 OrderRequest,后者拒绝空清单、空商品以及礼品收件人为空或空白。giftFor(null) 不能隐式变成普通订单,因此单独保存“已请求礼品”标记;List.copyOf 还使建成后的商品清单与调用方后续改动隔离。

1
2
3
OrderRequest request = new Scenario.After(List.of("paper"))
.giftFor("Ada")
.build();

build() 才返回有效请求,Builder 的分步接口本身并不能证明所有字段合法;验证最终由 OrderRequest 的构造不变量承担。Alternative.standard(items) / Alternative.gift(items, recipient) 两个具名静态工厂共享同一个受校验的构造器,恰好覆盖本章只有一种可选项的需求,更少可变中间状态。若可选项多、构造有明确阶段,Builder 能降低长参数列表或互斥选项的误用;若只有一种可选项,流式 setter 并不是必要的模式。Before 的旧普通清单、After 与 Alternative 都要返回 paper,新礼品规则是独立断言。

方案 何时拒绝缺收件人 外部改动商品清单
Before 没有拒绝 保存调用者原 List
After build() 内由结果构造器拒绝 构造时复制
Alternative 具名工厂返回结果之前 同一个构造器复制

流式构造与 GoF Builder 的差别

giftFor(...).build() 处理的是参数收集和最终校验,属于常见的 Java Builder 写法。方法返回 this 只说明接口支持流式调用;若仍能随时修改已经交给业务层的请求,它并没有建立有效对象的边界。具名工厂适合本例只有普通、礼品两种明确组合的情况。

GoF Builder 关注另一处变化:同一套构造步骤,是否需要产生不同表示。ReceiptConstruction.construct 充当 Director,固定先开始、再逐项加入商品、最后取结果的顺序;Builder<T> 定义这些步骤,TextBuilder 和 SummaryBuilder 分别生产只读文本行与摘要值。已经通过校验的 OrderRequest 是输入,不是两个 Builder 共同持有的可变产品。

1
2
3
var request = Scenario.Alternative.gift(List.of("paper", "pen"), "Ada");
var text = ReceiptConstruction.construct(request, new ReceiptConstruction.TextBuilder());
var summary = ReceiptConstruction.construct(request, new ReceiptConstruction.SummaryBuilder());

同一输入得到三行文本和 Summary(2, "Ada")。再次使用两个 Builder 构造普通订单时,测试核对计数、收件人均被重置,第一次返回的只读文本不随第二次构造变化。构造步骤与结果表示之间的分离,才是这里辨认为 GoF Builder 的依据;链式方法名和单独一个 build() 不能承担这项证明。

这个附加场景确实有两种产品表示,因此保留构造步骤接口。如果收据永远只要一个字符串,直接格式化函数更短。Bloch 风格的可选参数 Builder 与 GoF Builder 可以共享“分步构造”手法,但解决的变化点不同;Oracle 的 Builder 文章也明确区分了两者。

flowchart LR
    Director[ReceiptConstruction.construct] --> Protocol[Builder begin / item / result]
    Text[TextBuilder] -.实现.-> Protocol
    Summary[SummaryBuilder] -.实现.-> Protocol
    Text --> Lines[不可修改文本列表]
    Summary --> Product[Summary]

可执行边界与练习

在 examples/design-patterns/ 运行 ./mvnw -B -ntp -pl labs/18 -am test 和累计 ./mvnw -B -ntp verify,原始输出、退出码见 examples/design-patterns/evidence/18/RUN.md。测试覆盖空串、null、空清单以及输入别名;没有数据库保存、跨线程共享 Builder 或真实收货地址校验。清单不能为空是本章教学合同,不是 Java 规范。

  1. 新增“礼品必须带贺卡内容”的变化,先让旧普通订单及合法礼品仍通过,再为缺贺卡、空贺卡和调用方修改原清单加断言,比较两种构造入口改动范围。
  2. 移除礼品可选项,只保留普通订单;删掉 Builder、保留 OrderRequest 构造不变量,解释剩下的具名工厂是否还必要。

本次补全增加的实验在 examples/design-patterns/evidence/local-completion-20261003/RUN.md 留存本地命令与原始输出;各章原云端日志保留其历史版本边界。

参考资料

上一节:17 两种产品如何成套;下一节:19 复制订单时共享了什么。