IThome 鐵人賽
設計模式
Factory Method
Codetopia
Codetopia 市政廳迎來了最混亂的一個星期一。市民服務櫃台前,人聲鼎沸,怨聲載道。補發身分證的、申報稅務的、登記寵物的、申請臨時停車許可的市民,全都擠在同一個入口。
前台主管 Ava 正焦頭爛額地盯著眼前的案件分派器 (dispatcher)。系統的核心是一段巨大無比的 if/elif/else
程式碼,像一條臃腫的貪食蛇,吞噬著所有進來的申請單(ticket)。
「搞什麼!我的臨時停車許可怎麼會派到稅務櫃檯去?」一位貨車司機氣沖沖地吼道。
Ava 一查,臉都綠了。原來是某位工程師為了修正一個 bug,不小心動到了 if
判斷的順序,導致案件分流大出包。媒體的閃光燈已經在外頭蠢蠢欲動,這起「錯件門」眼看就要登上明日頭條。
雪上加霜的是,市長辦公室(就是我們昨天才蓋好的那個 Singleton!)在此時發布了緊急公告:為刺激經濟,本週起將大幅簡化「營業登記」流程。這意味著,服務櫃台馬上要湧入海量的「營業登記 (BUSINESS
)」這種全新的案件類型!
Ava 望著那段牽一髮動全身的 if/elif
程式碼,陷入了絕望。難道每次新增業務,都要冒著搞垮整個系統的風險,去動這條巨蛇嗎?她對著團隊下達了死命令:「我們必須找到一種方法,讓主要派單流程永遠不必更動,但又能彈性地增加新的承辦員!」💡
🧭 術語卡 (今日導覽)
GoF (Gang of Four):設計模式經典,本文視為物件協作的「微觀」結構。
EIP / EDA:企業整合模式 / 事件驅動架構,本文視為訊息流動的「中觀」視角。
MAS (Multi-Agent Systems):多代理系統,本文視為角色協作的「宏觀」框架。
OCP (Open/Closed Principle):開閉原則,軟體實體應對擴充開放,對修改關閉。
在 Ava 找到解決方案前,服務櫃台的案件分派器 (dispatcher
) 就是一棟典型的違章建築。它看起來能運作,但內部結構極度脆弱,可讀性、可維護性、可測試性都趨近於零。
讓我們來看看這段充滿「壞味道」的程式碼,並用一組驗收測試證明它有多麼不堪一擊:
# 違章建築:一個函式包山包海,用巨大的 if/elif/else 分派任務
def monolithic_process_ticket(ticket):
ticket_type = ticket.get("type")
if ticket_type == "ID":
handler = "ID 專員"
elif ticket_type == "TAX":
handler = "稅務專員"
elif ticket_type == "PET":
handler = "寵物登記專員"
elif ticket_type == "PARKING":
handler = "臨停許可專員"
else:
handler = "綜合業務專員(可能派錯了!)"
return handler
# 為了讓新舊工法能跑「同一套」驗收,我們把它包裝成一個分派器類別
class LegacyDispatcher:
def dispatch(self, ticket):
return monolithic_process_ticket(ticket)
# 驗收測試腳本 (注意:這個函式將會被重複使用)
def run_acceptance_test(dispatcher):
print("--- 執行驗收測試 ---")
ticket_id = {"type": "ID"}
ticket_tax = {"type": "TAX"}
ticket_new_business = {"type": "BUSINESS"} # 新增的營業登記業務
# 執行分派
handler_id = dispatcher.dispatch(ticket_id)
handler_tax = dispatcher.dispatch(ticket_tax)
handler_business = dispatcher.dispatch(ticket_new_business)
# 斷言:這組測試注定會失敗!
try:
assert handler_id == "ID 專員"
assert handler_tax == "稅務專員"
assert handler_business == "營業登記專員"
print("\n✅ 驗收通過!一切正常... (咦?)")
except AssertionError:
print(f"\n❌ 驗收失敗!新增的 'BUSINESS' 案件被錯誤地指派給了 '{handler_business}'。")
# --- 違章建築現場驗收 ---
legacy_dispatcher = LegacyDispatcher()
run_acceptance_test(legacy_dispatcher)
看到了嗎?每新增一種業務(BUSINESS
),我們就必須去修改 monolithic_process_ticket
這個核心函式。這不僅違反了開閉原則 (OCP),也讓測試處於「新增 case → 修改核心 → re-run all」的高風險循環中。
為了解決這場混亂,Ava 引入了今天的工法——工廠方法模式 (Factory Method)。這是一種創生型模式,它的核心思想是:
在父類別中定義一個用於建立物件的介面(工廠方法),但將實際要建立哪種物件的決定權,延遲到子類別中去實現。
用 Codetopia 的話來說就是:
市政服務櫃台 (ServiceDesk
) 訂下一套標準處理流程 process()
,這套流程是固定不變的。
流程中有一個步驟叫做 create_clerk()
(建立承辦員),ServiceDesk
本身並不知道要建立誰,它只負責呼叫這個方法。
身分證櫃台 (IDDesk
)、稅務櫃台 (TaxDesk
) 等具體的櫃台,繼承標準流程,並各自覆寫 (override) create_clerk()
方法,明確指出自己要建立的是 IDClerk
還是 TaxClerk
。
這樣一來,process()
主流程就和具體的承辦員類別解耦了!要新增業務?簡單,新增一個新的櫃台子類別和對應的承辦員類別就好,完全不必碰觸原本穩定的主流程。
✅ 當一個類別無法預知它需要建立的物件屬於哪個類別時。 正如 ServiceDesk
不想知道天下有多少種 Clerk
。
✅ 當你希望子類別可以指定它所建立的物件類型時。 這給了系統極大的擴充彈性。
✅ 當你想將產品建立的邏輯與產品使用的邏輯分離時。 ServiceDesk
負責「使用」Clerk
(呼叫 handle
方法),而具體的 Desk
子類負責「建立」Clerk
。
⛔ 物件的建立邏輯非常簡單,且未來不太可能改變。 如果你的系統永遠只有一兩種 Clerk
,直接 new
或許更直觀。引入模式反而會增加不必要的複雜度(Over-engineering)。
⛔ 當你需要的是一整組、一系列相關或相互依賴的產品時。 例如,你需要一套「現代風格」的街燈、長椅和垃圾桶。這時,你該考慮的是我們明天的工法——抽象工廠模式 (Abstract Factory)。
如何閱讀三層並置圖?
視角 | 觀念/模式 | 在 Codetopia 的說法 |
---|---|---|
微觀 (GoF) | Factory Method (父類定流程,子類決定產品) | 服務櫃台基類 → 身分證/稅務櫃台 |
中觀 (訊息/事件) | Content-based Router / 動態派工 (Actor: Spawner) | 案件分派器 → 依案件內容,產生專職處理者 |
宏觀 (MAS) | 依任務屬性指派能力匹配的代理 | 市民任務 → 匹配到具備「身分證處理能力」的代理 |
三層並置圖實踐檢核清單:
ServiceDesk.process
) 是否穩定,不需要因為新增產品類型而修改?if/elif
?(若下方圖形無法正常渲染,請參考文末提供的 ASCII 版本)
(若下方圖形無法正常渲染,請參考文末提供的 ASCII 版本)
現在,讓我們用更專業、更穩固的 Python 程式碼,將這套優雅的工法實作出來。 (註:以下範例使用 Python 3.10+ 的型別提示語法)
import json
import io
import sys
from abc import ABC, abstractmethod
from enum import Enum
from dataclasses import dataclass
from typing import Protocol, runtime_checkable, ClassVar
# --- 1. 定義資料結構與契約 (Protocols) ---
class TicketType(Enum):
ID = "ID"
TAX = "TAX"
BUSINESS = "BUSINESS"
@dataclass
class Ticket:
type: TicketType
payload: dict
@runtime_checkable
class SupportsDispatch(Protocol):
def dispatch(self, ticket_data: dict) -> str: ...
# --- 2. 定義產品 (Clerks) 與抽象工廠 (ServiceDesk) ---
class Clerk(ABC):
@abstractmethod
def handle(self, ticket: Ticket) -> str: ...
class IDClerk(Clerk):
def handle(self, ticket: Ticket) -> str: return "ID 專員"
class TaxClerk(Clerk):
def handle(self, ticket: Ticket) -> str: return "稅務專員"
class BusinessClerk(Clerk):
def handle(self, ticket: Ticket) -> str: return "營業登記專員"
class ServiceDesk(ABC):
def process(self, ticket: Ticket) -> str:
clerk = self.create_clerk(ticket)
handler = clerk.handle(ticket)
return handler
@abstractmethod
def create_clerk(self, ticket: Ticket) -> Clerk: ...
# --- 3. 登錄式擴充:實現真正的 OCP ---
REGISTRY: dict[TicketType, type[ServiceDesk]] = {}
def register(ticket_type: TicketType):
def decorator(cls: type[ServiceDesk]):
REGISTRY[ticket_type] = cls
return cls
return decorator
@register(TicketType.ID)
class IDDesk(ServiceDesk):
def create_clerk(self, ticket: Ticket) -> Clerk: return IDClerk()
@register(TicketType.TAX)
class TaxDesk(ServiceDesk):
def create_clerk(self, ticket: Ticket) -> Clerk: return TaxClerk()
@register(TicketType.BUSINESS)
class BusinessDesk(ServiceDesk):
_cached_clerk: ClassVar[Clerk | None] = None # 使用類別層級快取
def create_clerk(self, ticket: Ticket) -> Clerk:
if BusinessDesk._cached_clerk is None:
print("(日誌: BusinessClerk 為昂貴資源,首次建立並快取)")
BusinessDesk._cached_clerk = BusinessClerk()
return BusinessDesk._cached_clerk
# --- 4. 現代化的分派器 ---
class ModernDispatcher:
def dispatch(self, ticket_data: dict) -> str:
raw_type = ticket_data.get("type")
try:
# 支援字串輸入並正規化
tt_value = str(raw_type).upper() if raw_type else ""
ticket_type = TicketType(tt_value)
desk_cls = REGISTRY.get(ticket_type)
if not desk_cls:
raise ValueError(f"未知的類型 '{raw_type}'")
ticket = Ticket(type=ticket_type, payload=ticket_data)
desk_instance = desk_cls()
handler = desk_instance.process(ticket)
log_entry = {"event": "dispatch_success", "type": ticket_type.value, "handler": handler}
print(f"日誌: {json.dumps(log_entry, ensure_ascii=False)}")
return handler
except (ValueError, KeyError) as e:
log_entry = {"event": "dispatch_failed", "type": raw_type, "error": str(e)}
print(f"日誌: {json.dumps(log_entry, ensure_ascii=False)}")
raise ValueError(f"案件分派失敗:無效或未知的類型 '{raw_type}'") from e
# --- 5. 驗收! ---
def run_full_acceptance_suite(dispatcher: SupportsDispatch):
# 正向案例
print("\n--- 執行正向驗收案例 ---")
run_acceptance_test(dispatcher)
# 負向案例
print("\n--- 執行負向驗收案例 (未知類型) ---")
original_stdout = sys.stdout
sys.stdout = captured_stdout = io.StringIO()
try:
dispatcher.dispatch({"type": "UNKNOWN"})
assert False, "應拋出 ValueError 但未拋出"
except ValueError as e:
assert "未知的類型" in str(e)
print("✅ 測試通過:未知類型已按預期拋出錯誤。")
# 驗證日誌
log_output = captured_stdout.getvalue()
assert '"event": "dispatch_failed"' in log_output
assert '"type": "UNKNOWN"' in log_output
print("✅ 測試通過:失敗日誌已按預期寫入。")
finally:
sys.stdout = original_stdout
# --- 執行驗收 ---
print("--- 違章建築現場驗收 ---")
run_acceptance_test(LegacyDispatcher())
print("\n\n--- 採用 Factory Method 工法進行驗收 ---")
run_full_acceptance_suite(ModernDispatcher())
劇情收束:完美!我們沿用了完全相同的 run_full_acceptance_suite
函式,不僅驗證了新業務的擴充,還確保了對未知業務的防禦能力和日誌記錄。僅僅是傳入了新的分派器實作,所有測試案例都順利通過。Ava 的團隊不再需要去碰觸那條可怕的貪食蛇,新增業務只需要新增一個檔案並加上一行 @register
裝飾器,真正實現了對修改關閉、對擴充開放。櫃台前的混亂,終於平息。
今天我們在主程式中直接執行了驗收測試,但在一個真實的專案中,我們會使用像 pytest
這樣的測試框架來管理。
@pytest.mark.parametrize
來一次測試所有已知的 TicketType
,讓測試案例更簡潔。sys.stdout
來捕獲程式的日誌輸出,並斷言其中是否包含關鍵資訊(如 event
、type
)。這對於驗證可觀測性至關重要。ModernDispatcher
時,你甚至可以 mock REGISTRY
字典,只註冊你當前想測試的 Desk
,從而實現更細粒度的單元測試。PET
)」業務。你需要:
TicketType
Enum 中增加 PET
。PetClerk
和 PetDesk
類別。PetDesk
類別前加上 @register(TicketType.PET)
。PET
的 assert
,然後重新執行,見證奇蹟!BusinessDesk
使用了類別層級快取。這種快取在多執行緒環境下安全嗎?如果不安全,你會如何修改?(提示:threading.Lock
)IDDesk
的 create_clerk
方法裡,你又看到了 if/switch
根據 ticket
的某個子類型來決定回傳 MaleIDClerk
或 FemaleIDClerk
,這聞起來是什麼「壞味道」?這暗示了什麼樣的設計問題?今天我們在微觀層面實作了工廠方法。當我們把視角拉高:
在中觀的企業整合模式 (EIP) 中,我們實作的其實就是一個 Content-Based Router。ModernDispatcher
就像一個路由器,根據訊息(ticket)的內容(type),將其派送到正確的通道(Desk 實例)。Factory Method 負責解耦「流程與產品建立」,而路由規則(REGISTRY
)則透過註冊表 (Registry) 或插件化機制補完,達成完全的開閉原則。
在 Actor Model 中,ServiceDesk
的子類就像一個 Spawner 或 Supervisor。它接收到一個任務後,職責就是「生出」一個專門處理該任務的子 Actor (Clerk
),然後將任務交給它,自己則繼續接收下一個任務。
在宏觀的多代理系統 (MAS) 中,這對應了「能力匹配 (Capability Matching)」。一個任務被宣告出來,系統中的黃頁服務 (Directory Facilitator) 會去尋找哪個代理宣告了自己擁有處理這類任務的「能力」,然後將任務指派過去。
流程不動、承辦可換;登錄擴充、零風險。
今天,我們用工廠方法模式化解了市民服務櫃台的危機,學會了如何在不更動核心流程的前提下,優雅地擴充系統功能。
但是,如果今天市長辦公室下令,所有櫃台都要換上「科技未來風」的整套辦公用品(表單、印章、號碼牌),或是另一套「溫暖木質風」的用品,我們該怎麼辦?Factory Method 一次只能建立一個產品,面對「成套」的需求,它就顯得力不從心了。
模式 | 處理對象 | 產出 |
---|---|---|
Factory Method | 單一產品 | 一個 Clerk |
Abstract Factory | 產品家族 | 一整套風格相容的 (表單, 印章, ...) |
明天,我們將挑戰更複雜的創生任務。敬請期待 Day 4:供應商聯盟的智慧——Abstract Factory 一次搞定成套風格!
附錄:ASCII 版圖示
微觀 Class Diagram (簡化版)
[ServiceDesk] (abstract)
| process(ticket)
| create_clerk(ticket) (abstract)
+----|> [IDDesk]
| '-> creates [IDClerk]
+----|> [TaxDesk]
'-> creates [TaxClerk]
中觀 Flowchart (簡化版)
[Ticket]
|
{Lookup REGISTRY by type}
|
[Get Desk Class]
|
(Instantiate Desk)
|
[Desk.process()]
|
(creates Clerk)
|
[Result]