iT邦幫忙

2025 iThome 鐵人賽

DAY 3
0
Software Development

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

Day 3:市民服務櫃台的秘密武器——Factory Method 搞定千變萬化的申請單!

  • 分享至 

  • xImage
  •  

Codetopia 創城記 (3)|市民服務櫃台的秘密武器——Factory Method 搞定千變萬化的申請單!

IThome 鐵人賽 設計模式 Factory Method Codetopia

1. 城市事件 (故事開場 & 痛點)

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):開閉原則,軟體實體應對擴充開放,對修改關閉。

2. 違章建築味道 (Anti-pattern)

在 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」的高風險循環中。

3. 今日工法 (Factory Method)

為了解決這場混亂,Ava 引入了今天的工法——工廠方法模式 (Factory Method)。這是一種創生型模式,它的核心思想是:

在父類別中定義一個用於建立物件的介面(工廠方法),但將實際要建立哪種物件的決定權,延遲到子類別中去實現。

用 Codetopia 的話來說就是:

  • 市政服務櫃台 (ServiceDesk) 訂下一套標準處理流程 process(),這套流程是固定不變的。

  • 流程中有一個步驟叫做 create_clerk()(建立承辦員),ServiceDesk 本身並不知道要建立誰,它只負責呼叫這個方法。

  • 身分證櫃台 (IDDesk)稅務櫃台 (TaxDesk) 等具體的櫃台,繼承標準流程,並各自覆寫 (override) create_clerk() 方法,明確指出自己要建立的是 IDClerk 還是 TaxClerk

這樣一來,process() 主流程就和具體的承辦員類別解耦了!要新增業務?簡單,新增一個新的櫃台子類別和對應的承辦員類別就好,完全不必碰觸原本穩定的主流程。

何時用 (When to Use)

  • 當一個類別無法預知它需要建立的物件屬於哪個類別時。 正如 ServiceDesk 不想知道天下有多少種 Clerk

  • 當你希望子類別可以指定它所建立的物件類型時。 這給了系統極大的擴充彈性。

  • 當你想將產品建立的邏輯與產品使用的邏輯分離時。 ServiceDesk 負責「使用」Clerk(呼叫 handle 方法),而具體的 Desk 子類負責「建立」Clerk

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

  • 物件的建立邏輯非常簡單,且未來不太可能改變。 如果你的系統永遠只有一兩種 Clerk,直接 new 或許更直觀。引入模式反而會增加不必要的複雜度(Over-engineering)。

  • 當你需要的是一整組、一系列相關或相互依賴的產品時。 例如,你需要一套「現代風格」的街燈、長椅和垃圾桶。這時,你該考慮的是我們明天的工法——抽象工廠模式 (Abstract Factory)

4. 三層並置圖 (視覺化藍圖)

如何閱讀三層並置圖?

  • 目的:把同一概念在三個縮放層次對齊,避免只停在類別圖而忽略「訊息怎麼流」、「角色怎麼協作」。
  • 順序:① 微觀 GoF → ② 中觀 訊息/事件 → ③ 宏觀 MAS。
視角 觀念/模式 在 Codetopia 的說法
微觀 (GoF) Factory Method (父類定流程,子類決定產品) 服務櫃台基類 → 身分證/稅務櫃台
中觀 (訊息/事件) Content-based Router / 動態派工 (Actor: Spawner) 案件分派器 → 依案件內容,產生專職處理者
宏觀 (MAS) 依任務屬性指派能力匹配的代理 市民任務 → 匹配到具備「身分證處理能力」的代理

三層並置圖實踐檢核清單:

  • 微觀檢核:你的核心流程 (ServiceDesk.process) 是否穩定,不需要因為新增產品類型而修改?
  • 中觀檢核:你的案件分派規則(路由邏輯)是否由資料(如字典或註冊表)驅動,而不是散落在各處的 if/elif
  • 宏觀檢核:你是否能清晰地定義出一張「任務-能力」對照表,將收到的請求映射到對應的處理單元?

微觀:櫃台與承辦員的結構圖 (Class Diagram)

(若下方圖形無法正常渲染,請參考文末提供的 ASCII 版本)

https://ithelp.ithome.com.tw/upload/images/20250917/20178500RkAPvT8C1y.png

中觀:案件分派流程 (Flowchart)

(若下方圖形無法正常渲染,請參考文末提供的 ASCII 版本)

https://ithelp.ithome.com.tw/upload/images/20250918/20178500A4Sp756em2.png

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

現在,讓我們用更專業、更穩固的 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 裝飾器,真正實現了對修改關閉、對擴充開放。櫃台前的混亂,終於平息。

6. 測試指北 (Testing Guide)

今天我們在主程式中直接執行了驗收測試,但在一個真實的專案中,我們會使用像 pytest 這樣的測試框架來管理。

  • 參數化測試:你可以使用 @pytest.mark.parametrize 來一次測試所有已知的 TicketType,讓測試案例更簡潔。
  • 日誌驗證:如範例所示,你可以暫時重導向 sys.stdout 來捕獲程式的日誌輸出,並斷言其中是否包含關鍵資訊(如 eventtype)。這對於驗證可觀測性至關重要。
  • 依賴隔離:在測試 ModernDispatcher 時,你甚至可以 mock REGISTRY 字典,只註冊你當前想測試的 Desk,從而實現更細粒度的單元測試。

7. 作業題 (動手實踐)

  1. 新增櫃台:請你親自動手,為 Codetopia 新增一個「寵物登記 (PET)」業務。你需要:
    1. TicketType Enum 中增加 PET
    2. 建立 PetClerkPetDesk 類別。
    3. PetDesk 類別前加上 @register(TicketType.PET)
    4. 在驗收腳本中加入對 PETassert,然後重新執行,見證奇蹟!
  2. 思考快取策略:在範例中,BusinessDesk 使用了類別層級快取。這種快取在多執行緒環境下安全嗎?如果不安全,你會如何修改?(提示:threading.Lock)
  3. 反模式紅旗 🚩:請思考,如果在 IDDeskcreate_clerk 方法裡,你又看到了 if/switch 根據 ticket 的某個子類型來決定回傳 MaleIDClerkFemaleIDClerk,這聞起來是什麼「壞味道」?這暗示了什麼樣的設計問題?

8. 升維鉤子 (進階視野)

今天我們在微觀層面實作了工廠方法。當我們把視角拉高:

  • 在中觀的企業整合模式 (EIP) 中,我們實作的其實就是一個 Content-Based RouterModernDispatcher 就像一個路由器,根據訊息(ticket)的內容(type),將其派送到正確的通道(Desk 實例)。Factory Method 負責解耦「流程與產品建立」,而路由規則(REGISTRY)則透過註冊表 (Registry) 或插件化機制補完,達成完全的開閉原則。

  • Actor Model 中,ServiceDesk 的子類就像一個 SpawnerSupervisor。它接收到一個任務後,職責就是「生出」一個專門處理該任務的子 Actor (Clerk),然後將任務交給它,自己則繼續接收下一個任務。

  • 在宏觀的多代理系統 (MAS) 中,這對應了「能力匹配 (Capability Matching)」。一個任務被宣告出來,系統中的黃頁服務 (Directory Facilitator) 會去尋找哪個代理宣告了自己擁有處理這類任務的「能力」,然後將任務指派過去。

9. 結語

流程不動、承辦可換;登錄擴充、零風險。

今天,我們用工廠方法模式化解了市民服務櫃台的危機,學會了如何在不更動核心流程的前提下,優雅地擴充系統功能。

但是,如果今天市長辦公室下令,所有櫃台都要換上「科技未來風」的整套辦公用品(表單、印章、號碼牌),或是另一套「溫暖木質風」的用品,我們該怎麼辦?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]


上一篇
Day 2:獨一無二的市長辦公室!—— Singleton 模式的權力與詛咒
系列文
Codetopia 新手日記:設計模式與原則的 30 天學習之旅3
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言