我先提个问题:你写代码到现在,有没有遇到过那种构造方法参数多得吓人的类?十几个参数往里一塞,传参的时候全靠数位,传错了编译器还不吭声,运行起来才炸。你要是没踩过这个坑,那运气真不错;踩过的话,估计你已经意识到——普通的构造函数写法,撑不起复杂对象的创建场景。
这个场景,恰好就是创建型设计模式里**建造者模式(Builder Pattern)**的主场。
简单说,建造者模式解决的就是“复杂对象怎么优雅地创建”这个问题。它把对象的构建过程和表示分离开来,让你可以用同样的构建过程,一步步拼装出不同表现的对象。今天我不打算从教科书角度给你念一遍概念,而是直接以我一个写了好几年业务代码、被构造器折磨过的老开发视角,把这个模式拆开揉碎,跟你说清楚它到底解决什么痛点、代码该怎么组织、实操当中有哪些坑。
1. 建造者模式到底解决了什么问题
很多文章上来就讲结构、画类图,但我更喜欢先聊场景。你只有知道“为什么需要它”,才能真正理解代码为什么长成那样。
1.1 从“构造函数灾难”说起
假设你要做一个订单系统。订单这个对象,字段大概有:订单号、用户ID、商品列表、总金额、折扣、收货地址、支付方式、备注、发票信息、是否加急……写个构造函数,你数数字段,可能就要传十来个参数。
这时候你遇到的第一层尴尬是可读性崩塌。new Order("A001", 123, goods, 299.5, 0.8F, "xx省xx市...", "wechat", "快点发", invoice, true)这一行代码,谁能一眼看出299.5是打折前的总额还是折后价?0.8F是折扣率还是某个金额?传错了参数,编译期不报错,运行期数据错了都不知道在哪里找。
第二层尴尬是必填和可选字段不好区分。有些字段是必须的,比如订单号、用户ID、商品列表;有些是可选的,比如备注、发票信息。一个全参数构造方法要求调用者必须传齐所有参数,哪怕很多字段他根本没有;多个重载构造方法又会导致代码爆炸,也就是经典的“telescoping constructor”问题——字段一多,重载组合数吓死人。
第三层尴尬是对象不可变性和步骤化构建的矛盾。你其实希望创建好的订单对象是不可变的,后续不能被人乱改,否则数据一致性没法保证。但用 setter 方法去赋值,就得把字段和 setter 全部开放出来,等于给了全局一个改数据的机会。这俩需求天然冲突。
1.2 Builder模式的核心思路
建造者模式的思路很简单:不要在构造函数里一次性把所有参数塞进去,而是用一个独立的“建造者”对象,一步步地把每个参数喂进去,最后调用build()一次性生成不可变的目标对象。
我打个比方。你去餐厅点一个定制披萨,你不会对着后厨喊“我要一个大份、芝士多一点、火腿、蘑菇、青椒、不要洋葱、酱料加倍、烤焦一点”这么一口气报完——事实上你也可以这么做,但很容易让传话的人听漏。更常见的做法是:你告诉服务员一步步来,“饼底要大的”“加芝士”“加火腿”“加蘑菇”……服务员记在手卡上,最后确认一遍,交给后厨。
Builder 就是那个服务员。它帮你把一次复杂的下单过程,拆解成多次清晰的、可校验的调用,最后再统一交付结果。
这种思路还有个隐藏优点:建造过程本身可以被复用和组装。你可以预置几个“套餐”构造流程,比如“标准订单模板”和“加急订单模板”,内部调用同一套 builder 步骤,只是参数不同。这就是“同样的构建过程,不同的表现结果”的含义。
1.3 适合场景与不适合场景
不是所有对象都值得上 Builder,这一点我得说清楚。我见过有人为了一个只有两个字段的类也硬写一个 Builder,那纯属制造噪音。
适合用 Builder 的场景,大致有三个特征:
- 字段多:推荐在超过 5 个字段时考虑,尤其是其中还有较多可选字段时。
- 参数之间有依赖关系或约束:比如“传了折扣就必须传原价”“配送地址要在下单时校验”。构造器模式适合做参数组合校验,而构造函数很难优雅表达。
- 对象需要不可变:不暴露 setter,创建完就定型,Builder 是通用解法。
反之,字段少、创建逻辑简单、对性能极其敏感(比如在一个每秒创建百万次对象的循环里),那直接构造函数或者静态工厂方法就好,完全没必要加一层 builder 的间接调用开销。
2. 经典角色拆解与代码骨架:每一个角色是干什么的
模式归模式,落到代码里必须对应到类。经典 GoF 定义里,建造者模式有四个角色:Product(产品)、Builder(抽象建造者)、ConcreteBuilder(具体建造者)、Director(指挥者)。初学者最大的困惑往往是:我到底需要写几个类?哪些能省?下面一个个拆。
2.1 四个角色的职责边界
- Product(产品类):就是你要创建的那个复杂对象。它只关心“我长什么样”,不关心自己怎么被拼出来。内部字段可以全部是
private final,只提供 getter,不做 setter,保证不可变性。 - Builder(抽象建造者接口):定义构建产品各个部件的抽象方法。注意,这些方法都返回 Builder 自身,这是为了实现链式调用。
- ConcreteBuilder(具体建造者实现):实现 Builder 接口。它内部持有一个“半成品”的产品引用,负责把部件一个个装上去。装好之后通过
build()返回最终产品。 - Director(指挥者):用来封装构建顺序和构建过程。它是可选的,并不总是需要。Director 的职责是“知道构建流程”,而 Builder 知道“怎么构建某个部件”。两者分开,可以做到流程复用。
角色之间怎么协作?一句话总结:调用者指挥 Director,Director 按流程调用 Builder,Builder 一步步产出 Product。如果某些产品构建流程非常简单直接,Director 也可以不要,调用者直接操作 Builder 链式调用即可。
2.2 标准 Java 实现样例
说再多不如直接上代码。我写一个外卖订单的简单例子,完整展示这套结构。
// 1. 产品类:一份外卖订单 public class Order { // 全部字段 final,创建后不可变 private final String orderNo; private final String userId; private final List<String> items; private final double totalPrice; private final String address; private final String remark; // 私有构造函数,只能由 Builder 调用 private Order(Builder builder) { this.orderNo = builder.orderNo; this.userId = builder.userId; this.items = Collections.unmodifiableList(builder.items); this.totalPrice = builder.totalPrice; this.address = builder.address; this.remark = builder.remark; } // getters ... public String getOrderNo() { return orderNo; } // 其余 getter 省略 // 2. 静态内部 Builder 类 public static class Builder { private String orderNo; private String userId; private List<String> items = new ArrayList<>(); private double totalPrice; private String address; private String remark; public Builder orderNo(String val) { this.orderNo = val; return this; } public Builder userId(String val) { this.userId = val; return this; } public Builder addItem(String item) { this.items.add(item); return this; } public Builder totalPrice(double val) { this.totalPrice = val; return this; } public Builder address(String val) { this.address = val; return this; } public Builder remark(String val) { this.remark = val; return this; } // 3. build():创建产品前的最后校验 public Order build() { if (orderNo == null || orderNo.isEmpty()) { throw new IllegalStateException("订单号不能为空"); } if (userId == null) { throw new IllegalStateException("用户ID不能为空"); } if (items.isEmpty()) { throw new IllegalStateException("订单必须包含至少一件商品"); } if (address == null) { throw new IllegalStateException("收货地址不能为空"); } return new Order(this); } } }调用端的样子大概是:
Order order = new Order.Builder() .orderNo("A1001") .userId("U9527") .addItem("黄焖鸡米饭") .addItem("冰可乐") .totalPrice(28.5) .address("XX省XX市程序员路 1 号") .remark("不要辣") .build();这个实现看起来简单,其实已经把核心要点都用上了:私有构造函数、用Collections.unmodifiableList对集合做保护性拷贝、build()里做必填校验。
2.3 为什么要返回 this:链式调用的底层逻辑
注意 builder 的每个赋值方法都返回this。这个设计很不起眼,但它是流式 API 的基础。每次调用都返回同一个 Builder 对象,而不是返回 void,这样你才能一直点下去:.orderNo(...).userId(...).items(...).build()。
我见过有人学了一半,自己写 Builder 时不小心把返回类型写成了void,结果链式调用彻底失效,只能回到分行赋值的写法。那代码虽然不是不能用,但已经丢掉了这个模式大半的优雅。
这个方法返回this的设计在 Java 里有个专业叫法——fluent interface(流式接口)。它不是 Builder 模式专属,但 Builder 模式是流式接口最重要的应用场景之一。你在 Guava、OkHttp 这类知名开源库里能见到大量这种风格的 API,本质上都是在践行同样的模式。
3. 从简版到经典版:实操过程中的模式演进
很多人会问:那我什么时候用上面那种简化的 Builder(Builder 作为内部类),什么时候用带 Director 的经典四角色版本?这是特别典型的实战问题。我给你的答案很简单:绝大多数业务场景用内部类 Builder 就够了;Director 多用在框架级、组件级的复杂组装代码里。
3.1 为什么业务开发里“内部类 Builder”是主流
把 Builder 写成目标类的静态内部类,有一个天然优势:它可以访问目标类的私有构造函数、私有字段。这意味着产品类的构造函数可以直接是private,从源头杜绝了外部new出来乱改的可能性。同时,因为 Builder 和 Product 在同一个文件里,代码的内聚性高,阅读时上下文信息集中,维护成本低。
Java 里有一个隐藏语言特性支撑这种写法:内部类可以访问外部类的私有成员。正是因为这一点,Order的构造函数才能是private,而Order.Builder却能访问它。这是 Kotlin、C# 等语言里常见的 data class + builder 写法所没有的优势,也解释了为什么 Java 社区里 Builder 通常以内部类形态出现。
额外交代一个细节:集合字段构建时,我上面用了addItem这样一个“增量添加”的方法,而不是提供一个items(List<String> val)一次性整体赋值。为什么?因为实际业务里,商品列表往往是循环往里面加的。如果 builder 只提供整体 setter,调用方就需要自己先拼好 List 再传进来,这就退化成“先组装再传参”的旧路子了,享受不到逐步构建的灵活性。
3.2 经典版 Director 的价值与应用场景
Director 听名字挺高大上,但它做的事情其实很朴素:把 builder 的调用顺序封装起来。举个例子,如果你要把构建订单的流程固化成“标准订单”和“加急订单”两种套餐,Director 就能派上用场。
public class OrderDirector { private final Order.Builder builder; // 注入一个通用的 builder public OrderDirector(Order.Builder builder) { this.builder = builder; } public Order createStandardOrder(String orderNo, String userId, List<String> items, String address) { return builder.orderNo(orderNo) .userId(userId) .addRemarks("") .build(); } public Order createExpressOrder(String orderNo, String userId, List<String> items, String address) { return builder.orderNo(orderNo) .userId(userId) .addItem("打包费") .remark("加急配送,请尽快送达") .build(); } }Director 把 builder 的复杂调用顺序封装成语义化方法,对调用方来说就很好用了:director.createExpressOrder(...)一看就知道是创建加急订单,不用关心内部构建细节。
但是,为什么实际业务里 Director 用得少?因为大部分业务场景只有一个固定的构建流程,不存在多种流程切换。如果你用内部类 Builder 链式调用就能完成构建,再加一层 Director 属于多余的间接层,反而破坏代码的可读性。Director 真正适用的场景,是构建流程本身有多套模板、需要被外部统一调度的时候,比如报表引擎里不同报表类型的生成流程、UI 框架里不同组件的装配流程,这种场景 Director 的价值才体现出来。所以选择标准很简单:确认有多套构建流程再引入 Director,否则砍掉它,保持轻量。
3.3 逐步构造与参数校验:Builder 的隐藏收益
Builder 的另外一个核心价值,在于它是逐步构造的,这带来一个多大用处很多人没意识到:参数校验的时机和方式完全变了。
普通构造函数里做参数校验,只能写一堆if判断堆在函数入口。Builder 模式里,你可以有两种校验方式:
- 实时校验:在赋值方法里对当前参数做校验,比如
.addItem(null)直接抛异常,这样问题可以尽早暴露,定位到具体是哪个 setter 触发的。 - 最终校验:像前面代码里
build()里那样,把所有字段的依赖约束集中校验一遍。比如“折扣存在时原价不能为 0”“加急订单必须填加急原因”。
在实际项目里,你通常两者都要。逐字段的即时校验 + 全局组合校验,组合起来就像是给对象创建上了两道保险。这比“构造完再业务校验”要优雅一整个层次,也是我坚持推荐 Builder 处理复杂对象的原因之一。
还有一个非常隐蔽但很重要的好处:字段缺失检测。如果你用传统的构造方法或 JavaBean setter 方式,创建一个本该 10 个字段的对象,结果漏了 1 个字段赋值,程序不会报错,只会带着残缺数据往下跑,要到非常后面的环节才发现,排查成本极高。但在 Builder 的build()里做全量必填校验,漏字段直接启动失败,快速失败(fail-fast)原则在这里体现得淋漓尽致。
4. 与其他创建型模式的对比与选型指南
很多初学者把“工厂模式”和“建造者模式”搞混,因为它们都负责“创建对象”。但二者关注的维度完全不同。这里把创建型家族的几位成员放一起做个对比,你以后选型会清楚很多。
4.1 工厂模式 vs 建造者模式
工厂模式(含简单工厂、工厂方法、抽象工厂)的核心意图是“根据条件决定创建哪种产品”,它关心的是产品的“类型”和“族系”,比如根据支付渠道参数返回支付宝支付对象还是微信支付对象。工厂通常封装了类型判断和创建细节,但对产品内部的组装过程并不关心,也不负责每一步参数的逐步设置。
建造者模式的核心意图是“如何一步步组装一个产品”,它关心的是“过程”和“参数”,而产品类型通常只有一个。你传入什么参数我就组什么对象,不存在“根据参数返回不同类型产品”的分支逻辑。
用大白话讲:工厂模式是“告诉食堂你要吃什么菜,食堂帮你把菜端上来”;建造者模式是“告诉服务员你要什么料,看着人家一步步把汉堡叠起来”。工厂提供了抽象,建造者提供了过程。两者并不互斥,甚至经常配合使用:工厂负责决定具体构造哪个 Builder,再用这个 Builder 去一步步造产品。
我给一个具体的场景对比。比如一个支付系统:支付渠道有多种(支付宝、微信、银行卡),每种渠道的配置参数不同,但你又不想让调用方知道具体支付对象怎么创建——这时候应该用工厂模式,根据 channel 参数返回不同的支付处理器。但如果你需要一个支付请求对象,它有金额、订单号、回调地址、签名方式、扩展参数等十几个字段,其中大部分可选——这时候应该用建造者模式来组这个请求对象。注意,这两个场景完全可以同时存在:先通过工厂拿到具体的支付处理器类型,再通过该处理器内部的 Builder 组装请求。
4.2 单例模式与原型模式的边界划分
创建型模式家族里还有单例模式和原型模式,也顺手提一嘴,避免你被面试官反复拷打。
- 单例模式保证一个类只有一个实例,重点是“实例唯一性”,与对象怎么构造关系不大。它适合全局配置、线程池、连接池这类资源,是创建型里最特殊的一位——它对构建过程不关心。
- 原型模式通过克隆现有实例来创建新对象,重点是“复制已有的实例”。它适合创建成本高、且对象间差异很小的场景,比如根据一份基础问卷模板克隆出多份微调后的问卷。跟建造者模式的区别很清晰:原型是“拷贝”,建造者是“组装”。
- 抽象工厂模式和工厂方法的区别也常被放到面试里考:工厂方法定义一个创建对象的接口,让子类决定实例化哪个类,是一个类的创建延迟;抽象工厂提供一个创建一系列相关或相互依赖对象的接口,是产品族的创建,和单一具体类没有强绑定。建造者模式可以看成抽象工厂的“更精细版”——抽象工厂一次返回一个成品家族,建造者则是不紧不慢地一步步组完再交付。
为了让你一眼看明白,我放个对比表:
| 模式 | 核心关注点 | 典型场景 | 和建造者模式的关键差异 |
|---|---|---|---|
| 工厂方法/抽象工厂 | 创建哪种类型/产品族 | 根据条件返回不同产品 | 不负责逐步组装,直接返回成品 |
| 单例模式 | 实例唯一性 | 全局配置、线程池 | 与构建过程无关 |
| 原型模式 | 复制已有实例 | 模板克隆、深拷贝 | 靠复制而非逐步组装 |
| 建造者模式 | 如何分步组装复杂对象 | 字段多、参数多的对象创建 | 链式组装 + 最终校验 |
4.3 根据项目实际情况选择模式的经验法则
看了一个对比表,你可能还在纠结“我到底该用哪个”。我给你一套我自己的选型思路,也许不完全正统,但很实用:
- 对象字段少(少于 5 个),直接构造函数,别折腾。
- 字段多、步骤多、有必填和可选之分,用建造者模式。
- 关心“根据输入返回不同类型/不同实现”,用工厂模式。
- 对象创建出来基本不变,但你又想复制一份再改改,用原型模式。
- 全局只需要一份的东西,用单例模式。
- 如果对象只是“多字段”但创建后并不会被复用为模板或切换类型,优先考虑手写 Builder 或 Lombok 的
@Builder,而不是过早引入工厂+建造者的完整组合。
选型的过程,本质是在“灵活度”和“复杂度”之间做权衡。Builder 模式确实灵活,但每多一点灵活,代码量就多一层;如果一个类只有 3 个字段,硬套 Builder 反而拖慢开发。经验法则第一条不是空话——代码简洁性的优先级应当排在“模式完整度”之前,这是我踩过几次坑之后真实的体会。
5. 高频踩坑记录与排查技巧实录
Builder 模式看上去不难,但真正落地到项目里,问题会在代码层面、框架层面、约定层面同时出现。我自己这些年在这上面栽了不少跟头,也帮同事排查过不少,下面把高频问题集中整理一下。
5.1 不可变集合被意外修改:保护性拷贝
这是最常见的一个 bug。很多人写的 Product 类里有一个List<String> items字段,Builder 里也维护一个List<String> items,build 的时候直接把 builder 的 list 赋给 product 的 list。看起来没问题,但实际操作中一旦后续有人拿到order.getItems()往里面add一个元素,所有的“订单项”就被悄无声息地改了。
这不是 Builder 模式本身的问题,而是对象设计时没有做防御性拷贝。解决办法很简单,在构造函数里做一层不可变包装:
this.items = Collections.unmodifiableList(builder.items);注意这里不只是“包装一下”,还有一层“拷贝”的语义:如果你用Collections.unmodifiableList(builder.items)直接包住原有 list,那外部如果还持有 builder 的引用,通过 builder 的 list 依然能改数据。更稳妥的写法是new ArrayList<>(builder.items)之后再包一层不可变视图,双重保险。如果字段是Map、Set,同理。写框架组件时,这块细节尤其容易踩坑,因为这些对象会被到处传来传去。
还有个细节我要提醒:如果你用的是 Lombok 的@Builder,它默认对集合字段的行为是直接赋值,不会帮你做防御性拷贝或不可变包装。所以使用 Lombok 时,需要自己写一个 build 方法或者在 getter 里包一层Collections.unmodifiableList,否则同样会踩这个坑。
5.2 忘记调用 build() 或重复调用 build()
漏调build()的后果,可能比你想的严重。Builder 链式调用写起来很爽,但如果某条分支路径上忘了.build(),你会拿到一个半成品的 Builder 对象,而不是产品对象。编译器一般不会直接报错——如果你把链式调用的返回值赋给变量,类型不匹配倒是会报;但如果你直接在方法参数里用了 builder,编译器是检查不出来的,大概率要等运行期才炸。
我的建议是:尽可能把 Builder 的使用收敛到单一的表达式中,也就是一个链式调用以 .build() 结尾,中间不要拆行、不要开一个变量单独持有。这样做的好处是,你一眼就能看出这个对象最终在哪里被构建出来,也不会有中间状态逃逸的问题。
重复调用build()导致的问题更隐蔽:如果你的Builder内部状态在build()之后没有重置,第二次调用build()会重复生成一个一模一样的对象,如果你在 build 里加了某些副作用逻辑(比如扣减库存、分配 ID),那问题就大了。规范做法是:要么让 Builder 每次 build 时都基于当前状态生成新的 Product,不破坏 Builder 内部状态;要么规定 Builder 是一次性的,build 后就不能再用。我个人推荐后者,在使用文档里明确写出来,比在代码层面控制要省心得多。
5.3 继承体系下 Builder 的泛型自引用难题
这是 Builder 模式里少有的硬核难点。如果你有一个父类Animal,还有一个子类Dog,两个类都想要自己的 Builder,并且希望子类继承父类的 Builder 链式方法,问题就来了:父类 Builder 的方法返回Animal.Builder,但子类调用这些方法后编译器认为返回值是Animal.Builder,你就无法再调用Dog.Builder上特有的方法了,链式调用断裂。
解法是泛型自引用(self-referential generic)。核心思路是父类 Builder 声明为泛型Builder<T extends Builder<T>>,方法的返回类型是T,这样子类继承时指定T为子类自身的 Builder 类型。这是一个比较烧脑的设计,很多资深开发也未必日常写过,但如果你在做一个模型层次清晰的项目、又想让每个子类都能链式构建,就必须掌握。
public abstract class AnimalBuilder<T extends AnimalBuilder<T>> { protected String name; public T name(String val) { this.name = val; return self(); } protected abstract T self(); public abstract Animal build(); } public class DogBuilder extends AnimalBuilder<DogBuilder> { private String breed; public DogBuilder breed(String val) { this.breed = val; return this; } @Override protected DogBuilder self() { return this; } @Override public Dog build() { // 组装 Dog } }注意name()方法返回的是T,在DogBuilder里T被确定为DogBuilder,所以new DogBuilder().name("旺财").breed("金毛").build()这条链不会断。这种细节不实操基本学不到,我在第一次遇到“子类 Builder 链断了”时也查了半天资料。如果你写的是没有继承体系的普通业务类,那这套泛型技巧可以先不学,够用就好。
5.4 Builder 与设计模式“配套使用”的经典组合
Builder 模式不是孤立存在的,它和别的模式搭配时威力更大。说一下我实战中常用的三组组合:
- 工厂 + Builder:由工厂根据配置选择具体 builder 类型,再统一走相同装配流程。这在复杂系统里很常见。
- Builder + 不可变对象 + 值对象:Builder 构建出的对象往往是值对象,天然适合作为领域模型中的 VO、DTO,不需要额外逻辑,直接丢给 JSON 序列化。
- Builder + 策略模式:不同 Builder 实现可以看作不同构建策略,Director 在运行时切换具体 builder,就实现了策略切换。
这三组组合不是面试八股,是真实业务里解决复杂问题的高频玩法。比如报表引擎里,根据报表类型选择不同的 builder,build 出不同的报表模型,后面接策略模式做数据填充,模式之间层层解耦,代码结构会非常清晰。
5.5 常见问题速查表
| 现象 | 根因 | 解决方式 | 发生频率 |
|---|---|---|---|
| 集合字段被外部修改 | 未做防御性拷贝或不可变包装 | 构造时new ArrayList<>(builder.items)+Collections.unmodifiableList | 高 |
| 合法参数被误判为缺失 | build() 校验条件过严 | 明确必填字段清单,区分“默认值”和“未设置” | 中 |
| 子类 Builder 链式调用断裂 | 泛型使用不当,返回类型被推断为父类 Builder | 泛型自引用T extends Builder<T> | 中 |
| 同一个 builder 多次 build 导致重复创建 | 未明确 builder 一次性使用约定 | 文档约定或 build 后打标记 | 低 |
| 调用方绕过 Builder 直接 new 目标类 | 构造函数未私有化 | 构造函数设为 private,强制 Builder 创建 | 中 |
| 参数组合校验遗漏 | 各字段单独校验了,但相互依赖关系没查 | 在 build() 里增加跨字段约束校验 | 高 |
| 链式调用中间某步返回 null 导致 NPE | setter 方法返回了 null 而不是 this | 统一返回 this | 低 |
这个表里的问题,我基本都亲手排查过。前三个出现概率最高,建议你把它们作为你写第一个 Builder 版本时的重点排查项,省得到时候踩雷。
6. 扩展变体与工具链生态:从手写到框架支持
手写 Builder 在小型项目里没问题,但在大型项目里,几十上百个数据类,每一个都手写 Builder,那代码量会非常可观。所以现在主流语言和框架都加入了 Builder 的自动化支持。
6.1 Lombok @Builder 的用法、限制与坑
Java 生态里最常用的当然是 Lombok 注解。你写一个类,加一个@Builder注解,就能自动生成链式调用所需的整套代码。这个效率提升是巨大的,很多团队已经把 Lombok 的@Builder列为标准配置。
但 Lombok 也有它的限制。我最想提醒的是两点:
第一,默认不对集合做防御性拷贝和不可变包装。前面也提到过,如果你直接用@Builder构建一个带List字段的类,生成出来的 product 里的 list 和 builder 里的 list 是同一个引用。解决方式是自己在类里写一个private static的构造方法或者构建完成方法,在外面包一层不可变集合;更常见的是在 getter 上直接 returnCollections.unmodifiableList(this.list),保证调用方拿不到可变引用。
第二,某些边界情况注解支持不完善。比如需要带泛型自引用 Builder 的继承体系,用 Lombok 就不太方便(虽然可以通过@Builder加@SuperBuilder的组合来实现继承场景,但使用门槛更高,生成代码的阅读性也更差)。这种场景我建议你保留手写方式,或者用@SuperBuilder搭配@AllArgsConstructor一起用,具体根据版本测试效果。
第三,Lombok 会增加编译期依赖。虽然这对很多项目不是问题,但在某些对依赖极其敏感或禁止注解处理器的环境中,Lombok 根本没法用。你需要预判团队的技术栈约束,别等到集成构建失败再来手忙脚乱地替换。
6.2 Java 新版本与 Kotlin 等语言里的 Builder 简法
Java 8 之前的 Builder 手写代码量确实不少。Java 8 之后,你其实可以用Function组合等方式简化 Builder 的调用,比如写一个通用的“字段赋值函数”集合,但这种做法的可读性不如传统 Builder 好,所以我并不是特别推荐在业务代码里用太花哨的函数式写法来替代 Builder。
Java 16+ 的 record 类型让“不可变数据载体”变得更简单,record 本身自带全参构造函数、equals、hashCode、toString,天然是一个不可变对象。但 record 没有自带 Builder,你需要配合 Builder 或直接把 record 的规范构造方法暴露出去。两者结合起来用效果很好:record 充当 Product,Builder 充当组装者,构建出的对象简洁、不可变、语义清晰。
Kotlin 生态里,最接近 Builder 模式的其实是具名参数 + 默认参数,比如Order(orderNo = "A1001", userId = "U9527", remark = "不要辣")。这种语法天然不需要 Builder,因为参数有了名字调用可读性不会崩塌,默认值机制还解决了可选字段的问题。所以你会发现 Kotlin 项目里写 Builder 的很少,不是 Kotlin 不能写,而是语言特性替代了这个模式的适用场景。如果你以后转 Kotlin,或者团队跨语言协作,这个对比能帮你迅速理解为什么对方不用 Builder。
6.3 写一个“多用型”通用 Builder 工具组件
大型项目里手写 Builder 太多也是一种重复劳动。一个可行的思路是:写一个通用的 Builder 工具类,通过反射或函数式接口来赋值。比如用一个VarBuilder<T>,内部维护一个目标对象实例和一个字段赋值器的 Map,调用者通过.with("fieldName", value)动态赋值。这种设计在需要大量动态构建场景(比如导出报表字段映射)时有用,但缺点是失去了编译期类型检查,字段名拼错一点就不会被编译器发现,调试起来反而更费劲。
我个人并不推荐一上来就写“通用 Builder 组件”。Builder 模式的本质价值在于编译期类型安全、可读性和可控校验,通用组件如果为了灵活性把类型安全丢了,那等于捡了芝麻丢了西瓜。只有当你非常明确需要“动态字段构建”时,才值得引入反射式方案。常规业务里,Lombok@Builder加手写少量特殊类,是平衡效率和稳妥性的最优解。
7. 什么时候不应用建造者模式:过度设计预警
写了这么多,还是想泼一盆冷水。Builder 模式好用,但绝对不是万金油。我在代码评审里见过不少“为了模式而模式”的写法,看起来每个类都工工整整套上了 Builder,实际上维护成本不降反升。这里集中说几个“不该用硬用”的信号。
7.1 参数少却硬套 Builder 的典型症状
如果一个类只有两三个字段,比如Color(int r, int g, int b),你用 Builder 不仅没法提升可读性,还会徒增代码量。调用端new ColorBuilder().r(255).g(0).b(0).build()比new Color(255, 0, 0)啰嗦多了。更有意思的是,在很多语言里这类简单数据类型用值对象自带的全参构造函数或者工厂方法,可读性本来就够好,Builder 反而是负资产。
另外,如果一个对象的全部参数都是必填、无默认值、无组合校验需求,也用不着 Builder。构造函数本身就是最好的约束工具:编译器强制你传够所有参数,少传一个都编译不过。Builder 把它变成可选项,反而让“必填字段”的约束只能靠运行期抛异常来保证,从编译期安全退化为运行期风险,这是一种倒退。
7.2 过度设计对可维护性带来的隐性破坏
Builder 模式每增加一层抽象,都在增加阅读成本和调用成本。在一个大型代码库里,调用方需要先了解“这个类有 Builder”“Builder 支持哪些方法”“哪些字段是必填的”“build() 会不会抛异常”,这比看一个简单构造函数费劲多了。如果你的 Builder 还引入了 Director、抽象接口、具体实现类,那对一个新人来说,理解链路就更长了。
所以我的原则是:字段少于 5 个、无继承、无多步骤组装,一律不上 Builder。代码优雅的前提永远是简单,而不是模式套得多。很多现代语言已经在语言层面解决了很多“模式想解决的问题”了,比如上面提到的具名参数、默认参数、record、可选类型等。你先想想语言本身有没有更简单的工具,再考虑上不上模式,这才是成熟开发者的判断方式。
7.3 与静态工厂方法的分工取舍
建造者模式和静态工厂方法二者并不冲突:简单的参数可以直接用静态工厂方法(比如User.createNormalUser()),复杂的参数组合才走 Builder。在很多项目里,两者是共存的:外部以静态工厂方法统一入口,内部再委托给 Builder 构造复杂对象。这样调用方感知到的是一层简洁的语义化工厂,而 Builder 的具体细节被封在内部。这是我在大型项目里比较推崇的公共 API 设计:对外简洁,对内精细,各取所长。
8. 最后再分享一点个人习惯和项目落地建议
文章到这里,核心内容基本讲完了。最后分享几个我每次在项目里落地 Builder 时都会顺手执行的习惯,也算是对你时间的一种负责。
习惯一:写一个“Builder 模式检查清单”。每当我要给一个新类配 Builder,我会按这个清单过一遍:构造函数是否私有?集合字段是否做了不可变包装?build() 是否对必填字段和跨字段约束做了校验?Builder 是否会残留可变状态?继承场景泛型是否正确?这套清单帮我挡掉了不少低级 bug。
习惯二:尽量把 Builder 的验收校验做在 build() 里,而不是分散到各个 setter。因为字段与字段之间的约束关系,只有在所有参数都到位以后才能准确判断。拿订单举例,你在设置“折扣”时可以校验折扣范围,但你没法在孩子设置“原价”之前确认“折后价是否大于 0”。把这些整体约束统一放在 build() 收口,是最清晰可控的位置。
习惯三:给自己定一个“Builder 使用边界”。如果一个类有 5 个以上的字段,且其中有可选字段或字段间存在约束,我才用到 Builder。这条边界没有标准答案,但有了自己的标准,你做起设计决策会果断很多,也不容易被“模式崇拜”带偏。
建造者模式这个创建型模式,说到底是软件开发里一种“跟复杂性作战”的实用武器。它不神秘,也不难——难的是在合适的场景里果断使用它,在不合适的场景里果断放弃它。希望你读完这篇之后,除了掌握它的写法和原理,更能在自己的项目里形成一套清晰的选型判断力。这个能力,比记住模式本身值钱得多。