资讯动态

建造者模式实战:从构造函数膨胀到流式Builder的优雅重构

发布时间:2026/10/22 11:56:53 来源:尧图企业网站定制
写代码这几年我见过的烂项目各有各的烂法但有一种混乱特别典型构造函数从 3 个参数一路加到 11 个新增字段永远是硬塞在参数列表末尾。调用方看着一行new GatewayConfig(baseUrl, 30, 60, 3, true, true, false, HMAC-SHA256)只能靠数括号猜每个位置到底代表什么。这其实就是一个建造者模式Builder本该解决、却被拖到上线之后才重视的问题。这期聊建造者模式我会先把痛点讲透再拆 GoF 的经典骨架然后给一份可以直接抄的流式写法最后聊聊工厂模式和 Builder 的边界以及我实际维护过的两个隐蔽坑。适合正在系统补设计模式的初级开发者也适合准备在团队里推广这模式的资深工程师。1. 建造者模式到底在解决什么问题1.1 构造函数膨胀从“还能用”到“不敢改”先看一个典型场景。报表类Report的字段不断增长新增字段是项目经理一句话的事代码则是每个版本多一个构造重载public class Report { private final String title; private final String author; private final String header; private final boolean printPageNumber; private final boolean printWatermark; private final int fontSize; public Report(String title) { this(title, null); } public Report(String title, String author) { this(title, author, null); } public Report(String title, String author, String header) { this(title, author, header, true, false, 12); } // 四个、五个……重载数量开始失控 public Report(String title, String author, String header, boolean printPageNumber, boolean printWatermark, int fontSize) { this.title title; this.author author; this.header header; this.printPageNumber printPageNumber; this.printWatermark printWatermark; this.fontSize fontSize; } }这个类只要再加一个“是否显示目录”的布尔字段重载组合数就会继续膨胀。更现实的情况是新增字段后旧的构造函数调用点仍然编译通过新字段默默变成默认值。等测试发现某个线上报表没显示页脚团队已经分不清是哪个调用方忘了传还是默认值本身就有问题。从工程角度说构造重载不是不能用但一旦可选参数超过两三个组合复杂度就开始指数级恶化。这也是 Builder 模式最朴素的动机把构造对象时眼花缭乱的参数列表换成一行行带名字的方法调用。1.2 参数错位编得过去跑偏千里重载构造函数的第二个隐患是参数位置错乱。new Report(Q4, 某部门, 页眉, true, false, 14)只看源码根本看不出第 4 个参数是“是否打印页码”还是“是否带水印”。一旦两个布尔值传反编译不会报错程序也能正常跑但输出的报表和预期完全相反。这类问题在配置类上尤其危险。我在一个内部网关项目里见过一次线上事故两个布尔参数enableAccessLog和enableRequestId被传反导致本应打印的访问日志全被吞掉排查问题时监控一片空白。原因就是构造函数调用点离定义处太远写代码的人只记得“第六个参数是 true”却不记得第六个参数具体是哪个字段。Builder 的链式调用之所以有效是因为它把每个参数变成了一个方法名.enableAccessLog(true)和.enableRequestId(false)写出来读代码的人不需要翻类定义就知道在配什么。这带来的不是语法上的优雅而是把“猜含义”这件事的成本直接从调用方身上移除了。1.3 两个容易被忽略的收益不可变与流程控制很多人以为 Builder 只是“少写几个重载”其实它顺带解决了另两个问题。第一个是对象不可变性。经典做法里产品类的字段全部final构造器私有只允许通过 Builder 的build()方法创建。没有 setter对象创建完成后就不可能被半路改掉。对于配置类、值对象这类到处共享的对象不可变能省掉一大堆并发问题。第二个是分步构建的流程控制。复杂对象的组装有顺序比如构建房子必须先打地基、再搭框架、最后封顶。如果调用方直接 new他没有义务知道这个顺序而 Builder 配合指挥者Director可以把“先干什么后干什么”沉淀到一处不会因为某个调用方图省事而跳过关键步骤。2. 经典骨架拆解一个模式四个角色2.1 四个角色分别管什么GoF 原版定义里Builder 模式有四个角色角色职责典型形态产品Product最终要构建的复杂对象字段多为 private final抽象建造者Builder定义构建各组件的步骤接口接口或抽象类具体建造者ConcreteBuilder实现每个步骤持有正在构建的产品内部管理一个 Product 实例指挥者Director编排步骤的具体顺序接收 Builder调用步骤方法值得注意日常开发中我们接触最多的是静态内部类那种流式 Builder它经常省略 Director。但 GoF 的经典结构里Director 是独立存在的它不关心具体步骤怎么做只关心步骤的顺序。2.2 一份可以直接跑起来的经典实现用一个盖房子的例子来演示。产品是House两个具体建造者分别造木屋和石屋指挥者控制统一流程public class House { private String foundation; private String structure; private String roof; private String interior; public void setFoundation(String foundation) { this.foundation foundation; } public void setStructure(String structure) { this.structure structure; } public void setRoof(String roof) { this.roof roof; } public void setInterior(String interior) { this.interior interior; } Override public String toString() { return House{foundation foundation , structure structure , roof roof , interior interior }; } } public interface HouseBuilder { void buildFoundation(); void buildStructure(); void buildRoof(); void buildInterior(); House getResult(); } public class WoodenHouseBuilder implements HouseBuilder { private House house new House(); public void buildFoundation() { house.setFoundation(木质地基); } public void buildStructure() { house.setStructure(木质框架); } public void buildRoof() { house.setRoof(木质屋顶); } public void buildInterior() { house.setInterior(基础装修); } public House getResult() { return house; } } public class StoneHouseBuilder implements HouseBuilder { private House house new House(); public void buildFoundation() { house.setFoundation(混凝土地基); } public void buildStructure() { house.setStructure(石质墙体); } public void buildRoof() { house.setRoof(石板屋顶); } public void buildInterior() { house.setInterior(精装修); } public House getResult() { return house; } } public class ConstructionDirector { public House construct(HouseBuilder builder) { builder.buildFoundation(); builder.buildStructure(); builder.buildRoof(); builder.buildInterior(); return builder.getResult(); } }客户端这样用ConstructionDirector director new ConstructionDirector(); HouseBuilder woodenBuilder new WoodenHouseBuilder(); House woodenHouse director.construct(woodenBuilder); HouseBuilder stoneBuilder new StoneHouseBuilder(); House stoneHouse director.construct(stoneBuilder);这里能看到 Builder 和工厂模式非常关键的区别如果只是new WoodenHouse()那只是一个简单创建但当我们希望“无论什么材质都必须按地基层、框架层、屋顶、内饰这个顺序来”流程控制就很重要这正是 Director 存在的意义。2.3 Director 到底是不是必须的不少开发者觉得 Director 多余直接在外面调用builder.buildFoundation()这样也能干活。这种判断在构建步骤足够简单时是成立的流式 Builder 就是这么取舍的。但一旦“构建顺序”本身就是业务约束Director 就有价值了。举个例子某系统初始化需要先加载配置、再建立连接池、最后注册探活。如果不抽取 Director每个调用方都要记住这个顺序一旦有人顺序错了排查成本很高。把这些固定动作封装进director.build(clientBuilder)调用方只需要传一个 Builder 进来剩下的流程由 Director 保证。我的习惯是当同一个构建流程出现三处以上重复时立刻抽 Director 或等价的服务方法而不是继续在每个调用方脑子里维护顺序。2.4 流式 Builder日常开发最常用的变体现在的项目里最常见的是静态内部类 链式方法的流式 Builder它省掉 Director把“设置参数”和“校验”放在链式调用的过程中和终点的build()。下面是一个通知配置类import java.util.ArrayList; import java.util.List; public class NotificationConfig { private final String channel; private final ListString receivers; private final boolean retryOnFailure; private final int maxRetries; private final String templateId; private NotificationConfig(Builder builder) { this.channel builder.channel; this.receivers builder.receivers; this.retryOnFailure builder.retryOnFailure; this.maxRetries builder.maxRetries; this.templateId builder.templateId; } public static Builder builder() { return new Builder(); } public static class Builder { private String channel email; private ListString receivers new ArrayList(); private boolean retryOnFailure true; private int maxRetries 3; private String templateId; public Builder channel(String channel) { this.channel channel; return this; } public Builder addReceiver(String receiver) { this.receivers.add(receiver); return this; } public Builder retryOnFailure(boolean retryOnFailure) { this.retryOnFailure retryOnFailure; return this; } public Builder maxRetries(int maxRetries) { this.maxRetries maxRetries; return this; } public Builder templateId(String templateId) { this.templateId templateId; return this; } public NotificationConfig build() { if (channel null || channel.isEmpty()) { throw new IllegalStateException(channel 不能为空); } if (receivers.isEmpty()) { throw new IllegalStateException(receivers 不能为空); } return new NotificationConfig(this); } } }这种写法的核心是默认值放在 Builder 字段初始化处必填校验放在 build() 处可选参数用带返回 this 的 setter 风格方法暴露。调用方拿到的是可读性极强的链式代码产品本身又保持不可变一举两得。3. 跟工厂模式的分界线3.1 为什么这两个模式总被混在一起谈很多教材把工厂模式和 Builder 放在同一章因为两者都负责创建对象看起来都“用方法代替 new”。面试里也经常被问“客户端到底该用工厂还是 Builder”最直接的回答是工厂模式回答“返回哪种实现”Builder 模式回答“一个复杂对象怎么被一步步造出来”。拿汽车举例工厂模式像汽车总装线的“选型开关”你告诉它“我要 SUV”它直接给你一辆 SUVBuilder 模式像总装线本身它规定底盘、发动机、车漆、内饰这些工序并且允许你在不同工序里替换具体实现。3.2 一张对比表维度工厂模式建造者模式核心问题创建哪种产品复杂产品如何一步步组装调用方式一行代码返回成品多步链式或分步调用后 build()产品复杂度适合中等复杂度、可通过接口切换适合多参数、多部件、有顺序要求的对象是否关注组装过程通常不关注明确关注步骤和校验典型场景根据配置创建不同消息推送器构造一个包含十几项可选参数的配置对象3.3 一看就是误用的三个信号第一参数只有两三个还硬套 Builder。这类代码的链式调用比构造函数更长阅读成本反而更高属于为模式而模式。我的经验是可选参数超过三四个或者调用方容易混淆参数含义再考虑 Builder。第二产品之间差异大到无法抽象出统一步骤。Builder 要求所有具体建造者都实现同一套步骤接口。如果每种产品的组装流程完全不同强行抽象只会得到一堆空实现和 if 分支这时候更适合用工厂模式加策略。第三你的对象创建后还需要频繁修改字段。Builder 构建出的通常是不可变对象如果你需要的是“建完还能改”的 DTO直接在业务层用普通 setter 反而更简单别让 Builder 变成一种负担。4. 实战复盘一次从“配置地狱”到 Builder 的重构4.1 一个真实的“配置地狱”现场某内部网关模块需要一个配置类最初只有baseUrl和timeout两个字段。随着对接方变多又加了重试次数、签名方式、回调地址、日志开关等构造函数膨胀到了 12 个参数。调用点长这样GatewayConfig config new GatewayConfig( https://gateway.internal, 30, 60, 3, true, true, false, HMAC-SHA256 );这段代码最大的问题不是长而是没有任何一处能告诉你第 4 个参数是“最大重试次数”还是“超时秒数”。曾经有个同事把connectTimeout和readTimeout的位置写反了接口偶发超时排查过程持续了两天最后才发现是这两个数字传反了。4.2 重构后的链式调用到底好在哪用 Builder 重写之后同一个配置类的调用点变成GatewayConfig config GatewayConfig.builder() .baseUrl(https://gateway.internal) .connectTimeoutSeconds(30) .readTimeoutSeconds(60) .maxRetries(3) .retryOnServerError(true) .enableAccessLog(true) .enableRequestId(false) .signType(HMAC-SHA256) .build();所有参数都带名字没人需要猜。更重要的是新增字段时不再需要修改构造函数签名只需要在 Builder 里加一个方法和对应的字段所有旧的调用点完全不受影响。4.3 顺手把跨字段校验收进 build()重构前网关配置的校验逻辑写在构造函数的最后散落着大量if新同学根本不知道哪些约束有效。重构后可以把字段约束分两层基础格式校验放在链式方法里跨字段关联校验放在build()里。public static class Builder { private String baseUrl; private int connectTimeoutSeconds 30; private int readTimeoutSeconds 60; private int maxRetries 3; private boolean retryOnServerError true; private boolean enableAccessLog true; private boolean enableRequestId true; private String signType HMAC-SHA256; public Builder baseUrl(String baseUrl) { if (baseUrl null || !baseUrl.startsWith(https://)) { throw new IllegalArgumentException(baseUrl 必须是合法的 https 地址); } this.baseUrl baseUrl; return this; } public Builder connectTimeoutSeconds(int seconds) { if (seconds 0) { throw new IllegalArgumentException(connectTimeoutSeconds 必须大于 0); } this.connectTimeoutSeconds seconds; return this; } public GatewayConfig build() { if (connectTimeoutSeconds readTimeoutSeconds) { throw new IllegalStateException(connectTimeout 必须小于 readTimeout); } if (maxRetries 0 !retryOnServerError) { throw new IllegalStateException(开启重试时 retryOnServerError 必须为 true); } return new GatewayConfig(this); } }这样的分层校验有一个额外的好处单元测试能精确验证某个链式方法的约束分支不需要为了构造整个对象先凑出一堆无关参数。4.4 隐性收益代码评审和新人上手都变快了重构之后新来的同事第一次看配置类就能直接读懂调用处的意图不用再拉着别人问“那个 true 是干嘛的”。代码评审的 diff 也更清晰以前加一个参数构造函数签名和所有调用点一起改diff 里全是干扰项现在加一个 Builder 方法改动范围被限制在一个文件里reviewer 很容易抓住实质变化。5. 常见问题与避坑手册5.1 问题速查表症状可能原因排查与解法Builder 构建出来的对象还能被外部改字段产品类漏了 final或暴露了 setter产品字段全部 final提供私有构造器build() 之后复用同一个 Builder结果参数越攒越多Builder 是有状态对象每次 build 不清空认为 Builder 是一次性的循环里每次重新创建继承体系下链式方法返回类型变成父类子类 Builder 没有正确使用自引用泛型用T extends BuilderT的 CRTP 写法有些可选字段没设默认值对象出现 null忘了在 Builder 字段上初始化在 Builder 字段声明处给默认值跨字段校验散落在各个 setter 里把组合约束放到了单字段校验中单字段校验留在链式方法组合校验集中到 build()5.2 线程安全与 Builder 复用陷阱Builder 本身是可变对象字段在链式调用过程中不断被修改所以绝对不要把同一个 Builder 实例共享给多个线程使用。更隐蔽的问题是复用。我在一个定时任务里踩过这个坑代码大概是NotificationConfig.Builder builder NotificationConfig.builder(); for (String userId : userIds) { NotificationConfig config builder .channel(sms) .addReceiver(userId) .build(); send(config); }第一次循环还正常第二次开始receivers数组里已经沾上了上一个用户最终一批用户收到了对方的通知。正确做法是每一次循环都新建 Builder或者在build()里对 Builder 做一次性消费语义但大多数团队不会做到后者所以养成“每次构建前 new 一个 Builder”的习惯最保险。5.3 继承体系下的 Builder 怎么写如果产品有继承关系Builder 的链式类型很容易丢失。子类调用父类方法后返回类型变成父类 Builder后面再接子类特有的方法就编译不过。标准解法是 CRTP 式的自引用泛型public abstract class Shape { protected final String color; Shape(Builder? builder) { this.color builder.color; } public abstract static class BuilderT extends BuilderT { protected String color black; public T color(String color) { this.color color; return self(); } public abstract Shape build(); protected abstract T self(); } } public class Circle extends Shape { private final double radius; private Circle(Builder builder) { super(builder); this.radius builder.radius; } public static class Builder extends Shape.BuilderBuilder { private double radius 1.0; public Builder radius(double radius) { this.radius radius; return self(); } Override public Circle build() { return new Circle(this); } Override protected Builder self() { return this; } } }这样写new Circle.Builder().color(red).radius(2.5).build()全程都是Builder类型链式调用不会在中途断掉。代价是泛型签名稍显复杂所以如果层级不超过两层也可以接受强转或直接放弃链式连续调用。5.4 自动生成 Builder 的工具没你想的那么省心现在很多工程会用编译期注解工具自动生成 Builder 代码省掉手写的大量模板。但我在项目里见过两个很隐蔽的问题。第一默认值丢失。工具生成的 Builder 通常不会继承字段的初始值除非专门标注“这是默认值”。于是原本private boolean retry true的字段在 Builder 里可能变成false但代码看起来又像有默认值等到生产环境出问题才暴露。第二必填约束失效。自动生成的 Builder 把所有字段都当成可选原来靠“Builder 构造函数只接收必填参数”来保证的约束在这种场景下完全不存在任何字段都可以不设置就build()结果对象带着 null 满天飞。如果团队必须用这类工具我建议至少保留手写build()或者手写必填参数的 Builder 构造函数不要全交给生成器。最后再分享一个小技巧判断一个类要不要用 Builder我有个很简单的自测方法写一段调用代码挡住类定义让旁边的人读调用点看他能不能准确说出每个值的含义。如果猜不出来Builder 大概率能改善如果能一眼看懂就放过它别为了模式而模式。我个人在实际项目里维护过很多配置类体会最深的是当构造函数参数超过 6 个时每一次修改都是一次事故机会而 Builder 不光是让你写起来舒服它更像一张能挡住低级错误的网。那种“凌晨三点被监控电话叫醒”的经历我实在不想再体验第二次。如果你所在的团队还在靠数参数位置猜字段那这篇文章里的流式 Builder 写法可以明天就直接试到新代码里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑