cv.cm

編碼 · 設計模式

建造者模式詳解與範例

閱讀約 6 分鐘

簡答

建造者是一種建立型設計模式,它把複雜物件的建構過程與最終呈現的樣貌分開,讓同一套逐步進行的流程能產生不同的結果。在現代程式碼中,它通常以流式 API(fluent API)的形式出現:你在建造者上呼叫 .url()、.header()、.timeout() 這類方法,最後由 .build() 回傳一個完成且通過驗證的物件。它可以取代帶有大量選用參數的冗長建構函式。

意圖

把複雜物件的建構過程與它的呈現樣貌分離,讓同一套建構流程可以產生不同的呈現。建造者是《設計模式》(Gang of Four,簡稱 GoF)一書中五種建立型模式之一。

它解決的問題

想像一個 HTTP 請求物件,包含網址、方法、任意數量的標頭、選用的本文、逾時時間與重試次數。要替這些東西寫一個建構函式,很難正確呼叫:

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 中建造者還有用嗎?

有時有用。帶預設值的選項物件可以涵蓋大多數簡單的情況。當物件需要逐步組裝、需要跨欄位驗證,或是希望得到不可變的結果時,建造者仍然很有用。

建造者和工廠方法有什麼差別?

工廠方法決定要實例化哪個類別,並在一次呼叫中回傳物件。建造者則讓用戶端透過多次呼叫,控制單一物件如何組裝。

全部編碼教學