如果你正在读这行字,大概率已经写过这样的代码:某个类的构造器从 3 个参数一路涨到 15 个,调用点一行代码顶半屏,谁看谁懵;或者有人图省事,把所有字段的 setter 全部暴露,对象 new 出来之后被四面八方改得面目全非。今天要聊的建造者模式(Builder Pattern)就是解决这类问题最经典、也最实用的方案。它不是那种面试完就吃灰的理论,而是你在 Spring、OkHttp、Lombok 这些天天见的框架里随时能摸到的东西。适合正在系统学习设计模式、或者在工程里被复杂对象构造折磨的同学参考。
我尽量不把它讲成教材。咱们从一个我自己踩过的坑说起,然后一步步把建造者模式的完整脉络拆开:四个角色、主流落地写法、与工厂模式的边界、工程里的变体、以及那些容易翻车的边角问题。看完你应该能在自己的项目里直接动手。
1. 为什么写这段:从"参数爆炸"到 Builder
1.1 一个订单类的演进:从 3 个字段到 15 个字段
先还原一个很典型的演进过程。最开始你有一个 Order 类,字段就三个:orderId、amount、createTime。这时候用构造函数没什么问题,一眼能看懂。
但业务不会放过你。第二周加 customerId,第三周加 address、phone,第四周加 promotionId、couponId,到后面还有 remark、tags、priority……字段到了 15 个的时候,构造函数已经没法看了。
java复制Order order = new Order(
"ORD-20240601-001",
new BigDecimal("199.90"),
LocalDateTime.now(),
"CUST-1024",
"上海市浦东新区xx路xx号",
"13800138000",
"PROMO-88",
"COUPON-77",
"618预售订单",
List.of("预售", "高优先级"),
OrderPriority.HIGH
);
你告诉我,第十个参数是什么?恐怕只有写这段代码的人自己知道。一旦有人不小心传错位置,比如把 customerId 和 orderId 换了个顺序,编译器根本不会报错,只有等线上订单崩了才追悔莫及。
这个阶段最常见的选择是"放弃构造器,改用 setter"。
java复制Order order = new Order();
order.setOrderId("ORD-20240601-001");
order.setAmount(new BigDecimal("199.90"));
order.setCustomerId("CUST-1024");
order.setRemark("618预售订单");
代码是好读了一点,但又埋了一个更大的雷:对象在构建完成之前,就已经处于"活着"的状态。任何线程只要拿到这个对象引用,就能在属性没设置完整时读到 orderId == null 的中间状态。而且 setter 是 public 的,后面谁都能改这个对象——一个订单金额可以被随便 setAmount 改掉,这在核心业务里几乎等于灾难。
1.2 三种创建方式的横向对比
我把这三种方式放到一起比一比,你就能理解为什么 Builder 会成为主流偏好了。
| 维度 | 构造函数重载 | JavaBean Setter | Builder |
|---|---|---|---|
| 调用点可读性 | 差,参数位置不透明 | 一般,多行 set 略啰嗦 | 好,链式语义清晰 |
| 对象不可变性 | 好,字段可以 final | 差,setter 全暴露 | 好,字段可以 final |
| 参数校验时机 | 构造时统一校验 | 分散在 setter 或事后遗漏 | build() 统一校验 |
| 必填参数约束 | 靠重载排列组合,极易爆炸 | 无法强制 | build() 里运行时校验 |
| 代码量 | 少,但参数多时接口爆炸 | 中等 | 模板不少,但 Lombok 可优化 |
看这张表就明白:构造函数的问题是参数一多就丧失可读性,setter 的问题是破坏不可变性和线程安全,而 Builder 几乎同时解决了这两类问题——它能把"参数填充"这件事从构造函数里解放出来,同时又把字段牢牢锁成 final。
1.3 建造者模式真正解决的本质问题
有人可能觉得,Builder 不就是写了一堆链式 setter?没那么简单。
它解决的第一个本质问题是参数语义化。调用端不是 new Order(id, amount, customer, ...),而是 Order.builder().orderId("...").amount(...).customerId("..."),每个值都有名字,读代码像读一句自然语言,不需要猜。
第二个本质问题是构建过程的中间态隔离。在 Builder 模式里,Order 的构造器是私有方法,外部拿不到新创建、未完成的对象;所有可变状态都临时存放在 Builder 内部。只有 build() 被调用时,才一次性生成一个完整、不可变的产品对象。这就把"构建过程中可能出现的错误状态"和"最终交付使用对象"彻底分隔开。
第三个本质问题是把校验逻辑集中到一个地方。业务规则、必填判断、字段间的关联校验,都可以在 build() 里统一处理,而不是散落在一堆 setter 里各管各的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. GoF 原始设计里的四个角色:Product、Builder、ConcreteBuilder 与 Director
2.1 谁负责什么:四个角色的职责边界
《设计模式》原书把建造者模式定义为四个角色的协作,我先用最朴素的文字把这套结构描述清楚:
- Product(产品):最终要构建出来的复杂对象,比如那个 15 个字段的
Order。 - Builder(抽象构建器):定义构建过程需要的"动作",比如
buildPartA()、buildPartB(),它不关心这些动作的具体实现。 - ConcreteBuilder(具体构建器):实现 Builder 接口,真正决定每一步怎么把零件拼到产品上,并维护当前正在构建的产品实例。
- Director(导演):负责编排调用顺序。它持有 Builder,按照某个固定的流程依次调
buildPartA()、buildPartB(),最后返回完整产品。
用一句话概括:Builder 负责"零件怎么造",Director 负责"先造哪个再造哪个"。如果你开发过点单程序,这就很像后厨分工——Builder 是各个厨师,每个人会做汉堡的面包、肉饼、生菜;Director 是那个喊"按顺序出餐"的领班。
2.2 Director 不是必需品:什么时候才真正需要导演
说点实在的:在我见过的真实业务代码里,Director 的使用频率非常低。大部分情况下,调用方用链式调用自己就掌握了构建顺序,根本不需要一个额外的类来指挥。
但这不是说 Director 没用。当构建流程本身存在多个固定的步骤算法时,Director 的价值就出来了。举个例子,生成一份 HTML 周报,流程永远是"先写页面头部,再插入业务数据表格,最后写版权尾部"。如果这个流程散落在五六个调用方里,一旦新增需求要调整顺序,你就得改五六个地方。把这段流程封装进 ReportDirector.generate(),调用方传入不同的 Builder(HTML Builder、PDF Builder),就能按同一套顺序产出不同格式的报表。这才叫用对了地方。
所以我的建议是:不要为了凑齐模式角色而硬加 Director。你的业务如果只是"一个对象、一组参数、一次构建",那 Director 就是多余的;如果你发现同样的构建步骤在多个场景重复,再考虑把它抽出来。
2.3 去掉抽象接口的最小实现,算不算 Builder
很多刚开始学模式的人会纠结:我写的类只有一个 Builder 静态内部类,没有抽象接口,也没有 ReportBuilder、PDFBuilder 这些子类,这算不算建造者模式?
算。模式不是法律条文,边界是弹性的。当系统只需要一种构建方式时,抽象接口带来的收益是零,只会增加文件数量和维护成本。你在工程里最常见到的其实是这种"退化版本"——一个产品类配一个静态内部 Builder。它保留了建造者模式最核心的价值:分步设置参数、统一校验、产出不可变对象。至于接口、多态、Director,那是扩展需求来的时候才需要的东西。
3. 静态内部类 Builder:目前工程里最主流的落地写法
3.1 一段能直接复用的 Java Builder 代码
直接上代码。这是我在项目里最常用的一版模板,字段按需增删即可。
java复制public class Order {
private final String orderId;
private final BigDecimal amount;
private final String customerId;
private final String remark;
private final List<String> tags;
private Order(Builder builder) {
this.orderId = builder.orderId;
this.amount = builder.amount;
this.customerId = builder.customerId;
this.remark = builder.remark;
// 集合字段做防御性拷贝,避免外部列表后续被修改
this.tags = builder.tags == null
? Collections.emptyList()
: Collections.unmodifiableList(new ArrayList<>(builder.tags));
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private String orderId;
private BigDecimal amount;
private String customerId;
private String remark;
private List<String> tags;
public Builder orderId(String orderId) {
this.orderId = orderId;
return this;
}
public Builder amount(BigDecimal amount) {
this.amount = amount;
return this;
}
public Builder customerId(String customerId) {
this.customerId = customerId;
return this;
}
public Builder remark(String remark) {
this.remark = remark;
return this;
}
public Builder tags(List<String> tags) {
this.tags = tags;
return this;
}
public Order build() {
if (orderId == null || orderId.isBlank()) {
throw new IllegalArgumentException("orderId 不能为空");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("amount 必须大于 0");
}
return new Order(this);
}
}
}
调用端长这样:
java复制Order order = Order.builder()
.orderId("ORD-20240601-001")
.amount(new BigDecimal("199.90"))
.customerId("CUST-1024")
.remark("618预售订单")
.tags(List.of("预售", "高优先级"))
.build();
注意几个细节:
- 每个 setter 方法返回
this,这是链式调用的基础。 Order的构造器是private,外部只能通过builder().build()创建对象。tags字段做了防御性拷贝,防止外部 list 在对象创建后被修改。Builder 模式保证"构建过程中"和"构建完成后"的集合状态不共享,这是很多人会漏掉的一步。
3.2 校验逻辑应该放在哪:build() 为什么是唯一正确的位置
有个容易踩的坑:刚接触 Builder 的同学喜欢在每个 setter 方法里做检查,比如 amount 设置成负数时直接抛异常。表面看是"尽早失败",实际会带来两个问题。
第一,链式调用在设置全部参数之前,对象一定是残缺的。比如 orderId 还没设,这时候调用 amount(...) 做校验,你没法校验那些依赖 orderId 的规则。如果每个字段整一堆独立校验,很快校验逻辑就会纠缠不清。
第二,校验分散会造成重复代码。同一个校验规则出现在 builder 里多个方法,改一处忘一处,最后还是出 bug。
正确的做法是把所有校验集中在 build() 方法里。这一步是构建流程的终点,所有字段都已经准备齐全,此时统一做必填判断、数值范围判断、字段之间的关联判断,再返回产品。这样调用方只要记住一条规则:build() 可能抛异常,其他地方不会。
3.3 Python 与 C++ 怎么借鉴这套思路
建造者模式不是 Java 的专利,只是 Java 的静态内部类写法特别适合表现它。我在 Python 项目里一般这样写:
python复制class Order:
def __init__(self, order_id, amount, customer_id, remark=None):
self.order_id = order_id
self.amount = amount
self.customer_id = customer_id
self.remark = remark
@staticmethod
def builder():
return OrderBuilder()
class OrderBuilder:
def __init__(self):
self._order_id = None
self._amount = None
self._customer_id = None
self._remark = None
def order_id(self, value):
self._order_id = value
return self
def amount(self, value):
self._amount = value
return self
def customer_id(self, value):
self._customer_id = value
return self
def remark(self, value):
self._remark = value
return self
def build(self):
if not self._order_id:
raise ValueError("order_id 不能为空")
return Order(self._order_id, self._amount, self._customer_id, self._remark)
Python 有 dataclass 之后,字段定义和 init 可以简化,但链式 Builder 的"语义化参数"价值依然保留,特别是当参数多、可选参数多的时候。
C++ 里对应的思路通常叫流式接口(Fluent Interface),本质和 Builder 一样。很多 C++ 库的配置类就是 Config().setTimeout(100).setRetry(3).build() 的形态。核心要点都一样:*this 的引用返回 + 最终的 build/validate。
4. 建造者模式和工厂模式的分工:到底谁管"创建"
4.1 "点套餐"与"自助选配":从使用意图看模式分野
很多初学者会把建造者模式和工厂模式搞混,因为它们都涉及对象创建。我打一个比方你就明白差异了。
工厂模式像去餐厅"点套餐":你告诉服务员"来一份商务套餐",不需要关心里面有几道菜、什么顺序上菜,厨房做好了端上来就是完整的产品。对调用方来说,参数是隐式的,逻辑是黑盒。
建造者模式像"自助选配":你站在档口前,逐个决定"要这份、不要那个、酱料多一点",每一步你都在显式配置,最后按"确认出餐"按钮才拿到成品。对调用方来说,参数是显式的,配置过程完全可见。
所以判断标准很简单:如果你只关心得到"一个可用的对象",不关心细节——用工厂;如果你希望调用方明确指定一堆可选项,组合出定制化对象——用 Builder。 工厂是"隐藏创建逻辑",Builder 是"展示创建细节"。
4.2 工厂与 Builder 可以共存:框架里最常见的组合
真实项目里两者经常混用,而不是二选一。最经典的形式是:一个静态工厂方法返回 Builder,或者工厂方法内部用 Builder 去构建对象。
举个例子,我写过一个 HttpClientConfig 配置类:
java复制public final class HttpClientConfig {
private final int connectTimeout;
private final int maxRetries;
private final boolean followRedirects;
private HttpClientConfig(Builder builder) {
this.connectTimeout = builder.connectTimeout;
this.maxRetries = builder.maxRetries;
this.followRedirects = builder.followRedirects;
}
public static Builder custom() {
return new Builder();
}
public static HttpClientConfig defaultConfig() {
return custom()
.connectTimeout(3000)
.maxRetries(1)
.followRedirects(true)
.build();
}
}
这里的 custom() 是工厂方法,返回一个 Builder;defaultConfig() 也是工厂方法,但内部用了 Builder 来拼默认配置。调用方既可以拿默认配置一把梭,也可以从 Builder 开始做定制。这种组合特别适合"既要默认值,又要可定制"的场景,Spring、OkHttp、RestTemplate 里大量存在同样的套路。
4.3 少用 Builder 的场景:别把它当万能膏药
说句大实话:Builder 模式也有被滥用的时候。我现在看到两三个字段的类也套 Builder,心里会咯噔一下——代码量翻了几倍,可读性反而更差。
什么情况下我建议别用 Builder:
- 字段少于 3 个,且都是必填基础类型。直接构造函数最清爽,写 Builder 就是过度设计。
- 构建过程只有一种固定方式,且参数不需要组合。用一个工厂方法直接返回成品,比 Builder 更省事。
- 对象需要频繁修改属性值,天生就是可变对象(比如 DTO 在某些场景下)。这种情况用 setter 反而合理,强行 final 字段只会让代码拧巴。
我记得有一次接手一个老模块,里面有个只有 2 个字段的类,前同事也给它套了 Builder。我重构时直接删掉 Builder,换回构造函数,整个调用链清晰了多少不说,文件行数少了 60 行。模式是为了让代码更稳、更可读,不是为了凑角色。
5. 工程里的 Builder 变体:从 Lombok 到嵌套构建对象
5.1 Lombok @Builder:模板代码终结者,以及它埋的坑
写 Java 的同学最爽的是 Lombok 的 @Builder 注解,一行搞定所有 Builder 模板代码:
java复制@Builder
public class ApiRequest {
private final String path;
private final String method;
private final Map<String, String> headers;
}
引入依赖:
xml复制<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
用起来和手写 Builder 完全一样:ApiRequest.builder().path("/api/order").method("POST").build()。
但 Lombok 有个坑必须知道:@Builder 默认不生成无参构造器,生成的 Builder 内部也不会自动执行业务校验。如果你在 Spring MVC 里把 ApiRequest 作为 @RequestBody 接收,Jackson 反序列化时默认要找无参构造器或者字段 setter,直接报错。
解决方案有两个:
- 加
@NoArgsConstructor和@AllArgsConstructor,但这样会破坏"不可变"初衷。 - 用
@JsonDeserialize(builder = ApiRequest.ApiRequestBuilder.class)让 Jackson 走 Builder 路径,同时给每个字段加@JsonProperty。这样能保住不可变,代码稍微多写一点。
我一般倾向在核心领域对象上用方案 2,在轻量 DTO 上就接受方案 1,看你对"不可变"的要求有多严格。
5.2 嵌套 Builder 与真实框架中的 Builder
真实世界里的对象很少是扁平的,经常是"对象套对象"。Builder 模式对这种情况同样友好,写出来的代码像一棵树,层次一眼能看懂。
java复制ReportRequest request = ReportRequest.builder()
.header(Header.builder()
.title("月度业务报表")
.createdAt(LocalDate.now())
.build())
.body(Body.builder()
.addLine(Line.builder()
.label("营收")
.value("1.2亿")
.build())
.build())
.build();
这种嵌套写法在配置对象、报表请求、批量导入任务里非常常见。每个子对象都有自己的 Builder,构建时从上到下逐层组装,最后合成一个完整的不可变对象树,可读性和可维护性都很好。
框架层面的例子就更不用说了。OkHttp 的 Request.Builder、Spring 的 UriComponentsBuilder、RestTemplate 的 HttpEntity 构建,都是 Builder 的教科书实现。你在用这些框架的时候其实一直在消费 Builder 的好处,只是没特意去想"哦,这是设计模式"。
5.3 新领域的呼应:从 Agent 配置到工作流编排
最近几年我在 AI Agent 和自动化工作流的代码里,也频繁看到和 Builder 完全同构的思路。
比如构建一个多 Agent 系统的配置:需要指定系统提示词、工具列表、模型参数、记忆配置,甚至主从模式下还要配置多个子 Agent 的角色和调用方式。这一套参数如果全部塞构造器,和当年 15 个字段的 Order 没任何区别。
java复制AgentConfig config = AgentConfig.builder()
.systemPrompt("你是一个数据分析助手")
.addTool(new SearchTool())
.addSubAgent("calculator", SubAgentConfig.builder()
.model("gpt-4o")
.temperature(0.2)
.build())
.memory(MemoryConfig.builder()
.type("vector")
.size(1024)
.build())
.build();
某种程度上,"把一个 Agent 看成是多个子 Agent 和工具的组装结果"这个思想,和建造者模式的底层逻辑是一样的:把复杂对象拆成可配置的零件,分步组装,最后统一产出完整实体。所以学习这个模式,不只是为了应付面试,它会潜移默化地影响你怎么设计新系统的配置层。
6. 继承、必填校验、反序列化:Builder 最容易翻车的三个地方
6.1 build() 里不校验 = 把定时炸弹留给下游
我见过不少团队用 Lombok @Builder 建完对象直接扔到业务代码里跑,结果下游到处是 NPE。原因很简单:@Builder 默认不校验必填字段,orderId 忘记设置时,build() 照样返回对象,等用到 order.getOrderId().length() 才炸。
手写 Builder 时,build() 里的校验一定要认真写。以下是两个容易被忽略的校验思路:
- 必填判断:关键业务字段为空直接抛
IllegalArgumentException或自定义的BizException。 - 业务规则判断:比如
discountAmount不能大于totalAmount、endTime不能在startTime之前。这类关联校验只能在build()里做,因为每个字段单独看都是合法的,组合起来才非法。
把校验前置在 build(),等于把错误挡在对象进入业务逻辑之前。后面所有代码都可以放心依赖字段的有效性,不用每个方法都写 if 判空。
6.2 继承场景中 Builder 的泛型之痛
继承 + Builder 是一对很难搞的组合,面试里也特别爱考。
经典问题是这样:父类 Base 有自己的 Builder,子类 Child 想复用父类字段的构建方法,同时还要追加自己的字段。如果你让子类 Builder 直接继承父类 Builder:
java复制class Child extends Base {
public static class Builder extends Base.Builder<Builder> {
// 子类字段的链式方法返回 this
}
}
理想很丰满,现实是父类 Builder 里的链式方法如果不处理泛型自引用,返回类型是 Base.Builder,调用 childBuilder.baseField("x").childField("y") 时,.childField 根本调不出来。
工程上我一般有两个方案:
- 组合代替继承:子类 Builder 内部持有父类 Builder,子类方法里调用父类 builder 的方法,build 时组合父对象。代码不算漂亮,但坑最少。
- 用 Lombok 的
@SuperBuilder:它专门处理继承场景,父类和子类都标@SuperBuilder,生成器会保留返回类型为子类 Builder,写起来省心很多。
真实项目里,我优先建议别把对象继承层级设计得太深,因为 Builder 的可读性优势在继承场景会被大量模板代码抵消。能用组合就组合,不能组合再用 @SuperBuilder。
6.3 反序列化框架下 Builder 的兼容方案
还有一个高频翻车点:你辛辛苦苦把对象设计成了"只有私有构造器 + Builder",结果一接 Jackson 反序列化就崩。
问题根源在于 Jackson 默认通过无参构造器或字段 setter 来创建对象,你的类两者都没有。解决思路有两条线:
方案一:让 Jackson 使用 Builder 反序列化
java复制@JsonDeserialize(builder = Order.Builder.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public class Order {
// ...
}
然后在 Builder 类里,对应字段需要 @JsonProperty 声明的 setter 风格方法。这种方式能保留不可变性,是更符合 Builder 设计初衷的做法。
方案二:妥协开放一个包级私有无参构造器
字段不设 final,让 Jackson 通过反射填充。好处是省事,坏处是给"不可变性"留了个后门,内部代码不小心就能 new 出半成品对象。
我自己的取舍是:核心领域对象用方案一,传输模型用方案二。因为传输模型的生命周期短、字段多,为它写一堆 @JsonProperty 不划算;核心业务对象要长期驻留、被多处复用,宁可多写点注解也要守住不变性。
最后再分享一个小技巧
如果你也在设计一个偏底层的配置类,我建议在 Builder 上额外加一个 validate() 私有方法,让 build() 里只调它。这样以后新增校验规则,只需要改一处,不会把 build() 变成两百行的垃圾场。
我在实际项目里用 Builder 的取舍标准一直很固定:类字段超过 4 个,或者存在多个可选参数、且不希望对象被中途修改时,就上 Builder;只有两三个字段,直接构造器最清爽,硬套 Builder 反而变成过度设计。以前我也纠结过"这算不算标准建造者模式",后来想通了——模式的价值在于让代码更稳、更可读,而不是为了凑齐四个角色。如果你在项目里踩到过其他奇怪的 Builder 坑,欢迎在评论区聊聊。
