编码 · 设计模式
建造者模式详解与示例
简短回答
建造者是一种创建型设计模式,它把复杂对象的构造与其最终表示分离,使同样的分步构造过程可以产生不同的结果。在现代代码中,它通常表现为流式 API:在建造者上依次调用 .url()、.header() 和 .timeout() 等方法,最后由 .build() 返回一个构造完成并经过校验的对象。它可以取代带有许多可选参数的冗长构造函数。
意图
将复杂对象的构造与它的表示分离,使同样的构造过程可以创建不同的表示。建造者是《设计模式》(四人组,GoF)一书中五种创建型模式之一。
它要解决的问题
设想一个 HTTP 请求对象,包含 URL、方法、任意数量的请求头、可选的请求体、超时时间和重试次数。为这一切写一个构造函数,调用起来很难不出错:
class HttpRequest {
constructor(
readonly url: string,
readonly method: string,
readonly headers: Record<string, string>,
readonly body?: string,
readonly timeoutMs?: number,
readonly retries?: number,
) {}
}
// Which argument is which? Easy to swap 3 and 5000 by mistake.
const req = new HttpRequest("https://api.example.com", "POST", {}, undefined, 3, 5000);这被称为伸缩构造函数(telescoping constructor)。它还没有地方放置“GET 请求不能带请求体”之类的规则,也无法逐步构建,比如在发送前于多个地方陆续添加请求头。
结构与参与者
| 参与者 | 职责 | 示例中的对应 |
|---|---|---|
| 建造者(Builder) | 声明创建产品各个部分的步骤。 | RequestBuilder 的各个方法 |
| 具体建造者(ConcreteBuilder) | 实现这些步骤,保存中间状态并返回结果。 | RequestBuilder |
| 指挥者(Director) | 可选。按固定顺序执行这些步骤,生成常用配置。 | jsonPost() |
| 产品(Product) | 被构建的复杂对象。 | HttpRequest |
TypeScript 实现
每个步骤保存一个值并返回 this,因此调用可以链式书写。build() 会校验各项组合,并返回一个不可变的产品:
interface HttpRequest {
readonly url: string;
readonly method: "GET" | "POST";
readonly headers: Readonly<Record<string, string>>;
readonly body?: string;
readonly timeoutMs: number;
}
class RequestBuilder {
private method: "GET" | "POST" = "GET";
private headers: Record<string, string> = {};
private body?: string;
private timeoutMs = 10_000;
constructor(private readonly url: string) {}
post(body: string): this { this.method = "POST"; this.body = body; return this; }
header(name: string, value: string): this { this.headers[name] = value; return this; }
timeout(ms: number): this { this.timeoutMs = ms; return this; }
build(): HttpRequest {
if (this.timeoutMs <= 0) throw new Error("timeout must be positive");
return Object.freeze({
url: this.url, method: this.method, headers: { ...this.headers },
body: this.body, timeoutMs: this.timeoutMs,
});
}
}
const request = new RequestBuilder("https://api.example.com/orders")
.header("Accept", "application/json")
.timeout(5_000)
.build();
console.log(request.method, request.timeoutMs, request.headers);
// GET 5000 { Accept: 'application/json' }指挥者只不过是知道某种常见配方的代码。在 TypeScript 中,它往往就是一个驱动建造者的普通函数:
interface Steps {
header(name: string, value: string): this;
post(body: string): this;
}
// Director: a reusable recipe that drives any builder with these steps.
function jsonPost<B extends Steps>(builder: B, data: unknown): B {
return builder
.header("Content-Type", "application/json")
.post(JSON.stringify(data));
}
class LogBuilder implements Steps {
log: string[] = [];
header(name: string, value: string): this { this.log.push(`${name}=${value}`); return this; }
post(body: string): this { this.log.push(`POST ${body}`); return this; }
}
console.log(jsonPost(new LogBuilder(), { id: 7 }).log);
// [ 'Content-Type=application/json', 'POST {"id":7}' ]这展示了 GoF 的原始思想:同样的构造步骤,由同一个指挥者驱动,可以交给不同的建造者,产生不同的表示,这里产生的是日志而不是请求。
何时使用,何时不用
以下情况适合使用建造者:
- 对象有许多可选部分,或者各部分必须逐个添加。
- 构造过程有涉及多个字段的规则,应该在
build()中统一检查一次。 - 你希望最终得到的对象是不可变的,同时仍能分步组装。
- 同一个构造过程需要产生不同的表示,例如把一份文档导出为 HTML、Markdown 或 PDF。
以下情况应避免使用:
- 对象只有几个字段。构造函数更短,同样清晰。
- 你的语言已经支持具名参数或可选参数。在 TypeScript 中,一个选项对象往往就能完全省去建造者。
interface RequestOptions {
url: string;
method?: "GET" | "POST";
headers?: Record<string, string>;
timeoutMs?: number;
}
function createRequest({ url, method = "GET", headers = {}, timeoutMs = 10_000 }: RequestOptions) {
return { url, method, headers, timeoutMs };
}
console.log(createRequest({ url: "/orders", timeoutMs: 5_000 }).method); // GET实际应用
- Java:
java.net.http.HttpRequest.newBuilder()返回一个HttpRequest.Builder;依次链式调用uri()、header()和timeout(),最后调用build()。 - Java:
Locale.Builder根据语言、地区和文字三个部分组装并校验一个区域设置。 - Java 和 C#:
StringBuilder先把许多片段追加在一起,最后才生成不可变的字符串。 - 许多 SQL 查询构造器和测试数据构造器也采用同样的流式风格。
相关模式
- 抽象工厂创建整个产品族,并立即返回每个产品;建造者则一步步构造一个复杂产品,最后才返回它。
- 组合模式的树结构常常用建造者来创建,因为逐个节点地构建一棵树,天然就是一系列步骤。
- 流式接口(fluent interface)是一种链式调用并返回
this的 API 风格。建造者经常使用它,但两者并不是一回事。 - 当大多数新对象只是现成模板的小幅变化时,原型可以作为替代方案。
常见问题
为什么每个建造者方法都返回 this?
返回建造者本身,就能在一个表达式里链式调用,比如 .header(...).timeout(...).build()。在 TypeScript 中返回 this 还能让子类保持正确的类型。
一定要有指挥者类吗?
不需要。在 GoF 的描述中,指挥者是可选的。实际中,一个应用常用步骤序列的函数通常就够了。
在 TypeScript 中建造者还有用吗?
有时有用。带默认值的选项对象能覆盖大多数简单场景。但当对象需要逐步组装、需要跨字段校验,或者想要不可变的结果时,建造者仍然很有价值。
建造者与工厂方法有什么区别?
工厂方法决定实例化哪个类,并在一次调用中返回对象。建造者则让客户端通过多次调用来控制一个对象的组装方式。