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,遍历时NPE | Builder字段没初始化,也没做防御性拷贝 | 在字段声明处初始化,或在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重写一遍,你会立刻明白这篇文章讲的所有内容。