← 返回题目列表

如何用分阶段 Builder 或类型安全 Builder 保证必填参数?

高频 困难 第 14 / 26 题 更新于 2026/08/01
建造者模式分阶段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 的优点是可读性强,但它通常不会阻止调用方少填字段。比如一个注册请求必须有 usernamepasswordemail,普通 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() 保证字段取值和组合关系正确。

约束类型分阶段 Builderbuild() 校验
必填字段是否调用很适合可以兜底
字符串是否为空不适合很适合
数字范围不适合很适合
固定顺序很适合不直观
跨字段业务规则部分适合很适合

例如注册命令可以强制调用 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 解决必填流程不能漏。答题时不要只说“编译期安全”,还要补上代价:接口变多、必填字段演进会破坏兼容、运行时校验仍然不能省。