写代码三年之后我回头看自己早期的项目发现一个很普遍的坏味道一个对象的构造器里塞五六个参数调用的时候自己都分不清哪一个是金额、哪一个是费率或者干脆暴露一堆setter对象刚new出来是个残缺体中间任何一步漏了赋值运行期就给你抛个空指针。后来接触了建造者模式Builder这个问题才算有了一个体面的解法。它是Java设计模式里我觉得实用性排前三的创建型模式尤其在对象字段多、参数组合复杂、还要保证不可变性的场景里几乎是最优雅的答案。这篇文章不打算抄概念定义我直接把建造者模式从原理到实战拆开讲包括经典写法、简化写法、常见坑和排查思路都是我实际踩过的、修过的照着用基本不会翻车。1. 建造者模式到底在解决什么问题1.1 从参数爆炸说起一个对象的构造参数一旦超过四个代码就开始失控了。不信你回想一下是不是见过这样的代码new Order(SO20250110001, U10086, BigDecimal.valueOf(199.00), BigDecimal.valueOf(0.8), new Address(某省,某市,某区,某路1号), 1, 备注, LocalDateTime.now(), WEB, 0, 0, null, null);这还不算最夸张的。等字段涨到十五六个的时候调用处根本分不清第7个参数是支付方式还是下单渠道。就算你按IDE提示一个个看字段名去填能不能保证填对顺序纯靠人品。更麻烦的是很多字段还不是必填的——比如优惠券ID比如发票信息有就传没有就传null。用传统构造器处理这种需求你只能写一堆重载构造器或者用一个参数最全的构造器然后疯狂传null。两种方案都很糟糕。重载构造器的组合爆炸问题更阴间。三个可选字段理论上就要8个构造器五个可选字段就是32个。Java编译器不反对你这么写但维护的人会想打人。我最早尝试的解法是JavaBean风格也就是无参构造器加一堆setterOrder order new Order(); order.setOrderNo(SO20250110001); order.setUserId(U10086); order.setAmount(BigDecimal.valueOf(199.00)); // 后面还有十来个setter...这样做的确解决了参数爆炸问题每个字段赋值都清清楚楚。但它带来了新问题——对象在构建过程中处于半成品状态。你new Order()的那一刻这个对象就是不合法的没有订单号、没有金额、没有用户ID。如果对象在构建期间被其他线程看到了或者某个方法在setter没调完的时候就被触发了系统里就飘着一个状态残缺的对象。这就是所谓部分对象泄漏的风险。而且JavaBean强调可变性所有字段都有setter等于把封装性拱手让人。1.2 建造者和工厂模式的边界很多人会把建造者模式和工厂模式搞混我第一次学的时候也绕了一下。其实边界很清晰工厂模式解决的是创建哪个类的问题——你给它一个类型标识它返回给你对应的产品实例重点是类族的选择建造者模式解决的是同一个类怎么组装的问题——产品类始终只有一个但它的内部结构复杂、组装步骤多变重点是复杂对象的逐步构建过程。打比方说工厂模式就像一家餐厅你点餐输入菜名厨师把做好的菜端出来返回成品你不关心菜是怎么做出来的。建造者模式则像是你自己下厨每一步都可以自己控制——先放油、再下菜、最后调味每一步都能干预。在代码上工厂模式通常是开一个接口或抽象类然后一堆子类各自实现创建逻辑建造者模式则是把目标类的构建过程拆分成多个步骤Builder里的每个方法只负责一类属性设置。理解这个区别非常关键因为选错了模式代码不是过度设计就是设计不足。如果你只是需要根据条件创建不同子类型那用工厂不要硬上建造者如果你是一个大对象内部有十几个字段字段之间有依赖约束还要保持对象不可变那建造者模式就是正解。2. 建造者模式的标准实现2.1 四个核心角色经典建造者模式由四个角色组成。第一个是Product产品也就是我们最终要构建的那个复杂对象它本身通常是一个不可变类所有属性在构造时一次性赋值。第二个是Builder抽象建造者它是一个接口或抽象类定义了构建各个部件的方法签名。第三个是ConcreteBuilder具体建造者它实现Builder接口维护一个正在组装中的产品引用并且实现每个部件的具体构建逻辑。第四个是Director指挥者它负责编排构建顺序——也就是先做什么、再做什么的逻辑。这四个角色各司其职Director管步骤ConcreteBuilder管实现Product管结果。从调用方的视角看调用方只需要接触Director和Builder的接口根本不关心产品内部有多少个零件、每个零件怎么装。这其实就是构建过程与最终表示分离的核心含义。2.2 经典实现代码示例我用一个组装电脑的例子来讲标准写法。电脑这个产品有CPU、内存、硬盘、显卡等部件而且配置可以千变万化public class Computer { private final String cpu; private final String memory; private final String disk; private final String gpu; public Computer(String cpu, String memory, String disk, String gpu) { this.cpu cpu; this.memory memory; this.disk disk; this.gpu gpu; } public String getCpu() { return cpu; } public String getMemory() { return memory; } public String getDisk() { return disk; } public String getGpu() { return gpu; } Override public String toString() { return Computer{cpu cpu , memory memory , disk disk , gpu gpu }; } }抽象建造者接口public interface ComputerBuilder { void buildCpu(String cpu); void buildMemory(String memory); void buildDisk(String disk); void buildGpu(String gpu); Computer build(); }具体建造者public class StandardComputerBuilder implements ComputerBuilder { private String cpu; private String memory; private String disk; private String gpu; Override public void buildCpu(String cpu) { this.cpu cpu; } Override public void buildMemory(String memory) { this.memory memory; } Override public void buildDisk(String disk) { this.disk disk; } Override public void buildGpu(String gpu) { this.gpu gpu; } Override public Computer build() { return new Computer(cpu, memory, disk, gpu); } }指挥者public class ComputerDirector { private final ComputerBuilder builder; public ComputerDirector(ComputerBuilder builder) { this.builder builder; } public Computer constructGamingPc() { builder.buildCpu(i7-13700K); builder.buildMemory(32GB); builder.buildDisk(2TB SSD); builder.buildGpu(RTX 4070); return builder.build(); } public Computer constructOfficePc() { builder.buildCpu(i5-13400); builder.buildMemory(16GB); builder.buildDisk(512GB SSD); builder.buildGpu(集成显卡); return builder.build(); } }你看出来了吧Director在这里的价值是把一套固定流程抽出来以后不管你是哪个具体建造者只需要按这个流程走就能得到配置一致的产品。换一个具体建造者进来比如促销版电脑建造者同样的流程可能产出不同品牌的部件。这就是同样的构建过程可以创建不同的表示。2.3 经典写法的适用边界说实话经典的四角色写法在实际业务代码里用得不算多。它更适合框架层面的场景比如某配置中心要读取不同格式的配置文件并生成统一的配置对象解析步骤固定但格式不同用一个Director编排流程就很合适。如果只是普通业务对象组装硬上Director会显得很重代码里多出很多不必要的关系。这也是为什么实际项目里我们更常用下一节要讲的简化版。3. 实际开发中更常用的简化写法3.1 静态内部类Builder如果你打开一个现代Java项目的代码库看到最多的其实是这样一种写法产品类内部嵌套一个静态Builder类Builder的每个方法都返回this最后用build()方法创建产品实例。这种写法把抽象建造者和具体建造者合并成了一个静态内部类省掉了Director代码量直接减半可读性反而更高。我直接给代码这是我在项目中用得最多的版本public class Order { private final String orderNo; private final String userId; private final BigDecimal amount; private final String address; private final LocalDateTime createTime; private final String couponId; private final String remark; private Order(Builder builder) { this.orderNo builder.orderNo; this.userId builder.userId; this.amount builder.amount; this.address builder.address; this.createTime builder.createTime; this.couponId builder.couponId; this.remark builder.remark; } public static class Builder { private String orderNo; private String userId; private BigDecimal amount; private String address; private LocalDateTime createTime; private String couponId; private String remark; public Builder orderNo(String orderNo) { this.orderNo orderNo; return this; } public Builder userId(String userId) { this.userId userId; return this; } public Builder amount(BigDecimal amount) { this.amount amount; return this; } public Builder address(String address) { this.address address; return this; } public Builder createTime(LocalDateTime createTime) { this.createTime createTime; return this; } public Builder couponId(String couponId) { this.couponId couponId; return this; } public Builder remark(String remark) { this.remark remark; return this; } public Order build() { return new Order(this); } } // getters... }调用方式是这样的Order order new Order.Builder() .orderNo(SO20250110001) .userId(U10086) .amount(BigDecimal.valueOf(199.00)) .address(某省某市某区某路1号) .createTime(LocalDateTime.now()) .build();注意几个细节。第一产品的构造器用了private修饰意味着外部只能通过Builder来创建实例这样就把创建路径锁死了不允许任何人绕过Builder直接new一个残缺对象。第二产品的所有字段都是final类本身没有暴露任何setter所以创建完的对象是不可变的——这个特性在多线程环境下特别有价值你不需要担心对象被修改也不需要做防御性拷贝。第三Builder的每个setter方法返回this这种链式调用的设计让代码读起来就像在填表字段名和方法名一一对应基本上不可能填错。3.2 build() 方法里的参数校验很多人写到这里就停了build()方法里直接new Product(this)看起来已经很完美。但在我实际开发中发现一个大坑缺少参数校验。你用Builder的初衷之一就是防止非法状态的对象被创建出来结果build()方法什么都没检查一个缺了必填字段的对象照样被new了出来然后在业务运行到一半的时候炸掉。正确的做法是在build()里集中做参数校验。因为此时所有字段都已经赋值完毕是检查字段间逻辑关系的最佳时机public Order build() { if (orderNo null || orderNo.isEmpty()) { throw new IllegalStateException(订单号不能为空); } if (userId null || userId.isEmpty()) { throw new IllegalStateException(用户ID不能为空); } if (amount null || amount.compareTo(BigDecimal.ZERO) 0) { throw new IllegalArgumentException(订单金额必须大于0); } if (createTime null) { this.createTime LocalDateTime.now(); } return new Order(this); }你可能会问为什么不直接在产品构造器里校验也可以但放在Builder.build()里有一个额外的好处可以给某些字段赋默认值。比如createTime没传就默认当前时间couponId没传就默认null。这些默认值逻辑放在Builder里产品的构造器保持纯粹只做赋值和基础非空校验职责更清晰。我见过另一种常见做法是在产品构造器里做完整校验Builder.build()里只做默认值补充然后把半成品传给产品构造器。两种都行关键是要选定一处作为守门员不要两头都做也不要两头都不做。3.3 为什么链式方法用字段名命名有一个小技巧容易被忽略Builder里方法的命名最好和字段名保持一致。比如字段是orderNo方法就是orderNo()而不是setOrderNo()。原因是链式调用时代码的可读性会大幅提升。你想一下这两个调用的区别// 风格A new Order.Builder().orderNo(SO001).userId(U88).amount(...).build(); // 风格B new Order.Builder().setOrderNo(SO001).setUserId(U88).setAmount(...).build();两种风格都能工作但风格A读起来更像是在直接描述对象的属性去掉了set这个动词整个调用链更轻盈。JDK里java.lang.StringBuilder就是这么设计的——append()方法返回this建成一个方法链。越流式越自然。4. 真实场景案例拆解4.1 案例一订单对象的组装我在一个老系统里接手过一段下单逻辑。那时候系统里的订单对象是典型的JavaBean二十多个字段几乎一半是可选的服务层方法里new出订单对象后调用十几行setter中间还穿插着各种业务判断看代码的时候感觉像在解谜。后来重构的时候我把订单对象改造成了不可变类内部嵌套Builder。开头的改造分了三步第一步把订单对象的所有字段标成final去掉所有setter保留getter。这一步会让所有直接new订单对象的代码报错正好用编译错误来找出所有需要改造的调用点。第二步原样照搬字段到内部Builder类里方法名与字段名保持一致每个方法返回this。第三步把原来服务层里散落的赋值逻辑搬到调用处用链式调用重写。改造效果非常明显。原来那段三十多行、夹杂if判断的赋值逻辑变成了一个简单的方法链。必填字段在Builder.build()里做了校验漏传直接抛异常再也不会有订单号为空但居然能下单成功这种诡异问题。更重要的是因为订单对象变成了不可变类在异步处理和缓存传递时不用再担心别的地方改了订单对象的状态。4.2 案例二用Lombok Builder 干掉样板代码如果你用的是Lombok上面的代码量还能再减掉90%。在类上标注Builder注解Lombok会自动帮你生成静态内部Builder类和链式方法。我贴一下用法Data Builder public class UserQuery { private String name; private Integer age; private String email; private String phone; private String status; private Integer pageNum; private Integer pageSize; }调用处UserQuery query UserQuery.builder() .name(张三) .age(28) .status(ACTIVE) .pageNum(1) .pageSize(20) .build();在使用Lombok Builder之前有个关键设置你得知道Builder没有参与构造逻辑它生成的Builder方法不会自动执行你的参数校验逻辑。如果你在类里自定义了全参构造器并且在这个构造器里做了校验那么Lombok生成的Builder内部其实是调用的这个全参构造器校验逻辑会生效如果你没有自定义构造器Lombok会用默认生成的全参构造器此时没有任何校验。所以用Lombok Builder时我建议配合构造器里的校验一起用或者干脆在build()之后手动调用一个validate()方法不要让校验逻辑缺席。另外提一句Lombok Builder和NoArgsConstructor、AllArgsConstructor配合使用时有一些坑。Builder默认会尝试生成一个包私有的全参构造器如果同时标注了NoArgsConstructorBar表示全参构造器会被覆盖掉结果Builder无法赋值。遇到这个编译错误解决办法是手动加一个AllArgsConstructor。这个坑我踩过真的找了半天。4.3 案例三StringBuilder 其实就是一个建造者很多人没意识到JDK自带的StringBuilder就是建造者模式的一个典型应用。你每次调用append()、insert()它都在内部维护一个char数组最后调用toString()产出最终字符串。它没有使用严格的抽象建造者指挥者结构但链式调用一次性产出终态对象构建过程与结果分离这三点完全符合建造者模式的精神。从这个例子里也能看出来建造者模式不一定非要四个角色齐全抓住核心思想根据场景做减法是完全正常的。JDK里的另一个例子是java.util.stream.Stream的collect过程与建造者思想也有相通之处。5. 常见坑点与避坑清单5.1 必须参数与可选参数的区分建造者模式最大的一个坑就是把必填参数和可选参数混为一谈。有些人把所有字段都设计成可选build()里一个都不校验等于把建造者模式退化成了保险的setter工厂。最后造出来的对象依旧可能是残缺的只是代码表面好看了一点。我的建议是建一个字段清单标注每个字段是必填还是可选。必填字段在build()方法里统一校验一个都不能漏。可选字段要么不设置要么用默认值。这个环节最忌讳的就是懒你省掉的每一个校验都是埋给未来同事的一个炸弹。5.2 继承场景下的Builder设计如果你要为一个有父类的子类设计Builder事情会难一点。假设父类是Animal子类是DogAnimal有自己的字段比如nameDog有自己的字段比如breed你想让Dog.Builder既能设置name又能设置breed还要保持链式调用。一种常见的做法是泛型自继承self-referential generic在父类Builder里用泛型约束this的类型public class Animal { protected final String name; protected Animal(Builder? builder) { this.name builder.name; } public abstract static class BuilderT extends BuilderT { private String name; public T name(String name) { this.name name; return self(); } protected abstract T self(); public abstract Animal build(); } } public class Dog extends Animal { private final String breed; private Dog(Builder builder) { super(builder); this.breed builder.breed; } public static class Builder extends Animal.BuilderBuilder { private String breed; public Builder breed(String breed) { this.breed breed; return this; } Override protected Builder self() { return this; } Override public Dog build() { return new Dog(this); } } }这样做的好处是当你写 new Dog.Builder().name(旺财).breed(金毛).build() 时name()方法返回的是T类型也就是Dog.Builder所以还能接着调用breed()链式调用不会断。如果把父类Builder的name()方法的返回类型写死成Animal.Builder链式调用到name()之后就无法再调用breed()了编译直接报错。这个泛型自继承的写法看起来有点绕但在有继承层次的领域对象里几乎是必须的值得花时间理解。5.3 和工厂模式、策略模式的组合用法在实际系统里建造者模式很少孤立使用。我见过一个很优雅的组合工厂模式负责根据订单类型选择不同的Builder建造者模式负责按类型组装订单的不同字段。工厂返回Builder调用方拿到Builder后统一调用build()最后得到不同结构的订单对象。这种组合的好处是工厂负责选谁来做Builder负责怎么做两个关注点完全解耦。要加一种新的订单类型只需要新增一个Builder实现和一条工厂分支不用动原有的构建逻辑和调用方。和策略模式的组合也类似——把Builder作为策略接口的载体让不同的策略算法各自构建出不同的输出对象。5.4 性能与对象数量问题曾有人问过我用建造者模式会不会有性能问题。答案是有但绝大多数场景下小到可以忽略。Builder本质上每次创建对象都多new了一个Builder辅助对象链式调用还有一系列方法调用开销。对于一个一天创建几万个对象的系统来说这点开销根本不值一提。但如果你的对象是那种每秒钟创建几十万个、且字段很少的极高频短生命周期对象那我还是建议直接用构造器别为了代码美观付出不必要的性能代价。工具要用在合适的地方。5.5 调试时的一个排查思路如果你遇到一个用Builder创建的对象某个字段的值不对别急着去看产品类的构造逻辑。先沿着调用链找到那个具体设置这个字段的链式方法检查传入的值。最常见的原因是调用方传参时的变量搞混了——比如从两个配置来源取值之后不小心把A的值传给了B的字段。其次是Builder在赋值前对原值做了转换比如把字符串trim了一下或转成了大写而调用方并没有预期到这个转换。这种问题靠肉眼就能看出但前提是调用链必须写得好——链上每个方法都清晰对应一个字段比十几个setter一起上排查起来快得多。6. 总结这是我用建造者模式的真实体验如果让我说一句掏心窝的话建造者模式是我在实际项目中收益最明显的设计模式之一。它不像单例、工厂那样用得多但容易用错也不像观察者、装饰器那样理解起来绕——建造者模式的核心思想很简单就是别让对象构造太痛苦。它真正改变的是代码的调用习惯你不再需要瞪大眼睛数构造器参数顺序也不再需要担心一个对象new出来了但状态残缺。每次看到队友用Builder写出那种一眼能读完的链式调用我心里就踏实。我最喜欢它的一点是它让不可变对象变得触手可及。现代Java开发里不可变性本身就是一种防御性编程尤其是在并发场景下更安全。配合Lombok注解一个具备完整建造能力、字段不可变、参数严格校验的对象五秒钟就能写出来。希望这篇文章能帮你少走一些弯路。如果你正被构造函数参数爆炸、部分对象泄漏或者setter满天飞的代码折磨着别犹豫尝试引入建造者模式把对象的创建变成一件让人放心的事。
阅读完成 · 觉得有帮助?