如何用分阶段 Builder 或类型安全 Builder 保证必填参数?
简化版
分阶段 Builder 是把构建过程拆成多个接口阶段,让调用方必须按顺序提供必填参数,最后才能拿到 build()。它把一部分运行时校验提前到编译期,适合必填字段明确、顺序有业务含义、配置错误代价较高的对象。
详细版
普通 Builder 往往在 build() 里检查必填字段:
User user = User.builder()
.name("Tom")
.age(18)
.build();
如果调用方忘了设置 name,只有运行到 build() 才会抛异常。分阶段 Builder 会把 name()、age()、optional()、build() 放在不同接口里,让编译器限制调用顺序。
典型写法是:
- 第一个阶段只暴露第一个必填字段;
- 第二个阶段只暴露第二个必填字段;
- 必填字段都设置完后,才暴露可选字段和
build(); - 可选字段阶段可以链式设置多个可选项;
- 最终对象仍然通过私有构造器创建,保持不可变。
这种方式不是所有 Builder 都需要。字段很多但大多数可选时,普通 Builder 更简单;必填字段少但不能遗漏时,分阶段 Builder 更有价值。面试回答时要说清楚:它的收益是编译期约束,代价是接口数量和 API 复杂度上升。
完整版教学
一、为什么普通 Builder 仍可能漏掉必填字段
普通 Builder 的优点是可读性强,但它通常不会阻止调用方少填字段。比如一个注册请求必须有 username、password、email,普通 Builder 可能这样写:
RegisterCommand cmd = RegisterCommand.builder()
.username("tom")
.email("tom@example.com")
.build();
这段代码编译能过,只有运行到 build() 才发现 password 缺失。如果这类对象只在测试里构造,运行时异常还能快速暴露;如果它出现在批量任务、配置加载或灰度发布链路中,错误可能要到线上路径才被触发。分阶段 Builder 的动机就是把“必须填”的约束从运行时尽量前移到编译期。
记忆钩子:普通 Builder 解决“参数多看不懂”,分阶段 Builder 进一步解决“必填项漏填还编译通过”。
二、分阶段 Builder 的核心结构
分阶段 Builder 通常用多个接口表达构建流程。每个方法返回下一个阶段的接口,而不是统一返回 Builder 本身。
public class RegisterCommand {
private final String username;
private final String password;
private final String email;
private final boolean newsletter;
private RegisterCommand(Builder builder) {
this.username = builder.username;
this.password = builder.password;
this.email = builder.email;
this.newsletter = builder.newsletter;
}
public interface UsernameStep {
PasswordStep username(String username);
}
public interface PasswordStep {
EmailStep password(String password);
}
public interface EmailStep {
OptionalStep email(String email);
}
public interface OptionalStep {
OptionalStep newsletter(boolean newsletter);
RegisterCommand build();
}
public static UsernameStep builder() {
return new Builder();
}
private static class Builder implements UsernameStep, PasswordStep, EmailStep, OptionalStep {
private String username;
private String password;
private String email;
private boolean newsletter = false;
public PasswordStep username(String username) {
this.username = username;
return this;
}
public EmailStep password(String password) {
this.password = password;
return this;
}
public OptionalStep email(String email) {
this.email = email;
return this;
}
public OptionalStep newsletter(boolean newsletter) {
this.newsletter = newsletter;
return this;
}
public RegisterCommand build() {
return new RegisterCommand(this);
}
}
}
调用方只能按接口暴露的顺序走:
RegisterCommand cmd = RegisterCommand.builder()
.username("tom")
.password("P@ssw0rd")
.email("tom@example.com")
.newsletter(true)
.build();
如果少写 password(),调用方拿不到 email();如果少写 email(),调用方拿不到 build()。这就是“用类型系统编码流程”。
三、它把哪些错误提前到了编译期
分阶段 Builder 适合提前约束三类错误。第一类是必填字段遗漏,例如没有用户名、没有数据库连接地址。第二类是固定顺序,例如先指定协议,再指定协议相关参数。第三类是分支流程,例如选择 OAuth 登录后必须提供 clientId 和 secret,选择 API Key 后必须提供 key。
用一个数字例子看收益:假设对象有 4 个必填字段和 6 个可选字段。普通 Builder 的 4 个必填字段每个都有“填/不填”两种状态,理论上有 2^4 = 16 种必填组合,其中只有 1 种完整。也就是说有 15 种组合要靠 build() 拦截。分阶段 Builder 可以把这些“不完整组合”在编译期挡掉。
普通 Builder:
username? password? email? tenant?
16 种组合 -> 15 种需要运行时发现
分阶段 Builder:
UsernameStep -> PasswordStep -> EmailStep -> TenantStep -> OptionalStep
不完整阶段没有 build()
当然,它不能替代所有校验。比如密码长度、邮箱格式、租户是否存在,这些仍然要在方法内部或 build() 里判断,因为编译器不知道字符串内容是否合法。
四、分阶段 Builder 与 build 校验不是二选一
很多人误以为用了分阶段 Builder 就不需要 build() 校验,这是不对的。类型系统只能表达“调用了某个方法”,不能表达“传入值一定合法”。例如 password("") 在编译期仍然合法,email("abc") 也能通过编译。
比较稳的做法是:用分阶段 Builder 保证字段出现,用 build() 保证字段取值和组合关系正确。
| 约束类型 | 分阶段 Builder | build() 校验 |
|---|---|---|
| 必填字段是否调用 | 很适合 | 可以兜底 |
| 字符串是否为空 | 不适合 | 很适合 |
| 数字范围 | 不适合 | 很适合 |
| 固定顺序 | 很适合 | 不直观 |
| 跨字段业务规则 | 部分适合 | 很适合 |
例如注册命令可以强制调用 username()、password()、email(),但仍要在 build() 中检查 password.length() >= 8、邮箱格式、用户名和邮箱是否冲突等规则。
五、分支型构建流程怎么设计
分阶段 Builder 还可以表达分支。比如创建支付请求时,选择银行卡支付必须设置卡号和 CVV;选择余额支付只需要账户 ID。
PaymentStart
├─ card() -> CardNoStep -> CvvStep -> AmountStep -> BuildStep
└─ wallet() -> AccountStep -> AmountStep -> BuildStep
这种设计能让 API 更贴近业务流程。调用方选择 card() 后,编译器只允许继续填写银行卡相关参数;选择 wallet() 后,就不会出现 cvv() 这种无意义方法。相比在一个大 Builder 里放十几个字段,然后在 build() 里判断哪些组合互斥,分支型 Builder 的可读性更好。
但它也有代价。如果支付方式从 2 种变成 8 种,接口数量可能迅速增长。此时可以只对最容易出错的主流程做分阶段,对少量分支仍用普通字段加 build() 校验。
六、什么时候不建议使用分阶段 Builder
如果对象只是 2 到 3 个字段,直接构造器更清楚。比如 new Point(10, 20) 不需要 Builder,更不需要分阶段 Builder。如果字段很多但没有明确顺序,也未必需要分阶段,普通 Builder 加清晰异常就够了。
一个简单判断标准是:
必填字段 <= 2 且含义清晰 -> 构造器或静态工厂
必填字段多,但顺序无业务含义 -> 普通 Builder + build 校验
必填字段多,且漏填代价高 -> 分阶段 Builder
存在强流程或分支协议 -> 分阶段/分支 Builder
分阶段 Builder 最怕被滥用成“接口炫技”。如果团队里大部分人读起来费劲,或者为了一个简单 DTO 写出 8 个接口,那维护成本就超过收益了。
七、面试中如何评价它的工程代价
分阶段 Builder 增加了 API 表面积。一个普通 Builder 可能只有 1 个类,分阶段 Builder 可能多出 4 到 8 个接口。IDE 自动补全会更准确,但源码也更长,泛型和继承场景会更复杂。
从演进角度看,新增可选字段很容易,只要加到 OptionalStep。新增必填字段则是破坏性变化,因为调用链必须多走一步。这恰好也是它的价值:如果字段真的变成必填,让所有调用点编译失败,比静悄悄运行时报错更安全。
测试上也要覆盖两层:编译期顺序靠 API 设计保证,运行时值校验仍要写单元测试。比如空字符串、非法邮箱、密码过短、跨字段冲突,都不能因为“用了分阶段”就省掉。
八、常见误区与追问
- 误区:分阶段 Builder 可以替代所有参数校验。 它只能保证调用流程,不能保证字符串、数字、外部资源等取值合法。
- 误区:所有 Builder 都应该做成类型安全 Builder。 简单对象和普通配置对象用它会增加接口数量,反而降低可维护性。
- 误区:只要方法按顺序返回不同接口就一定设计良好。 如果阶段不是业务真实约束,只是人为规定顺序,调用体验会变差。
- 追问:新增必填字段会怎样? 会造成调用点编译失败,这是破坏性变化,但能强制调用方显式处理新约束。
- 追问:分阶段 Builder 和构造器必填参数有什么区别? 构造器适合少量必填字段;分阶段 Builder 适合必填字段多、还需要可选项和流程表达的场景。
- 追问:能不能用泛型实现更复杂的类型安全 Builder? 可以,但泛型技巧会让代码更难读,业务团队通常优先选择多接口阶段写法。
- 追问:它适合配置类吗? 关键配置、协议配置、发布配置适合;普通 DTO 或框架绑定对象通常不适合。
九、加强记忆
记住三层边界:构造器解决少量必填参数,普通 Builder 解决参数多和可读性,分阶段 Builder 解决必填流程不能漏。答题时不要只说“编译期安全”,还要补上代价:接口变多、必填字段演进会破坏兼容、运行时校验仍然不能省。