Builder 中应该如何处理参数校验和默认值?
简化版
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,避免多处冲突。
八、交付前的代码与测试检查
- 缺少每个必填字段分别调用
build(),应得到明确且稳定的异常。 - 完全不设置可选字段,核对默认值来自唯一权威位置。
- 传入最小值、最大值和越界值,确认范围判断没有反向或 off-by-one。
- 构造两个互相冲突的字段组合,确认只在信息完整时执行跨字段校验。
- 传入集合或数组后修改原引用,已构建 Product 不应跟着变化。
- 若 getter 返回可变数据,再尝试修改返回值,内部状态仍应保持不变。
- 连续调用同一个 Builder 两次,确认语义是明确允许复制还是文档明确禁止复用。
- 两线程共享一个 Builder 做压力测试应被禁止或证明安全,不能靠偶然结果。
- 若使用 Lombok,检查生成代码、默认值、
@Singular、构造器可见性和框架兼容。 - 若迁移旧 API,比较默认值、异常类型、序列化字段和所有旧调用点行为。
记忆钩子:默认值负责“没填怎么办”,校验负责“填成这样能不能交付”,两者不能互相代替。
九、常见误区与追问
- 误区:所有校验都应在每个链式方法立即执行。 跨字段关系在其他字段尚未设置时无法正确判断。
- 误区:默认值同时写 Builder 和 Product 更保险。 双重来源容易漂移,行为会依赖创建路径。
- 误区:build 只需调用构造器。 它是应用默认、组合校验和快照复制的收口点。
- 追问:必填字段如何更早表达? 可放 Builder 构造参数、分阶段 Builder,或在 build 给出清晰异常。
- 追问:异常用 IllegalArgumentException 还是 IllegalStateException? 直接传入参数非法常用前者,Builder 当前组合不完整常用后者,团队需一致。
- 追问:默认值变更会影响什么? 新构建对象的行为和兼容性,应有测试、文档和版本评估。
十、加强记忆
Builder 里的默认值放字段初始化,必填和范围校验放 build() 收口,跨字段规则更应该放 build()。把 build() 当成最终质检员:没过质检,就不能交付 Product。