尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

建造者模式核心解析:从构造器地狱到优雅链式构建 1. 项目概述与核心需求解析1.1 为什么需要建造者模式从一段噩梦级构造器说起先说个我在代码评审里几乎每周都会撞见的场景。有个User类字段有用户名、邮箱、年龄、手机号、地址、头像URL、个性签名、关注数……一共十来个字段。然后我打开代码看到的是这样的东西User user new User(张三, zhangsanexample.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调用BuilderBuilder构造Product的各个部件最终Director通过Builder拿到完整体验的产品。这样设计的最大好处是新增一种产品变体比如新增博士论文只需要新增一个ConcreteBuilderDirector和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 ListString to; // 必填 private final ListString cc; // 可选 private final ListString bcc; // 可选 private final String subject; // 必填 private final String body; // 可选 private final ListAttachment 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 ListString to; // 必填 private ListString cc new ArrayList(); private ListString bcc new ArrayList(); private final String subject; // 必填 private String body ; private ListAttachment attachments new ArrayList(); private Priority priority Priority.NORMAL; private boolean requestReadReceipt false; public Builder(ListString 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(ListAttachment 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(bossexample.com), 季度汇报) .cc(teamexample.com) .body(这是季度业绩报告请查收。) .attachments(List.of(new Attachment(report.pdf))) .priority(Priority.HIGH) .requestReadReceipt(true) .build(); // 非法情况没有正文也没有附件build时会抛异常 try { EmailMessage invalid EmailMessage.builder( List.of(bossexample.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。这种不一致在调用方使用时会非常迷惑。我的习惯是统一原则所有参数都可以接受nullnull一律按无该属性处理。要么统一抛异常要么统一容错别混着来。坑三出现半个对象。如果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{cpuIntel i9-13900K, gpuNVIDIA RTX 4090, ram64, storage4096, osWindows 11 Pro} Computer{cpuIntel i5-13400, gpu集成显卡, ram16, storage1024, osWindows 11 专业版}从这个示例可以看到Director把构建什么配置和怎么构建完全分开了。新增一种电脑配置只需要新增一个ComputerBuilder实现类Director一行不用改。如果未来变更组装流程比如先装系统再装显卡也只改Director。这就是经典建造者的价值。不过这里也要补充一句经典风格里Product类用了大量setter暂时不是不可变对象这在现代Java风格里是有争议的。如果把setter去掉只通过Builder构造代码会更严谨但那样Director能做的就有限了。我的看法是经典GoF更侧重分步骤构建的过程抽象流式风格更侧重安全便捷地设置参数。两种视角各有用途看你的实际场景。4. 源码级实战Android与主流框架中的建造者模式4.1 安卓开发必知的BuilderAlertDialog、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(ListString 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注解或手写BuilderBuilder对象重复使用时上一次的数据残留集合字段是同一个引用build()后外部还能改build()里做防御性拷贝且Builder每次build()后重置内部状态build()里做了校验但还是有非法对象传出去校验逻辑漏掉了某些字段或者校验本身抛的是RuntimeException被吞了统一收集所有校验异常一次性抛错校验信息要具体到字段名链式调用时报无法返回Buildersetter方法的返回类型写成了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不是18Lombok 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(ListString 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重写一遍你会立刻明白这篇文章讲的所有内容。
返回列表