news 2026/9/8 11:32:50

建造者模式核心解析:从构造器地狱到优雅链式构建

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
建造者模式核心解析:从构造器地狱到优雅链式构建

1. 项目概述与核心需求解析

1.1 为什么需要建造者模式:从一段"噩梦级"构造器说起

先说个我在代码评审里几乎每周都会撞见的场景。有个User类,字段有用户名、邮箱、年龄、手机号、地址、头像URL、个性签名、关注数……一共十来个字段。然后我打开代码,看到的是这样的东西:

User user = new User("张三", "zhangsan@example.com", 25, "13800138000", "北京市朝阳区某街道某号", "https://example.com/avatar.jpg", "这个人很懒,什么都没写", 0, 0, 0);

这时候编译器是不会报错的,IDE也不会给你任何提示,但读代码的人已经快疯了。第一个问题是可读性,你根本不知道第三个参数25代表什么,也不知道第八个0是关注数还是点赞数还是别的什么。第二个问题是维护地狱,某天你要在中间插入一个新的必填字段,所有调用处全要跟着改一遍,漏一个就是编译错误或者更严重的运行时默认值错误。

第三个问题更隐蔽:当你面对五六个重载构造器的时候,调用方经常不知道该选哪一个。我见过new User("名字")只传一个参数的,也见过把六个参数硬凑上去但实际只需要四个的。这些写法都在埋雷。

这时候建造者模式就派上用场了。它要解决的核心问题不是"创建对象"本身,而是解决"创建复杂对象"时的可读性、可维护性和参数安全性。所谓"复杂",通常指字段多、有必填和可选之分、字段之间有约束关系、或者对象构造时需要经过若干步骤才算完整。这类对象如果裸用构造器,代码会变得难以阅读和维护。

建造者模式的核心思想,说人话就是:把"构建过程"和"最终产品"拆开。调用方不再一股脑把所有参数塞给构造函数,而是通过一个"半成品说明书"逐步设置每一步的内容,最后通过一个build()动作把配置转化成一个完整对象。整个过程像什么?像装修房子:你不会要求装修公司一次性把材料全塞给你,而是一步一步沟通——水电怎么走、地板用什么材质、墙面刷什么颜色,最后收房时拿到一个完整的成品。

这也就是为什么它在Java、Android、Kotlin、C#这些语言里这么流行,尤其是当API设计需要对调用方友好、对扩展方便时,建造者几乎成了标配。

1.2 谁适合用、谁不适合用:场景判断比写代码更重要

我用这么多年建造者模式,最大的体会是:很多人一学完这个模式就到处用,结果把简单问题搞复杂了。这恰恰背离了模式的本意。

先说适合用的场景,我归纳成三类:

第一类:对象字段多且参差不齐。比如配置类、请求参数类、复杂的领域模型。这些对象往往有大量可选字段,如果全用构造器,重载组合爆炸;如果用setter,又会丢失"不可变对象"的安全性。建造者可以兼顾两者:必填字段放在Builder构造阶段强制传入,可选字段用链式方法逐个设置。

第二类:对象构造有过程性和顺序性。有些对象不是"一步到位"的,而是需要经过多阶段准备。比如一个PDF报表,需要先设置页眉页脚、再填充内容、再配样式;比如一个批处理任务,需要配置数据源、适配器、处理器、监听器。这类场景使用Director(导演者)角色来封装固定的构建流程很合适。

第三类:避免构造器参数列表膨胀。这是最实际的需求。一旦你发现某个类的构造器参数超过四五个,而且调用处经常要靠猜或者靠文档才知道每个参数是什么意思,那基本就到了该上建造者的时候。

不适合用的场景也很明显:字段少(两三个)、字段几乎没有可选项、对象本身生命周期简单。这种情况直接构造器或者setter更干脆,硬套Builder只会增加代码量和阅读负担。我见过有人给一个只有两个字段的Point类也写了Builder,说实话那属于严重的过度设计。

另外有一条经验:如果你不确定要不要用Builder,可以先不写,等参数真的多起来再重构。Java和IDE的重构能力很强,从构造器迁移到Builder并不难,但反过来,从Builder拆回构造器就麻烦得多。所以原则是"迟引入,不早引入",除非你正在设计一个对外发布的API,必须从一开始就考虑调用方体验。

2. 核心概念拆解:四个角色一个都不能少吗

2.1 标准UML角色:Product(产品)、Builder(抽象建造者)、ConcreteBuilder(具体建造者)、Director(指挥者)

为了搞清楚建造者模式,我们先把GoF经典定义里的四个角色过一遍。这四个角色在任何一本设计模式教材里都能看到,但我想用写毕业论文的方式来类比,这样好记得多。

  • Product(产品):最终要交付的东西。对应你写的毕业论文本身——封面、目录、正文章节、参考文献、附录。
  • Builder(抽象建造者):定义了建造产品各个部件的抽象接口。对应"论文写作规范",规定了必须有哪些章节、每个章节应该包含什么。
  • ConcreteBuilder(具体建造者):实现Builder接口,真正去完成某个具体版本的部件构造,并提供获取产品的方法。对应"本科论文撰写者"和"硕士论文撰写者",都遵循规范,但写出来的论文深度和格式细节不一样。
  • Director(指挥者):负责按照固定顺序调用Builder的方法,控制构建流程。对应你的导师/教务系统,它规定了你先写开题报告,再写初稿,然后修改,最后定稿,整个流程是固定的。

它们之间的协作关系,一句话概括:Director调用Builder,Builder构造Product的各个部件,最终Director通过Builder拿到完整体验的产品。这样设计的最大好处是:新增一种产品变体(比如新增"博士论文"),只需要新增一个ConcreteBuilder,Director和Product都不用改。遵不遵守"开闭原则"一目了然。

2.2 经典建造者 vs 流式建造者:两种实现风格的取舍

这里我想重点说一个很多人容易混淆的点。GoF原版的建造者是带Director的经典风格,而我们在现代Java代码里见到的绝大多数Builder(比如Lombok的@Builder、OkHttp的Request.Builder、Retrofit的Retrofit.Builder)其实都是流式(Fluent)风格,或者叫简化版建造者。

经典风格的代码流程是这样的:

ComputerBuilder builder = new GamingComputerBuilder(); Director director = new Director(builder); director.construct(); // 控制构建顺序 Computer computer = builder.getResult();

流式风格是这样的:

Computer computer = Computer.builder() .cpu("Intel i7") .gpu("RTX 4070") .ram(32) .storage("1TB SSD") .build();

两者的本质是一样的:把复杂对象的构造拆分成多个步骤。区别在于经典风格把"构建步骤的编排"抽到了Director里,适合构建流程固定且复杂的场景;流式风格把"构建步骤的选择权"交给了调用方,适合字段多但组合自由、构建步骤没有强约束的场景。

那为什么现代Java生态里流式风格成了主流?我的理解是,大多数实际项目的"复杂对象"并没有那么强的流程依赖性。比如一个网络请求的Request对象,它确实字段多、可选多,但CPU先设置还是URL先设置根本没区别。这时候引入Director反而是多余的抽象,会让调用方多写几行代码。反而是链式写法直接又直观,IDE自动补全还能提示有哪些可选项,体验好得多。

所以我的建议是:学习阶段两种都要掌握,经典风格帮你理解设计思想,流式风格帮你应用到实际开发。如果工作项目里没有特殊要求,优先选流式风格,它更符合现代API设计直觉,代码量也更少。

2.3 不可变性与键值对参数:建造者带来的隐藏优势

聊完两种实现风格,我想再深挖一个建造者模式常常被忽视的价值:它和不可变对象(Immutable Object)的搭配简直天作之合。

Java领域里有个共识:不可变对象是线程安全的,是值对象的标准形态,也是函数式编程风格的基础。一个不可变对象,所有字段都是final的,没有setter,一旦创建就不能修改。这样的对象可以安全地在多线程间共享,不会出现并发修改问题;也可以用作HashMap的key,不会因为哈希值变化导致查找失败。

问题来了:不可变对象怎么创建?如果字段多,你只能写一个超长构造器;如果字段有可选,你只能写多个重载构造器。这两个方案我们开头已经论证过都很糟糕。而建造者模式提供了一条完美的路径:Builder负责收集参数(可变状态),build()瞬间创建不可变对象(不再变化)。例如:

public class User { private final String username; private final String email; private final int age; private User(UserBuilder builder) { this.username = builder.username; this.email = builder.email; this.age = builder.age; } public static UserBuilder builder() { return new UserBuilder(); } public static class UserBuilder { private String username; private String email; private int age; public UserBuilder username(String username) { this.username = username; return this; } public UserBuilder email(String email) { this.email = email; return this; } public UserBuilder age(int age) { this.age = age; return this; } public User build() { return new User(this); } } }

注意这里User的构造函数是private,唯一的创建入口是User.builder()。这在API设计上还有一个隐含好处,就是强制调用方走Builder,而不是绕开它裸new。有些团队甚至把构造器标记为private并且配合Builder做参数校验,这样所有非法状态在build()时就能被拦截,而不是等到运行时爆炸。

另外一点和建造者高度相关的小技巧是"键值对参数"。有时候你会看到某些库的API长这样:

HttpClient client = new HttpClient.Builder() .config(ConfigKey.TIMEOUT, 5000) .config(ConfigKey.RETRY, 3) .build();

这种设计适合配置项非常多且未来可能无限扩展的场景。把所有配置项收敛到一个枚举+值的Map里,Builder本身不需要为每个字段写单独方法,新增配置项时枚举加一个值就行。虽然牺牲了一些类型安全,但换来了扩展的极大便利。实际项目里如果配置项超过15个且不断在变,我会认真考虑这种键值对风格。

3. 实操过程与核心环节实现

3.1 纯手写建造者:从零搭建一个完整的Builder(含参数校验)

纸上谈兵不如直接上手。下面我用一个典型场景——构建一个"邮件消息"对象,带大家完完整整走一遍手写Builder的全过程。为什么选邮件消息?因为它字段不少(收件人、抄送、密送、主题、正文、附件、优先级、是否需要回执),有必填(收件人、主题)、有可选(其余都是),而且有字段间约束(比如有正文或附件才允许发送),非常适合演示Builder的校验能力。

第一步,定义产品类EmailMessage。注意所有字段都是final,构造器直接复用Builder的字段:

public class EmailMessage { private final List<String> to; // 必填 private final List<String> cc; // 可选 private final List<String> bcc; // 可选 private final String subject; // 必填 private final String body; // 可选 private final List<Attachment> attachments; // 可选 private final Priority priority; // 枚举,默认NORMAL private final boolean requestReadReceipt; private EmailMessage(Builder builder) { this.to = builder.to; this.cc = builder.cc; this.bcc = builder.bcc; this.subject = builder.subject; this.body = builder.body; this.attachments = builder.attachments; this.priority = builder.priority; this.requestReadReceipt = builder.requestReadReceipt; } public static Builder builder() { return new Builder(); } // 省略各个getter }

第二步,定义静态内部类Builder。这里有几个设计要点:

  • 必填字段通过Builder构造方法传入,编译器强制调用方提供,从根源上杜绝"忘传必填参数"。
  • 可选字段用链式方法,方法名就是字段名,返回this,方便连续调用。
  • 列表字段的初始化放在字段声明处,避免null
  • build()方法里做集中校验,不合法直接抛IllegalArgumentException
public static class Builder { private final List<String> to; // 必填 private List<String> cc = new ArrayList<>(); private List<String> bcc = new ArrayList<>(); private final String subject; // 必填 private String body = ""; private List<Attachment> attachments = new ArrayList<>(); private Priority priority = Priority.NORMAL; private boolean requestReadReceipt = false; public Builder(List<String> to, String subject) { this.to = to; this.subject = subject; } public Builder cc(String... ccAddresses) { this.cc = Arrays.asList(ccAddresses); return this; } public Builder bcc(String... bccAddresses) { this.bcc = Arrays.asList(bccAddresses); return this; } public Builder body(String body) { this.body = (body == null) ? "" : body; return this; } public Builder attachments(List<Attachment> attachments) { this.attachments = (attachments == null) ? new ArrayList<>() : attachments; return this; } public Builder priority(Priority priority) { this.priority = (priority == null) ? Priority.NORMAL : priority; return this; } public Builder requestReadReceipt(boolean requestReadReceipt) { this.requestReadReceipt = requestReadReceipt; return this; } public EmailMessage build() { if (to == null || to.isEmpty()) { throw new IllegalStateException("to 收件人不能为空"); } if (subject == null || subject.trim().isEmpty()) { throw new IllegalStateException("subject 主题不能为空"); } if (body.isEmpty() && attachments.isEmpty()) { throw new IllegalStateException("正文和附件不能同时为空"); } return new EmailMessage(this); } }

第三步,调用方使用。我写段客户端代码,展示两种合法和非法的情况:

// 合法情况 EmailMessage email = EmailMessage.builder( List.of("boss@example.com"), "季度汇报") .cc("team@example.com") .body("这是季度业绩报告,请查收。") .attachments(List.of(new Attachment("report.pdf"))) .priority(Priority.HIGH) .requestReadReceipt(true) .build(); // 非法情况:没有正文也没有附件,build时会抛异常 try { EmailMessage invalid = EmailMessage.builder( List.of("boss@example.com"), "空邮件") .build(); } catch (IllegalStateException e) { System.out.println("构建失败: " + e.getMessage()); }

第三步代码里的类型可以更简单一点,但为了演示,先这么写,运行是可以跑通的。实际开发中,你可能还要考虑更复杂的校验,比如邮箱格式校验、附件大小限制、正文模板渲染等,这些都是build()方法内部可以扩展的。

3.2 手写过程中容易踩的坑:可变集合、空值、以及"半个对象"

手写Builder看着简单,但我在Code Review里见过太多细节问题,这里集中说三个最典型的坑。

坑一:Builder复用时集合字段没有清空。有些场景Builder会被复用,比如在一个循环里多次build(),每次只想改一个字段。如果attachments是直接在字段上初始化的ArrayList,第一次build()后追加元素,第二次build()时旧数据还在。解决办法:build()方法里对集合字段做防御性拷贝,也就是new ArrayList<>(this.attachments)。这样做还有一个额外好处:产品对象内部的集合不会被外部引用修改,真正保证了不可变性。

坑二:空值处理不一致。同一个Builder里,body(null)把null转成空字符串,attachments(null)把null转成空列表,但cc(null)直接抛NPE。这种不一致在调用方使用时会非常迷惑。我的习惯是统一原则:所有参数都可以接受null,null一律按"无该属性"处理。要么统一抛异常,要么统一容错,别混着来。

坑三:出现"半个对象"。如果build()方法在参数校验中途抛异常,某些字段已经赋值给产品对象,但另一些没有——这在实际代码中不太容易发生,因为所有字段都在构造器的第一条语句赋值,但如果你的Builder有副作用(比如在build()里修改了传入的集合),就可能在异常后留下脏状态。所以经验之谈:build()方法尽量做成纯函数,不修改外部传入的对象,不产生副作用。

3.3 经典GoF风格实现:ComputerBuilder + Director 完整演示

虽然流式Builder更常用,但经典风格能让你把建造者模式的"构建流程编排"这个精髓吃透。我写一个组装电脑的演示,让大家直观感受Director的作用。

首先定义产品Computer以及部件枚举/类:

public class Computer { private String cpu; private String gpu; private int ram; // 单位 GB private int storage; // 单位 GB private String os; public void setCpu(String cpu) { this.cpu = cpu; } public void setGpu(String gpu) { this.gpu = gpu; } public void setRam(int ram) { this.ram = ram; } public void setStorage(int storage) { this.storage = storage; } public void setOs(String os) { this.os = os; } @Override public String toString() { return "Computer{" + "cpu='" + cpu + '\'' + ", gpu='" + gpu + '\'' + ", ram=" + ram + ", storage=" + storage + ", os='" + os + '\'' + '}'; } }

然后是抽象建造者和两个具体建造者:

public interface ComputerBuilder { void buildCpu(); void buildGpu(); void buildRam(); void buildStorage(); void buildOs(); Computer getResult(); }
public class GamingComputerBuilder implements ComputerBuilder { private Computer computer = new Computer(); @Override public void buildCpu() { computer.setCpu("Intel i9-13900K"); } @Override public void buildGpu() { computer.setGpu("NVIDIA RTX 4090"); } @Override public void buildRam() { computer.setRam(64); } @Override public void buildStorage() { computer.setStorage(4096); } @Override public void buildOs() { computer.setOs("Windows 11 Pro"); } @Override public Computer getResult() { return computer; } }
public class OfficeComputerBuilder implements ComputerBuilder { private Computer computer = new Computer(); @Override public void buildCpu() { computer.setCpu("Intel i5-13400"); } @Override public void buildGpu() { computer.setGpu("集成显卡"); } @Override public void buildRam() { computer.setRam(16); } @Override public void buildStorage() { computer.setStorage(1024); } @Override public void buildOs() { computer.setOs("Windows 11 专业版"); } @Override public Computer getResult() { return computer; } }

然后是导演者Director,它封装了"组装电脑"的固定流程:

public class Director { private ComputerBuilder builder; public Director(ComputerBuilder builder) { this.builder = builder; } public void construct() { builder.buildCpu(); builder.buildGpu(); builder.buildRam(); builder.buildStorage(); builder.buildOs(); } }

客户端调用如下:

ComputerBuilder builder = new GamingComputerBuilder(); Director director = new Director(builder); director.construct(); Computer gaming = builder.getResult(); builder = new OfficeComputerBuilder(); director = new Director(builder); director.construct(); Computer office = builder.getResult(); System.out.println(gaming); System.out.println(office);

运行后输出:

Computer{cpu='Intel i9-13900K', gpu='NVIDIA RTX 4090', ram=64, storage=4096, os='Windows 11 Pro'} Computer{cpu='Intel i5-13400', gpu='集成显卡', ram=16, storage=1024, os='Windows 11 专业版'}

从这个示例可以看到,Director把"构建什么配置"和"怎么构建"完全分开了。新增一种电脑配置只需要新增一个ComputerBuilder实现类,Director一行不用改。如果未来变更组装流程(比如先装系统再装显卡),也只改Director。这就是经典建造者的价值。

不过这里也要补充一句:经典风格里Product类用了大量setter,暂时不是不可变对象,这在现代Java风格里是有争议的。如果把setter去掉,只通过Builder构造,代码会更严谨,但那样Director能做的就有限了。我的看法是:经典GoF更侧重"分步骤构建"的过程抽象,流式风格更侧重"安全便捷地设置参数"。两种视角各有用途,看你的实际场景。

4. 源码级实战:Android与主流框架中的建造者模式

4.1 安卓开发必知的Builder:AlertDialog、OkHttp、Retrofit用到了什么程度

聊建造者模式,Android开发者最有共鸣。因为Android里Builder太常见了,几乎约等于"链式调用"的代名词。

举最经典的AlertDialog例子。早期Android(甚至现在)使用AlertDialog.Builder创建对话框:

AlertDialog dialog = new AlertDialog.Builder(context) .setTitle("删除确认") .setMessage("确定要删除这条记录吗?此操作不可恢复。") .setPositiveButton("删除", (dialogInterface, which) -> delete()) .setNegativeButton("取消", null) .create();

这个Builder的特别之处在于:它内部封装了极其复杂的Dialog构造逻辑——主题、样式、按钮布局、窗口属性等,如果不通过Builder,直接new一个AlertDialog的话,Android API甚至不让你直接设置这些属性(很多方法带@hide注解)。这完美展示了建造者模式的另一个价值:对调用方隐藏底层复杂性,构建过程由Builder内部掌控。

再看OkHttp,这是一个典型的HTTP客户端,它的Request对象是这样构建的:

Request request = new Request.Builder() .url("https://api.example.com/users") .get() .addHeader("Authorization", "Bearer xxx") .build();

注意OkHttp的Request同样不可变,构造器是私有的。所有参数只能通过Builder链式设置,build()时做必要的校验(比如URL不能为空),然后生成不可变对象。这套设计和我们前面讲的流式风格完全一致。

Retrofit则展示了一个更复杂的例子。Retrofit.Builder()需要配置baseUrl、ConverterFactory、CallAdapterFactory等一大堆组件,其中baseUrl是必填项,配置错误会在build()时直接抛异常:

Retrofit retrofit = new Retrofit.Builder() .baseUrl("https://api.example.com/") .addConverterFactory(GsonConverterFactory.create()) .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) .build();

这几乎是建造者模式在现代第三方库中的标准用法。我平时看源码最大的感受是:不是这些库的作者喜欢写模式,而是当你的库面向海量调用方时,建造者模式能提供最好的使用体验和最低的误用率。它是API设计的门面。

4.2 使用注解简化锅炉板代码:Lombok @Builder是不是"万能药"

Java后端开发绕不开Lombok。@Builder注解可以自动生成Builder代码,极大减少手写量。我写个对比:

@Builder public class UserProfile { private String nickname; private String avatarUrl; private String bio; private int age; private boolean verified; }

就这么几行,Lombok自动生成:

UserProfile profile = UserProfile.builder() .nickname("老张") .avatarUrl("https://example.com/avatar.png") .bio("爱代码爱生活") .age(30) .verified(true) .build();

@Builder确实爽,但我要泼两盆冷水。第一,@Builder不能帮你做参数校验。手写Builder时,你可以在build()里写一堆校验逻辑;Lombok的@Builder不会生成这些逻辑,要校验你还是得手动在生成的产品类构造器里写,或者在build()方法里用@Builder(buildMethodName = "build", builderMethodName = "builder")配合手工方法再包一层。第二,@Builder生成的Builder里的字段默认值处理不等于构造器默认值处理。如果字段在类里写了默认值(比如private int age = 18;),Lombok生成的Builder不会自动继承这个默认值,必须在Builder上也显式设置。

所以我的实际工作建议是:

  • 内部项目字段简单,不需要校验,直接用@Builder,省事。
  • 对外API或领域模型字段有约束,优先手写Builder,或者用@Builder加上@Builder.Default注解明确默认值,并额外写一个静态工厂做校验入口。
  • 团队代码规范里如果禁用Lombok(有些公司因为注解处理器的兼容性问题会禁用),那就只能手写。

4.3 建造者模式与工厂模式的边界:别再傻傻分不清

最后再解答一个让很多初学者头疼的问题:建造者模式和工厂模式到底有什么区别?网上有很多抽象的解释,什么"一个是构建复杂对象,一个是创建系列对象",但我觉得可以用一句话说透:

工厂模式关心的是"创建谁",建造者模式关心的是"怎么创建"。

工厂模式把"创建哪个对象"的决定权收走,调用方只需要告诉工厂我要什么类型,工厂负责new出来。典型如:

Animal animal = AnimalFactory.create("dog"); Animal animal2 = AnimalFactory.create("cat");

调用方并不知道也没有能力控制对象怎么初始化——工厂内部全包了。

建造者模式则把"怎么一步步设置参数"的控制权交给调用方,调用方像调音量一样逐项调整,最后收一个成品:

Computer computer = Computer.builder() .cpu("i9") .ram(64) .build();

工厂模式适合产品有明确分类、构造过程相对简单的场景;建造者模式适合产品构造复杂、需要分步设置参数的场景。两者并不互斥,现实中也常常组合使用:工厂方法内部返回Builder构建出来的对象,或者Builder的build()方法内部委托给一个工厂去创建底层部件。

我在实际项目里最常见的组合用法是:用静态工厂方法封装建造者,提供更语义化的入口。比如:

public static EmailMessage urgent(List<String> to, String subject, String body) { return builder(to, subject) .body(body) .priority(Priority.HIGH) .requestReadReceipt(true) .build(); }

调用方想要一封紧急邮件,直接EmailMessage.urgent(...),不用手动配优先级和回执。这就是建造者模式加静态工厂的组合拳,干净又有表现力。

5. 常见问题与排查技巧实录

5.1 高频问题速查表:NPE、默认值失效、Builder复用脏数据

我整理了一张问答表,都是我在实际项目和回答同事问题时遇到的最高频问题,可以直接当速查手册用。

问题现象根因解决方案
调用build()后某个集合字段是null,遍历时NPEBuilder字段没初始化,也没做防御性拷贝在字段声明处初始化,或在build()new ArrayList<>(...)赋值
@Builder时类的字段默认值"丢了"Lombok不会自动同步类字段默认值到Builder字段在Builder字段上加@Builder.Default注解,或手写Builder
Builder对象重复使用时,上一次的数据残留集合字段是同一个引用,build()后外部还能改build()里做防御性拷贝,且Builder每次build()后重置内部状态
build()里做了校验,但还是有非法对象传出去校验逻辑漏掉了某些字段,或者校验本身抛的是RuntimeException被吞了统一收集所有校验异常,一次性抛错;校验信息要具体到字段名
链式调用时报"无法返回Builder"setter方法的返回类型写成了void或写错了自己每个setter方法必须返回this,返回类型是当前Builder类型
新增字段后忘了改Builder和build()手写Builder与产品类字段天然存在重复维护用IDE自动生成Builder;或者使用Lombok/CDI工具减少重复代码

这张表里我特别想再强调第二行的场景。很多人第一次用@Builder踩坑都是因为默认值:

@Builder public class UserProfile { private String nickname; private int age = 18; // 期望默认18 } // 实际使用 UserProfile p = UserProfile.builder().nickname("张三").build(); System.out.println(p.getAge()); // 输出0,不是18!Lombok Builder默认age是0

所以如果你需要age默认是18,得这样写:

@Builder public class UserProfile { private String nickname; @Builder.Default private int age = 18; }

5.2 让我印象最深的线上事故:Builder校验失败导致的消息乱发

我必须分享一个真实案例,因为这个问题让我对"Builder的校验位置"产生了新的认知。

事情是这样的:我们的服务需要给用户发邮件通知,邮件内容由平台运营配置。某次上线后,开始有用户反馈收到了"主题正常、正文为空白"的邮件。当时的第一反应是模板渲染出问题了,结果排查到最后,发现根因在Builder的build()方法里。

当时的邮件对象代码写得类似这样:

EmailMessage email = EmailMessage.builder(toList, subject) .body(template.render()) .build();

EmailMessage.Builder.build()里,有这样一个校验:

if (body == null || body.isEmpty()) { throw new IllegalStateException("正文不能为空"); }

按道理说,如果template.render()返回空字符串,build()应该抛异常,邮件根本创建不出来。但问题在于,那个版本的代码里body字段在校验前就被统一转成了空字符串,更重要的是,外层调用方有一个catch块:

try { emailService.send(email); } catch (IllegalStateException e) { log.warn("邮件发送失败,跳过: {}", e.getMessage()); }

然后邮件发送流程里,某些分支其实构造了一个"空正文"的EmailMessage作为占位,被发送出去了。也就是说,校验器本身没错,错在校验失败的处理方式过于安静,让一个理论上不应该存在的对象从另一个分支溜了出去。

这个事故给我的教训是:Builder的校验逻辑固然重要,但它不应该作为业务正确性的唯一防线。业务层面的关键约束,在调用入口就该校验;Builder里的校验只是"最后的闸门",而且要保证这个闸门关闭时足够显眼,不能静默吞掉异常。从那以后,我在设计Builder时会更注意两点:一是校验失败时要抛出包含足够上下文的异常(最好带上对象的主要属性,方便日志排查);二是关键业务链路里,build()的调用处不要无脑catch后吞掉,要区分可恢复错误和致命错误。

5.3 进阶技巧:让Builder支持"局部更新"和"默认配置模板"

建造者模式做到这里,已经能覆盖大多数场景了。我再分享两个进阶玩法,属于类库设计时的小技巧。

技巧一:支持从已有对象快速创建一个新对象。我们经常需要"复制当前对象但改一个字段"的场景。传统不可变对象要做到这点很麻烦,但Builder可以轻松搞定。在产品类里加一个toBuilder()方法:

public Builder toBuilder() { return new Builder(to, subject) .cc(cc.toArray(new String[0])) .bcc(bcc.toArray(new String[0])) .body(body) .attachments(attachments) .priority(priority) .requestReadReceipt(requestReadReceipt); }

调用方就可以这样写:

EmailMessage corrected = original.toBuilder() .priority(Priority.HIGH) .build();

这在处理"对已有配置做微调"的场景非常有用,比如批量任务里每个子任务只是超时时间不同。

技巧二:提供默认配置模板方法。如果某些组合是高频用法,可以在Builder上提供静态工厂方法:

public static Builder defaultReminderEmail(List<String> to, String subject, String body) { return new Builder(to, subject) .body(body) .priority(Priority.NORMAL) .requestReadReceipt(false); }

调用方在默认模板基础上再定制,既保证了基础一致性,又给了灵活性。我在设计SDK API时经常这么干,调用方体验非常好。

这两个技巧的核心思想是一致的:建造者模式不只是为了创建对象,更是为了让"创建和修改复杂对象"这件事实在话地简单。只要你理解了这个底层需求,很多变体都可以自由发挥。

6. 写在最后:我对建造者模式的一点真实体会

做个简单的收尾吧。我见过不少初学者把二十三种设计模式背得滚瓜烂熟,但一写代码就忘干净。建造者模式是我认为最容易被"会"但最难"用对"的模式之一。

会,是指你知道Builder长什么样、能写出来;用对,是指你清楚它适用于什么边界、什么时候不要去用它、怎么为调用方设计出丝滑的构建体验。如果你读完这篇文章只记住一件事,我希望是这句:建造者模式解决的是"复杂对象构造的可读性与安全性",而不是"为了让类看起来更高级"。

我个人在实际项目里的选择标准一般是这样的:字段超过四个、有多个可选参数、或者对象要求不可变,而且这个对象会被多处构造——那我就上Builder;如果只是内部临时用的两三个字段的POJO,直接用构造器完事,绝不为了模式而模式。设计模式是工具,不是目的,它服务于代码的可读性和可维护性,这个初心不能丢。

最后再分享一个代码评审时的小技巧:当你在Review里看到某个Builder,你可以在心里问三个问题——它有没有隐藏掉构造的复杂性?它有没有让调用方的代码更清晰?它有没有在build()时保护了对象状态的合法性?如果三个问题的答案都是肯定的,那这个Builder大概率是个合格的Builder;如果有一个是否定的,那可能就需要重构一下了。

纸上得来终觉浅,绝知此事要躬行。找个机会把你自己项目里那个最头疼的"长参数构造器"用Builder重写一遍,你会立刻明白这篇文章讲的所有内容。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 11:31:55

RTMP转WebRTC低延迟播放:基于SRS的测试环境搭建实战

之前在项目中需要快速验证一条“RTMP 推流 WebRTC 低延时播放”的链路是否可行&#xff0c;结果卡在测试环境搭建上&#xff1a;资料很散&#xff0c;有的讲 RTMP 推流&#xff0c;有的讲 WebRTC 播放&#xff0c;却没有人把中间转换层说清楚。折腾了两天才跑通第一帧画面&…

作者头像 李华
网站建设 2026/9/8 11:31:45

补贴驱动已死,产品竞争崛起

《补贴驱动已死&#xff0c;产品竞争崛起》——优惠退场淘汰的不是新能源车&#xff0c;而是没有竞争力的玩家“补贴少了&#xff0c;销量却更高了。”8月&#xff0c;头部车企单月销量突破44万辆&#xff0c;海外销量超过18万辆&#xff1b;另一家新势力交付量首次站上10万辆&…

作者头像 李华
网站建设 2026/9/8 11:29:26

AI简历网站开发全指南:从技术架构到商业变现的实战路径

AI 简历网站这个方向&#xff0c;这两年被聊得很多&#xff0c;但真正把全流程跑通的人不算多。原因不是缺大模型接口&#xff0c;而是很多人卡在“不知道做一个什么样的简历站”和“做完不知道怎么变现”这两步。这次我们就把这件事完整拆开&#xff1a;从项目选型、技术架构、…

作者头像 李华
网站建设 2026/9/8 11:28:51

Humanizer实战指南:去AI味、优化AI写作与提示词技巧

1. humanizer是什么&#xff1a;先搞懂这个热门词到底在说啥最近总有人问我humanizer的事&#xff0c;还有人把“humanizer skill”挂在嘴边。其实这词在圈子里已经火了一阵子了&#xff0c;但真正搞明白它在讲什么的人&#xff0c;并没有想象中那么多。先说结论&#xff1a;hu…

作者头像 李华
网站建设 2026/9/8 11:26:16

数学建模竞赛必备:40分钟掌握VISIO专业绘图技巧

如果你正在备战数学建模竞赛&#xff0c;却因为绘图工具而头疼——无论是国赛、美赛还是其他数模竞赛&#xff0c;流程图画不专业、拓扑图布局混乱、协作时版本冲突&#xff0c;那么这篇文章就是为你准备的。很多人误以为VISIO只是个简单的绘图工具&#xff0c;但真正在数学建模…

作者头像 李华
网站建设 2026/9/8 11:25:45

HALCON与C#联合编程:CAD图纸读取与显示实战指南

简介&#xff1a;一个完整的HALCON与C#联合编程示例&#xff0c;面向机器视觉初学者和C#桌面应用开发者&#xff0c;演示如何读取CAD文件并在界面中显示。工程采用相对路径引用测试图纸&#xff0c;将下载后的文件夹放到桌面即可运行&#xff1b;若halcondotnet.dll版本不匹配&…

作者头像 李华