iT邦幫忙

2025 iThome 鐵人賽

DAY 9
0
Software Development

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

Day 9:部件與聚合——Composite:把群組當單體操控

  • 分享至 

  • xImage
  •  

Codetopia 創城記 (9)|部件與聚合——Composite:把群組當單體操控

IThome 鐵人賽 設計模式 Composite Codetopia

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

晚場活動前 30 分鐘,市府內容策展英雄 Mira (米菈) 正與時間賽跑。她必須將三則公告(交通、舞台、天氣)打包成一份 NightPack 快訊包,一次性投放到所有通路。挑戰在於,「廣場大螢幕」需要顯示整包內容,而「App 推播」卻只要一則精簡摘要。更別提,她還得支援在群組中嵌套子群組。

就在這時,對講機傳來了採購部門的緊急通知。砰!顯示設備供應商要從 Vendor-A 緊急切換到 Vendor-B。💣 結構的複雜性與輸出的多樣性,瞬間交織成了一場完美的風暴。

【驗收標準】

  • GivenTrafficAlertEventPromoWeatherNotice 三則獨立公告,以及一個包含了這三則公告(並允許巢狀子群組)的 NightPack 群組。

  • When:Mira 需要對「廣場大螢幕」投放完整內容,對「App 推播」只投放摘要;同時,顯示設備的驅動程式從 Vendor-A 切換到 Vendor-B。

  • Then:在完全不修改任何單一公告類別的前提下,只需對 NightPack 呼叫一次 publish(),就能正確地、遞迴地將所有內容(無論是完整版還是摘要版)發送到指定的設備上。更換驅動程式(Driver)只影響輸出通道,不影響群組的結構與內容邏輯。

2. 術語卡 🧭

  • Composite (部件/聚合):單體與樹同介面、遞迴統一操作。它讓你將「單體物件 (Leaf)」和「物件群組 (Composite)」組合成樹狀結構,並讓客戶端能以一致的介面來對待它們。

  • Aggregator / Splitter (EIP):企業整合模式中的訊息處理器。「聚合器」將多則訊息合併成一則;「拆分器」則將一則訊息拆分成多則。這對應到我們將多則公告「成組」或「解包」的行為。

  • CampaignGroupAgent (MAS):在多代理系統中,一個「群組代理」可以統一對外接收任務,然後在內部協調、委派給各個子代理執行。

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

讓我們把時間倒轉回 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})

# 巢狀一變、通路一改,核心流程就得重寫——這不是擴充,是還債。
# 開放封閉原則?在這裡,它早就被炸得粉身碎骨了。

問題點 💔

  • 沒有共同介面:「單一公告」和「公告群組」的操作方式完全不同,呼叫端必須知道自己處理的是哪種類型。

  • 缺乏遞迴性:巢狀結構只能靠一層又一層的迴圈硬解,程式碼醜陋且難以維護。

  • 流程綁死型別:一旦新增一種新的群組類型,或變更通路,就必須回來修改這個核心流程,違反了開放封閉原則。

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

單體與樹同介面,遞迴操作(把群組當單體)——這就是 Composite (組合/部件) 模式 的精髓。總設計師的身影在人群中一閃而過,只留下一張餐巾紙,上面畫著一個簡單的樹狀結構圖。核心思想只有一個:讓「單體」與「群組」擁有相同的介面,然後讓群組的行為自然地遞迴下去!

我們抽出一個共同的 NoticeUnit (Component) 介面,讓 SingleNotice (Leaf) 和 NoticeGroup (Composite) 都去實作它。對呼叫端來說,它眼中再也沒有單體和群組之分,只有 NoticeUnit。當它呼叫 NoticeGrouppublish() 方法時,群組會聰明地、遞迴地去呼叫它所有子節點的 publish() 方法。

拍案! 就這樣,無論你的公告結構有多複雜,對外的操作永遠只有簡單的一行。

何時用 (When to Use)

  • 當你需要處理一個樹狀結構,並且希望以統一的方式操作其中的所有物件時(例如:檔案系統的目錄與檔案、UI 框架中的容器與控制項、文件的章節與段落)。

  • 當你想讓客戶端程式碼忽略物件組合的差異,專注於共同的操作時。

何時不要用 (When NOT to Use)

  • 如果你的結構永遠都只是一個扁平的列表,不會有巢狀,那就不需要引入這個模式的複雜性。

  • 如果每個節點類型的操作都截然不同,而且需要強型別來區分(例如,檔案只能 read(),目錄只能 list()),那麼強行統一介面可能會讓介面變得臃腫。此時,可以考慮日後會介紹的 Visitor (訪問者模式),在固定的結構上外掛多種不同的操作。

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

導播,鏡頭拉一下!讓我們從微觀的類別結構,一路看到中觀的訊息流與宏觀的代理協作,來理解這個「把群組當單體」的優雅設計。

視角 觀念/模式 在 Codetopia 的說法
微觀 (GoF) Component / Leaf / Composite NoticeUnit / SingleNotice / NoticeGroup
中觀 (EIP/EDA) Aggregator / Splitter / Fan-out 公告整併、摘要分流、群組遞送
宏觀 (MAS) Group Agent / 子代理協作 CampaignGroupAgent 委派子公告代理

Mermaid|類圖(微觀 GoF)

這張圖清晰地展示了 SingleNotice (葉節點) 和 NoticeGroup (組合節點) 如何共同實作 NoticeUnit 介面。NoticeGroup 內部持有一組 NoticeUnit 的子節點,形成遞迴結構。

https://ithelp.ithome.com.tw/upload/images/20250923/20178500UAXiOH7xzw.png

Mermaid|時序圖(中/宏觀 EIP/MAS)

從訊息流的角度看,事件總線 (IncidentBus) 只需對最外層的 NoticeGroup 發出一次 publish() 命令。群組內部會自動將這個命令「扇出 (Fan-out)」給它所有的子節點,無論是單體還是其他子群組。

https://ithelp.ithome.com.tw/upload/images/20250923/20178500k1IzQUoj2r.png

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

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 這樣的變體則負責特定的聚合策略。三者各司其職,互不越界。

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

  1. 動手實作:請你新增一個 CountableGroup 類別,它的 publish() 方法不發送內容,而是發送一則包含子節點總數的訊息,例如 {"count": 5}

  2. 開放思考:回頭看看 3. 笑中帶淚 的失敗場景。如果你是當時的 Mira,你會如何利用 NoticeGroupSummaryGroup 來徹底重構那個 publish_batch 函式?

  3. 小投票:未来,市府希望支援「依條件動態展開」的群組(例如:只在晚上 9 點後才顯示某則公告)。你會選擇:

    A. 在 NoticeGroup.publish() 內部加上時間判斷邏輯。

    B. 交給 Decorator (裝飾者模式) 在呼叫 publish() 前後進行包裝?

    請留言 A 或 B,並附上一句你的理由。(這是在為明天的內容鋪梗喔!)

  4. 反模式紅旗 🚩

    • 型別分支地獄:如果你的程式碼裡還到處都是 if isinstance(unit, NoticeGroup): ... else: ...,那代表你根本沒有真正地使用共同介面,Composite 的精神已死。

    • 假 CompositeNoticeGroup 沒有完整實作和 SingleNotice 相同的 publish() 介面,迫使呼叫端必須區分對待它們。

    • 副作用交織NoticeGroup 的邏輯裡,同時混雜了「聚合策略」和「輸出通道」的細節。請記住,讓 Bridge/Driver 處理通道,Composite 專心處理樹狀結構。

    • 循環引用:在 add() 子節點時,不小心把祖先節點加了進來,導致 publish() 呼叫時無限遞迴。(我們已在範例中加入防呆!)

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

  • 企業整合模式 (EIP)NoticeGroup 的行為就像一個 Aggregator,將多則公告訊息整合成一則邏輯上的大訊息。SummaryGroup 則是在聚合之上,增加了一層 Content-based RoutingTransformation 的策略。最後透過 Fan-out 將命令傳遞給所有子節點。

  • 多代理系統 (MAS):一個 CampaignGroupAgent (群組公告代理) 接收到投放任務後,可以查詢黃頁服務 (DF),找到所有符合條件的子公告代理,然後並行地將任務委派下去,實現了更高層次的協作。

9. ✅ 回到現場(同一組驗收通過)

  • Given:三則獨立公告和一個 NightPack 群組,以及既有的 LED 驅動。

  • When:Mira 需要改用 Vendor-B 的新驅動,並新增一個只送摘要的「App 推播」通道。

  • Then:她只需建立一個 SummaryGroup 實例來處理摘要邏輯,並為 Vendor-B 實作一個新的 DisplayDriverSingleNoticeNoticeGroup 的核心程式碼完全不用動。night_pack.publish(vendor_b_driver)summary_pack.publish(vendor_b_driver) 兩次呼叫,就優雅地搞定了所有整包、摘要與巢狀的需求。驗收完美通過!

10. 測試指北(契約/遞迴/端到端)

  • 契約測試:確保所有 NoticeUnit 的實作都遵循 publish(driver) 介面。可以建立一個 FakeDriver,用來收集所有 send() 的呼叫紀錄,然後根據 add() 的順序,斷言呼叫次數與 payload 順序是否符合預期。

  • 遞迴測試:建立一個「群組中有子群組」的複雜樹狀結構。呼叫最外層群組的 publish() 後,驗證樹中所有的 Leaf (葉節點) 都被觸發了且只被觸發了一次。

  • 邊界測試:撰寫測試案例,驗證 add() 方法能正確拋出 ValueError 來阻止循環引用。

  • 父子一致性測試:驗證對已經有 _parentNoticeGroup 再次 add() 會拋出 ValueError(禁止多重父節點/re-parenting)。

  • 複雜度備忘publish() 的時間複雜度約為 O(N),其中 N 是樹中的節點總數。真正的瓶頸通常在於 Driver 的 I/O 成本。

11. 結語 & 預告

今日總結:單體與樹同介面、遞迴統一操作;把群組當單體,邏輯穩、擴充易。

明日預告:今天的公告群組雖然強大,但功能是固定的。如果我們想在不修改原始公告的前提下,為它動態地加上「加密」、「壓縮」或「簽名」等新功能呢?明天,讓我們請出裝修大師——Decorator (裝飾者模式),看看它如何為既有物件巧妙地「加料」!


12. 附錄:ASCII 版圖示

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

類圖(微觀 GoF)

+----------------+         +----------------+
|   NoticeUnit   |         |  SingleNotice  |
|----------------|         |----------------|
|                |<------- | +publish()     |
| +publish()     |         +----------------+
+----------------+
        ^                   +----------------+
        |                   |  NoticeGroup   |
        |                   |----------------|
        +-------------------| -children      |
                            | +add()         |
                            | +remove()      |
                            | +publish()     |
                            +----------------+
                                    |
                                    | 包含多個
                                    v
                            +----------------+
                            |   NoticeUnit   |
                            +----------------+

時序圖(中/宏觀 EIP/MAS)

    IncidentBus          NoticeGroup         SingleNotice 1      SingleNotice 2
        |                     |                    |                    |
        | publish(driver)     |                    |                    |
        |-------------------->|                    |                    |
        |                     | publish(driver)    |                    |
        |                     |------------------->|                    |
        |                     |                    |                    |
        |                     |<-------------------|                    |
        |                     | publish(driver)    |                    |
        |                     |----------------------------------->     |
        |                     |                    |                    |
        |                     |<-----------------------------------|    |
        |<--------------------|                    |                    |
        |                     |                    |                    |

樹狀結構示意圖

                   +-------------------+
                   |    NoticeGroup    |
                   +-------------------+
                  /         |          \
                 /          |           \
    +-----------+   +---------------+    +-------------+
    | 交通速報     |   | 舞台A更新     |    | 天氣提醒    |
    +-----------+   +---------------+    +-------------+
                                |
                        +---------------+
                        | 子群組         |
                        +---------------+
                        /              \
               +----------+        +----------+
               | 子公告1   |        | 子公告2   |
               +----------+        +----------+

Composite模式核心概念

    客戶端代碼                    統一介面
        |                          |
        | publish()                |
        V                          V
  +-----------------+     +-------------------+
  |                 |     |    NoticeUnit     |
  |  不需要關心     |     +-------------------+
  |  單體或群組差異  |              ^
  |                 |             / \
  +-----------------+            /   \
                                /     \
                  +-------------+     +--------------+
                  | SingleNotice |     | NoticeGroup  |
                  +-------------+     +--------------+
                                            |
                                            | 包含
                                            V
                                     +-------------+
                                     | NoticeUnit* |
                                     +-------------+

上一篇
Day 8:抽象與實作的橋梁——Bridge 模式終結組合爆炸!
系列文
Codetopia 新手日記:設計模式與原則的 30 天學習之旅9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言