iT邦幫忙

2025 iThome 鐵人賽

DAY 8
0
Software Development

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

Day 8:抽象與實作的橋梁——Bridge 模式終結組合爆炸!

  • 分享至 

  • xImage
  •  

Codetopia 創城記 (8)|抽象與實作的橋樑——Bridge 模式終結組合爆炸!

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

Codetopia 科技藝術節第二天,人潮擠爆了中央廣場!市長辦公室為了即時疏導人流,下令要將一份緊急的「城市公告」(包含交通快訊、活動播報、天氣預警)同步送到全城各式各樣的顯示設備上。

這下可忙壞了資訊局的菜鳥工程師 Andy。你看,眼前有一整排五花八門的設備等著他處理:老舊的 LED 路邊顯示板、剛換裝的 LCD 觸控面板、官網的即時滾動條,還有市民手機裡的 App 推播。

「天啊…」Andy 哀嚎著。他發現每增加一台新設備,就得為每一種公告類型(交通、活動、天氣)複製貼上一整輪的 if/else 判斷,甚至還要為了不同廠商的 SDK、不同的語系樣式,再額外建立好幾個類別來硬湊。

昨天才剛接好的 IncidentBus 事件總線雖然穩定地把事件送了過來,但 「要顯示什麼」(公告的內容語意)「怎麼顯示」(設備的驅動細節) 這兩件事,像兩條濕透的麻繩一樣,死死地糾纏在一起。開發和測試的進度,就這樣被這場「組合爆炸」給徹底拖垮了。💥

【驗收標準】

  • Given:一份「交通速報」公告,以及一台由 Vendor-A 廠商提供的 LED 顯示板。

  • When:市府突然更換了新的供應商 Vendor-B,並且緊急追加了「App 推播」這個新通路。

  • Then:在完全不修改任何現有公告類別的前提下,只需替換或新增對應的「驅動程式」,所有公告就能照常送達,並順利通過驗收。

2. 術語卡 🧭

  • Bridge (GoF):將抽象 (例如公告的語意) 與實作 (例如輸出設備的驅動) 徹底分離,讓這兩個維度可以各自獨立擴充,互不干擾。

  • Port/Driver (六角形架構):抽象就是應用程式核心的 Port;實作就是外部溝通的具體 Adapter/Driver

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

Andy 為了快速交差,決定使出蠻力。他心想:「不就是公告類型 × 裝置驅動 × 語系嗎?我全寫出來就是了!」於是,他的程式碼裡出現了一片壯觀的類別灌木叢:

  • TrafficAlertOnLedZh, TrafficAlertOnLedEn, TrafficAlertOnLcdZh

  • EventPromoOnWebZh, EventPromoOnAppEn

(旁白:「孩子,你這是交叉繁殖,不是軟體開發啊!」)

這種寫法立刻引發了災難。每當市長早上喝咖啡時突發奇想,要加一個新的顯示設備或支援一種新語系,Andy 就得含淚擴增一整排的新類別。公告的格式邏輯與設備的通訊細節緊緊糾纏,測試案例數量呈指數級增長,重用性?那是什麼,能吃嗎?

# 壞味道:語意(公告)與設備/供應商細節糾纏在一起,if/elif 暴增
class NoticeService:
    def publish_traffic_alert(self, msg: str, device: str, vendor: str, locale: str):
        payload = {}
        # 糟糕!公告的語系樣式跟具體設備綁死了
        if device == "LED":
            if vendor == "A":
                payload = {"title": "交通速報" if locale == "zh" else "Traffic Alert",
                           "body": msg, "style": "bold-16x8"}
                self._send_led_vendor_a(payload)
            elif vendor == "B":
                # 換個廠商,程式碼就要跟著改
                payload = {"title": "交通速報" if locale == "zh" else "Traffic Alert",
                           "body": msg, "style": "bold-16x8"}
                self._send_led_vendor_b(payload)

        elif device == "APP":
            payload = {"title": "交通速報" if locale == "zh" else "Traffic Alert",
                       "body": msg, "priority": "high"}
            self._send_app_push(payload)
        # ... 再加 LCD、Web、更多 vendor → if/elif 這棵樹就等著長成神木吧

    # 下面全是通訊細節(實作),跟公告語意混在一起
    def _send_led_vendor_a(self, payload: dict): print(f"[LED Vendor A] Sending: {payload}")
    def _send_led_vendor_b(self, payload: dict): print(f"[LED Vendor B] Sending: {payload}")
    def _send_app_push(self, payload: dict): print(f"[App Push] Sending: {payload}")

# 使用:每新增一種設備或換 vendor,就得回來修改 NoticeService 的程式碼
svc = NoticeService()
svc.publish_traffic_alert("X1 路口壅塞", device="LED", vendor="A", locale="zh")

問題點 💔

  • 組合爆炸:類別的成長方向是「公告 × 設備/vendor × 語系」的交叉座標。

  • 測試地獄:需要覆蓋大量的 if/elif 路徑;更換供應商這種小事,竟然會牽動核心的公告邏輯。

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

就在 Andy 瀕臨崩潰時,總設計師悄然現身,遞給他一張設計圖,上面只有一個詞:Bridge (橋接模式)。他只留下設計圖就退到人群裡,把解法的舞台交還給 Andy。

核心觀念 💡:這座「橋」的精髓,就是把**「要說什麼」(公告的抽象)** 與 「怎麼說」(設備驅動的實作) 拆解到兩條完全獨立的維度上。抽象端(Notice)只負責組織訊息內容與樣式;實作端(DisplayDriver)則專心處理與 LED/LCD/Web/App 的通訊細節。

這才是優雅的解法——從此兩軸各自演進、互不牽連。這正是 Bridge 模式在我們 Codetopia 藍圖裡的定位:維度分離,終結組合爆炸。從數學直覺來看,它讓類別的數量從 O(公告種類 × 設備種類) 的乘法關係,降低為 O(公告種類 + 設備種類) 的加法關係。

何時用 (When to Use)

  • 當同一個抽象概念需要有多種實作,而且兩者都可能獨立擴充時(例如:跨平台的 UI 渲染、支援多種資料庫的儲存庫、不同傳輸協定的訊息發送器)。

  • 當系統存在兩條(或更多)正交的變化維度時(例如:公告種類 vs. 輸出設備)。

何時不要用 (When NOT to Use)

  • 如果只有一條維度會變化(例如只是想切換不同的排序演算法),那麼更簡單的 Strategy (策略模式) 就足夠了。

  • 如果只是要解決兩個既有介面的名稱或參數不匹配問題,昨天的 Adapter (轉接器模式) 更對症下藥。

  • 如果你的需求是一整個「產品家族」要能被成套替換(例如 UI 的 Modern 風格與 Classic 風格),那麼 Abstract Factory (抽象工廠模式) 會更貼切。

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

導播,鏡頭拉一下!讓我們從三個不同的尺度,來看看這座優雅的「橋」是怎麼搭建起來的。

視角 觀念/模式 在 Codetopia 的說法
微觀 (GoF) Abstraction ↔ Implementor 公告 ↔ 顯示驅動程式
中觀 (EIP/EDA/Actor) Semantic vs. Channel Decoupling 事件語意 vs. 傳輸通道解耦
宏觀 (MAS) Agent Collaboration 公告代理 ↔ 驅動代理協作

5.1 微觀 (GoF|UML 類別圖)

這張圖清楚地展示了「公告」與「顯示驅動」是如何透過 bridge 連結,同時又能在各自的繼承體系中獨立發展。

https://ithelp.ithome.com.tw/upload/images/20250922/20178500WIINVKm4Ru.png

5.2 中觀 (EIP/EDA|時序圖)

從事件流的角度看,Notice 收到來自事件總線的語意事件後,只負責將其轉化為標準 payload,然後交給 DisplayDriver,完全不關心它最終是怎麼被送出去的。

https://ithelp.ithome.com.tw/upload/images/20250922/20178500mbIlwKB1rc.png

5.3 宏觀 (MAS|代理協作圖)

在宏觀的城市代理系統中,AnnouncerAgent (公告代理) 甚至可以動態地去 Directory Facilitator (黃頁) 查詢當下可用的顯示驅動代理,實現了更高層次的動態組裝。

https://ithelp.ithome.com.tw/upload/images/20250922/20178500OJNrNzrPfy.png

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

Andy 恍然大悟,立刻按照總設計師的藍圖重構了他的程式碼。你看,現在的結構多麼清晰!

from typing import Protocol, Dict, Any
# 註:Python < 3.8 可改從 typing_extensions 匯入 Protocol。

# Implementor:設備/通道驅動的共同介面(可替換、可擴張)
# 這就是我們說的「怎麼說」
class DisplayDriver(Protocol):
    def send(self, payload: Dict[str, Any]) -> None: ...

# 每個 Driver 只專心做好一件事:跟特定設備溝通
class LedVendorADriver:
    def send(self, payload: Dict[str, Any]) -> None:
        # 這裡封裝了與 LED Vendor A SDK 溝通的複雜細節
        print(f"[LED Vendor A] Sending: {payload}")

class AppPushDriver:
    def send(self, payload: Dict[str, Any]) -> None:
        # 這裡負責呼叫行動推播服務
        print(f"[App Push] Sending: {payload}")

# Abstraction:公告抽象,專注於「要說什麼」
class Notice:
    def __init__(self, driver: DisplayDriver):
        self._driver = driver

    def set_driver(self, driver: DisplayDriver):
        self._driver = driver

    # 子類別負責決定公告的具體內容與樣式
    def render(self) -> Dict[str, Any]:
        raise NotImplementedError

    # publish 方法將 render 的結果交給 driver
    def publish(self):
        payload = self.render()
        print(f"[Notice] Rendering payload: {payload}")
        self._driver.send(payload)

# RefinedAbstraction:具體的公告類型
class TrafficAlert(Notice):
    def __init__(self, driver: DisplayDriver, msg: str, locale: str = "zh"):
        super().__init__(driver)
        self.msg, self.locale = msg, locale

    def render(self) -> Dict[str, Any]:
        title = "交通速報" if self.locale == "zh" else "Traffic Alert"
        return {"title": title, "body": self.msg, "style": "warning"}

# --- 使用場景(完全符合驗收標準!)---
print("--- SCENARIO START ---")
# Given:一份「交通速報」與既有的 LED 顯示板
led_driver = LedVendorADriver()
alert = TrafficAlert(led_driver, "X1 路口壅塞")
alert.publish()

print("\n--- SWITCHING DRIVER ---")
# When:採購換了供應商,再加「App 推播」
app_driver = AppPushDriver()
alert.set_driver(app_driver)

# Then:完全不用修改 TrafficAlert 類別,公告照常送達!
alert.publish()
print("\n--- SCENARIO END ---")

關鍵差異

  • 反例語意通道/供應商混為一談;正解則用 Notice(抽象)與 DisplayDriver(實作)漂亮地分離了兩個維度。

  • 現在,新增設備/供應商 = 只需要增加一個 Driver 類別;新增公告類型 = 只需要增加一個 Notice 的子類別

  • 測試範圍縮小:我們可以分別獨立地測試 render() 的輸出是否正確,以及每個 Driver 的通訊行為是否正常。

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

總設計師離開前,笑著留下了幾個問題,考驗一下你是否真的掌握了 Bridge 的精髓:

  1. 動手實作:請你實作一個 WebTickerDriver,它會把公告送到官網的跑馬燈上。挑戰是在完全不修改任何 NoticeTrafficAlert 類別的前提下完成。

  2. 開放問題:回頭看看 3. 笑中帶淚 中 Andy 的那個失敗場景。如果你是當時的英雄,你會如何使用 Bridge 模式重構 NoticeService 類別,讓它既能處理不同公告,又能彈性地切換輸出設備?

  3. 反模式紅旗 🚩:在專案中,你聞到了哪些「味道」,會讓你警覺可能有人誤用了 Bridge 模式?

    • 抽象洩漏Noticerender() 方法裡,還看得到 if device == 'LED' 這樣的判斷。

    • 偽 Bridge:有人建立了一個萬能的 GodDriver,裡面用 if/else 塞進了所有設備的處理邏輯。

    • 維度搞錯:團隊其實只是需要切換演算法(Strategy),卻大費周章地建了一座橋,導致結構過於複雜。

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

將今天的 Bridge 模式放到更宏觀的架構視野中,你會發現它與許多重要概念遙相呼應:

  • 六角形架構 (Hexagonal)Notice 就是應用程式核心定義的 Port;而每一個 Driver 都是一個具體的 Adapter。更換供應商,就等於是插拔不同的 Adapter,核心領域邏輯紋絲不動。

  • 企業整合模式 (EIP)Notice 負責定義訊息的語意標準的 payload;而 Driver 則決定了訊息要走哪個 Outbound Channel(例如 WebSocket、HTTP POST 或 MQTT)。

  • 多代理系統 (MAS):「公告代理」可以根據情境,動態地從「黃頁 (DF)」查詢可用的「設備驅動代理」,並透過標準的「代理通訊語言 (ACL)」將任務派發出去,實現了運行時的動態組裝。

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

  • Given:一份「交通速報」與既有的 LED 顯示板(LedVendorADriver)。

  • When:市府更換供應商,並追加「App 推播」(AppPushDriver)。

  • Then不修改任何公告類別 (TrafficAlert),僅透過 set_driver() 替換/新增驅動程式,所有公告皆能依情境示範(見上方 Scenario)正常送達,驗收通過。

10. 測試指北(契約/替換/端到端)

  • 契約測試:確保所有 DisplayDriver 的實作都遵循 send(payload) 接受 {title, body} 最小集合的契約(見下方的極簡契約測試)。

  • 替換測試:以假的 TestDriver 注入 Notice,驗證 publish() 僅呼叫一次 send(),且 payload 內容正確。

  • 端到端測試:模擬 IncidentBus 發出各類事件,驗證 Notice 能正確 render() 並觸發對應的 Driver 送出訊息,同時觀察 ack/err 的處理。

極簡契約測試

下面這段程式碼強化了「抽象可測、實作可替換」的核心概念,而且不需要引入任何測試框架。

class TestDriver:
    def __init__(self): self.sent=[]
    def send(self, payload): self.sent.append(payload)

# 注入假的 TestDriver 來驗證抽象層的行為
td = TestDriver()
TrafficAlert(td, "X1 路口壅塞").publish()
assert td.sent and td.sent[0]["title"] in ("交通速報", "Traffic Alert")
print("\n✅ Contract Test Passed: Notice correctly called driver's send method.")

11. 結語 & 預告

今日總結:抽象與實作分離;兩軸獨立演進,避免類別組合爆炸。

明日預告:今天的公告只能單獨發送,但如果我想把多則公告「打包」成一個群組,像操作單一公告一樣統一顯示呢?敬請期待 Composite (組合模式) 的登場!

12. 附錄:ASCII 版圖示

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

微觀 (GoF|類圖) ASCII 版本 🏗️

   <<Abstraction>>                  <<Implementor>>
╔════════════════════╗        ╔═════════════════════╗
║       Notice       ║ bridges║   DisplayDriver     ║
║────────────────────║───────▶║  <<interface>>      ║
║ + publish()        ║        ║─────────────────────║
║ + setDriver()      ║        ║ + send(payload)     ║
╚════════════════════╝        ╚═════════════════════╝
         △                              △
         |                              | implements
  ╔══════════════╗               ╔════════════════════╗
  ║ TrafficAlert ║               ║  LedVendorADriver  ║
  ╚══════════════╝               ╚════════════════════╝
  ╔══════════════╗               ╔════════════════════╗
  ║  EventPromo  ║               ║   AppPushDriver    ║
  ╚══════════════╝               ╚════════════════════╝

中觀 (EIP/EDA|時序圖) ASCII 版本 ⏱️

🚌 IncidentBus    🎨 Notice Abstraction   📡 DisplayDriver Implementor
      │                      │                      │
      │ on(Event{...})       │                      │
      │─────────────────────▶│                      │
      │                      │ send({title, ...})   │
      │                      │─────────────────────▶│
      │                      │                      │
      │                      │       ack/err        │
      │                      │◀─────────────────────│
      │                      │                      │

宏觀 (MAS|代理協作) ASCII 版本 🏛️

📢 Announcer Agent    📖 Directory Facilitator   🔌 Driver Agents
      │                         │                      │
      │  lookup("DisplayDriver")│                      │
      │────────────────────────▶│                      │
      │                         │                      │
      │      endpoint(s)        │                      │
      │◀────────────────────────│                      │
      │                         │                      │
      │      send(payload)      │                      │
      │───────────────────────────────────────────────▶│
      │                         │                      │
      │           ack           │                      │
      │◀───────────────────────────────────────────────│
      │                         │                      │

上一篇
Day 7:新舊系統的轉接站——Adapter 模式搞定介面不相容!
下一篇
Day 9:部件與聚合——Composite:把群組當單體操控
系列文
Codetopia 新手日記:設計模式與原則的 30 天學習之旅9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言