写代码这几年,我见过的烂项目各有各的烂法,但有一种混乱特别典型:构造函数从 3 个参数一路加到 11 个,新增字段永远是硬塞在参数列表末尾。调用方看着一行new GatewayConfig(baseUrl, 30, 60, 3, true, true, false, "HMAC-SHA256"),只能靠数括号猜每个位置到底代表什么。这其实就是一个建造者模式(Builder)本该解决、却被拖到上线之后才重视的问题。
这期聊建造者模式,我会先把痛点讲透,再拆 GoF 的经典骨架,然后给一份可以直接抄的流式写法,最后聊聊工厂模式和 Builder 的边界,以及我实际维护过的两个隐蔽坑。适合正在系统补设计模式的初级开发者,也适合准备在团队里推广这模式的资深工程师。
1. 建造者模式到底在解决什么问题
1.1 构造函数膨胀:从“还能用”到“不敢改”
先看一个典型场景。报表类Report的字段不断增长,新增字段是项目经理一句话的事,代码则是每个版本多一个构造重载:
public class Report { private final String title; private final String author; private final String header; private final boolean printPageNumber; private final boolean printWatermark; private final int fontSize; public Report(String title) { this(title, null); } public Report(String title, String author) { this(title, author, null); } public Report(String title, String author, String header) { this(title, author, header, true, false, 12); } // 四个、五个……重载数量开始失控 public Report(String title, String author, String header, boolean printPageNumber, boolean printWatermark, int fontSize) { this.title = title; this.author = author; this.header = header; this.printPageNumber = printPageNumber; this.printWatermark = printWatermark; this.fontSize = fontSize; } }这个类只要再加一个“是否显示目录”的布尔字段,重载组合数就会继续膨胀。更现实的情况是,新增字段后旧的构造函数调用点仍然编译通过,新字段默默变成默认值。等测试发现某个线上报表没显示页脚,团队已经分不清是哪个调用方忘了传,还是默认值本身就有问题。
从工程角度说,构造重载不是不能用,但一旦可选参数超过两三个,组合复杂度就开始指数级恶化。这也是 Builder 模式最朴素的动机:把构造对象时眼花缭乱的参数列表,换成一行行带名字的方法调用。
1.2 参数错位:编得过去,跑偏千里
重载构造函数的第二个隐患是参数位置错乱。new Report("Q4", "某部门", "页眉", true, false, 14),只看源码根本看不出第 4 个参数是“是否打印页码”还是“是否带水印”。一旦两个布尔值传反,编译不会报错,程序也能正常跑,但输出的报表和预期完全相反。
这类问题在配置类上尤其危险。我在一个内部网关项目里见过一次线上事故:两个布尔参数enableAccessLog和enableRequestId被传反,导致本应打印的访问日志全被吞掉,排查问题时监控一片空白。原因就是构造函数调用点离定义处太远,写代码的人只记得“第六个参数是 true”,却不记得第六个参数具体是哪个字段。
Builder 的链式调用之所以有效,是因为它把每个参数变成了一个方法名:.enableAccessLog(true)和.enableRequestId(false)写出来,读代码的人不需要翻类定义就知道在配什么。这带来的不是语法上的优雅,而是把“猜含义”这件事的成本直接从调用方身上移除了。
1.3 两个容易被忽略的收益:不可变与流程控制
很多人以为 Builder 只是“少写几个重载”,其实它顺带解决了另两个问题。
第一个是对象不可变性。经典做法里,产品类的字段全部final,构造器私有,只允许通过 Builder 的build()方法创建。没有 setter,对象创建完成后就不可能被半路改掉。对于配置类、值对象这类到处共享的对象,不可变能省掉一大堆并发问题。
第二个是分步构建的流程控制。复杂对象的组装有顺序,比如构建房子必须先打地基、再搭框架、最后封顶。如果调用方直接 new,他没有义务知道这个顺序;而 Builder 配合指挥者(Director)可以把“先干什么后干什么”沉淀到一处,不会因为某个调用方图省事而跳过关键步骤。
2. 经典骨架拆解:一个模式,四个角色
2.1 四个角色分别管什么
GoF 原版定义里,Builder 模式有四个角色:
| 角色 | 职责 | 典型形态 |
|---|---|---|
| 产品(Product) | 最终要构建的复杂对象 | 字段多为 private final |
| 抽象建造者(Builder) | 定义构建各组件的步骤接口 | 接口或抽象类 |
| 具体建造者(ConcreteBuilder) | 实现每个步骤,持有正在构建的产品 | 内部管理一个 Product 实例 |
| 指挥者(Director) | 编排步骤的具体顺序 | 接收 Builder,调用步骤方法 |
值得注意,日常开发中我们接触最多的是静态内部类那种流式 Builder,它经常省略 Director。但 GoF 的经典结构里,Director 是独立存在的,它不关心具体步骤怎么做,只关心步骤的顺序。
2.2 一份可以直接跑起来的经典实现
用一个盖房子的例子来演示。产品是House,两个具体建造者分别造木屋和石屋,指挥者控制统一流程:
public class House { private String foundation; private String structure; private String roof; private String interior; public void setFoundation(String foundation) { this.foundation = foundation; } public void setStructure(String structure) { this.structure = structure; } public void setRoof(String roof) { this.roof = roof; } public void setInterior(String interior) { this.interior = interior; } @Override public String toString() { return "House{foundation='" + foundation + "', structure='" + structure + "', roof='" + roof + "', interior='" + interior + "'}"; } } public interface HouseBuilder { void buildFoundation(); void buildStructure(); void buildRoof(); void buildInterior(); House getResult(); } public class WoodenHouseBuilder implements HouseBuilder { private House house = new House(); public void buildFoundation() { house.setFoundation("木质地基"); } public void buildStructure() { house.setStructure("木质框架"); } public void buildRoof() { house.setRoof("木质屋顶"); } public void buildInterior() { house.setInterior("基础装修"); } public House getResult() { return house; } } public class StoneHouseBuilder implements HouseBuilder { private House house = new House(); public void buildFoundation() { house.setFoundation("混凝土地基"); } public void buildStructure() { house.setStructure("石质墙体"); } public void buildRoof() { house.setRoof("石板屋顶"); } public void buildInterior() { house.setInterior("精装修"); } public House getResult() { return house; } } public class ConstructionDirector { public House construct(HouseBuilder builder) { builder.buildFoundation(); builder.buildStructure(); builder.buildRoof(); builder.buildInterior(); return builder.getResult(); } }客户端这样用:
ConstructionDirector director = new ConstructionDirector(); HouseBuilder woodenBuilder = new WoodenHouseBuilder(); House woodenHouse = director.construct(woodenBuilder); HouseBuilder stoneBuilder = new StoneHouseBuilder(); House stoneHouse = director.construct(stoneBuilder);这里能看到 Builder 和工厂模式非常关键的区别:如果只是new WoodenHouse(),那只是一个简单创建;但当我们希望“无论什么材质,都必须按地基层、框架层、屋顶、内饰这个顺序来”,流程控制就很重要,这正是 Director 存在的意义。
2.3 Director 到底是不是必须的
不少开发者觉得 Director 多余,直接在外面调用builder.buildFoundation()这样也能干活。这种判断在构建步骤足够简单时是成立的,流式 Builder 就是这么取舍的。但一旦“构建顺序”本身就是业务约束,Director 就有价值了。
举个例子,某系统初始化需要先加载配置、再建立连接池、最后注册探活。如果不抽取 Director,每个调用方都要记住这个顺序,一旦有人顺序错了,排查成本很高。把这些固定动作封装进director.build(clientBuilder),调用方只需要传一个 Builder 进来,剩下的流程由 Director 保证。我的习惯是:当同一个构建流程出现三处以上重复时,立刻抽 Director 或等价的服务方法,而不是继续在每个调用方脑子里维护顺序。
2.4 流式 Builder:日常开发最常用的变体
现在的项目里,最常见的是静态内部类 + 链式方法的流式 Builder,它省掉 Director,把“设置参数”和“校验”放在链式调用的过程中和终点的build()。下面是一个通知配置类:
import java.util.ArrayList; import java.util.List; public class NotificationConfig { private final String channel; private final List<String> receivers; private final boolean retryOnFailure; private final int maxRetries; private final String templateId; private NotificationConfig(Builder builder) { this.channel = builder.channel; this.receivers = builder.receivers; this.retryOnFailure = builder.retryOnFailure; this.maxRetries = builder.maxRetries; this.templateId = builder.templateId; } public static Builder builder() { return new Builder(); } public static class Builder { private String channel = "email"; private List<String> receivers = new ArrayList<>(); private boolean retryOnFailure = true; private int maxRetries = 3; private String templateId; public Builder channel(String channel) { this.channel = channel; return this; } public Builder addReceiver(String receiver) { this.receivers.add(receiver); return this; } public Builder retryOnFailure(boolean retryOnFailure) { this.retryOnFailure = retryOnFailure; return this; } public Builder maxRetries(int maxRetries) { this.maxRetries = maxRetries; return this; } public Builder templateId(String templateId) { this.templateId = templateId; return this; } public NotificationConfig build() { if (channel == null || channel.isEmpty()) { throw new IllegalStateException("channel 不能为空"); } if (receivers.isEmpty()) { throw new IllegalStateException("receivers 不能为空"); } return new NotificationConfig(this); } } }这种写法的核心是:默认值放在 Builder 字段初始化处,必填校验放在 build() 处,可选参数用带返回 this 的 setter 风格方法暴露。调用方拿到的是可读性极强的链式代码,产品本身又保持不可变,一举两得。
3. 跟工厂模式的分界线
3.1 为什么这两个模式总被混在一起谈
很多教材把工厂模式和 Builder 放在同一章,因为两者都负责创建对象,看起来都“用方法代替 new”。面试里也经常被问:“客户端到底该用工厂还是 Builder?”最直接的回答是:工厂模式回答“返回哪种实现”,Builder 模式回答“一个复杂对象怎么被一步步造出来”。
拿汽车举例:工厂模式像汽车总装线的“选型开关”,你告诉它“我要 SUV”,它直接给你一辆 SUV;Builder 模式像总装线本身,它规定底盘、发动机、车漆、内饰这些工序,并且允许你在不同工序里替换具体实现。
3.2 一张对比表
| 维度 | 工厂模式 | 建造者模式 |
|---|---|---|
| 核心问题 | 创建哪种产品 | 复杂产品如何一步步组装 |
| 调用方式 | 一行代码返回成品 | 多步链式或分步调用后 build() |
| 产品复杂度 | 适合中等复杂度、可通过接口切换 | 适合多参数、多部件、有顺序要求的对象 |
| 是否关注组装过程 | 通常不关注 | 明确关注步骤和校验 |
| 典型场景 | 根据配置创建不同消息推送器 | 构造一个包含十几项可选参数的配置对象 |
3.3 一看就是误用的三个信号
第一,参数只有两三个,还硬套 Builder。这类代码的链式调用比构造函数更长,阅读成本反而更高,属于为模式而模式。我的经验是:可选参数超过三四个,或者调用方容易混淆参数含义,再考虑 Builder。
第二,产品之间差异大到无法抽象出统一步骤。Builder 要求所有具体建造者都实现同一套步骤接口。如果每种产品的组装流程完全不同,强行抽象只会得到一堆空实现和 if 分支,这时候更适合用工厂模式加策略。
第三,你的对象创建后还需要频繁修改字段。Builder 构建出的通常是不可变对象,如果你需要的是“建完还能改”的 DTO,直接在业务层用普通 setter 反而更简单,别让 Builder 变成一种负担。
4. 实战复盘:一次从“配置地狱”到 Builder 的重构
4.1 一个真实的“配置地狱”现场
某内部网关模块需要一个配置类,最初只有baseUrl和timeout两个字段。随着对接方变多,又加了重试次数、签名方式、回调地址、日志开关等,构造函数膨胀到了 12 个参数。调用点长这样:
GatewayConfig config = new GatewayConfig( "https://gateway.internal", 30, 60, 3, true, true, false, "HMAC-SHA256" );这段代码最大的问题不是长,而是没有任何一处能告诉你第 4 个参数是“最大重试次数”还是“超时秒数”。曾经有个同事把connectTimeout和readTimeout的位置写反了,接口偶发超时,排查过程持续了两天,最后才发现是这两个数字传反了。
4.2 重构后的链式调用到底好在哪
用 Builder 重写之后,同一个配置类的调用点变成:
GatewayConfig config = GatewayConfig.builder() .baseUrl("https://gateway.internal") .connectTimeoutSeconds(30) .readTimeoutSeconds(60) .maxRetries(3) .retryOnServerError(true) .enableAccessLog(true) .enableRequestId(false) .signType("HMAC-SHA256") .build();所有参数都带名字,没人需要猜。更重要的是,新增字段时不再需要修改构造函数签名,只需要在 Builder 里加一个方法和对应的字段,所有旧的调用点完全不受影响。
4.3 顺手把跨字段校验收进 build()
重构前,网关配置的校验逻辑写在构造函数的最后,散落着大量if,新同学根本不知道哪些约束有效。重构后可以把字段约束分两层:基础格式校验放在链式方法里,跨字段关联校验放在build()里。
public static class Builder { private String baseUrl; private int connectTimeoutSeconds = 30; private int readTimeoutSeconds = 60; private int maxRetries = 3; private boolean retryOnServerError = true; private boolean enableAccessLog = true; private boolean enableRequestId = true; private String signType = "HMAC-SHA256"; public Builder baseUrl(String baseUrl) { if (baseUrl == null || !baseUrl.startsWith("https://")) { throw new IllegalArgumentException("baseUrl 必须是合法的 https 地址"); } this.baseUrl = baseUrl; return this; } public Builder connectTimeoutSeconds(int seconds) { if (seconds <= 0) { throw new IllegalArgumentException("connectTimeoutSeconds 必须大于 0"); } this.connectTimeoutSeconds = seconds; return this; } public GatewayConfig build() { if (connectTimeoutSeconds >= readTimeoutSeconds) { throw new IllegalStateException("connectTimeout 必须小于 readTimeout"); } if (maxRetries > 0 && !retryOnServerError) { throw new IllegalStateException("开启重试时 retryOnServerError 必须为 true"); } return new GatewayConfig(this); } }这样的分层校验有一个额外的好处:单元测试能精确验证某个链式方法的约束分支,不需要为了构造整个对象先凑出一堆无关参数。
4.4 隐性收益:代码评审和新人上手都变快了
重构之后,新来的同事第一次看配置类就能直接读懂调用处的意图,不用再拉着别人问“那个 true 是干嘛的”。代码评审的 diff 也更清晰:以前加一个参数,构造函数签名和所有调用点一起改,diff 里全是干扰项;现在加一个 Builder 方法,改动范围被限制在一个文件里,reviewer 很容易抓住实质变化。
5. 常见问题与避坑手册
5.1 问题速查表
| 症状 | 可能原因 | 排查与解法 |
|---|---|---|
| Builder 构建出来的对象还能被外部改字段 | 产品类漏了 final,或暴露了 setter | 产品字段全部 final,提供私有构造器 |
| build() 之后复用同一个 Builder,结果参数越攒越多 | Builder 是有状态对象,每次 build 不清空 | 认为 Builder 是一次性的;循环里每次重新创建 |
| 继承体系下链式方法返回类型变成父类 | 子类 Builder 没有正确使用自引用泛型 | 用T extends Builder<T>的 CRTP 写法 |
| 有些可选字段没设默认值,对象出现 null | 忘了在 Builder 字段上初始化 | 在 Builder 字段声明处给默认值 |
| 跨字段校验散落在各个 setter 里 | 把组合约束放到了单字段校验中 | 单字段校验留在链式方法,组合校验集中到 build() |
5.2 线程安全与 Builder 复用陷阱
Builder 本身是可变对象,字段在链式调用过程中不断被修改,所以绝对不要把同一个 Builder 实例共享给多个线程使用。更隐蔽的问题是复用。
我在一个定时任务里踩过这个坑,代码大概是:
NotificationConfig.Builder builder = NotificationConfig.builder(); for (String userId : userIds) { NotificationConfig config = builder .channel("sms") .addReceiver(userId) .build(); send(config); }第一次循环还正常,第二次开始receivers数组里已经沾上了上一个用户,最终一批用户收到了对方的通知。正确做法是每一次循环都新建 Builder,或者在build()里对 Builder 做一次性消费语义,但大多数团队不会做到后者,所以养成“每次构建前 new 一个 Builder”的习惯最保险。
5.3 继承体系下的 Builder 怎么写
如果产品有继承关系,Builder 的链式类型很容易丢失。子类调用父类方法后,返回类型变成父类 Builder,后面再接子类特有的方法就编译不过。标准解法是 CRTP 式的自引用泛型:
public abstract class Shape { protected final String color; Shape(Builder<?> builder) { this.color = builder.color; } public abstract static class Builder<T extends Builder<T>> { protected String color = "black"; public T color(String color) { this.color = color; return self(); } public abstract Shape build(); protected abstract T self(); } } public class Circle extends Shape { private final double radius; private Circle(Builder builder) { super(builder); this.radius = builder.radius; } public static class Builder extends Shape.Builder<Builder> { private double radius = 1.0; public Builder radius(double radius) { this.radius = radius; return self(); } @Override public Circle build() { return new Circle(this); } @Override protected Builder self() { return this; } } }这样写,new Circle.Builder().color("red").radius(2.5).build()全程都是Builder类型,链式调用不会在中途断掉。代价是泛型签名稍显复杂,所以如果层级不超过两层,也可以接受强转或直接放弃链式连续调用。
5.4 自动生成 Builder 的工具,没你想的那么省心
现在很多工程会用编译期注解工具自动生成 Builder 代码,省掉手写的大量模板。但我在项目里见过两个很隐蔽的问题。
第一,默认值丢失。工具生成的 Builder 通常不会继承字段的初始值,除非专门标注“这是默认值”。于是原本private boolean retry = true的字段,在 Builder 里可能变成false,但代码看起来又像有默认值,等到生产环境出问题才暴露。
第二,必填约束失效。自动生成的 Builder 把所有字段都当成可选,原来靠“Builder 构造函数只接收必填参数”来保证的约束,在这种场景下完全不存在,任何字段都可以不设置就build(),结果对象带着 null 满天飞。如果团队必须用这类工具,我建议至少保留手写build()或者手写必填参数的 Builder 构造函数,不要全交给生成器。
最后再分享一个小技巧
判断一个类要不要用 Builder,我有个很简单的自测方法:写一段调用代码,挡住类定义,让旁边的人读调用点,看他能不能准确说出每个值的含义。如果猜不出来,Builder 大概率能改善;如果能一眼看懂,就放过它,别为了模式而模式。
我个人在实际项目里维护过很多配置类,体会最深的是:当构造函数参数超过 6 个时,每一次修改都是一次事故机会,而 Builder 不光是让你写起来舒服,它更像一张能挡住低级错误的网。那种“凌晨三点被监控电话叫醒”的经历我实在不想再体验第二次。如果你所在的团队还在靠数参数位置猜字段,那这篇文章里的流式 Builder 写法,可以明天就直接试到新代码里。