iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0
Software Development

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

Day 10:裝修加料——Decorator:不動原始物,疊上新能力

  • 分享至 

  • xImage
  •  

Codetopia 創城記 (10)|裝修加料——Decorator:不動原始物,疊上新能力

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

Codetopia 的年度晚會即將拉開序幕,內容策展英雄 Mira 和整合工程師 Ken 正在做最後的系統檢查。

就在倒數十分鐘,採購專員 Mia 像一陣風一樣衝進了控制室,遞來一份剛生效的資安條款:「緊急狀況!所有對外公告,現在開始都必須加上數位簽名!部分內容需要加密,而且有些嘉賓資訊,必須在晚上九點後才能顯示!」

Ken 的眉頭鎖得更深了。現有的 SingleNoticeNoticeGroup 是城市資訊系統的基石,穩定運行了數個月,總設計師早就下令:「核心類別,一個字都不准動!」

Mira 看著既有的 publish 流程,腦中一片混亂。難道要在最後關頭,用一堆 if-else 來硬幹嗎?這不僅是技術災難,更是對優雅架構的背叛。時間,分秒必爭。如何在不修改核心程式碼的前提下,為既有的公告物件「穿上」這些新功能?💡

2. 術語卡 🧭

  • Decorator(裝飾者):以相同介面包裝目標物,在不改動原類別的前提下,動態地為其疊加行為與責任。

  • Middleware / Filter(中介軟體/過濾器):在企業整合模式(EIP)或事件驅動架構(EDA)中,對應於一連串可組合的前後置處理器,形成裝飾鏈。

  • Wrapper Agent(包裝代理):在多代理系統(MAS)中,包裹既有代理以擴充其能力,例如加上簽名、節流或審計功能。

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

面對壓力,Mira 決定先用「最直覺」的方式來解決問題。「不就是多了幾個條件嗎?加幾個旗標就好了!」

於是,一段充滿「味道」的程式碼誕生了:

# 用於示範的簡化反例
from datetime import datetime
# ❌ 反例:旗標地獄 + 違反開放封閉原則
def publish_notice(notice, driver, encrypt=False, sign=False, after9pm=False):
    # 違反單一職責,把所有邏輯混在一起
    if after9pm and datetime.now().hour < 21:
        print("DEBUG: 時間未到,公告被悄悄吞掉...")
        return

    payload = {"title": notice.title, "body": notice.body}

    if sign:
        # 簽名邏輯寫死在此
        payload["sig"] = naive_sign(payload)

    if encrypt:
        # 加密邏輯也寫死在此
        payload["body"] = cheap_encrypt(payload["body"])

    driver.send(payload) # 明天如果要加上「壓縮」功能?那只好再來一個 if ...

砰! 這段程式碼剛寫完,Ken 就忍不住拍了桌子。「Mira,妳這是要把我們推進火坑啊!」

  • 問題點一:違反開放封閉原則。每增加一個新功能(壓縮、追蹤 ID),都必須回來修改這個核心函式,風險極高。

  • 問題點二:組合與順序災難。「先簽名再加密」和「先加密再簽名」的結果天差地遠,用旗標根本無法靈活調整順序。

  • 問題點三:測試地獄。要測試所有旗標的組合,根本是一場永無止境的噩夢。

這條路,顯然是死胡同。它完美展示了旗標地獄、子類爆炸、順序災難等問題——而這些,恰好都是 Decorator 模式專治的症狀。⚠️

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

就在兩人一籌莫展之際,總設計師的身影出現在門口。他看了一眼程式碼,笑著說:「別慌,我們不是要修改房子,只是需要幫它做點『裝修』。」

這就是 Decorator(裝飾者)模式 的核心思想:用一個擁有相同介面的物件,去包裹(wrap)另一個物件,從而在不改變其原始結構的情況下,疊加上新的能力。 就像俄羅斯娃娃一樣,一層套一層,每一層都增加一點新功能。

何時用 (When to Use)

  • 想在不修改原始類別的情況下,為物件動態添加功能時。特別適用於非功能性需求(NFR),如日誌、快取、權限、簽名、加密等。

  • 當這些功能可以自由組合,且組合的順序很重要時。(例如:先壓縮 -> 再加密 -> 最後簽名)。

  • 當你需要逐步為一個簡單的物件增加複雜度,同時又想避免產生大量功能混雜的子類時

何時不要用 (When NOT to Use)

  • 需求是根本性的介面轉換或型別變更 → 這時候你該先考慮 Adapter(轉接器) 模式,它的職責是「翻譯」介面,而非「增強」功能。

  • 需求是控制物件的存取、延遲載入或遠端代理Proxy(代理) 模式更貼切,它關注的是「控制」而非「裝飾」。

  • 只需要一個簡單、全域性的行為,且不需要動態組合 → 可能用 Facade(外觀) 統一入口,或在 Pipeline 中加入一個固定步驟就足夠了,不必動用 Decorator 這麼靈活的結構。

5. 導播切景 (表格+兩張 Mermaid)

導播,鏡頭拉一下!讓我們從三個不同的視角來看看「裝修」這件事在 Codetopia 是如何運作的。

視角 觀念/模式 在 Codetopia 的說法
微觀 (GoF) Component / ConcreteComponent / Decorator / ConcreteDecorator NoticeUnit / SingleNotice / NoticeDecorator / (Sign/Encrypt/After9pm)
微觀 (GoF) Component / ConcreteComponent / Decorator / ConcreteDecorator NoticeUnit / SingleNotice / NoticeDecorator / (Sign/Encrypt/After9pm)
中觀 (EIP/EDA) Filter/Middleware Chain (過濾器/中介軟體鏈) 簽名 → 加密 → 壓縮 → 重試 的處理管線
宏觀 (MAS) Wrapper Agent (包裝代理) 一個「審計代理」包裹著一個「投放代理」,來記錄所有發送的訊息

Mermaid|類圖(微觀 GoF 結構)

這張圖揭示了 Decorator 的核心結構:裝飾者 (NoticeDecorator) 和被裝飾者 (SingleNotice) 都繼承自同一個介面 (NoticeUnit),並且裝飾者內部還會「持有」一個被裝飾的物件。

https://ithelp.ithome.com.tw/upload/images/20250924/20178500NwQPxeRlAe.png

Mermaid|時序圖(中觀 Filter/Middleware 流程)

這張圖展示了一條由各種 Driver 裝飾器組成的處理鏈。請求從最外層的裝飾器開始,一路向內傳遞,直到最終的 ConsoleDriver

https://ithelp.ithome.com.tw/upload/images/20250924/20178500fLVhY5rjSK.png

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

「看好了,」總設計師邊說邊在白板上寫下了程式碼,「我們可以兵分兩路來裝飾:一路裝飾『通路』(Driver),負責 I/O 和安全;另一路裝飾『內容』(Notice),負責業務邏輯。」

# === 1. 共用協定與環境設定 ===
import hmac, hashlib, os, json, base64
from typing import Protocol, Dict, Any, Optional, Callable, runtime_checkable
from datetime import datetime
from zoneinfo import ZoneInfo

# 從環境變數讀取密鑰,提供一個預設值
SECRET_KEY = os.environ.get("APP_HMAC_KEY", "default-secret-key-for-demo").encode()

# === 2. Driver 相關定義 ===
@runtime_checkable
class DisplayDriver(Protocol):
    def send(self, payload: Dict[str, Any]) -> None: ...

class ConsoleDriver:
    def send(self, payload: Dict[str, Any]) -> None:
        print(f"[SINK] >> {json.dumps(payload, ensure_ascii=False)}")

class DriverDecorator(DisplayDriver):
    def __init__(self, inner: DisplayDriver): self.inner = inner
    def send(self, payload: Dict[str, Any]) -> None: self.inner.send(payload)

class SignDriver(DriverDecorator):
    """簽名裝飾器:使用 HMAC-SHA256 計算簽名"""
    def send(self, payload: Dict[str, Any]) -> None:
        # 註:簽名計算基於「加入 sig 前」的 payload。
        # 若在 SignDriver 之後還會增刪欄位(不建議),需將簽名移至最外層。
        signature = hmac.new(SECRET_KEY, json.dumps(payload, sort_keys=True).encode(), hashlib.sha256).hexdigest()
        signed_payload = dict(payload, sig=signature)
        super().send(signed_payload)

class EncryptDriver(DriverDecorator):
    """加密裝飾器:(範例) 將 body 內容用 Base64 編碼"""
    def send(self, payload: Dict[str, Any]) -> None:
        encrypted_payload = dict(payload)
        if "body" in encrypted_payload and isinstance(encrypted_payload["body"], str):
            encrypted_payload["body"] = base64.b64encode(encrypted_payload["body"].encode()).decode()
            encrypted_payload["enc"] = "b64"  # 註記演算法
        super().send(encrypted_payload)

class RetryDriver(DriverDecorator):
    """重試裝飾器:當發生錯誤時,最多重試指定次數"""
    def __init__(self, inner: DisplayDriver, times: int = 3):
        super().__init__(inner)
        self.times = times
    def send(self, payload: Dict[str, Any]) -> None:
        last_error: Optional[Exception] = None
        for attempt in range(self.times):
            try:
                print(f"[Decorator Check] >> Attempt {attempt + 1}/{self.times}...")
                return super().send(payload)
            except Exception as e:
                last_error = e
        raise last_error or RuntimeError("Send failed after multiple retries")

# === 3. Notice 相關定義 ===
class NoticeUnit(Protocol):
    def publish(self, driver: DisplayDriver) -> None: ...

class SingleNotice:
    def __init__(self, title: str, body: str): self.title, self.body = title, body
    def publish(self, driver: DisplayDriver) -> None: driver.send({"title": self.title, "body": self.body})

class NoticeDecorator(NoticeUnit):
    def __init__(self, inner: NoticeUnit): self.inner = inner
    def publish(self, driver: DisplayDriver) -> None: self.inner.publish(driver)

class After9pm(NoticeDecorator):
    """時間限制裝飾器:注入時鐘以利測試"""
    def __init__(self, inner: NoticeUnit, clock: Callable[[], datetime] = lambda: datetime.now(ZoneInfo("Asia/Taipei"))):
        super().__init__(inner)
        self.clock = clock
    def publish(self, driver: DisplayDriver) -> None:
        if self.clock().hour >= 21:
            print("[Decorator Check] >> After 9 PM, proceeding...")
            self.inner.publish(driver)
        else:
            print("[Decorator Check] >> Before 9 PM, skipped.")
            driver.send({"status": "skipped", "reason": "time_limit"})

# --- 4. 使用場景:自由組合 ---
basic_notice = SingleNotice("舞台 A 更新", "嘉賓加碼一首安可曲")

# 需求一:廣場大螢幕,先加密、再簽名,並帶有重試機制
# 關鍵:裝飾器「由外向內」執行。最外層的 RetryDriver 會先執行。
square_driver = RetryDriver(EncryptDriver(SignDriver(ConsoleDriver())), times=3)

# 需求二:App 推播,只需簽名,且 21:00 後才放行
app_driver = SignDriver(ConsoleDriver())
test_time_before = datetime(2025, 9, 24, 20, 59, tzinfo=ZoneInfo("Asia/Taipei"))
test_time_after = datetime(2025, 9, 24, 21, 0, tzinfo=ZoneInfo("Asia/Taipei"))

app_notice_before = After9pm(basic_notice, clock=lambda: test_time_before)
app_notice_after = After9pm(basic_notice, clock=lambda: test_time_after)


print("--- 模擬發送到「廣場大螢幕」(重試->加密->簽名)---")
basic_notice.publish(square_driver)

print("\n--- 模擬發送到「App」(20:59,應被跳過)---")
app_notice_before.publish(app_driver)

print("\n--- 模擬發送到「App」(21:00,應成功發送)---")
app_notice_after.publish(app_driver)

🔒 Security Note

本文中的程式碼範例主要用於教學,旨在闡明 Decorator 模式的結構。

  • 加密:Base64 是一種編碼而非加密,無法保護資料機密性。在真實世界的應用中,請務必使用如 AES-GCMlibsodium 等經過驗證的對稱加密函式庫。

  • 簽名:範例中的 HMAC-SHA256 是確保訊息完整性與來源驗證的良好起點。在某些場景下,也可以考慮使用如 Ed25519 等數位簽章演算法。

  • 產線建議:實務上,建議的處理順序為 Encrypt -> Sign(簽署加密後的密文)。金鑰與 IV 應透過 KMS 等金鑰管理服務管理;伺服器端應在解析內容前,優先驗證簽章。

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

  • EIP/EDA (中觀):Decorator 模式的思想,完美對應到訊息處理中的 Filter/Middleware Chain。每一個 Decorator 就像是管線 (Pipeline) 中的一個過濾器,對流經的訊息進行加工。

  • MAS (宏觀):在多代理系統中,我們可以用 Wrapper Agent 來擴充既有代理的功能。例如,一個普通的「任務執行代理」可以被一個「審計包裝代理」包裹,後者會記錄所有執行的任務和結果。

  • 部署位置:像 Driver 這樣的裝飾器鏈,可以靈活地部署在發布者(Publisher)端,也可以部署在通道配接器(Channel Adapter)上,取決於你的架構設計。

8. ✅ 回到現場 (驗收通過)

看著新架構,Ken 迅速完成了部署。

  • Given: 既有的 SingleNoticeConsoleDriver,程式碼完全未被修改。

  • When:

    • 為「廣場大螢幕」的通路組合 RetryDriver(EncryptDriver(SignDriver(ConsoleDriver())))

    • 為「App 推播」的內容組合 After9pm(SingleNotice(...), clock=...)

  • Then:

    • 所有新功能都透過裝飾器疊加上去,並且可以任意重排順序。

    • 廣場螢幕:公告內容先被加密,然後整個加密後的 payload 被簽名,最後由重試機制包裹,符合資安與穩定性要求。

    • App 推播:在 20:59 時,公告被 After9pm 裝飾器攔截並回報 skipped;調整時鐘到 21:00 後,公告成功發布。

    • 未來若要新增「壓縮」功能,只需再寫一個 CompressionDriver 並插入鏈中即可。

晚會的時鐘敲響,第一則公告準時出現在大螢幕上。危機解除!✅

9. 測試指北 (Testing Guide)

一個可擴展的裝飾器架構,必須有穩固的測試來保護。

  • 契約測試 (Contract):確保每一個 Decorator 都嚴格遵守 DisplayDriver 協定。若要讓 isinstance 能在執行期檢查 Protocol,記得加上 @runtime_checkable 裝飾器;否則,應依賴靜態型別檢查(如 mypy)或 Duck Typing 來驗證行為。

  • 順序測試 (Order):要驗證「先加密再簽名」的順序是否正確,可以建立一個 TraceDriver 來收集歷程,然後斷言 payload 的狀態。

    from typing import Dict, Any
    class TraceDriver(ConsoleDriver):
        def __init__(self): self.history = []
        def send(self, payload: Dict[str, Any]):
            self.history.append(payload.copy())
            super().send(payload)
    
    # 測試
    trace_driver = TraceDriver()
    EncryptDriver(SignDriver(trace_driver)).send({"body": "secret"})
    # sink_payload 是最終傳遞到最內層 Driver 的物件
    sink_payload = trace_driver.history[-1]
    # 斷言 sink_payload 包含 'enc' 和 'sig' 兩個鍵
    
  • 時間測試 (Time):對於像 After9pm 這樣與時間相關的邏輯,務必注入時鐘!這樣你才能在測試中固定時間,分別斷言 20:59(跳過)和 21:00(放行)的行為。

  • 韌性測試 (Resilience):測試 RetryDriver 時,可以注入一個會在前兩次呼叫時拋出異常的假 Driver,並断言最终呼叫成功。

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

Mira 和 Ken 看著白板上的程式碼,恍然大悟。原來功能可以像積木一樣疊加上去!現在,換你來當一次 Codetopia 的英雄了。

動手實作 🔧

  1. RateLimitDriver:請你實作一個 RateLimitDriver(limit_per_min) 裝飾器。當呼叫頻率超過設定的限制時,就暫緩發送或直接丟棄請求。

  2. 反思與比較:試著把「簽名」功能從 Driver 層搬到 Notice 層,做成 SignedNotice(notice) 這樣的裝飾器。然後比較一下,把簽名這個責任放在「內容層」和「通路層」,各自有什麼優缺點?

反模式紅旗 🚩

  • 旗標地獄:在單一流程中用 if encrypt / if sign 來控制行為,是 Decorator 模式要極力避免的。

  • 子類爆炸:如果為每種功能的組合都創建一個新的子類(例如 SignedAndEncryptedNotice),那類別的數量將會失控。

  • 裝飾順序不明:團隊必須清楚記錄或透過測試來確保「先密後簽」與「先簽後密」的行為符合預期,否則會埋下安全隱憂。

小投票 🤔

假設現在要新增一個「A/B 測試標籤」的功能,你會把它實作在哪一層?

A. Driver Decorator (通路層,所有經過的 payload 都會被一致處理)

B. Notice Decorator (內容層,更靠近資料的語意,可以針對特定內容加標籤)

在留言區留下你的選擇和一句理由吧!

結語 & 預告

不動原始物,用裝飾器疊功能;順序可換,能力可疊。

今天,我們學會了如何在不驚動既有程式碼的情況下,為其穿上華麗的新衣。但當系統內部越來越複雜,有數十個子系統需要協調時,我們又該如何提供一個簡單、統一的入口呢?

明日預告:Day 11|Facade:為複雜的城市內部系統,打造一個親民的「一站式服務窗口」!


12. 附錄:ASCII 版圖示

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

類圖(微觀 GoF 結構)

+----------------+      +------------------+
| <<interface>>  |      |  SingleNotice    |
|   NoticeUnit   |<-----|  +publish()      |
| +publish()     |      +------------------+
+----------------+
       ^
       |
+----------------+      +------------------+
| NoticeDecorator|----->|    NoticeUnit    |
| +publish()     |wraps +------------------+
+----------------+
    ^     ^    ^
    |     |    |
    |     |    |
+---+  +--+  +-+-------+
|      |     |         |
v      v     v         v
+------+  +--+--+  +---+-----+
|After9pm| |Sign |  |Encrypt  |
+--------+ +-----+  +---------+

時序圖(中觀 Filter/Middleware 流程)

Client      RetryDriver     EncryptDriver     SignDriver     ConsoleDriver
  |              |               |               |                |
  |--send()----->|               |               |                |
  |              |--send()------>|               |                |
  |              |(retry logic)  |               |                |
  |              |               |--send()------>|                |
  |              |               |(encryption)   |                |
  |              |               |               |--send()------->|
  |              |               |               |(signing)       |
  |              |               |               |                |(final output)
  |<----ack------|<--------------|--------------<|<---------------|
  |              |               |               |                |

裝飾器堆疊圖

                  +---------------+
                  |               |
                  |  BasicNotice  |
                  |               |
                  +-------+-------+
                          ^
                          | wrapped by
                 +--------+--------+
                 |                 |
                 |    After9pm     |
                 |                 |
                 +-----------------+


   +-----------------+     +-----------------+     +-----------------+     +-----------------+
   |                 |     |                 |     |                 |     |                 |
   | ConsoleDriver   |<----| SignDriver      |<----| EncryptDriver   |<----| RetryDriver     |
   |                 |     |                 |     |                 |     |                 |
   +-----------------+     +-----------------+     +-----------------+     +-----------------+
         ^                       ^                      ^                       ^
         |                       |                      |                       |
         |                       |                      |                       |
    Inner Driver           Inner Driver           Inner Driver           Outermost Driver
    (最內層執行)             (第二層執行)             (第三層執行)             (最外層先執行)

上一篇
Day 9:部件與聚合——Composite:把群組當單體操控
下一篇
Day 11:市民服務「一道門」搞定!Facade 模式的簡潔藝術
系列文
Codetopia 新手日記:設計模式與原則的 30 天學習之旅13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言