iT邦幫忙

2025 iThome 鐵人賽

DAY 5
0
Software Development

Codetopia 新手日記:設計模式與原則的 30 天學習之旅系列 第 5

Day 5:都更署的秘密武器——Builder 模式搞定複雜物件的分步施工!

  • 分享至 

  • xImage
  •  

Codopia 創城記 (5)|都更署的秘密武器——Builder 模式搞定複雜物件的分步施工!

iThome 鐵人賽 設計模式 Builder Creational Patterns Codetopia

1. 今日熱點 (故事開場 & 痛點)

午夜十二點,Codetopia 市府發布「舊城區緊急都更案」,都更署的專案經理 Rex |Director 正對著施工藍圖,眉頭深鎖。

挑戰在於:施工流程固定(地基→鋼骨→外牆→水電→驗收),但成品卻需依街區需求,產出截然不同的小宅版塔樓版。過去那種「一個函式蓋到底」的作法,任何微調都會引爆測試災難。

Rex 的思緒飄回了昨天。他想起 Day 4 的「供應商聯盟 (Abstract Factory)」,那招「一次換全套」很漂亮,但今天不適用。他需要的不是換掉整個施工隊,而是**「同一套分步流程」,但能彈性地對應不同建造工法,最終產出迥異的成品。**

他要的,是一種能將「施工順序」與「每一步作法」徹底解耦的智慧。

2. 術語卡(今日會用到)

🧭 術語卡(今日會用到)

  • Builder:一種創生模式,旨在將複雜物件的「建造步驟」與其「最終成品表示」分離,讓同一套建造流程能創建不同產品。

  • Director:指導者角色,負責掌控建造的順序與流程(施工藍圖),不關心細節。

  • Pipes-and-Filters / Pipeline:一種中觀架構模式,將大流程拆解成一系列可組合的「濾器」節點。

  • 常見誤用:許多人會把 Builder 當成「多參數建構子」的語法糖。但 Builder 的核心價值在於分步建構流程/表示分離,而不僅僅是鏈式調用。

3. 笑中帶淚 (反例/壞味道)

在 Rex 找到新方法前,舊的施工現場簡直是一場災難,充滿了各種「壞味道」:

  • 一鍋燴施工法 🍲:一個 buildBuilding(...) 巨石函式,塞滿 if-else。想加新材料?恭喜,準備再加 10 個分支判斷,然後祈禱不要改壞既有邏輯。

  • 半成品外洩事故 💧:外部服務可以直接操作施工中的 currentWalls[] 陣列。結果就是,隔壁團隊為了修 Bug,順手就把你蓋到一半的牆給拆了。

  • 流程硬寫在水泥裡 🧱:將「蓋塔樓要多打一遍地基」這種流程邏輯,硬焊在 UI 按鈕事件裡。導致就算換了施工隊(Builder),流程也改不掉。

  • 永無寧日的測試地獄 🔥:同一套消防驗收腳本,在小宅和塔樓版下結果居然不一樣!只因建造流程和實作緊緊糾纏,引發了無法預期的副作用。

說穿了,這一切混亂的根源就是:「蓋房子的順序」「每一步怎麼蓋」 這兩件事,被粗暴地揉在了一起。

4. 王牌出手 (核心觀念/何時用/不適用)

為了解決這場施工災難,Rex 採用了今天的王牌工法——建造者模式 (Builder Pattern)

它的核心思想是:

將一個複雜物件的建構過程與其表示分離,使得同樣的建構過程可以創建不同的表示。

用都更署的行話來說,就是確立兩大角色的神聖分工:

  • 總監 (Director):Rex 擔任此角。他手握施工藍圖,只負責下達指令的順序,完全不碰鋼筋水泥。更重要的是,由他封裝完整的建造流程並負責最終交付

  • 施工隊 (Builder):Leo(小宅隊)和 Nora(塔樓隊)是實作專家。他們負責詮釋總監的指令。當 Rex 喊「上鋼骨」,Leo 隊會用輕型鋼材,而 Nora 隊則用高強度合金鋼。

最終,施工隊會交出一個完整且封存(不可變)的建築成品 (Building)。在這之後,誰都不能再對它敲敲打打。

何時用 (When to Use)

  • 當產物結構複雜,且建立過程有固定的步驟時

  • 當同一套建造流程,需要產出多種不同表現(變體)時

  • 當你希望同一套驗收劇本,能覆蓋所有產品變體時

  • 當你需要一個「階段性」的建造過程,並在最後產出一個不可變的物件時

何時不要用 (When NOT to Use) ⚠️

  • 如果物件只是構造函數參數多,但沒有固定的建造步驟:用命名參數 (Named Parameters) 或 DTO 就夠了。

  • 如果建造步驟變化極大,需要動態組合:更適合升級為管線模式 (Pipeline)

    • 量化門檻參考:當你的步驟矩陣(步驟 × 變體)中的條件分支 > 15 個,或每月穩定新增 ≥ 2 種變體時,就該認真考慮轉為資料驅動的管線配置,以避免 Builder 類別爆炸。
設計模式小教室:Builder vs. Abstract Factory (Day 4)
情境 Abstract Factory Builder

|一次拿到成套、風格一致的零件家族|✅|△|
|遵循固定流程、分步驟建構複雜成品|△|✅|
|同一驗收腳本覆蓋多種產品變體|△|✅|

5. 導播切景 (表+圖)

導播,鏡頭拉一下!讓我們用三種不同的焦段,來看看這個分工合作的施工現場。

視角 觀念/模式 在 Codetopia 的說法
微觀 (GoF) Builder + Director Rex (Director) 依序呼叫建造步驟,並從 Builder 手中取得最終成品。Leo (HouseBuilder) 和 Nora (TowerBuilder) 各自實現這些步驟。
中觀 (EIP/EDA/Actor) Pipes-and-Filters / Pipeline 將「打地基→鋼骨→…」看作一條訊息處理管線。每一步的完成觸發 StepCompleted 事件,交給下一個節點。
宏觀 (MAS) 任務分解與協作 規劃代理 (PlanningAgent) 將任務分解,派發給施工代理 (ConstructionAgent) 和驗收代理 (InspectionAgent)。Director 只做排程與監控。

微觀:都更署的組織架構 (Class Diagram)

讀圖口訣:「Director 定序並交付,Builder 埋頭定工法」。

https://ithelp.ithome.com.tw/upload/images/20250919/20178500gQxxei9G0Q.png

中觀:不可逆的施工管線 (Flowchart)

讀圖口訣:「流程只有一條主幹;不同工法在步驟內實現」。

https://ithelp.ithome.com.tw/upload/images/20250919/20178500bzVFcgKtRX.png

6. 最小實作 (程式碼範例)

讓我們用 Python 腳本來模擬這場職責分明、產出安全且具備可觀測性的施工秀。

from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Callable, Optional
from dataclasses import dataclass

# --- 1. 產品本身:一個不可變且易於閱讀的安置大樓 ---
@dataclass(frozen=True)
class Building:
    parts: tuple[str, ...]

    def __str__(self) -> str:
        return " | ".join(self.parts)

# --- 2. 施工隊的「契約」 (Builder Interface) ---
class IBuilder(ABC):
    """
    Builder 契約:
    - get_product() 交付一個不可變的產品,並且會重置 Builder 的內部狀態。
    - 這意味著 get_product() 不是冪等的 (idempotent),連續呼叫會得到空的產品(因交付後已重置)。
    - Builder 的實例通常不是執行緒安全的。
    """
    @abstractmethod
    def reset(self) -> None: ...

    @abstractmethod
    def build_foundation(self) -> None: ...

    @abstractmethod
    def build_frame(self) -> None: ...

    @abstractmethod
    def build_walls(self) -> None: ...

    @abstractmethod
    def build_mep(self) -> None: ...

    @abstractmethod
    def get_product(self) -> Building: ...

# --- 3. 具體的施工隊:小宅隊 & 塔樓隊 ---
class HouseBuilder(IBuilder):
    def __init__(self) -> None:
        self._parts: list[str] = []

    def reset(self) -> None:
        self._parts = []

    def build_foundation(self) -> None: self._parts.append("溫馨小宅的地基")
    def build_frame(self) -> None: self._parts.append("5層樓輕鋼骨")
    def build_walls(self) -> None: self._parts.append("磚造外牆")
    def build_mep(self) -> None: self._parts.append("家用級水電系統")

    def get_product(self) -> Building:
        product = Building(parts=tuple(self._parts))
        self.reset()
        return product

class TowerBuilder(IBuilder):
    def __init__(self) -> None:
        self._parts: list[str] = []

    def reset(self) -> None:
        self._parts = []

    def build_foundation(self) -> None: self._parts.append("摩天塔樓的深層地基")
    def build_frame(self) -> None: self._parts.append("30層樓高強度鋼骨")
    def build_walls(self) -> None: self._parts.append("玻璃帷幕外牆")
    def build_mep(self) -> None: self._parts.append("商用級水電系統")

    def get_product(self) -> Building:
        product = Building(parts=tuple(self._parts))
        self.reset()
        return product

# --- 4. 總監 (Director),負責指揮、監控並交付最終成品 ---
class Director:
    def __init__(self, builder: IBuilder, on_step: Optional[Callable[[str], None]] = None):
        self._builder = builder
        self._on_step = on_step or (lambda step: None)

    def change_builder(self, builder: IBuilder) -> None:
        self._builder = builder

    def _execute_and_notify(self, step_name: str, step_index: int, step_action: Callable[[], None]):
        """執行一個步驟並觸發回調"""
        step_action()
        self._on_step(f"{step_index}:{step_name}")

    def build_minimal_viable_product(self) -> Building:
        self._builder.reset()
        self._execute_and_notify("foundation", 1, self._builder.build_foundation)
        return self._builder.get_product()

    def build_full_featured_product(self) -> Building:
        self._builder.reset()
        steps = [
            ("foundation", self._builder.build_foundation),
            ("frame", self._builder.build_frame),
            ("walls", self._builder.build_walls),
            ("mep", self._builder.build_mep),
        ]
        for i, (name, action) in enumerate(steps, start=1):
            self._execute_and_notify(name, i, action)
        return self._builder.get_product()

# --- 5. 驗收時間 ---
def run_validation_suite(building: Building):
    """同一套驗收腳本,不管產品是什麼,都要通過!"""
    print(f"\n[驗收開始] 正在檢測...")
    print(f"  - 產品結構: {building}")
    assert building.parts, "驗收失敗:產出為空,請檢查 Builder 是否遺漏步驟"
    print(f"[驗收通過] ✅ 該建築符合基本規範!")


if __name__ == "__main__":
    events = []
    # Rex 的指揮中心,現在有了事件監控儀表板
    director = Director(HouseBuilder(), on_step=lambda s: events.append(f"StepCompleted:{s}"))

    print("--- Rex 下令:建造 A 區的小宅版!---")
    small_house = director.build_full_featured_product()
    run_validation_suite(small_house)
    print(f"施工事件紀錄: {events}")

    print("\n" + "="*40 + "\n")

    events.clear() # 清空上次的事件紀錄
    print("--- Rex 下令:更換施工隊,建造 B 區的塔樓版!---")
    director.change_builder(TowerBuilder())
    skyscraper = director.build_full_featured_product()
    run_validation_suite(skyscraper)
    print(f"施工事件紀錄: {events}")

7. 測試指北 (Testing Guide)

一個好的架構,必須是高度可測試的。Builder 模式的職責分離特性,讓測試變得非常清晰。

  • 測試 Director(流程正確性):我們不關心產出物,只關心 Director 是否按照正確的順序呼叫了 Builder 的方法。這時可以使用一個假的 Builder (Fake/Mock)。

    # (使用 pytest 風格)
    def test_director_build_sequence_is_correct():
        class FakeBuilder(IBuilder): # 一個只記錄呼叫順序的假 Builder
            def __init__(self): self.calls = []
            def reset(self): self.calls.append("reset")
            def build_foundation(self): self.calls.append("foundation")
            def build_frame(self): self.calls.append("frame")
            def build_walls(self): self.calls.append("walls")
            def build_mep(self): self.calls.append("mep")
            def get_product(self): return Building(parts=("fake",))
    
        fake_builder = FakeBuilder()
        director = Director(fake_builder)
        director.build_full_featured_product()
    
        # 斷言必須嚴格檢查順序與次數
        expected_sequence = ["reset", "foundation", "frame", "walls", "mep"]
        assert fake_builder.calls == expected_sequence
    
  • 測試 Builder(產出契約正確性):我們需要確保每個具體的 Builder 都能產出符合契約的產品。這可以使用參數化測試,讓同一套驗收邏輯跑在所有 Builder 上。

    # (使用 pytest 風格)
    import pytest
    
    def meets_building_code(building: Building) -> bool:
        # 語義檢查:只要有地基,且至少有4個部分,就符合基本法規
        # 這種寫法比檢查 building.parts[0] 更具韌性
        has_foundation = any("地基" in p for p in building.parts)
        return has_foundation and len(building.parts) >= 4
    
    @pytest.mark.parametrize("builder_class", [HouseBuilder, TowerBuilder])
    def test_builder_product_contract(builder_class):
        builder = builder_class()
        director = Director(builder)
        product = director.build_full_featured_product()
    
        assert isinstance(product, Building)
        assert meets_building_code(product), f"{builder_class.__name__} 未能產出符合法規的建築!"
    

8. 鄉民出題 (動手+反模式紅旗)

想加入都更署的王牌施工隊嗎?先來挑戰這幾個任務吧!

  1. 流程的彈性:假設遇到雨天,需要跳過「外牆上漆」這個可選步驟。你認為這個判斷邏輯(if is_raining: skip_painting())應該放在 Director 還是 Builder 裡?寫下你的選擇與取捨的理由。(提示:「決定流程」是誰的職責?「如何實作」又是誰的職責?)

  2. 反模式紅旗 🚩:

    • Director 洩漏對具體工法的認知:如果在 Director 的程式碼中出現 if isinstance(self._builder, TowerBuilder): self._builder.add_gargoyle(),這嚴重違反了開放封閉原則,因為每當新增一種 Builder,你都可能需要回來修改 Director。

    • Builder 產出可變物件:如果 get_product() 回傳的 Building 物件,在交付後還能被外部修改(例如 building.parts.append("違建")),這就破壞了 Builder 模式「一次性安全建構」的核心承諾。

    • 誤解 get_product() 的語義:連續呼叫 builder.get_product(),第二次卻拿到一個空的產品,代表呼叫者不了解其「交付即重置」的契約。這在介面註解中必須說清楚。

9. 城市望遠鏡 (進階視野)

今天我們在微觀層面用 Builder 模式精準分工。如果將這個「分步建構」的概念放大到整個城市架構:

  • EIP/EDA (事件驅動) 的世界裡,這條施工流程就像一個訊息管線 (Pipeline)。每一步都是一個濾器 (Filter)。當需要回滾或重試時,可以引入 Saga 模式來管理長交易的補償步驟。例如,為 Builder 的每個步驟都定義一個對應的補償函式,並以正向步驟的反序來執行補償,確保交易的一致性。

  • MAS (多代理系統) 的宏觀視角下,Director 更像是一個輕量級的排程與監控代理。它不直接呼叫方法,而是向城市的「黃頁服務 (DF)」查詢,有哪些代理宣告了「打地基」或「拉水電」的能力,然後將任務派發出去,並監控服務水準協議 (SLA)。這樣一來,要替換掉某個承包商,只需更新黃頁上的註冊資訊,完全不用修改排程邏輯。

10. 二十字摘要 & 明日預告

流程固定表現可換;Director 定序、Builder 定工法,完工物件一次封存。

今天,我們見證了都更署如何運用 Builder 模式,優雅地解決了複雜物件的建造難題。

然而,有時候我們需要的不是從零開始一步步蓋,而是快速地複製一個現有的「標準戶型」,再做點個性化微調。

明天,我們將拜訪城市的「樣板局」。敬請期待 Day 6《Prototype》—— 樣板局出動:複製藍本、按需微調,見證「複製勝於創建」的時刻!


附錄:ASCII 版圖示

為了確保在不支援 Mermaid 渲染的環境中也能正常閱讀,以下提供文中圖表的 ASCII 替代版本:

1. Builder 模式類別圖 (ASCII 版) 🏗️

                          👨‍💼 Director
                    ╔═══════════════════════╗
                    ║ - builder: IBuilder   ║
                    ║ + change_builder()    ║
                    ║ + build_full_feat...()║
                    ║ + build_minimal...()  ║
                    ╚═══════════╤═══════════╝
                                │ uses
                                ▼
                         📋 <<Interface>>
                            IBuilder
                    ╔═══════════════════════╗
                    ║ + reset()             ║
                    ║ + build_foundation()  ║
                    ║ + build_frame()       ║
                    ║ + build_walls()       ║
                    ║ + build_mep()         ║
                    ║ + get_product()       ║
                    ╚═══════════╤═══════════╝
                                │
                    ┌───────────┴───────────┐
                    ▼                       ▼
           🏠 HouseBuilder            🏢 TowerBuilder
        ╔═══════════════════╗    ╔═══════════════════╗
        ║ - _parts: list    ║    ║ - _parts: list    ║
        ║ + get_product()   ║    ║ + get_product()   ║
        ╚═══════════════════╝    ╚═══════════════════╝
                    │                       │
                    │ creates               │ creates
                    ▼                       ▼
                            🏛️ Building
                    ╔═══════════════════════╗
                    ║ + parts: tuple[str,...]║
                    ║ 🔒 (不可變物件)       ║
                    ╚═══════════════════════╝

2. Builder 施工流程圖 (ASCII 版) 🚧

                           🎬 開始
                             │
                             ▼
                     ╔═══════════════╗
                     ║ 🔄 reset()    ║
                     ╚═══════╤═══════╝
                             │
                             ▼
                     ╔═══════════════════╗
                     ║ 🏗️ build_foundation║
                     ╚═══════════╤═══════╝
                                 │
                                 ▼
                     ╔═══════════════════╗
                     ║ 🏭 build_frame()  ║
                     ╚═══════════╤═══════╝
                                 │
                                 ▼
                     ╔═══════════════════╗
                     ║ 🧱 build_walls()  ║
                     ╚═══════════╤═══════╝
                                 │
                                 ▼
                     ╔═══════════════════╗
                     ║ ⚡ build_mep()    ║
                     ╚═══════════╤═══════╝
                                 │
                                 ▼
                     ╔═══════════════════╗
                     ║ 📦 get_product()  ║
                     ╚═══════════╤═══════╝
                                 │
                                 ▼
                       ╔═══════════════╗
                       ║ ✅ 交付成品   ║
                       ╚═══════════════╝

    ┌─────────────────────── 💡 設計智慧 ───────────────────────┐
    │                                                          │
    │ 🎯 Director 控制整個流程序列                             │
    │ 🔧 同一流程步驟,不同 Builder 有不同實作:               │
    │                                                          │
    │   🏗️ build_foundation:                                  │
    │      🏠 小宅隊 → 淺地基                                 │
    │      🏢 塔樓隊 → 深地基                                 │
    │                                                          │
    │   🏭 build_frame:                                       │
    │      🏠 小宅隊 → 輕鋼架                                 │
    │      🏢 塔樓隊 → 高強度鋼骨                             │
    │                                                          │
    │   🧱 build_walls:                                       │
    │      🏠 小宅隊 → 磚造外牆                               │
    │      🏢 塔樓隊 → 玻璃帷幕                               │
    │                                                          │
    │   ⚡ build_mep:                                         │
    │      🏠 小宅隊 → 家用級水電                             │
    │      🏢 塔樓隊 → 商用級水電                             │
    │                                                          │
    └──────────────────────────────────────────────────────────┘

3. 職責分工一覽表 📋

╔═══════════════╦═══════════════════════╦═══════════════════════════╗
║    🎭 角色    ║       ✅ 職責         ║        ❌ 不負責         ║
╠═══════════════╬═══════════════════════╬═══════════════════════════╣
║ 👨‍💼 Director  ║ 🎯 控制建造順序       ║ 🔧 具體施工細節           ║
║   (總監)      ║ 📊 流程監控           ║ 🧱 材料選擇               ║
║               ║ 📦 最終交付           ║ ⚒️ 工法實作               ║
╠═══════════════╬═══════════════════════╬═══════════════════════════╣
║ 👷‍♂️ Builder   ║ 🔨 具體施工實作       ║ 📋 決定建造順序           ║
║   (施工隊)    ║ 🧱 材料與工法         ║ 🎯 流程控制               ║
║               ║ 🔧 產品組裝           ║ ✅ 品質驗收               ║
╠═══════════════╬═══════════════════════╬═══════════════════════════╣
║ 🏛️ Building   ║ 📦 封裝成品資料       ║ 🏗️ 建造過程管理          ║
║   (建築成品)  ║ 🔍 提供查詢介面       ║ ✏️ 後續修改               ║
║               ║ 🔒 保證不可變性       ║ 📊 施工狀態追蹤           ║
╚═══════════════╩═══════════════════════╩═══════════════════════════╝

                    🎨 Builder 模式的美學
    ┌────────────────────────────────────────────────────────────┐
    │                                                            │
    │  「同一套舞譜,不同的舞者」                                │
    │                                                            │
    │  🎼 Director = 指揮家,掌握節拍與順序                      │
    │  💃 Builder = 舞者,詮釋每個動作的風格                     │
    │  🎭 Product = 完美演出,一次定型永不更改                   │
    │                                                            │
    │  ✨ 核心價值:流程標準化 + 實作個性化                      │
    │                                                            │
    └────────────────────────────────────────────────────────────┘

4. 實戰檢查清單 ✅

🔍 Builder 模式健康檢查

╔══════════════════════════════════════════════════════════════╗
║                     ✅ 良好實踐                              ║
╠══════════════════════════════════════════════════════════════╣
║ 🎯 Director 只呼叫介面方法,不關心具體實作                   ║
║ 🔒 get_product() 回傳不可變物件                             ║
║ 🔄 get_product() 呼叫後自動重置 Builder                     ║
║ 📋 Builder 介面完整定義所有建造步驟                         ║
║ 🧪 同一套測試腳本能驗證所有 Builder 變體                    ║
╚══════════════════════════════════════════════════════════════╝

╔══════════════════════════════════════════════════════════════╗
║                     🚨 危險信號                              ║
╠══════════════════════════════════════════════════════════════╣
║ ⚠️ Director 出現 isinstance() 檢查具體 Builder              ║
║ 💥 產品物件交付後還能被外部修改                             ║
║ 🔴 連續呼叫 get_product() 得到不一致結果                    ║
║ 🌪️ Builder 步驟之間有隱藏的狀態依賴                         ║
║ 📈 Builder 類別數量爆炸式增長                               ║
╚══════════════════════════════════════════════════════════════╝

上一篇
Day 4:供應商聯盟的智慧——Abstract Factory 一次搞定成套風格!
下一篇
Day 6:樣板局——複製勝於創建,但魔鬼藏在深拷貝裡
系列文
Codetopia 新手日記:設計模式與原則的 30 天學習之旅6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言