05|建造者:把构建过程与有效对象分开
配套代码:GitHub 仓库 · 本篇完整源码 · 行为测试。使用 JDK 25 与 Gradle,包名为
com.hanserwei.patterns.builder。
系列导航:Java 25 设计模式学习指南
文章需要必填标题,也可以附带多个标签。继续扩充构造器会出现多个相似签名,调用处难以判断每个参数含义;如果先 new 空文章再调用 setter,又会让“标题尚未设置”的对象流入业务层。
我们希望构建过程允许分步配置,但交给业务层的 Article 从诞生起就是有效、独立的对象。构建器承担临时可变状态,文章承担稳定的结果状态。
从问题提炼设计意图
建造者分离复杂对象的逐步构建与最终表示。本例使用 Java 常见的嵌套 Builder 变体,通过 build 校验并产生不可变结果。
对象职责与协作关系
| 示例角色 | 职责 |
|---|---|
Article |
最终产品,只暴露读取能力 |
Article.Builder |
构建器,收集必填项与可选标签 |
Demo |
装配方,组织具体构建步骤 |
classDiagram
Article ..> Builder : reads validated state
Builder ..> Article : builds
Demo ..> Builder : configures
代码思路:变化应该落在哪个对象上
标题作为 Builder 构造器参数出现,使必填项在调用处显式可见。addTag 返回当前构建器,允许链式调用;它不创建 Article,而是继续修改构建阶段的参数。
build 是有效对象的边界。空白标题在这里失败,成功时调用 Article 的私有构造器。产品没有公开 setter,字段是 private final,因此“修改配置”与“使用文章”两个阶段拥有不同的 API。
真正保证标签独立的是 List.copyOf,而不只是 final。final 只禁止字段重新指向其他列表;如果把 builder.tags 直接赋给 article.tags,继续调用 addTag 仍能修改旧文章。复制之后,重用同一个构建器创建多个结果也不会相互污染。
关键实现与独立运行
配套仓库中的包名是 com.hanserwei.patterns.builder,源码目录为 src/main/java/com/hanserwei/patterns/builder/。仓库地址统一见系列导航。以下展示关键文件的完整内容;其余角色和测试在同一仓库中,每个顶级类型各占一个文件。
Article.java:
package com.hanserwei.patterns.builder;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/** 由构建器校验并创建的不可变文章. */
public final class Article {
/** 发布后不再变化的标题. */
private final String title;
/** 构造时复制的不可变标签集合. */
private final List<String> tags;
/** 从已校验的构建器取得快照,避免后续构建影响旧对象. */
private Article(Builder builder) {
title = builder.title;
tags = List.copyOf(builder.tags);
}
/** 返回文章标题. */
public String title() {
return title;
}
/** 返回不可修改的标签快照. */
public List<String> tags() {
return tags;
}
/** 收集可选参数并在最终创建时维护文章不变量. */
public static final class Builder {
/** 必填标题,构造完成后不允许替换. */
private final String title;
/** 构建过程中的可变标签,仅由构建器持有. */
private final List<String> tags = new ArrayList<>();
/** 指定必填标题;空白标题在 build 时统一拒绝. */
public Builder(String title) {
this.title = Objects.requireNonNull(title, "title");
}
/** 添加非空白标签,并返回构建器以继续配置. */
public Builder addTag(String tag) {
if (tag == null || tag.isBlank()) {
throw new IllegalArgumentException("Tag must not be blank");
}
tags.add(tag);
return this;
}
/** 校验标题并创建独立文章快照. */
public Article build() {
if (title.isBlank()) {
throw new IllegalArgumentException("Title must not be blank");
}
return new Article(this);
}
}
}
Demo.java 展示调用方如何装配这些对象:
package com.hanserwei.patterns.builder;
/** 演示本章对象的装配方式和可观察结果. */
public final class Demo {
/** 禁止实例化演示入口. */
private Demo() {}
/** 运行独立示例;args 为未使用的命令行参数. */
public static void main(String[] args) {
Article article = new Article.Builder("Java 25").addTag("OOP").build();
System.out.println(article.title() + article.tags());
}
}
在配套代码仓库根目录运行;Windows 使用 gradlew.bat 替换 ./gradlew:
./gradlew runBuilder
./gradlew test --tests 'com.hanserwei.patterns.builder.PatternTest'
示例的业务输出如下,省略 Gradle 自身的任务提示:
Java 25[OOP]
用测试确认模式的行为
重用构建器不污染旧对象;返回列表不能修改;空白标题无法 build。三个断言分别守住快照隔离、只读边界和业务有效性。
对应测试位于 src/test/java/com/hanserwei/patterns/builder/PatternTest.java。建议先运行现有测试,再改动一个协作环节,观察哪个断言能够发现问题。
常见用法
- 请求配置、查询条件和文章等对象包含多个可选参数。
- 构造过程要分阶段收集数据,最终统一校验跨字段规则。
- 需要创建不可变对象,同时让调用方拥有易读的配置接口。
适用边界与容易踩的坑
GoF 的经典建造者还会把构建步骤抽象成 Builder 接口,并让 Director 复用步骤序列,生成不同产品表示。本例没有 Director,也没有多种产品表示;它是更轻量的 Java Builder 用法,不应把链式调用本身当成完整定义。
如果对象只有两个必填字段、没有复杂校验,一个普通构造器通常更清楚。Builder 增加了类、状态和失败时机;“每个类都带 Builder”不是 OOP 的要求。
List.copyOf 只复制容器,不会递归复制可变元素。本例标签是不可变 String,因此足够;如果标签变成带可变属性的对象,还必须规定它们的复制或不可变策略。构建器本身也不承诺线程安全。
与相近模式比较
简单工厂主要选择产品类型;建造者主要组织一个产品的构建步骤。原型从现有对象的状态出发复制,建造者则从参数和步骤出发创建。
动手练习
增加可选摘要和“摘要长度不得超过标题长度三倍”的规则,在 build 中校验跨字段关系。再实现一个 Director,为每周刊物复用固定标签配置,并解释它应该持有构建器实例还是每次创建新的构建器。
系列导航:Java 25 设计模式学习指南