IThome 鐵人賽
設計模式
Composite
Codetopia
晚場活動前 30 分鐘,市府內容策展英雄 Mira (米菈) 正與時間賽跑。她必須將三則公告(交通、舞台、天氣)打包成一份 NightPack
快訊包,一次性投放到所有通路。挑戰在於,「廣場大螢幕」需要顯示整包內容,而「App 推播」卻只要一則精簡摘要。更別提,她還得支援在群組中嵌套子群組。
就在這時,對講機傳來了採購部門的緊急通知。砰!顯示設備供應商要從 Vendor-A 緊急切換到 Vendor-B。💣 結構的複雜性與輸出的多樣性,瞬間交織成了一場完美的風暴。
【驗收標準】
Given:
TrafficAlert
、EventPromo
、WeatherNotice
三則獨立公告,以及一個包含了這三則公告(並允許巢狀子群組)的NightPack
群組。When:Mira 需要對「廣場大螢幕」投放完整內容,對「App 推播」只投放摘要;同時,顯示設備的驅動程式從 Vendor-A 切換到 Vendor-B。
Then:在完全不修改任何單一公告類別的前提下,只需對
NightPack
呼叫一次publish()
,就能正確地、遞迴地將所有內容(無論是完整版還是摘要版)發送到指定的設備上。更換驅動程式(Driver)只影響輸出通道,不影響群組的結構與內容邏輯。
Composite (部件/聚合):單體與樹同介面、遞迴統一操作。它讓你將「單體物件 (Leaf)」和「物件群組 (Composite)」組合成樹狀結構,並讓客戶端能以一致的介面來對待它們。
Aggregator / Splitter (EIP):企業整合模式中的訊息處理器。「聚合器」將多則訊息合併成一則;「拆分器」則將一則訊息拆分成多則。這對應到我們將多則公告「成組」或「解包」的行為。
CampaignGroupAgent (MAS):在多代理系統中,一個「群組代理」可以統一對外接收任務,然後在內部協調、委派給各個子代理執行。
讓我們把時間倒轉回 Mira 崩潰的前一刻。她試圖手刻一個 BatchNoticeService
來解決問題。(旁白:「是的,你沒看錯,就是那個註定要加班的寫法。」)
她的程式碼很快就變成了一場災難。裡面充斥著這樣的邏輯:「如果是群組,就用 for 迴圈一個個送;如果不是,就直接送」。接著,為了應付「摘要模式」和「完整模式」,她又複製貼上了一整套 if/else
。當巢狀群組的需求一來,她只好含淚加上第二層、第三層的 for 迴圈,以及無窮無盡的 isinstance(...)
型別判斷。
# 壞味道:型別判斷地獄,完全沒有遞迴性與一致性
def publish_batch(notices, mode='full', driver=None):
for notice in notices:
# 糟糕!每次操作前都要檢查型別
if isinstance(notice, list): # 假設用 list 代表群組
# 如果是巢狀群組,就要再寫一層迴圈,惡夢開始
for sub_notice in notice:
if mode == 'full':
driver.send({"title": sub_notice.title, "body": sub_notice.body})
else: # 摘要模式
driver.send({"title": sub_notice.title})
else: # 如果是單一公告
if mode == 'full':
driver.send({"title": notice.title, "body": notice.body})
else:
driver.send({"title": notice.title})
# 巢狀一變、通路一改,核心流程就得重寫——這不是擴充,是還債。
# 開放封閉原則?在這裡,它早就被炸得粉身碎骨了。
問題點 💔
沒有共同介面:「單一公告」和「公告群組」的操作方式完全不同,呼叫端必須知道自己處理的是哪種類型。
缺乏遞迴性:巢狀結構只能靠一層又一層的迴圈硬解,程式碼醜陋且難以維護。
流程綁死型別:一旦新增一種新的群組類型,或變更通路,就必須回來修改這個核心流程,違反了開放封閉原則。
單體與樹同介面,遞迴操作(把群組當單體)——這就是 Composite (組合/部件) 模式 的精髓。總設計師的身影在人群中一閃而過,只留下一張餐巾紙,上面畫著一個簡單的樹狀結構圖。核心思想只有一個:讓「單體」與「群組」擁有相同的介面,然後讓群組的行為自然地遞迴下去!
我們抽出一個共同的 NoticeUnit
(Component) 介面,讓 SingleNotice
(Leaf) 和 NoticeGroup
(Composite) 都去實作它。對呼叫端來說,它眼中再也沒有單體和群組之分,只有 NoticeUnit
。當它呼叫 NoticeGroup
的 publish()
方法時,群組會聰明地、遞迴地去呼叫它所有子節點的 publish()
方法。
拍案! 就這樣,無論你的公告結構有多複雜,對外的操作永遠只有簡單的一行。
當你需要處理一個樹狀結構,並且希望以統一的方式操作其中的所有物件時(例如:檔案系統的目錄與檔案、UI 框架中的容器與控制項、文件的章節與段落)。
當你想讓客戶端程式碼忽略物件組合的差異,專注於共同的操作時。
如果你的結構永遠都只是一個扁平的列表,不會有巢狀,那就不需要引入這個模式的複雜性。
如果每個節點類型的操作都截然不同,而且需要強型別來區分(例如,檔案只能 read()
,目錄只能 list()
),那麼強行統一介面可能會讓介面變得臃腫。此時,可以考慮日後會介紹的 Visitor (訪問者模式),在固定的結構上外掛多種不同的操作。
導播,鏡頭拉一下!讓我們從微觀的類別結構,一路看到中觀的訊息流與宏觀的代理協作,來理解這個「把群組當單體」的優雅設計。
視角 | 觀念/模式 | 在 Codetopia 的說法 |
微觀 (GoF) | Component / Leaf / Composite | NoticeUnit / SingleNotice / NoticeGroup |
中觀 (EIP/EDA) | Aggregator / Splitter / Fan-out | 公告整併、摘要分流、群組遞送 |
宏觀 (MAS) | Group Agent / 子代理協作 | CampaignGroupAgent 委派子公告代理 |
這張圖清晰地展示了 SingleNotice
(葉節點) 和 NoticeGroup
(組合節點) 如何共同實作 NoticeUnit
介面。NoticeGroup
內部持有一組 NoticeUnit
的子節點,形成遞迴結構。
從訊息流的角度看,事件總線 (IncidentBus
) 只需對最外層的 NoticeGroup
發出一次 publish()
命令。群組內部會自動將這個命令「扇出 (Fan-out)」給它所有的子節點,無論是單體還是其他子群組。
Mira 看到設計圖後恍然大悟,立刻重構了她的程式碼。你看,現在的結構不但清晰,還加入了防呆機制,並且漂亮地解決了「摘要」問題。
from typing import Protocol, List, Dict, Any, Optional, runtime_checkable, Iterable
# 註:Python < 3.8 可改從 typing_extensions 匯入 Protocol, runtime_checkable。
# 這是昨天 Bridge 模式留下的驅動介面,今天無縫接軌
@runtime_checkable
class DisplayDriver(Protocol):
def send(self, payload: Dict[str, Any]) -> None: ...
# Component: 單體與群組的共同介面。
# @runtime_checkable 讓 isinstance(obj, NoticeUnit) 能在運行時檢查契約。
@runtime_checkable
class NoticeUnit(Protocol):
def publish(self, driver: DisplayDriver) -> None: ...
# Leaf: 單一公告,樹的葉節點
class SingleNotice:
def __init__(self, title: str, body: str):
self.title = title
self.body = body
def publish(self, driver: DisplayDriver) -> None:
driver.send({"title": self.title, "body": self.body})
# Composite: 公告群組,加入了防循環與穩定順序的保證
class NoticeGroup:
def __init__(self):
self._children: List[NoticeUnit] = []
self._parent: Optional["NoticeGroup"] = None
def add(self, unit: NoticeUnit) -> None:
# 禁止把祖先加為子節點(形成循環),亦禁止多重父節點(避免共享同一實例)
if unit is self or (isinstance(unit, NoticeGroup) and self._has_ancestor(unit)):
raise ValueError("Cyclic composition is not allowed.")
# 如果要嚴格禁止「搬家」語義(re-parenting),可在此阻擋已有父節點的單位
if isinstance(unit, NoticeGroup) and unit._parent is not None:
raise ValueError("Re-parenting is not allowed. A group already has a parent.")
self._children.append(unit) # 插入順序 = 發佈順序
if isinstance(unit, NoticeGroup):
unit._parent = self
def remove(self, unit: NoticeUnit) -> None:
self._children.remove(unit)
# ✅ 封裝:提供一個迭代器介面,而不是直接暴露 _children
def iter_children(self) -> Iterable[NoticeUnit]:
return tuple(self._children) # 回傳 tuple 快照,維持遍歷穩定性
# 語義更清楚:回傳 self 是否「擁有」指定 group 作為其祖先
def _has_ancestor(self, group: "NoticeGroup") -> bool:
p = self
while p:
if p is group: return True
p = p._parent
return False
def publish(self, driver: DisplayDriver) -> None:
# 實務上建議注入 logger,而非使用 print
print(f"Publishing group with {len(self._children)} items...")
for unit in self.iter_children():
unit.publish(driver)
# ✅ 職責分離:專門處理「摘要」邏輯的群組變體
class SummaryGroup(NoticeGroup):
def publish(self, driver: DisplayDriver) -> None:
titles: List[str] = []
def collect_titles(unit: NoticeUnit):
if isinstance(unit, SingleNotice):
titles.append(unit.title)
elif isinstance(unit, NoticeGroup):
# ✅ 透過公開介面 iter_children() 存取,而非 _children
for child in unit.iter_children():
collect_titles(child)
collect_titles(self)
# 實務上建議注入 logger
print(f"Publishing summary with {len(titles)} titles...")
# 結構化輸出,方便不同通路做後續轉換(而非再去 parse 字串)
driver.send({"summary": {"title": "晚場快訊摘要", "titles": titles}})
# --- 使用場景 ---
class ConsoleDriver:
def send(self, payload: Dict[str, Any]) -> None:
print(f"[CONSOLE] >> {payload}")
alert = SingleNotice("交通速報", "X1 路口改道")
promo = SingleNotice("舞台 A 更新", "DJ Set 延後")
weather = SingleNotice("天氣提醒", "入夜可能降雨")
night_pack = NoticeGroup()
night_pack.add(alert); night_pack.add(promo); night_pack.add(weather)
summary_pack = SummaryGroup()
summary_pack.add(alert); summary_pack.add(promo); summary_pack.add(weather)
driver = ConsoleDriver()
print("--- 1. Publishing Full NightPack ---")
night_pack.publish(driver)
print("\n--- 2. Publishing SummaryPack for App ---")
summary_pack.publish(driver)
關鍵差異 ✨
Composite
模式負責物件的結構與遞迴遍歷;Bridge
模式負責輸出通道;而像 SummaryGroup
這樣的變體則負責特定的聚合策略。三者各司其職,互不越界。動手實作:請你新增一個 CountableGroup
類別,它的 publish()
方法不發送內容,而是發送一則包含子節點總數的訊息,例如 {"count": 5}
。
開放思考:回頭看看 3. 笑中帶淚
的失敗場景。如果你是當時的 Mira,你會如何利用 NoticeGroup
和 SummaryGroup
來徹底重構那個 publish_batch
函式?
小投票:未来,市府希望支援「依條件動態展開」的群組(例如:只在晚上 9 點後才顯示某則公告)。你會選擇:
A. 在 NoticeGroup.publish() 內部加上時間判斷邏輯。
B. 交給 Decorator (裝飾者模式) 在呼叫 publish() 前後進行包裝?
請留言 A 或 B,並附上一句你的理由。(這是在為明天的內容鋪梗喔!)
反模式紅旗 🚩
型別分支地獄:如果你的程式碼裡還到處都是 if isinstance(unit, NoticeGroup): ... else: ...
,那代表你根本沒有真正地使用共同介面,Composite 的精神已死。
假 Composite:NoticeGroup
沒有完整實作和 SingleNotice
相同的 publish()
介面,迫使呼叫端必須區分對待它們。
副作用交織:NoticeGroup
的邏輯裡,同時混雜了「聚合策略」和「輸出通道」的細節。請記住,讓 Bridge/Driver 處理通道,Composite 專心處理樹狀結構。
循環引用:在 add()
子節點時,不小心把祖先節點加了進來,導致 publish()
呼叫時無限遞迴。(我們已在範例中加入防呆!)
企業整合模式 (EIP):NoticeGroup
的行為就像一個 Aggregator,將多則公告訊息整合成一則邏輯上的大訊息。SummaryGroup
則是在聚合之上,增加了一層 Content-based Routing 和 Transformation 的策略。最後透過 Fan-out 將命令傳遞給所有子節點。
多代理系統 (MAS):一個 CampaignGroupAgent
(群組公告代理) 接收到投放任務後,可以查詢黃頁服務 (DF),找到所有符合條件的子公告代理,然後並行地將任務委派下去,實現了更高層次的協作。
Given:三則獨立公告和一個 NightPack
群組,以及既有的 LED 驅動。
When:Mira 需要改用 Vendor-B 的新驅動,並新增一個只送摘要的「App 推播」通道。
Then:她只需建立一個 SummaryGroup
實例來處理摘要邏輯,並為 Vendor-B 實作一個新的 DisplayDriver
。SingleNotice
和 NoticeGroup
的核心程式碼完全不用動。night_pack.publish(vendor_b_driver)
和 summary_pack.publish(vendor_b_driver)
兩次呼叫,就優雅地搞定了所有整包、摘要與巢狀的需求。驗收完美通過!
契約測試:確保所有 NoticeUnit
的實作都遵循 publish(driver)
介面。可以建立一個 FakeDriver
,用來收集所有 send()
的呼叫紀錄,然後根據 add()
的順序,斷言呼叫次數與 payload 順序是否符合預期。
遞迴測試:建立一個「群組中有子群組」的複雜樹狀結構。呼叫最外層群組的 publish()
後,驗證樹中所有的 Leaf
(葉節點) 都被觸發了且只被觸發了一次。
邊界測試:撰寫測試案例,驗證 add()
方法能正確拋出 ValueError
來阻止循環引用。
父子一致性測試:驗證對已經有 _parent
的 NoticeGroup
再次 add()
會拋出 ValueError
(禁止多重父節點/re-parenting)。
複雜度備忘:publish()
的時間複雜度約為 O(N),其中 N 是樹中的節點總數。真正的瓶頸通常在於 Driver 的 I/O 成本。
今日總結:單體與樹同介面、遞迴統一操作;把群組當單體,邏輯穩、擴充易。
明日預告:今天的公告群組雖然強大,但功能是固定的。如果我們想在不修改原始公告的前提下,為它動態地加上「加密」、「壓縮」或「簽名」等新功能呢?明天,讓我們請出裝修大師——Decorator (裝飾者模式),看看它如何為既有物件巧妙地「加料」!
為了確保在不支援 Mermaid 渲染的環境中也能正常閱讀,以下提供文中圖表的 ASCII 替代版本:
+----------------+ +----------------+
| NoticeUnit | | SingleNotice |
|----------------| |----------------|
| |<------- | +publish() |
| +publish() | +----------------+
+----------------+
^ +----------------+
| | NoticeGroup |
| |----------------|
+-------------------| -children |
| +add() |
| +remove() |
| +publish() |
+----------------+
|
| 包含多個
v
+----------------+
| NoticeUnit |
+----------------+
IncidentBus NoticeGroup SingleNotice 1 SingleNotice 2
| | | |
| publish(driver) | | |
|-------------------->| | |
| | publish(driver) | |
| |------------------->| |
| | | |
| |<-------------------| |
| | publish(driver) | |
| |-----------------------------------> |
| | | |
| |<-----------------------------------| |
|<--------------------| | |
| | | |
+-------------------+
| NoticeGroup |
+-------------------+
/ | \
/ | \
+-----------+ +---------------+ +-------------+
| 交通速報 | | 舞台A更新 | | 天氣提醒 |
+-----------+ +---------------+ +-------------+
|
+---------------+
| 子群組 |
+---------------+
/ \
+----------+ +----------+
| 子公告1 | | 子公告2 |
+----------+ +----------+
客戶端代碼 統一介面
| |
| publish() |
V V
+-----------------+ +-------------------+
| | | NoticeUnit |
| 不需要關心 | +-------------------+
| 單體或群組差異 | ^
| | / \
+-----------------+ / \
/ \
+-------------+ +--------------+
| SingleNotice | | NoticeGroup |
+-------------+ +--------------+
|
| 包含
V
+-------------+
| NoticeUnit* |
+-------------+