iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0
Software Development

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

Day 7:新舊系統的轉接站——Adapter 模式搞定介面不相容!

  • 分享至 

  • xImage
  •  

Codetopia 創城記 (7)|新舊系統的轉接站——Adapter 模式搞定介面不相容!

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

Codetopia 週末的「科技藝術節」人潮洶湧,市長辦公室希望能即時掌握各路口的影像數據,以便動態調配交通號誌。新的「事件總線 IncidentBus」系統蓄勢待發,準備接收全市的感測器資料。然而,一個棘手的問題浮上檯面。

負責影像的舊廠牌攝影機,像是個只會說古老方言的耆老,它吐出的資料格式是過時的 XML。而新潮的「事件總線」,則像個只聽得懂流行語的年輕人,它只接受標準化的 JSON/SensorEvent 介面。

攝影機廠商攤手:「我們這系統穩定運行十年了,改不了。」

事件總線的開發商也搖頭:「我們的平台介面是全市標準,恕難客製。」

兩邊廠商的皮球,就這樣踢到了採購專員 Mia 的腳下。眼看活動在即,整合時程壓力山大,Mia 焦急地找到了整合工程師 Ken。

「Ken,我們被夾在中間了!有沒有辦法…不動兩邊,但又能讓他們溝通?」Mia 遞上一杯咖啡,眼神充滿期盼。

Ken 推了推眼鏡,在白板上畫了一個盒子,連接在攝影機和事件總線之間。「別擔心,Mia。我們不需要他們任何一方改變。我們在中間蓋一座『轉接站』,把舊的 XML 資料流,平滑地轉成新的 JSON 介面就行了。」

🧭 術語卡(今日會用到)

  • Adapter(GoF):如同萬國轉接頭,用一個包裝器將某個類別的介面,轉換成客戶端期望的另一種介面。

  • Message Translator / Canonical Data Model(EIP):在訊息通道中進行格式或語意轉換,將五花八門的資料統一成標準樣式。

  • Anti-corruption Layer(DDD):建立一個隔離層,保護你的核心領域模型不被外部遺留系統的「方言」所污染。

  • MAS/ACL:在多代理人系統中,讓不同系統的代理透過協定翻譯來互相溝通,而無需修改各自的內部邏輯。

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

在 Ken 接手之前,曾有位實習生試圖「快速解決」問題。他沒有建立轉接站,而是在接收資料的入口到處埋設 if/else 的翻譯邏輯。

# 反例:在呼叫端 hardcode 格式判斷,造成介面污染與測試崩壞
def pushToBus(raw_data):
  event = {}
  # 👎 喔不,呼叫端被迫知道太多別人家的事
  if raw_data.format == "xml":
    # 邏輯散落在各處,未來維護的惡夢
    parsed_obj = parseXML(raw_data.content)
    event = {
        "id": parsed_obj["@id"],
        "t": parsed_obj["ts"],
        "kind": "traffic",
        "loc": "unknown"
    }
  elif raw_data.format == "csv":
    row = raw_data.content.split(",")
    event = { "id": row[0], "t": row[1], "kind": "env", "loc": row[2] }
  else:
    raise Exception("unsupported format")

  # 👎 IncidentBus 的 publish 方法被迫與外部各種奇怪的格式綁死
  IncidentBus.publish(event)

壞味道:呼叫端被迫知道「別人的長相」,嚴重違反封裝;每新增一種格式都要改動呼叫端,技術債會快速累積。就像應該在門口設翻譯櫃檯,而不是讓受理窗口自己學所有方言。

王牌出手(核心觀念)

Adapter 模式的核心精神,就是「尊重差異,優雅轉譯」。它透過建立一個符合 Target (目標介面) 的包裝器,將 Adaptee (被適配者,也就是舊系統) 的介面包裹起來。如此一來,呼叫端就只需要面向標準的 Target 介面溝通,完全不用知道背後那個「說方言的耆老」是誰。

這個模式主要解決的問題,就是當你遇到介面不相容的兩個系統時,在不動兩端的前提下,讓它們能夠協同工作。

在我們的故事中:

  • Mia 扮演了需求驅動的角色,她負責協調供應商,取得舊系統的資料範本與欄位說明,確立了整合的邊界與時程。

  • Ken 則是 Adapter 的實作者,他專注於打造那座轉接站,確保了兩邊系統零改動,並維持了清晰的系統邊界。

值得注意的是,Adapter 多了一層轉換,會產生些微效能成本。若轉換不只關乎格式,還牽涉複雜的商業語意對齊,就該考慮升級為權責更分明的 Translator 搭配 Canonical Model。若需雙向同步,更要小心定義錯誤回傳的語意(如解析錯誤、格式不符)與重試機制,且欄位對應也需伴隨 schema 版本進行管理。

何時用(When to Use)

  • 整合遺留系統:當你想使用一個既有的、無法修改的類別,但它的介面與你的新系統不相容時。

  • 統一多來源介面:當你需要處理來自多個不同來源、格式各異的資料時,可以為每個來源建立一個 Adapter,將它們統一到一個標準介面。

  • 簡化測試替身:當你要測試的對象依賴於一個複雜的外部系統時,可以為該外部系統建立一個 Adapter,並在測試時輕易地用一個假的 Adapter (Test Double) 替換掉它。

何時不要用(When NOT to Use)

  • 可以直接修改時:如果只是命名或參數順序的微小差異,且你對兩邊的程式碼都有控制權,直接重構使其一致通常是更簡單的選擇。

  • 涉及複雜業務規則轉換:如果轉換不只是格式對應,還牽涉到大量的業務邏輯重寫或雙向資料同步,那可能需要升級為更完整的 Translator 搭配 Anti-corruption Layer,甚至考慮 Bridge 模式(明天會講!)。

  • 效能極度敏感:多一層轉換必然會帶來微小的效能開銷。在每微秒都至關重要的場景下,需要審慎評估。

導播切景(三層並置圖)

導播,鏡頭拉一下!讓我們看看「轉接站」這個概念,在不同的系統尺度下是如何呈現的。

  • 目的:對齊三個縮放層次:① 物件協作 → ② 訊息流 → ③ 代理協作。

  • 順序:GoF → EIP/EDA/Actor → MAS。

視角 觀念/模式 在 Codetopia 的說法
微觀(GoF) Adapter Ken 的 CCTVAdapter,讓舊攝影機符合標準事件介面。
中觀(EIP/EDA) Message Translator / Canonical Data Model 事件總線前的訊息翻譯器,將 XML 流轉為標準 JSON 事件。
宏觀(MAS) 協定翻譯代理 (Protocol Translation Agent) 專職「翻譯代理」,在黃頁登錄 XML→JSON 轉譯服務。

微觀(GoF|類圖)

https://ithelp.ithome.com.tw/upload/images/20250921/20178500aExxLgNa7w.png

中觀(EIP/EDA|時序圖)

https://ithelp.ithome.com.tw/upload/images/20250921/20178500PBQST9PTbK.png

最小實作(程式碼範例)

這就是 Ken 寫的核心程式碼。優雅、簡潔,而且呼叫端 ingest 函式完全不知道 XML 的存在。

from xml.etree import ElementTree as ET
from typing import Protocol, List, Dict, Callable

class Target(Protocol):
    def fetch_events(self) -> List[Dict]: ...

class LegacyCCTVClient:
    def get_xml(self) -> str:
        return '<events><event id="CAM-A1" ts="2025-09-20T14:30:00Z" kind="traffic" loc="X1-Main-Street"/></events>'

class CCTVAdapter(Target):
    def __init__(self, adaptee: LegacyCCTVClient):
        self._adaptee = adaptee

    def fetch_events(self) -> List[Dict]:
        """XML -> Canonical Event List"""
        raw_xml = self._adaptee.get_xml()
        try:
            root = ET.fromstring(raw_xml)
        except ET.ParseError as e:
            # 明確錯誤語意,方便上游重試或告警
            raise ValueError(f"malformed-xml: {e}") from e

        out: List[Dict] = []
        for e in root.findall("event"):
            out.append({
                "id": e.attrib.get("id"),
                "ts": e.attrib.get("ts"),   # 可選:在此轉成標準 ISO8601 時區格式
                "kind": e.attrib.get("kind", "unknown"),
                "loc": e.attrib.get("loc")
            })
        return out

def ingest(source: Target, bus_publish_func: Callable[[Dict], None]):
    """契約:bus_publish_func 接收 Canonical 事件,無回傳"""
    for ev in source.fetch_events():
        bus_publish_func(ev)

反模式紅旗(Red Flags)

  • 👃 介面滲漏:Adapter 的方法簽名中,仍然回傳了 Adaptee 的物件型別(例如 fetch_events() 直接回傳了舊 SDK 的 LegacyEventObject),這等於翻譯只做了一半。

  • 👃 抽象洩漏:呼叫端的程式碼中,還出現了 if source is CCTVAdapter: 這樣的判斷。這表示呼叫端依然知道「現在其實是 XML 來的資料」,Adapter 的封裝被打破了。

  • 👃 萬能轉接器 (God Adapter):試圖用一個巨大的 Adapter 類別,透過內部的 if/else 來處理所有可能的外來格式。正確的做法是為每種不相容的介面建立一個專屬、具名的 Adapter。

測試指北

  • 單元:以 golden file 驗證 XML→Event 對映,覆蓋缺欄/非法時間等邊界。

  • 合約:對 Target.fetch_events() 寫契約測試:每個事件至少含 id/ts/kind

  • 端到端:以 Fake IncidentBus 驗證 publish 與錯誤回傳語意,不必依賴真總線。

  • 錯誤:注入壞 XML/逾時,檢查重試/告警行為。

上線監測

  • 追蹤轉譯失敗率、平均/尾延遲、吞吐量與重試次數;若出現背壓,啟動降速或排程轉譯。

鄉民出題(動手練習)

  1. 再接再厲:現在市府又採購了一批只能輸出 CSV 格式的環境感測器 ("sensor-01,2025-09-20T15:00:00Z,air_quality,Y2-Park")。請你建立一個新的 CsvSensorAdapter,並在不修改 ingest() 函式的前提下,將其資料匯入事件總線。

  2. 智慧分流:如果來源有很多種 (XML, CSV, ...),你會如何設計一個「策略選擇器」或「工廠」,可以根據來源的類型 (source.type) 自動挑選對應的 Adapter?(提示:想想 Day 3 的 Factory Method)

  3. 契約精神:請思考如何為 Target 介面寫一個「契約測試」。這個測試要能確保任何一個實作 Target 的 Adapter(不管是 CCTVAdapter 還是 CsvSensorAdapter),其 fetch_events() 方法回傳的事件列表中,每個事件都至少包含 idts 這兩個欄位。

💡 小投票

假設明年市府預算充足,決定全面汰換掉舊的 CCTV 攝影機,所有新設備都原生支援 JSON 介面。這時,你會怎麼處理 Ken 寫的 CCTVAdapter

A. 保留 Adapter:當作一個過渡層,讓系統平滑退場,避免大幅修改呼叫端。

B. 直接移除:刪除 Adapter,直接修改呼叫端的程式碼,讓它改用新的 Client 介面。

請在留言區選擇 A 或 B,並用一句話說明你的理由!

城市望遠鏡(進階視野)

  • EIP/EDA (中觀):在企業整合模式中,Adapter 的概念對應到 Message Translator。它通常被放在事件匯流排 (Event Bus) 的入口處,將所有傳入的訊息都轉換為標準資料模型 (Canonical Data Model),確保匯流排內部流通的事件語言是統一的。

  • Actor Model (中觀):在 Actor 系統中,你可以有一個監督者 (Supervisor) Actor,它底下管理著一群專職的 Adapter Actor。當有新任務進來時,監督者會像一個路由器 (Router),根據任務格式,將其派發給對應的 Adapter Actor 進行處理。

  • MAS (宏觀):在多代理人系統中,一個「翻譯代理」會在 Directory Facilitator (DF,就像代理世界的黃頁) 上宣告自己的能力與服務契約版本(例如:「我提供 v1 版本的 XML 轉 JSON 服務」)。其他代理可以透過 Agent Communication Language (ACL) 來查詢並使用這項服務,甚至協商版本升級或失敗回報機制。

✅ 回到現場

Given:Mia 自供應商取得 CCTV 的 XML 樣本與欄位對照。
When:Ken 實作的 CCTVAdapter.fetch_events() 被呼叫,並將事件 publish() 到 IncidentBus。
Then:IncidentBus 收到含 {id,ts,kind,loc} 的標準 JSON 事件,儀表板可直接渲染;舊系統與新總線零改動通過驗收。

二十字摘要 & 明日預告

  • 二十字摘要:不動兩端,中間設轉接;統一事件語言。

  • 明日預告Bridge——抽象×實作分離,避免組合爆炸。


附錄:ASCII 版圖示

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

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

            🎯 Target Interface
    ╔═══════════════════════════╗
    ║      《 Target 》         ║
    ║    <<interface>> 📋       ║
    ╠═══════════════════════════╣
    ║ + fetchEvents(): Event[]  ║
    ╚═══════════════════════════╝
                 △
                 ║ implements
                 ║
    ╔═══════════════════════════╗    uses    ╔═══════════════════════════╗
    ║    🔌 CCTVAdapter         ║ ────────▶ ║  📹 LegacyCCTVClient     ║
    ║                           ║            ║                           ║
    ║ - adaptee: LegacyCCTV     ║            ║ + getXML(): string        ║
    ║                           ║            ║                           ║
    ║ + fetchEvents(): Event[]  ║            ║   (舊系統 XML 輸出) 📄    ║
    ╚═══════════════════════════╝            ╚═══════════════════════════╝
              轉接器                                  被適配者

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

📹 LegacyCCTV    🔌 CCTVAdapter(Ken)   🚌 IncidentBus(JSON)
      │                   │                      │
      │    📄 getXML()    │                      │
      │ ──────────────────▶                      │
      │                   │                      │
      │                   │ 🔄 Parse & Transform │
      │                   │    XML ──▶ JSON     │
      │                   │                      │
      │                   │  ✨ publish({       │
      │                   │     id: "CAM-A1",   │
      │                   │     ts: "2025...",   │
      │                   │     kind: "traffic", │
      │                   │     loc: "X1-Main"   │
      │                   │  })                  │
      │                   │ ─────────────────────▶
      │                   │                      │
      │                   │     ✅ ack / ❌ err  │
      │                   │ ◀─────────────────────
      │                   │                      │

      🏷️ Mia 提供 XML 樣本與欄位對照表

系統架構概覽 ASCII 版本 🏛️

   🏢 舊 CCTV 系統           🔌 Adapter 轉接站              🚌 事件總線系統
╔══════════════════════╗    ╔══════════════════════╗       ╔══════════════════════╗
║  📹 Legacy CCTV      ║   ║   🔧 CCTVAdapter     ║      ║  🎯 IncidentBus      ║
║                      ║    ║                      ║       ║                      ║
║ ┌──────────────────┐ ║    ║ ┌──────────────────┐ ║     ║ ┌──────────────────┐ ║
║ │   📷 Camera      │ ║XML ║ │ 📄➡️📋 Parser  │ ║JSON ║ │  ⚡ Event        │ ║
║ │     Driver       │ ║───▶║ │                  │ ║───▶║ │    Handler       │ ║
║ │                  │ ║    ║ │  🔄 JSON         │ ║       ║ │                  │ ║
║ │  (10年穩定運行)   │ ║    ║ │    Converter     │ ║       ║ │  (標準化介面)     │ ║
║ └──────────────────┘ ║    ║ └──────────────────┘ ║       ║ └──────────────────┘ ║
║                      ║    ║                      ║       ║                      ║
║   🔒 不可修改         ║    ║   ✨ 新增轉接層      ║       ║   📊 儀表板可直接用   ║
╚══════════════════════╝    ╚══════════════════════╝       ╚══════════════════════╝

          舊方言                    🌉 轉譯橋樑                新標準語言
         XML 格式  ────────────────────────────────────▶  JSON 格式

🎭 Ken 的魔法:讓古老的 XML 攝影機,能和時髦的 JSON 事件總線愉快聊天!
💡 關鍵:兩邊系統都不用改,只要中間加個「翻譯官」就搞定了!

上一篇
Day 6:樣板局——複製勝於創建,但魔鬼藏在深拷貝裡
下一篇
Day 8:抽象與實作的橋梁——Bridge 模式終結組合爆炸!
系列文
Codetopia 新手日記:設計模式與原則的 30 天學習之旅9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言