1. 建造者模式到底在解决什么问题
我先从一个实际场景说起。假设你正在做一个点餐系统,订单对象有十来个字段:菜品、数量、规格、辣度、加料列表、备注、配送地址、联系人……有些字段必填,有些选填,各种组合让人头皮发麻。第一版你可能直接用构造器重载,写了五六个不同参数的构造函数;第二版觉得太乱,改成setter方式,满屏的order.setXXX()看得人眼睛疼;第三版又想兼顾不可变性,结果发现setter全删了之后,对象根本没法灵活创建。
这就是建造者模式(Builder Pattern)要解决的核心痛点:当一个对象的构造参数很多、且存在大量选填项时,如何让创建过程既安全、又可读、还能保持对象不可变。
建造者模式属于创建型设计模式,Gof 23种设计模式之一。它的核心思想是将复杂对象的构建过程与它的表示分离——同一个构建过程,可以构建出不同的表示。听起来有点绕,翻译成人话就是:你把“怎么一步步组装一个对象”的逻辑,从“这个对象本身”里拆出去,单独交给一个构造函数级别的“工头”去管,工头指挥着具体的“施工队”一步步把对象搭出来。调用方只需要告诉工头“我要什么”,不需要关心施工队怎么干活。
这个模式在软考、面试、期末考里都是高频考点,但很多人学完就忘,因为光背UML图确实记不住——四个角色(Product、Builder、ConcreteBuilder、Director)看一遍觉得明白了,关上书啥也没留下。我这篇就换个思路:先不讲UML,直接从真实编码痛点出发,一步一步把这四个角色“推导”出来,理解了“为什么要这样设计”,那个类图你自然就记住了。
2. 从构造器爆炸到Builder:一个完整的推导过程
2.1 构造器重载和多参构造的困境
假设我们有这样一个Order类:
public class Order { private String dishName; // 菜品名,必填 private int quantity; // 数量,必填 private String spiciness; // 辣度,选填,默认不辣 private List<String> extras; // 加料,选填 private String note; // 备注,选填 private String address; // 地址,选填 }如果要支持所有可能的组合,构造器会长成什么样?四个可选字段就是2的4次方等于16种组合,哪怕你只写最常用的几个重载,代码也已经很臃肿。而且调用端根本分不清new Order("宫保鸡丁", 1, "中辣", null, null, null)里那一堆null到底哪些是哪个字段——只靠位置区分参数,是这个方案最大的反人类之处。
另一个常见方案是JavaBean模式:无参构造加一堆setter。new Order()然后逐个setXxx()。这个方案解决可读性问题,但引入了两个新问题:对象在构造过程中处于“半初始化”状态,可能被其他线程看到不完整数据;同时一旦setter公开,你很难保证这个对象后续不被偷偷修改,不可变性基本宣告放弃。
2.2 四个角色是怎么推导出来的
好,现在我们来推导演进过程。
第一步,我们希望有一个东西能“一步步设置参数”,还要可读,那自然是一个链式调用的类,每个方法返回this。这个东西就是Builder。它负责“接收参数”。
第二步,我们希望最终创造出来的对象不可变,即字段全是final,没有setter。那Builder在构建时就得把这些参数一次性传给对象构造函数,并要求对象构造时做完整校验。这个东西就是Product(最终产品),它只关心“我是一个完整的对象”,不关心你是怎么把参数凑齐的。
第三步,如果构建过程比较复杂,比如有的步骤有先后依赖,有的步骤有默认策略,把这段“构建流程”写在调用方里,会导致调用方重复且容易出错。于是我们抽出Director(指挥者),封装“构建流程”本身,调用方只负责告诉指挥者“用哪个施工队”。这就像你去餐厅点餐:你只和服务员说“一份夫妻肺片少辣”,服务员(Director)去后厨指挥厨师(ConcreteBuilder)完成整套流程——而不是你自己冲进后厨。
第四步,不同的菜品对应不同的施工细节,但流程骨架都一样。所以抽象出Builder接口,声明“加辣”“加料”“设置备注”这些步骤;具体的ConcreteBuilder负责真正实现这些步骤,产出的具体产品可能也不同——比如SpicyOrderBuilder和ColdDishOrderBuilder,或者更常见的,OrderBuilder针对不同菜品类型产生不同风味订单。
到这里,四个角色就全齐了:Product(产品)、Builder(抽象建造者)、ConcreteBuilder(具体建造者)、Director(指挥者)。你现在再回头看Gof那张类图,是不是就有血有肉了。
2.3 为什么说它和工厂模式是两回事
面试时最容易被问倒的一个问题:建造者模式和工厂模式有什么区别?
我的理解很简单:工厂模式关注“创建哪种产品”,建造者模式关注“如何一步步创建产品”。工厂模式通常一步到位,返回给你一个完整的对象,你不需要关心内部怎么装配;建造者模式则是分步操作,你在每一步可以指定不同的选项,最后才build()出来。类比的话,工厂像“自动售货机”——投币、选货、掉出来,完事;建造者像“点火锅”——锅底、蘸料、配菜、饮料,一样一样选,选完才算完整一单。
实际工程中两者常常配合使用:用工厂方法根据配置返回不同的Builder,再用Builder分步设置属性。这不冲突,反而更清晰。
3. 实战落地:一个可用的Java实现
3.1 先写一个即使是新手也能复制的版本
我直接给一份标准到不能再标准的Java示例,以点餐订单为例:
public class Order { // 所有字段final,保证不可变 private final String dishName; private final int quantity; private final String spiciness; private final List<String> extras; private final String note; private final String address; private Order(Builder builder) { this.dishName = builder.dishName; this.quantity = builder.quantity; this.spiciness = builder.spiciness == null ? "不辣" : builder.spiciness; this.extras = builder.extras == null ? Collections.emptyList() : Collections.unmodifiableList(builder.extras); this.note = builder.note; this.address = builder.address; } public static Builder builder() { return new Builder(); } public static class Builder { // 必填字段 private String dishName; private int quantity; // 选填字段 private String spiciness; private List<String> extras; private String note; private String address; public Builder dishName(String dishName) { this.dishName = dishName; return this; } public Builder quantity(int quantity) { this.quantity = quantity; return this; } public Builder spiciness(String spiciness) { this.spiciness = spiciness; return this; } public Builder extras(List<String> extras) { this.extras = extras; return this; } public Builder note(String note) { this.note = note; return this; } public Builder address(String address) { this.address = address; return this; } public Order build() { if (dishName == null || dishName.isEmpty()) { throw new IllegalStateException("菜品名不能为空"); } if (quantity <= 0) { throw new IllegalStateException("数量必须大于0"); } return new Order(this); } } @Override public String toString() { return "Order{" + "dishName='" + dishName + '\'' + ", quantity=" + quantity + ", spiciness='" + spiciness + '\'' + ", extras=" + extras + ", note='" + note + '\'' + ", address='" + address + '\'' + '}'; } }调用方长这样:
Order order = Order.builder() .dishName("宫保鸡丁") .quantity(1) .spiciness("中辣") .extras(Arrays.asList("花生", "葱段")) .note("多放花生") .address("北京市朝阳区xxx") .build(); System.out.println(order);注意几个设计细节:
- 校验逻辑放在build()而不是构造器里:当然构造器里也可以校验,但把校验集中在Builder的
build()中,好处是调用方能在创建那一刻拿到明确异常,而不是等到运行时某个诡异的地方才炸。如果你的团队更看重“产品自身保证完整性”,可以把校验也放构造器,两者不冲突。 - 列表防御性拷贝:
extras传入后立即做unmodifiableList包装,防止外部引用继续修改内部状态。这是不可变对象最容易忽略的一环——你只是不给setter,但你把外部可变集合直接赋给内部字段,等于留了个后门。 - null默认值处理:
spiciness == null ? "不辣" : spiciness这种写法保证即使调用方漏设选填字段,产品也有合理默认值,不会出现null满天飞的情况。
3.2 支持Java平台的其他语言写法
热词里同时出现了C++、C#,说明很多读者是多语言选手。我简单给一下这两个语言的核心写法差异。
C#方向:C#用户很少手写Builder,因为有现成的对象初始化器语法:
var order = new Order { DishName = "宫保鸡丁", Quantity = 1, Spiciness = "中辣", Extras = new List<string> { "花生", "葱段" } };但要注意:对象初始化器本质上还是先构造再赋属性,对象不是不可变的。如果你需要不可变对象配合Builder,C# 9以后的record配合with表达式是更强方案。从面试角度说,C#的Builder通常更多用于构造过程复杂、需要步骤校验的场景,而不是单纯为了可读性。
C++方向:C++的Builder通常配合std::unique_ptr或std::optional来处理可选字段,链式调用的返回值要小心拷贝开销——一般返回Builder&而不是Builder,避免每次调用都触发拷贝构造。另外C++可以用[[nodiscard]]标记build(),强制调用方不要忽略构造结果。
3.3 经典变体:省略Director的链式风格
我上面示例其实没有真正的Director,而是把“构建流程”直接交给了调用方。这是现代工程中非常主流的做法,尤其是配合IDE自动补全时,链式调用已经足够清晰,再引入Director反而多一层抽象。
那什么时候应该保留Director?我建议这么判断:如果“构建步骤”本身是固定且可复用的,且未来可能换不同的构建策略,就用Director;如果只是让对象创建可读,直接链式Builder完全够用。比如导出报表:导Excel、导PDF、导CSV,底层构建步骤都是“加表头-加数据-加样式-输出”,这套流程就应该抽成Director,配合不同的Builder产生不同格式的文件。再比如你做一个配置解析器,JSON、YAML、XML的加载流程骨架相同,也可以抽Director。
省略Director的写法还有另一个大好处:更容易支持可选步骤随意组合。Director一旦规定了流程顺序,反而限制了灵活性——有的调用方就只想设两个参数,你非要他走完“加辣-加料-备注”的流程,那不是自找麻烦吗。
4. 工程中的高阶操作与经验法则
4.1 用建造者模式把“必填/选填”做得更优雅
有的团队对必填字段有执念,希望build()时报错能精确到字段。我见过一种做法:Builder构造函数就要求必填参数,选填参数通过链式方法传入。比如:
public static class Builder { private final String dishName; // 必填 private final int quantity; // 必填 private String spiciness; // 选填 public Builder(String dishName, int quantity) { this.dishName = dishName; this.quantity = quantity; } public Builder spiciness(String spiciness) { ... } }这样build()里的校验就可以减少很多——必填参数在编译期就保证存在了。我个人很喜欢这个风格,因为它把“对象的硬性前提”提前到了构造Builder那一刻,而不是拖延到最后运行时才发现问题。代价是调用方必须记住先把必填参数传给Builder构造器,链式风格稍弱。
还有一种是利用@Builder注解,Lombok的@Builder配上@NonNull注解,在build()时自动生成判空逻辑。项目里如果依赖Lombok,确实省事不少,但要注意Lombok生成的Builder对默认值的处理——比如List字段默认是null而不是空集合,如果你希望默认空列表,得自己在字段声明处初始化,或者定义@Builder.Default。这块有坑,后面问题清单里我会细说。
4.2 建造者模式与不可变对象的黄金组合
这些年函数式风格流行,不可变对象越来越受重视。建造者模式几乎是“不可变对象”的标准配套:你没法用setter改字段,于是创建时的灵活性就全靠Builder来提供。两者的组合有几个好处:
- 线程安全:不可变对象不需要加锁,天然线程安全。你可以在多线程环境里安全地共享同一个
Order实例。 - 防止误用:没有setter,就没有“对象被悄悄改坏”的途径。团队协作时,别人拿到你的对象只能读不能写,心智负担小很多。
- 缓存友好:不可变对象的哈希值可以缓存,适合做Map的key。
但这里有个细节很多人没注意:不可变对象的集合字段必须做防御性拷贝。你是把extras这个List传进来了,如果外面那个List之后被修改了,你的“不可变”对象也跟着变——就像你把备用钥匙交给了朋友,朋友随时能进屋翻东西。所以我在前面代码里写了Collections.unmodifiableList,这就是关键防线。
4.3 和经典框架里的Builder范本对照学习
如果你觉得抽象,我强烈建议去读一读主流框架里现成的Builder实现,比任何教程都管用。
StringBuilder应该是最早接触的:append()返回this,最后toString()返回不可变结果。它和典型Builder略有区别——不是把参数收集到最后一次性创建产品,而是边操作边维护内存,但精神内核一致。
OkHttp的Request.Builder是教科书级实现:必填字段(url)在Builder里校验,选填字段全部链式添加。我当年读它源码时才彻底理解“把校验放到Builder里”的精髓。
Spring的UriComponentsBuilder则展示了Director思想:它把URI构建拆成scheme()、host()、path()、queryParam()等步骤,最终build()生成不可变对象。客户端可以用多种方式拼装URI,这个构建过程本身被Builder封装得很好。
Lombok的@Builder是用代码生成替代手写,但你要明白它生成的结构和手写本质一模一样,只是省了样板代码。
建议学习方法:挑一个你项目里正在使用的框架,找到它的Builder源码,试着画出角色对应关系,很快就能内化。
5. 面试考点、软考速记与常见问题排查
5.1 面试官最爱问的5个建造者模式问题
我自己面试别人时,设计模式里问得最频繁的五个问题如下,供你自查:
问题1:什么时候用建造者模式?答:对象构造参数多(超过4-5个)、有大量选填字段、希望对象不可变、希望调用端代码可读性强。如果构造参数少且全部必填,直接用构造器更简洁;如果主要关心“同类产品的不同实现”,优先考虑工厂模式而不是建造者。
问题2:建造者模式和工厂模式区别?答:工厂关注“创建什么”,一步到位;建造者关注“怎么创建”,分步完成。工厂返回的产品通常是同一类型的不同实现,建造者甚至不要求产出的类型一致(比如JSON和XML两个Product类可以共用一个Builder接口)。实际中可配合使用。
问题3:Builder里的校验应该放在哪一步?答:分两层。必填项目的判空尽早放在build()里,让对象创建时就知道失败;字段之间的交叉约束(比如“设置了加辣就必须设置辣度”)也放build();字段自身格式校验(比如地址正则)可以在对应setter方法里提前拦截,这样能尽早暴露错误,不用等到最后一刻。
问题4:Builder和不可变对象如何协同?答:Product所有字段final,无setter;Builder持有可变参数副本,build()时一次性传给Product构造器;集合字段做防御性拷贝或不可变包装;Builder自身是可变的,Product是不可变的,两者各司其职。
问题5:为什么不直接用静态工厂方法?答:静态工厂方法适合参数较少(通常不超过3个)且参数含义清晰的情况。参数一多,静态工厂方法也得做重载,可读性依然差;且静态工厂方法无法解决“步骤化构建”的需求,比如依赖步骤间校验的复杂装配流程。
5.2 软考设计模式速记方法
软考(软件设计师/系统架构设计师)里设计模式是必考块,很多同学抱怨记不住。我的经验是不要死记UML图,而是记一句话业务场景,再推出类结构。
建造者模式对应软考里最典型的一句话场景:“将复杂对象的构建与表示分离,使得同样的构建过程可以创建不同的表示”。你把这句话拆成两个关键词:构建、表示。构建对应Builder和Director,表示对应Product和ConcreteBuilder。
我自己整理的速记卡片:
- 四个角色一句话:Director指挥,Builder声明步骤,ConcreteBuilder实现步骤,Product出成品。
- 类图特征:Product指向Builder(传参),Director持有Builder接口(指挥),ConcreteBuilder实现Builder且依赖Product(组装)。
- 识别技巧:看到代码里一堆
setXXX().setXXX().build()链式调用,八成就是建造者模式。
口诀角度,我给同学们分享一个记忆链:“招工头,雇工人,出产品”。招工头=Director创建;雇工人=Builder/ConcreteBuilder实现;出产品=Product。三个动作对应三个参与角色,UML关系顺下来就能画。
5.3 实战中我踩过的坑(含排查方法)
这里必须把一些“代码能跑但会坑队友”的细节暴露出来,我这些年在真实项目里都见过或踩过:
坑1:Builder被复用导致脏数据。
Builder是可变的,如果你把同一个Builder实例在多线程环境里共享,或者在一个循环里复用来构建多个对象,就会出现参数串味。比如:
Order.Builder builder = Order.builder(); for (int i = 0; i < 10; i++) { Order order = builder.dishName("菜" + i).quantity(1).build(); // 下一次循环时builder还留着上一次的dishName状态,直接覆盖还好,但extras这类集合是累加的! }如果extras是累加进去而不是每次清空,第二次build()时就会把第一次的元素带进来。排查方法:在build()里打日志输出Builder当前所有字段,很容易发现。更稳妥的做法是“每次build后生成一个全新的Builder实例”,或者约定的规则是“一个Builder实例只build一次”。
坑2:不可变对象的集合字段还是被改了。
我前面强调的防御性拷贝,很多人会忘。问题症状是:你明明没有setter,但某个同事通过order.getExtras().add("香菜")成功改动了内部集合。这是因为getExtras()返回了内部可变集合引用,等于公开了后门。解决方案:getExtras()返回unmodifiableList,或者在构造时拷贝。真正严重的问题是,你以为“不可变对象随便共享”,结果集合字段泄漏了内部状态,排查时极难定位。
坑3:Lombok @Builder和默认值冲突。
用Lombok时我给一个List<String> extras = new ArrayList<>()字段加了个默认空列表,满心以为不设置就默认为空,结果build()出来还是null。原因:Lombok的@Builder会生成一个全新的Builder类,所有字段初始值都是Java默认值(null/0/false),你写在Product字段上的初始化表达式只在无参构造里生效,不会传导到Builder里。解决办法是给字段加@Builder.Default注解。这个坑特别隐蔽,因为代码看着没毛病,跑起来才发现全是null。
坑4:build()抛异常时信息太模糊。
如果只写if (dishName == null) throw new RuntimeException("参数错误"),调用方根本不知道哪个参数错了。好的做法是每条校验抛出带有字段名的异常信息,甚至可以用Objects.requireNonNull(dishName, "dishName不能为空")。我见过生产事故最后定位到Builder校验,因为信息写得太敷衍,排查人员看了半天异常栈都不知道是谁在调用。
坑5:和构造器重载混用导致“歧义重载”。
有人为了兼容旧代码,既保留多参构造器,又提供Builder。如果构造器的参数列表恰好和Builder方法签名冲突,会导致调用方不知道该走哪条路径。我的建议是显式将构造器设为private,强制所有外部创建都必须走Builder,这样架构上更清爽,也不容易出歧义。
6. 什么场景别用建造者模式
聊到这儿,必须泼一盆冷水:建造者模式不是银弹,有些场景硬上反而更糟。
场景一:参数只有两三个。
比如一个Point类只有x、y坐标,你非要写个Builder,那是典型的过度设计。直接构造器或静态工厂就够了。判断标准很简单:代码写着费劲,读着也别扭,就说明用错地方了。
场景二:对象创建后需要频繁修改。
像实体类POJO,后面跟着一堆逻辑要动态改字段,你做成不可变对象纯属自找麻烦——每次修改都要重新build一个对象,性能和心智成本都吃不消。这种情况老老实实用可变的普通类加setter即可。我见过有人把数据库实体也用Builder封装成不可变类,结果ORM更新字段时尴尬得不行。
场景三:你根本不需要多个产品变体。
建造者模式最有优势的场景之一是“同样的构建流程可以产生不同的表示”。如果你只有一个Product类,且构建流程完全固定,那需要一个Builder吗?——如果你只是为了可读性,那值得;如果连可读性诉求都没有,那就纯属多余。
场景四:无脑套用某种框架注解。
有的团队全员强制@Builder,连只有一个必填字段的类也加Lombok注解。这导致代码里全是Foo.builder().a(1).b(2).build(),并没有比new Foo(1, 2)更清晰。注解本身没有错,错的是无脑套用。这也是我在代码评审时最想怼的情况之一——设计模式是用来解决问题的,不是用来晒技术品位的。
所以我的经验法则总结成一句话:当构造对象的复杂度高到“读代码的人需要靠猜才能拼出完整对象”时,就该上建造者模式了;如果你还没有这个痛点,先别动。
7. 最后聊点个人的实践体会
用了这么多年建造者模式,我最大的体会倒不是“代码变得多优雅”,而是它改变了团队协作时的沟通方式。在一个订单对象有十个字段的项目里,以前代码评审看到new Order("宫保鸡丁", 1, null, null, "快", "地址"),你根本没法快速定位哪个参数是什么;现在是Order.builder().dishName(...).quantity(...).build(),字段名就明晃晃摆在方法名里,可读性直接提升一个量级。代码是写给人看的,这个模式在“让人看懂”这件事上功不可没。
分享一个我个人的小习惯:每次写Builder,我都会顺手把build()里的校验写成链式的、可读性强的断言,比如Objects.requireNonNull(builder.dishName, "dishName must not be null")。这样一旦出错,日志里直接能看到字段名和原因,排查问题的时间能省一大半。这个习惯花不了几分钟,却能在半年后线上出问题时救你一次。
如果你刚接触设计模式,我建议从建造者模式开始练手——它的四个角色足够清晰,代码量不大,却能让你深刻理解“封装变化”和“单一职责”这两个OO核心思想。先去改改你手头那个参数最多的类,把它重构成Builder风格,跑一遍测试,你会比我更快把这张图刻进脑子里。