← 返回题目列表

Builder 中应该如何处理参数校验和默认值?

高频 中等 第 8 / 26 题 更新于 2026/07/28
建造者模式参数校验默认值build方法

简化版

Builder 的默认值通常在 Builder 字段初始化时设置,参数合法性一般集中在 build() 中校验。这样可以让调用方只设置关心的字段,同时保证最终对象一定满足业务约束。

详细版

在 Builder 中处理默认值和校验,常见做法是:

  • Builder 字段给出合理默认值;
  • 必填字段在 build() 中检查;
  • 字段范围在 build() 中检查;
  • 字段组合关系在 build() 中检查;
  • 创建最终对象前完成必要的防御性拷贝。

示例:

public class RetryPolicy {
    private final int maxAttempts;
    private final long intervalMs;
    private final boolean exponentialBackoff;

    private RetryPolicy(Builder builder) {
        this.maxAttempts = builder.maxAttempts;
        this.intervalMs = builder.intervalMs;
        this.exponentialBackoff = builder.exponentialBackoff;
    }

    public static class Builder {
        private int maxAttempts = 3;
        private long intervalMs = 1000;
        private boolean exponentialBackoff = false;

        public Builder maxAttempts(int maxAttempts) {
            this.maxAttempts = maxAttempts;
            return this;
        }

        public Builder intervalMs(long intervalMs) {
            this.intervalMs = intervalMs;
            return this;
        }

        public Builder exponentialBackoff(boolean value) {
            this.exponentialBackoff = value;
            return this;
        }

        public RetryPolicy build() {
            if (maxAttempts <= 0) {
                throw new IllegalArgumentException("maxAttempts must be positive");
            }
            if (intervalMs <= 0) {
                throw new IllegalArgumentException("intervalMs must be positive");
            }
            return new RetryPolicy(this);
        }
    }
}

默认值放在 Builder 字段上,调用方可以少传很多参数:

RetryPolicy policy = RetryPolicy.builder()
        .maxAttempts(5)
        .build();

需要注意:不要把校验完全放在 setter 方法里。单字段校验可以放 setter,但跨字段校验更适合在 build() 统一处理,因为那时字段信息最完整。

完整版教学

一、默认值应该放在哪里

默认值一般推荐放在 Builder 的字段初始化处:

private int timeoutMs = 3000;
private int maxConnections = 10;
private boolean keepAlive = true;

这样写有两个好处。

第一,调用方不需要关心所有参数,只设置变化点。

第二,默认值和构建逻辑集中在 Builder 内部,不会散落到业务代码里。

不推荐让调用方自己补默认值:

int timeout = inputTimeout == null ? 3000 : inputTimeout;

如果这种逻辑到处都是,默认值一变,全项目都要找。

二、哪些校验适合放在 setter 方法里

单字段、立即可判断的校验,可以放在 Builder 方法中。例如:

public Builder timeoutMs(int timeoutMs) {
    if (timeoutMs <= 0) {
        throw new IllegalArgumentException("timeoutMs must be positive");
    }
    this.timeoutMs = timeoutMs;
    return this;
}

这样调用方能更早知道错误。

但这种方式也有一个缺点:如果有些字段允许先传临时值,最后再统一修正,就会显得不够灵活。因此是否放在 setter,要看业务对象的构建语义。

三、为什么跨字段校验更适合放在 build()

跨字段校验需要看到多个字段。例如:

  • 开启 HTTPS 时必须配置证书;
  • 分页查询时 pageNo 和 pageSize 都必须合法;
  • 指定代理时必须同时配置代理地址和端口;
  • 开启指数退避时最大重试次数不能太大。

这些逻辑放在单个 setter 里会很别扭,因为设置某个字段时,另一个字段可能还没设置。

更合适的是:

public ClientConfig build() {
    if (httpsEnabled && certificate == null) {
        throw new IllegalStateException("certificate required when https enabled");
    }
    if (proxyHost != null && proxyPort <= 0) {
        throw new IllegalStateException("proxyPort required when proxyHost exists");
    }
    return new ClientConfig(this);
}

build() 是所有字段汇合的地方,也是创建最终对象前最后一道门。

四、默认值和业务规则要不要写进 Product

通常建议把构建相关逻辑放在 Builder,把对象不变量保护放在 Product。

如果 Product 构造方法只允许 Builder 调用,可以把校验都放在 Builder。但为了防御未来有人新增构造入口,也可以在 Product 私有构造方法里做兜底校验。

较稳的思路是:

  • Builder 负责用户友好的配置入口;
  • build() 负责主要校验;
  • Product 构造方法保证对象不变量;
  • 对外不暴露破坏状态的 setter。

这样即使后续代码演进,对象也不容易被构造坏。

五、用重试策略区分默认值、单字段校验和组合校验

RetryPolicy 默认重试 3 次、间隔 1000 ms;maxAttempts<=0 是单字段错误,可在 setter 或 build 拒绝;若开启指数退避后总等待不能超过 30 秒,则需要同时看到次数、间隔和开关,应在 build() 统一判断。

Builder 初始化 maxAttempts=3
Builder 初始化 intervalMs=1000
调用方只设置 exponentialBackoff=true
build(): 检查 maxAttempts > 0
build(): 检查 intervalMs > 0
build(): 计算最坏退避总等待
若超过 30000 ms -> 明确异常
否则创建 RetryPolicy
Product 再兜底核心不变量
未设置字段使用唯一默认来源

这条时间线把“可变构建阶段”和“稳定成品阶段”分开。Builder 可以反复接收参数,但 Product 只有在所有规则通过后才出现;若 Product 在第一步就被 new 出来再逐项修改,就仍然存在半初始化对象泄漏的窗口。

六、构建契约与对象不变量

评审维度本题结论
必填信息若没有必填字段也要明确;有 endpoint/证书等必填项则 build 统一检查。
默认值Builder 字段初始化为唯一默认来源,并通过无设置用例验证。
跨字段规则范围可早校验,跨字段和派生上限在 build() 完整计算。
可变引用处理配置含列表、证书数组时构造边界复制。
Builder 生命周期每次配置使用新 Builder,避免上次显式值覆盖本次默认语义。
Product 交付保证构建后无需再次猜测 null、非法范围或互斥字段。

build() 不是形式上的结束标记,而是对象从“参数集合”变成“合法业务值”的原子边界。单字段输入可以提前拒绝明显错误,跨字段规则必须等信息齐全后判断,Product 私有构造器还应保留必要兜底,避免未来新增创建入口绕过不变量。

七、与构造器、JavaBean 和工厂的选择边界

  • 字段少且全部必填时,短构造器或 record 通常比 Builder 更直接。
  • 字段多、可选项多、同类型参数易错时,命名步骤能显著提升调用可读性。
  • JavaBean setter 适合某些框架绑定,但对象可能在设置完成前就被观察到。
  • Builder 关注“同一种复杂对象怎样组装”;工厂模式关注“创建哪一种产品实现”。
  • 两者可以组合:工厂选择具体产品族或 Builder,Builder 再完成复杂组装。
  • 经典 Director 只有在固定步骤序列需要复用或存在多种表示时才有价值。
  • 链式 return this 只是语法,校验、默认值、拷贝和收口才是设计语义。
  • 若 Builder 比 Product 规则还复杂,应重新拆分对象职责,而不是继续堆方法。

稳定业务不变量可在 Product 构造器兜底;仅为调用体验的默认值主要留在 Builder,避免多处冲突。

八、交付前的代码与测试检查

  1. 缺少每个必填字段分别调用 build(),应得到明确且稳定的异常。
  2. 完全不设置可选字段,核对默认值来自唯一权威位置。
  3. 传入最小值、最大值和越界值,确认范围判断没有反向或 off-by-one。
  4. 构造两个互相冲突的字段组合,确认只在信息完整时执行跨字段校验。
  5. 传入集合或数组后修改原引用,已构建 Product 不应跟着变化。
  6. 若 getter 返回可变数据,再尝试修改返回值,内部状态仍应保持不变。
  7. 连续调用同一个 Builder 两次,确认语义是明确允许复制还是文档明确禁止复用。
  8. 两线程共享一个 Builder 做压力测试应被禁止或证明安全,不能靠偶然结果。
  9. 若使用 Lombok,检查生成代码、默认值、@Singular、构造器可见性和框架兼容。
  10. 若迁移旧 API,比较默认值、异常类型、序列化字段和所有旧调用点行为。

记忆钩子:默认值负责“没填怎么办”,校验负责“填成这样能不能交付”,两者不能互相代替。

九、常见误区与追问

  • 误区:所有校验都应在每个链式方法立即执行。 跨字段关系在其他字段尚未设置时无法正确判断。
  • 误区:默认值同时写 Builder 和 Product 更保险。 双重来源容易漂移,行为会依赖创建路径。
  • 误区:build 只需调用构造器。 它是应用默认、组合校验和快照复制的收口点。
  • 追问:必填字段如何更早表达? 可放 Builder 构造参数、分阶段 Builder,或在 build 给出清晰异常。
  • 追问:异常用 IllegalArgumentException 还是 IllegalStateException? 直接传入参数非法常用前者,Builder 当前组合不完整常用后者,团队需一致。
  • 追问:默认值变更会影响什么? 新构建对象的行为和兼容性,应有测试、文档和版本评估。

十、加强记忆

Builder 里的默认值放字段初始化,必填和范围校验放 build() 收口,跨字段规则更应该放 build()。把 build() 当成最终质检员:没过质检,就不能交付 Product。