设计模式 05 · 建造者模式
工厂三兄弟解决的是造哪一个、造哪一族的问题——重点在选。但还有一类创建难题,和选哪个无关:产品类型是确定的,就是一个订单,可这个订单本身特别复杂,有一大堆字段,有的必填、有的可选,还得在装配好之后保证数据合法。这时候真正的痛点不是造哪个,而是怎么把这一个复杂对象,干净、安全地一步步装配出来。这就是建造者模式(Builder)的战场。几乎每个写 Java 的人都在不知不觉中用过它——StringBuilder、Lombok 的Builder、OkHttp 的Request.Builder,全是这个模式。它是二十三式里体感最熟悉的一个,但很多人只会用.xxx().yyy().build()这套链式调用,并不清楚它到底解决了什么问题、为什么非它不可。这一篇我们就从一个复杂对象直接用构造函数会烂成什么样出发,一步步逼出建造者,并讲清它两种不同的面孔(《Effective Java》里的简化版,和 GoF 原版里那个容易被忽略的指挥者)。这篇文章按这条线索展开:先复现重叠构造器这个经典血案,看参数一多构造函数怎么失控;再看 “JavaBean setter” 这个常见补救为什么按下葫芦浮起瓢;然后引出建造者,用链式调用把复杂装配变得可读;接着讲建造者的两种形态,以及它两个常被忽略的杀手锏——强制必填校验和产出不可变对象;最后给出选型边界、现实身影,和它与工厂的清晰分界。贯穿例就用一个字段繁多的订单Order。目录重叠构造器:一个复杂对象引发的血案JavaBean setter:按下葫芦浮起瓢建造者模式:把装配过程链式化建造者的两种面孔:简化版与 GoF 原版两个被低估的杀手锏:必填校验与不可变什么时候值得上建造者现实身影,以及和工厂的界限一、重叠构造器:一个复杂对象引发的血案我们的订单Order慢慢长胖了。它现在有这些字段:订单号、用户 ID(这两个必填),以及一堆可选的——收货地址、优惠券、备注、是否需要发票、下单来源……先用最直觉的方式,给它写构造函数。问题是,可选字段的组合太多了:有人只传地址,有人传地址加优惠券,有人全传。为了应付各种组合,很多人会写出一串重叠构造器(telescoping constructor):publicclassOrder{privatefinalStringorderNo;// 必填privatefinallonguserId;// 必填privatefinalStringaddress;// 可选privatefinalStringcoupon;// 可选privatefinalStringremark;// 可选privatefinalbooleanneedInvoice;// 可选publicOrder(StringorderNo,longuserId){...}publicOrder(StringorderNo,longuserId,Stringaddress){...}publicOrder(StringorderNo,longuserId,Stringaddress,Stringcoupon){...}publicOrder(StringorderNo,longuserId,Stringaddress,Stringcoupon,Stringremark){...}publicOrder(StringorderNo,longuserId,Stringaddress,Stringcoupon,Stringremark,booleanneedInvoice){...}// ... 还想跳过 address 只传 coupon?对不起,还得再加重载}这段代码的问题,一眼就能看出好几个:构造函数爆炸:字段稍微一多,为覆盖各种传哪几个的组合,构造函数数量会失控式增长,而且很多组合根本没法用重载表达(比如跳过 address 只传 remark,因为类型可能冲突)。调用处完全没有可读性:看到new Order(NO123, 1001L, null, null, 尽快发货, true)这一行,你根本不知道那个true是什么意思、那两个null又跳过了什么。参数靠位置区分,一长串下来全靠数。极易传错:如果有好几个同类型参数(比如三个String),顺序写反了编译器根本不报错,运行时才出诡异的 bug——地址填进了备注,备注填进了地址。根源在于:构造函数天生不擅长处理参数多、且很多可选的场景。它用位置来对应参数,参数一多,位置就成了灾难。于是很多人转向了另一个方案。二、JavaBean setter:按下葫芦浮起瓢第二种常见写法,是放弃在构造函数里塞参数,改成先 new 一个空对象,再一个个 setter 填进去——也就是典型的 JavaBean 风格:OrderordernewOrder();// 先造一个空壳order.setOrderNo(NO123);order.setUserId(1001L);order.setRemark(尽快发货);// 想设哪个设哪个,可读性好多了order.setNeedInvoice(true);比起重叠构造器,它确实解决了可读性问题——每个 setter 名字都写着自己设的是什么,想设哪个设哪个,不用数位置。但它按下了可读性这个葫芦,却浮起了两个更严重的瓢:瓢一:对象在装配过程中,长时间处于不一致、不完整的状态。从new Order()到最后一个 setter 调用完之间,这个对象是半成品——orderNo可能还没设、userId可能还是 0。如果这中间对象被别的线程拿去用了,或者你忘了设某个必填字段,就会得到一个残缺的订单。构造和使用之间有一段危险的空窗期。瓢二:对象没法做成不可变的(immutable)。因为要靠 setter 赋值,字段就不能用final,类也就必须对外开放修改能力。而不可变对象有巨大的好处——天生线程安全、可以放心地共享和缓存、不会被谁在背后偷偷改掉。JavaBean 这种先建后设的模式,和不可变从根本上是冲突的:只要留着 setter,对象就永远是可变的、不安全的。现在我们看清了两难:重叠构造器保证了一次性构造完成、可以不可变,但可读性极差;JavaBean 可读性好,却牺牲了一致性和不可变。有没有办法同时拿到两者的好处——既有 setter 那样的可读性,又能一次性构造出一个完整、不可变的对象?这正是建造者模式的切入点。三、建造者模式:把装配过程链式化建造者模式的核心思路:把装配零件和最终成型这两个阶段分开。找一个专门的建造者(Builder)对象来接收各个字段(这一步可读、可分步、可任意顺序),等所有字段都设置好了,最后调用一个build()方法,由它一次性地、完整地构造出那个目标对象。具体做法是给Order配一个静态内部类Builder:publicclassOrder{privatefinalStringorderNo;privatefinallonguserId;privatefinalStringaddress;privatefinalStringcoupon;privatefinalStringremark;privatefinalbooleanneedInvoice;// 构造函数私有,只允许 Builder 来调,外部 new 不了privateOrder(Builderb){this.orderNob.orderNo;this.userIdb.userId;this.addressb.address;this.couponb.coupon;this.remarkb.remark;this.needInvoiceb.needInvoice;}publicstaticclassBuilder{privateStringorderNo;privatelonguserId;privateStringaddress;privateStringcoupon;privateStringremark;privatebooleanneedInvoice;// 每个 setter 都返回 this,于是可以链式调用publicBuilderorderNo(Stringv){this.orderNov;returnthis;}publicBuilderuserId(longv){this.userIdv;returnthis;}publicBuilderaddress(Stringv){this.addressv;returnthis;}publicBuildercoupon(Stringv){this.couponv;returnthis;}publicBuilderremark(Stringv){this.remarkv;returnthis;}publicBuilderneedInvoice(booleanv){this.needInvoicev;returnthis;}// 最后一步:一次性构造出完整的 OrderpublicOrderbuild(){returnnewOrder(this);}}}调用处就变成了这样,干净得像在读一句话:OrderordernewOrder.Builder().orderNo(NO123).userId(1001L).remark(尽快发货)// 可选的,想设就设.needInvoice(true)// 不想设的可选字段,直接不写.build();// 到这里才真正造出对象好处一下子全齐了:可读性拉满:每个字段设了什么一目了然,和 JavaBean 一样好读,还不用数位置;可选字段自由:想设哪个设哪个,不想设的不写,不再需要一堆重载构造器;对象一次成型且完整:目标对象直到build()那一刻才被创建,创建出来就是完整的,没有 JavaBean 那段半成品空窗期;可以做成不可变:Order的字段全是final、构造函数私有、没有任何 setter——造好之后谁也改不了。所有可变的脏活,都被隔离在了那个用完即弃的Builder里。这里那个每个方法return this的小技巧,就是链式调用(method chaining)的实现方式,也叫流式接口(fluent interface)。它让一连串的设置读起来像流水一样连贯。四、建造者的两种面孔:简化版与 GoF 原版需要说明的是:上面这种静态内部类 Builder 链式调用的写法,其实是《Effective Java》推广开的简化版,也是今天实际项目里用得最多的形态。而 GoF 原版的建造者模式,结构要更完整一些,多了一个常被忽略的角色——指挥者(Director)。理解这个区别,才算完整。GoF 原版把职责分成了两个角色:建造者(Builder):定义造一个复杂对象需要哪几个步骤(比如buildPartA()、buildPartB()),以及怎么交付成品。可以有多个具体建造者,用不同方式实现这些步骤。指挥者(Director):它不关心零件细节,只负责编排步骤的顺序和流程。它拿着一个建造者,按固定的顺序调用那些 build 步骤,最后拿到成品。打个比方最清楚:建造者是工人,知道怎么砌墙、怎么装门;指挥者是包工头,知道盖房子要先砌墙再装门这个流程。同样的流程(指挥者),换一个工人(建造者),就能盖出材料完全不同的房子——比如同一套装配订单的流程,换一个建造者,可以产出普通订单或者导出用的订单快照。那为什么现在大家更爱用没有指挥者的简化版?因为在多数业务场景里,装配步骤的顺序并不重要——先设地址还是先设备注,无所谓,最后build()就行。指挥者存在的价值,是当构造步骤有严格、固定、且可复用的流程时,把这个流程单独封装起来。如果没有这种流程复用的需求,指挥者就是多余的角色,去掉它反而更简洁。这又回到了那句老话:GoF 给的是完整形态,实际用时该减则减,别为了标准硬塞一个用不上的 Director——那也是一种过度设计。一句话总结两者关系:简化版 只有 Builder,适合字段多、顺序无所谓的对象装配(绝大多数场景);完整版 Builder Director,适合构造流程固定且需要复用的场景。五、两个被低估的杀手锏:必填校验与不可变很多人以为建造者只是让代码好看了点,其实它还有两个更实在的能力,是前面那些方案给不了的。杀手锏一:在build()里做一次性的完整校验。因为对象是在build()那一刻才统一构造的,你可以在这里集中拦截所有非法状态——必填字段没填、字段值不合法、字段之间互相矛盾,全在这里一次性查:publicOrderbuild(){// 必填校验:没有 orderNo 或 userId,直接不让造出来if(orderNonull||orderNo.isEmpty()){thrownewIllegalStateException(订单号必填);}if(userId0){thrownewIllegalStateException(用户 ID 非法);}// 跨字段校验:要发票却没填地址,也拦下if(needInvoiceaddressnull){thrownewIllegalStateException(需要发票时必须填写地址);}returnnewOrder(this);}这是 JavaBean 做不到的——JavaBean 靠一个个 setter,你根本没有一个所有字段都齐了的时机去做整体校验。而建造者天然就有build()这个临门一脚的检查点。它保证了一件事:只要一个Order对象被成功造出来,它就一定是合法的、完整的。这个要么造不出来、要么造出来就合法的保证,在业务上极其宝贵。杀手锏二:产出真正的不可变对象。前面说过,Order字段全final、构造私有、无 setter。这意味着order一旦到手,它的状态就被冻结了,你可以放心地把它传给任何方法、缓存起来、在多线程间共享,完全不用担心它在某个角落被偷偷改掉——因为它根本没有被改的能力。这在并发场景下省去了大量加锁的烦恼(第一篇里不可变对象天生线程安全这句话,在这里落了地)。这两点合起来,才是建造者相比 JavaBean 的真正价值:它不只是让创建代码更好看,而是让创建出来的对象更安全——合法性有保证、状态不可变。六、什么时候值得上建造者老规矩,泼冷水时间。建造者虽好,但它有明确的成本:你得为目标类额外写一个 Builder,字段一多,Builder 里就是一堆重复的样板 setter。所以它不是每个类都该配的。适合用建造者的信号:对象的参数很多(经验上超过四五个就该考虑了),尤其是有不少可选参数;你希望对象一旦创建就不可变、且创建出来就保证合法;想避免重叠构造器的数位置和 JavaBean 的半成品空窗期。不必用建造者的信号:字段就两三个,而且都是必填——直接一个构造函数最清爽,套 Builder 纯属找麻烦;对象本身就允许被随意修改(确实需要 setter),那 Builder 的不可变优势用不上。关于 Lombok:实际项目里,几乎没人手写那一大坨 Builder 样板代码了。给类加一个 Lombok 的Builder注解,编译期就会自动生成上面那整套 Builder,你直接.xxx().build()用就行。但注意:自动生成的build()默认不带业务校验,那些必填、跨字段的检查还是得你自己补(Lombok 也支持自定义 build 逻辑)。工具帮你省了样板,但保证造出来就合法这个设计意图,仍需你亲自把关。判断的核心还是那句话:先确认这个对象真的参数多、有可选、想不可变,建造者才配得上它的样板成本。给一个三字段的小对象套 Builder,是典型的为了用模式而用模式。七、现实身影,以及和工厂的界限建造者是你日常最常打交道的模式之一,认出它们:StringBuilder/StringBuffer:最经典的建造者,append()一步步拼接(每次返回this,所以能链式),最后toString()相当于build()交付成品。LombokBuilder:一个注解自动生成整套 Builder,是业务开发里最常见的用法。OkHttp 的Request.Builder、OkHttpClient.Builder:构造一个 HTTP 请求有一堆可选项(header、method、body、超时……),典型的建造者场景。java.util.stream.Stream.builder()、Calendar.Builder:标准库里的建造者应用。MyBatis 的SqlSessionFactoryBuilder:用建造者来组装配置复杂的SqlSessionFactory。最后划清建造者和工厂的界限,这是这五篇创建型模式的一个重要收束——它们都管创建,但关注点截然不同:工厂(方法/抽象工厂)建造者关注点造哪一个 / 哪一族(在多个类型里选)怎么一步步造好一个(单个复杂对象的装配)产品通常是不同的类(多态)通常是同一个类的复杂实例过程一步到位,create()直接返回分多步设置,最后build()交付典型信号“根据类型/渠道拿到对象”“参数一大堆、还想不可变”一句话记牢:工厂解决选择问题,建造者解决装配问题。你要在微信/支付宝里选一个,那是工厂;你要把一个有十几个字段的订单干净地拼出来,那是建造者。两者甚至可以配合——工厂内部完全可以用建造者来完成它那个产品的复杂装配。小结。建造者模式专治单个复杂对象的创建。它的来路很清晰:重叠构造器让参数多的对象没法看、易传错;JavaBean setter找回了可读性,却引入了半成品空窗期和无法不可变两个新问题;建造者把装配过程交给一个链式的 Builder,等零件齐了再用build()一次成型,既拿到了可读性,又能在build()里做完整校验、产出不可变对象——保证要么造不出来,要么造出来就合法且冻结。它有简化版(静态内部类 Builder)和 GoF 完整版(多一个编排流程的 Director)两种形态,按需选用。记住它和工厂的分界:工厂管选哪个,建造者管怎么拼。到这里,创建型模式只剩最后一个了——下一篇讲原型模式:当你想要一个和现有对象几乎一样的新对象时,与其从头new再一个个赋值,不如直接克隆一份,我们会从再来一单这个需求说起,顺便把 Java 里深拷贝和浅拷贝那点事彻底讲清。