如何把大构造器或 JavaBean 重构为 Builder?
简化版
重构思路是先识别必填字段、可选字段、默认值和校验规则,再新增 Builder,把复杂构造过程迁移到 build(),最后逐步收敛旧构造器或 setter 的使用。重构时要注意兼容现有调用方,避免一次性破坏太多代码。
详细版
常见坏味道有两种。
第一种是大构造器:
new ExportConfig("xlsx", true, false, 1000, "UTF-8", null, true);
第二种是 JavaBean 半初始化:
ExportConfig config = new ExportConfig();
config.setFormat("xlsx");
config.setPageSize(1000);
config.setCompress(true);
重构步骤可以这样做:
- 梳理字段:哪些必填、哪些可选、哪些有默认值;
- 梳理规则:字段范围、字段组合、互斥关系;
- 新增
Builder,先不删除旧 API; - 在
build()里集中校验; - Product 字段尽量改成
final; - 对集合、数组做防御性拷贝;
- 将新代码迁移到 Builder;
- 对旧构造器和 setter 标记废弃或限制可见性。
重构后的调用:
ExportConfig config = ExportConfig.builder()
.format("xlsx")
.pageSize(1000)
.compress(true)
.build();
这样调用方更可读,对象也更不容易处于非法状态。
完整版教学
一、先识别重构信号
不是所有对象都需要 Builder。适合重构的信号通常包括:
- 构造方法参数超过五六个;
- 布尔参数很多,调用方看不懂含义;
- 多个重载构造器互相调用;
- 可选参数越来越多;
- 对象先
new,再连续调用 setter; - 某些字段必须成组出现;
- 出现“创建后马上校验”的重复代码。
比如:
public ExportConfig(String format,
boolean includeHeader,
boolean includeChart,
int pageSize,
String charset,
String password,
boolean compress) {
// ...
}
这类构造器参数越多,越容易出现调用错误。
二、梳理字段分类
重构前不要急着写代码,先把字段分类。
| 字段类型 | 处理方式 |
|---|---|
| 必填字段 | build() 中检查,或通过 Builder 构造方法强制传入 |
| 可选字段 | Builder 方法按需设置 |
| 默认值字段 | Builder 字段初始化 |
| 派生字段 | 在 build() 或 Product 构造中计算 |
| 可变引用字段 | 构造时防御性拷贝 |
例如导出配置:
format必填;pageSize默认 1000;charset默认 UTF-8;password可选;compress可选;- 如果加密导出,必须有 password。
这个梳理会直接决定 Builder 的设计。
三、迁移大构造器
可以先保留旧构造器,内部委托给 Builder 或复用校验逻辑,避免一次性改爆调用方。
@Deprecated
public ExportConfig(String format, boolean includeHeader, int pageSize) {
this(ExportConfig.builder()
.format(format)
.includeHeader(includeHeader)
.pageSize(pageSize));
}
private ExportConfig(Builder builder) {
this.format = builder.format;
this.includeHeader = builder.includeHeader;
this.pageSize = builder.pageSize;
}
然后逐步把业务代码迁移为:
ExportConfig.builder()
.format("xlsx")
.includeHeader(true)
.pageSize(1000)
.build();
如果项目规模大,可以分多次提交迁移:先加新能力,再迁移调用点,最后清理旧 API。
四、迁移 JavaBean 半初始化对象
JavaBean 的问题是对象创建后可能处于不完整状态:
ExportConfig config = new ExportConfig();
service.use(config); // 此时字段可能还没设置完整
config.setFormat("xlsx");
Builder 可以避免半成品泄露。把无参构造器私有化或限制可见性,让对象只能从 build() 出来。
private ExportConfig(Builder builder) {
if (builder.format == null) {
throw new IllegalArgumentException("format required");
}
this.format = builder.format;
}
如果这个类被框架反序列化、ORM、配置绑定使用,就不能贸然删无参构造器和 setter,需要看框架约束。面试时讲到这一点,会显得很工程化。
五、重构时的风险控制
重构 Builder 时要注意兼容性。
第一,不要一次性删除公共构造器。先标记 @Deprecated,给调用方迁移窗口。
第二,保持行为一致。默认值、异常类型、边界值处理都要和原逻辑对齐,除非明确要修 bug。
第三,补测试。至少覆盖必填字段缺失、默认值、生效字段、非法组合、集合拷贝等场景。
第四,注意序列化兼容。如果对象参与 JSON 反序列化,Builder 可能需要额外注解或保留无参构造器。
六、用分阶段迁移保护旧调用方和序列化契约
已有 300 个调用点的大构造器不能一步删除。先加入 Builder 和等价测试,让旧构造器委托同一校验/快照逻辑;再分批迁移调用点,最后根据兼容策略降低旧 API 可见性或在大版本移除。
阶段 1: 记录旧构造器默认值与异常
阶段 2: 新增 Builder,不删除旧 API
阶段 3: Builder 与旧构造器委托同一核心构造
阶段 4: 契约测试比较两条路径结果
阶段 5: 新代码只用 Builder
阶段 6: 批量迁移 300 个调用点
阶段 7: 旧构造器标记 @Deprecated
阶段 8: 检查 JSON/ORM 等框架反射需求
阶段 9: 版本窗口后再移除 setter/构造器
全程保持默认值和序列化字段稳定
这条时间线把“可变构建阶段”和“稳定成品阶段”分开。Builder 可以反复接收参数,但 Product 只有在所有规则通过后才出现;若 Product 在第一步就被 new 出来再逐项修改,就仍然存在半初始化对象泄漏的窗口。
七、构建契约与对象不变量
| 评审维度 | 本题结论 |
|---|---|
| 必填信息 | 从旧构造器语义梳理必填项,不能凭新设计猜测。 |
| 默认值 | 迁移前后同一输入必须得到相同默认值,除非明确发布行为变更。 |
| 跨字段规则 | 复用一处核心校验,避免旧/新入口规则漂移。 |
| 可变引用处理 | 迁移时补齐集合防御性拷贝,但若改变可观察语义需评估兼容性。 |
| Builder 生命周期 | Builder 成为新入口;旧入口按弃用计划逐步收口。 |
| Product 交付保证 | 迁移完成后避免半初始化,且框架绑定/序列化仍满足既定契约。 |
build() 不是形式上的结束标记,而是对象从“参数集合”变成“合法业务值”的原子边界。单字段输入可以提前拒绝明显错误,跨字段规则必须等信息齐全后判断,Product 私有构造器还应保留必要兜底,避免未来新增创建入口绕过不变量。
八、与构造器、JavaBean 和工厂的选择边界
- 字段少且全部必填时,短构造器或 record 通常比 Builder 更直接。
- 字段多、可选项多、同类型参数易错时,命名步骤能显著提升调用可读性。
- JavaBean setter 适合某些框架绑定,但对象可能在设置完成前就被观察到。
- Builder 关注“同一种复杂对象怎样组装”;工厂模式关注“创建哪一种产品实现”。
- 两者可以组合:工厂选择具体产品族或 Builder,Builder 再完成复杂组装。
- 经典 Director 只有在固定步骤序列需要复用或存在多种表示时才有价值。
- 链式
return this只是语法,校验、默认值、拷贝和收口才是设计语义。 - 若 Builder 比 Product 规则还复杂,应重新拆分对象职责,而不是继续堆方法。
若对象是框架要求的可变 DTO,可保留无参构造与 setter;不要为追求纯模式破坏 ORM/序列化。
九、交付前的代码与测试检查
- 缺少每个必填字段分别调用
build(),应得到明确且稳定的异常。 - 完全不设置可选字段,核对默认值来自唯一权威位置。
- 传入最小值、最大值和越界值,确认范围判断没有反向或 off-by-one。
- 构造两个互相冲突的字段组合,确认只在信息完整时执行跨字段校验。
- 传入集合或数组后修改原引用,已构建 Product 不应跟着变化。
- 若 getter 返回可变数据,再尝试修改返回值,内部状态仍应保持不变。
- 连续调用同一个 Builder 两次,确认语义是明确允许复制还是文档明确禁止复用。
- 两线程共享一个 Builder 做压力测试应被禁止或证明安全,不能靠偶然结果。
- 若使用 Lombok,检查生成代码、默认值、
@Singular、构造器可见性和框架兼容。 - 若迁移旧 API,比较默认值、异常类型、序列化字段和所有旧调用点行为。
记忆钩子:重构顺序是先兼容、再迁移、最后收口,不是先删旧构造器。
十、常见误区与追问
- 误区:新增 Builder 后应立刻删除所有旧构造器。 公共 API 和大量调用点需要迁移窗口。
- 误区:重构可以顺便改变默认值而无需说明。 这属于行为变更,应单独评审和测试。
- 误区:ORM/JSON 一定能自动使用新 Builder。 不同框架需要无参构造、注解或专用 Builder 配置。
- 追问:旧构造器如何复用新逻辑? 委托统一私有构造/校验函数,避免双份规则。
- 追问:怎样防止调用点漏迁? 静态搜索、编译告警、弃用检查和分阶段 CI 统计。
- 追问:何时可以移除旧 API? 调用方迁完且版本兼容策略允许时,通常在明确的大版本窗口。
十一、加强记忆
把大构造器或 JavaBean 重构为 Builder,不是简单换写法,而是把“字段含义、默认值、校验规则、对象不变量”重新收拢。先兼容、再迁移、最后收口,这样改动才稳。