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 版本進行管理。
✅ 整合遺留系統:當你想使用一個既有的、無法修改的類別,但它的介面與你的新系統不相容時。
✅ 統一多來源介面:當你需要處理來自多個不同來源、格式各異的資料時,可以為每個來源建立一個 Adapter,將它們統一到一個標準介面。
✅ 簡化測試替身:當你要測試的對象依賴於一個複雜的外部系統時,可以為該外部系統建立一個 Adapter,並在測試時輕易地用一個假的 Adapter (Test Double) 替換掉它。
⛔ 可以直接修改時:如果只是命名或參數順序的微小差異,且你對兩邊的程式碼都有控制權,直接重構使其一致通常是更簡單的選擇。
⛔ 涉及複雜業務規則轉換:如果轉換不只是格式對應,還牽涉到大量的業務邏輯重寫或雙向資料同步,那可能需要升級為更完整的 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 轉譯服務。 |
這就是 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)
👃 介面滲漏: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/逾時,檢查重試/告警行為。
再接再厲:現在市府又採購了一批只能輸出 CSV 格式的環境感測器 ("sensor-01,2025-09-20T15:00:00Z,air_quality,Y2-Park"
)。請你建立一個新的 CsvSensorAdapter
,並在不修改 ingest()
函式的前提下,將其資料匯入事件總線。
智慧分流:如果來源有很多種 (XML, CSV, ...),你會如何設計一個「策略選擇器」或「工廠」,可以根據來源的類型 (source.type
) 自動挑選對應的 Adapter?(提示:想想 Day 3 的 Factory Method)
契約精神:請思考如何為 Target
介面寫一個「契約測試」。這個測試要能確保任何一個實作 Target
的 Adapter(不管是 CCTVAdapter
還是 CsvSensorAdapter
),其 fetch_events()
方法回傳的事件列表中,每個事件都至少包含 id
和 ts
這兩個欄位。
假設明年市府預算充足,決定全面汰換掉舊的 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——抽象×實作分離,避免組合爆炸。
為了確保在不支援 Mermaid 渲染的環境中也能正常閱讀,以下提供文中圖表的 ASCII 替代版本:
🎯 Target Interface
╔═══════════════════════════╗
║ 《 Target 》 ║
║ <<interface>> 📋 ║
╠═══════════════════════════╣
║ + fetchEvents(): Event[] ║
╚═══════════════════════════╝
△
║ implements
║
╔═══════════════════════════╗ uses ╔═══════════════════════════╗
║ 🔌 CCTVAdapter ║ ────────▶ ║ 📹 LegacyCCTVClient ║
║ ║ ║ ║
║ - adaptee: LegacyCCTV ║ ║ + getXML(): string ║
║ ║ ║ ║
║ + fetchEvents(): Event[] ║ ║ (舊系統 XML 輸出) 📄 ║
╚═══════════════════════════╝ ╚═══════════════════════════╝
轉接器 被適配者
📹 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 樣本與欄位對照表
🏢 舊 CCTV 系統 🔌 Adapter 轉接站 🚌 事件總線系統
╔══════════════════════╗ ╔══════════════════════╗ ╔══════════════════════╗
║ 📹 Legacy CCTV ║ ║ 🔧 CCTVAdapter ║ ║ 🎯 IncidentBus ║
║ ║ ║ ║ ║ ║
║ ┌──────────────────┐ ║ ║ ┌──────────────────┐ ║ ║ ┌──────────────────┐ ║
║ │ 📷 Camera │ ║XML ║ │ 📄➡️📋 Parser │ ║JSON ║ │ ⚡ Event │ ║
║ │ Driver │ ║───▶║ │ │ ║───▶║ │ Handler │ ║
║ │ │ ║ ║ │ 🔄 JSON │ ║ ║ │ │ ║
║ │ (10年穩定運行) │ ║ ║ │ Converter │ ║ ║ │ (標準化介面) │ ║
║ └──────────────────┘ ║ ║ └──────────────────┘ ║ ║ └──────────────────┘ ║
║ ║ ║ ║ ║ ║
║ 🔒 不可修改 ║ ║ ✨ 新增轉接層 ║ ║ 📊 儀表板可直接用 ║
╚══════════════════════╝ ╚══════════════════════╝ ╚══════════════════════╝
舊方言 🌉 轉譯橋樑 新標準語言
XML 格式 ────────────────────────────────────▶ JSON 格式
🎭 Ken 的魔法:讓古老的 XML 攝影機,能和時髦的 JSON 事件總線愉快聊天!
💡 關鍵:兩邊系統都不用改,只要中間加個「翻譯官」就搞定了!