夜幕降臨,海風帶著一絲寒意掠過「港灣數據驛站」。這裡本該是互動影像節的璀璨明珠,但現在,控制室裡只有刺眼的紅色警報和工程師們越來越鐵青的臉色。
「怎麼回事?百面 LED 牆全部過曝,亮到快把人眼睛閃瞎了!」
「票務閘門瘋狂報錯!新的票務供應商升級了授權協議,回傳的 auth_v2 格式我們的系統根本不認!」
「UI 團隊還在改視覺稿,但現場沒一個畫面能正常顯示......」
開幕夜前 6 小時,主顯示器供應商臨時改了 SDK,亮度參數從 0–1
的浮點數變成了 0–100
的整數。另一家票務服務商,也悄悄更換了票券授權的協議版本。問題橫跨三家公司、四種通訊協議,各團隊像無頭蒼蠅一樣互相甩鍋。
這不是單純的 bug,這是一場完美的架構災難。所有東西都黏在一起,牽一髮而動全身。眼看著一場科技盛宴就要變成一場公關浩劫。
MVC (Model-View-Controller):一種將應用程式分為三層的設計模式,View 專注呈現,Controller 處理用戶輸入,Model 掌管資料與業務邏輯。
分層架構 (Layered Architecture):將系統水平切割為表現層、應用層、領域層、基礎設施層,確保依賴方向單一。
六角架構 (Hexagonal Architecture / Ports & Adapters):核心業務邏輯(Domain)位於中心,透過定義好的「插座 (Ports)」,與外部世界(如 UI、資料庫、第三方服務)的「轉接頭 (Adapters)」溝通。核心思想:「插頭能換,牆不動」。
ACL (Anti-Corruption Layer):反腐敗層,一個位於核心領域與外部系統之間的翻譯層,負責雙向轉換詞彙、單位與模型,保護核心領域不被外部「污染」。
在深入之前,先用一張圖確立最重要的規則:所有依賴都必須指向核心。UI 層和基礎設施層 (Infrastructure) 只能向內依賴,核心的領域層 (Domain) 對外部一無所知。
讓我們把時間倒帶回災難發生前。當時的「港灣數據驛站」系統,是個典型的「義大利麵式」傑作。
有個巨大的 ExhibitController
類,長達三千行,它像個控制狂:
直觸 SDK:程式碼裡充滿了 VendorA_SDK.setBrightness(0.8)
這種寫法。當 VendorA 把參數改成 setBrightness(80)
時,**砰!**全城最亮的探照燈誕生了。
UI 邏輯混雜:Controller 裡有大量程式碼在計算按鈕的顏色、文字的跑馬燈效果,跟後端商業邏輯纏成一團。視覺團隊想換個皮?得先請後端工程師喝一週的咖啡。
供應商強耦合:驗證票券的邏輯,是照著 PayX
公司的 v1
版協議寫死的。當 PayY
以 v2
新協議加入時,if-else 地獄就此展開,最終徹底崩潰。
(旁白:「看啊,一個把所有雞蛋放在同一個籃子裡,然後對著籃子玩雜耍的經典案例。」)
這種設計,改一處,壞十處。這就是今晚所有混亂的根源。
就在眾人瀕臨絕望之際,一支由幾位專家組成的緊急應變小組抵達了現場。他們沒有急著修 bug,而是開始對系統動一場「乾濕分離」的建築手術。
第一刀:Maeve|市容視覺總監,執行 MVC 界面止血
Maeve (Maeve|市容視覺總監) 首先下令:「View 層與 Model 層立刻斷開!」所有前端畫面,不准再直接呼叫任何裝置 API。它們只能訂閱一個名為 DisplayViewModel
的純粹資料物件。(旁批:「這裡的 ViewModel 指的是專為 UI 綁定設計的資料形狀,並非核心的領域模型 (Domain Model),Controller 依舊要保持『薄』且不含商業規則。」)
第二刀:Orion|應用協調官,劃分用例邊界
Orion (Orion|應用協調官) 拿出白板,以市民的視角,將混亂的業務邏輯拆成三個清晰的「可驗收用例」:U1: 看展入場
、U2: 打卡互動
、U3: 消費折抵
。每個用例都是一個獨立的劇本,有自己的成功路徑和失敗補償方案。
第三刀:Cyrus|插座標準官,定義六角邊界
Cyrus (Cyrus|插座標準官) 接著定義了標準「插座 (Ports)」,它們描述的是業務能力,而非技術細節:TicketingPort
(票務能力)、DevicePort
(裝置控制能力)。任何外部 SDK 都必須透過對應的 Adapter 接上來,禁止直連核心業務邏輯。
第四刀:Eden 與 Luca|邊界守護者與適配大師
最後,由 Eden (Eden|邊界語彙審核員) 建立 ACL (反腐敗層)。這個翻譯層隸屬於 Adapter/Infrastructure 層,負責雙向翻譯,確保外部世界的術語被正規化為內部標準的領域物件,反之亦然,絕不污染核心。
而 Luca (Luca|適配工程師) 則連夜為所有供應商開發了對應的 Adapter (轉接頭),並用契約測試確保每個轉接頭都符合插座標準。
✅ 外部依賴多變:當你的系統需要與多個第三方服務(如金流、裝置 SDK、API 供應商)互動,而且這些服務經常變更時。
✅ 核心業務複雜且需保護:當你想保護珍貴的核心業務邏輯,不被外部技術細節或「髒資料」污染時。
✅ 多種前端或入口:當你的後端需要同時支援 Web、App、甚至指令列等多種使用者介面時,六角架構的 Inbound Port 能提供一致的入口。
✅ 追求高測試性:因為核心邏輯與外部世界解耦,你可以輕易地用「測試用的 Adapter (Test Double)」來取代真實的資料庫或 API,實現快速、可靠的單元測試。
⛔ 簡單的 CRUD 應用:如果你的應用只是一個簡單的資料庫前端,沒有複雜的業務規則,引入六角架構就像用牛刀殺雞,只會增加不必要的複雜度。
⛔ 專案初期或原型驗證:在需求極不穩定、連核心業務都還沒想清楚的階段,過早劃分邊界可能會成為一種束縛。
⛔ 團隊不熟悉:這不是一個能輕易上手的模式。如果團隊沒有相應的經驗,可能會創造出更難維護的「四不像」架構。
導播,鏡頭拉一下!讓我們用三個不同的縮放層級,看看這場救援行動背後的架構藍圖。
視角 | 關鍵觀念 | 在港灣數據驛站的對應 |
---|---|---|
微觀(MVC) | View / Controller / ViewModel | ExhibitView 訂閱 DisplayViewModel ;WebController 實作 Inbound Port 。 |
中觀(分層) | Presentation / Application / Domain / Infrastructure | UseCase: AdmitVisitor 位於 Application 層;ExhibitPolicy 位於 Domain 層;所有供應商的 SDK 都被歸類到 Infrastructure 層。 |
宏觀(六角) | Inbound/Outbound Ports + Adapters | Inbound Port:ExhibitControllerPort。Outbound Ports:TicketingPort, DevicePort。Adapters:VendorA_LED_Adapter, PayY_Ticketing_Adapter。 |
這張圖展示了嚴格的「向內依賴」原則。
規則:Adapter 依賴 Port;Port 不認識 Adapter;所有箭頭只向核心內縮。
在看程式碼前,先用這張「Ports ↔ Adapters 目錄」來對照抽象與實作的關係。
這是校正後的 Python 風格偽代碼。我們補上了 ValidatedTicket
等標準模型,讓 UseCase 的互動對象更加清晰、穩定。
# ============== 標準化模型 (跨層共用) ==============
class ValidationErrorCode:
EXPIRED = "TICKET_EXPIRED"
TAMPERED = "SIGNATURE_TAMPERED"
UNKNOWN_VENDOR = "UNKNOWN_VENDOR"
UNKNOWN = "UNKNOWN_ERROR"
class ValidatedTicket:
"""一個標準化的驗證結果,屏蔽了供應商細節。"""
def __init__(self, ok: bool, reason: str | None = None):
self._ok = ok
self.reason = reason
def is_ok(self) -> bool: return self._ok
# ============== 核心領域 (Domain) - 牆壁和插座 ==============
class Luma:
"""一個確保亮度值在 0.0 到 1.0 線性空間的領域物件。
如果供應商使用 0..100 或 gamma 曲線,皆於 Adapter 端轉換。"""
def __init__(self, value: float):
if not 0.0 <= value <= 1.0:
raise ValueError("Luma 必須介於 0.0 到 1.0 (線性空間)")
self.value = value
class DevicePort:
def set_luma(self, level: Luma) -> None:
raise NotImplementedError
class TicketingPort:
def validate(self, token: "TicketToken") -> ValidatedTicket:
raise NotImplementedError
# ============== 應用層 (Application) - 業務流程劇本 ==============
class AdmitVisitorUseCase:
def __init__(self, device: DevicePort, ticketing: TicketingPort):
self.device = device
self.ticketing = ticketing
def execute(self, token: "TicketToken"):
print("執行『看展入場』用例...")
validated_ticket = self.ticketing.validate(token)
if not validated_ticket.is_ok():
print(f"❌ 票券驗證失敗: {validated_ticket.reason}")
return False
print("✅ 票券驗證成功!調整現場燈光...")
self.device.set_luma(Luma(0.75))
return True
class ExhibitControllerPort:
"""Inbound Port: 定義了外部世界可以如何驅動應用。"""
def admit_visitor(self, token: "TicketToken") -> bool:
raise NotImplementedError
# ============== 基礎設施層 (Infrastructure) - 轉接頭和外部 SDK ==============
class VendorA_LED_Adapter(DevicePort):
def set_luma(self, level: Luma) -> None:
# ACL (反腐敗層): 0..1 線性空間 -> 0..100 供應商刻度
brightness = int(round(level.value * 100))
print(f"🔌 [VendorA Adapter] 呼叫 SDK,設定亮度為: {brightness}")
# 保證冪等(相同 Luma 重複呼叫無副作用)
# 在這層實作 timeout + backoff 重試
# 失敗以標準錯誤模型映射,並打點遙測
# VendorA_SDK.setBrightness(brightness)
class PayY_Ticketing_Adapter(TicketingPort):
def validate(self, token: "TicketToken") -> ValidatedTicket:
# ACL: 處理 v2 授權協議、驗證簽名、正規化時間基準
print(f"🔌 [PayY Adapter] 使用 v2 協議驗證票券...")
# is_valid = PayY_SDK.authorize_v2(token)
# if not is_valid:
# return ValidatedTicket(ok=False, reason=ValidationErrorCode.TAMPERED)
return ValidatedTicket(ok=True)
情境題:回顧「3) 笑中帶淚」的災難現場。如果你是 Luca,需要緊急支援 PayX
舊 v1
協議,你會如何設計 PayX_Ticketing_Adapter
?它和 PayY_Ticketing_Adapter
應該共享同一個 TicketingPort
嗎?為什麼?
實作題:請為你的個人專案畫一張簡單的六角架構圖。辨識出核心領域 (Domain),定義 1-2 個 Inbound/Outbound Ports,並列出對應的 Adapters (例如:REST API Controller 是一個 Inbound Adapter,它實作了 ControllerPort
;資料庫 ORM 是一個 Outbound Adapter,它實作了 RepositoryPort
)。
反模式紅旗:
滲漏的抽象 (Leaky Abstraction):當你的 Port 定義了方法 get_device_status_as_json_string()
,這就是紅旗!Port 不該知道 JSON。你該如何修正它?(提示:返回一個 Domain Object)
Port 肥大症:一個 Port 定義了二十幾個方法。這違反了什麼設計原則?(提示:介面隔離原則 ISP)。你會如何將它拆分成更小的、依業務能力劃分的 Ports?
今天我們看到的是單體應用內的「乾濕分離」。當 Codetopia 規模擴大,這套思想會自然演進,並更專注於穩定性與觀測性。
Adapter 不僅是翻譯官,更是第一線的防衛兵。當外部供應商不穩定時,它必須處理失敗。
Adapter 是絕佳的觀測點。我們應在此處統一度量衡,例如使用以下結構化命名來監控供應商:
app.adapter.outbound.device.set_luma.attempt
app.adapter.outbound.device.set_luma.failed
app.adapter.outbound.device.set_luma.latency_ms
有了清晰的邊界,我們可以安全地切換或灰度釋出新的 Adapter,而不用驚動核心業務。
核心業務是嬌貴的,用 Ports 和 Adapters 為它打造一間「乾濕分離」的衛浴吧!
日出時分,港灣數據驛站的燈光柔和而穩定。遙測數據顯示 95% 的請求都已平穩地走在新架構上。昨晚的混亂,最終催生了一套更具韌性的城市基礎設施。
但故事還沒結束。當一個請求需要跨越多個服務才能完成,如果中途失敗了,該如何優雅地「反悔」?明天,我們來聊聊分佈式世界中的後悔藥:Saga 模式。
為了確保在不支援 Mermaid 渲染的環境中也能正常閱讀,以下提供文中圖表的 ASCII 替代版本:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Presentation │────▶│ Application │────▶│ Domain │
│ (Controllers/ │ │ (UseCases) │ │ ▪ExhibitPolicy │
│ Views) │ │ │ │ ▪DevicePort │
└─────────────────┘ └─────────────────┘ │ ▪TicketingPort │
└─────────────────┘
▲ ▲
│ │
│ ┌─────────────────┐ │
└──────────────│ Infrastructure │──────────────┘
│ (Adapters/ │
│ SDKs/DB/Net) │
└─────────────────┘
│
▼
┌─────────────────┐
│ External SDKs │
│ & Vendors │
└─────────────────┘
依賴規則:所有箭頭都指向 Domain 核心
┌────────────────┐ ┌──────────────────────┐ ┌─────────────────┐
│ Domain │◄────┤ Adapter │◄────┤ VendorA SDK │
│ (核心) │ │ (ACL within) │ │ │
│ │ │ │ │ │
│ Luma (0–1 │────▶│ Map Luma → brightness│────▶│ brightness │
│ linear) │ │ (0–100) │ │ (0–100) │
│ │ │ 時間/單位/錯誤 │ │ (gamma/quirks?) │
└────────────────┘ │ 模型正規化 │ └─────────────────┘
└──────────────────────┘
雙向翻譯:Domain ↔ Adapter ↔ External SDK
┌─────────────────────────────────┐
│ External World │
│ │
┌───────────────┐ │ ┌──────────┐ ┌──────────────┐ │
│ User Interface│───┼──┤VendorA │ │ PayY │ │
│ (UI) │ │ │LED SDK │ │Ticketing SDK │ │
└───────────────┘ │ └──────────┘ └──────────────┘ │
└─────────────────────────────────┘
│
┌─────────────────────────────────┐
│ Adapters Layer │
│ │
┌───────────────┐ │ ┌──────────┐ ┌──────────────┐ │
│ WebController │───┼──┤VendorA │ │ PayY │ │
│ (Inbound) │ │ │LED │ │Ticketing │ │
│ │ │ │Adapter │ │Adapter │ │
└───────────────┘ │ │(Outbound)│ │(Outbound) │ │
│ └──────────┘ └──────────────┘ │
└─────────────────────────────────┘
│
┌─────────────────────────────────┐
│ Core Application & Domain │
│ │
│ ┌─────────────────────────────┐ │
│ │ Application │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ExhibitControllerPort │ │ │
│ │ │(Inbound Port) │ │ │
│ │ └─────────────────────────┘ │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │UseCase: AdmitVisitor │ │ │
│ │ └─────────────────────────┘ │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ Domain │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │Outbound Ports: │ │ │
│ │ │▪ TicketingPort │ │ │
│ │ │▪ DevicePort │ │ │
│ │ └─────────────────────────┘ │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ExhibitPolicy │ │ │
│ │ └─────────────────────────┘ │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────┘
核心規則:外層依賴內層,內層不知外層存在
┌─────────────────┐ ┌─────────────────┐
│ <<interface>> │ │ <<interface>> │
│ DevicePort │ │ TicketingPort │
├─────────────────┤ ├─────────────────┤
│+set_luma(Luma) │ │+validate(Token) │
│ : void │ │ : ValidatedTkt │
└─────────────────┘ └─────────────────┘
▲ ▲
│ implements │ implements
│ │
┌─────────────────┐ ┌─────────────────┐
│VendorA_LED_ │ │PayY_Ticketing_ │
│ Adapter │ │ Adapter │
├─────────────────┤ ├─────────────────┤
│+set_luma(Luma) │ │+validate(Token) │
│ : void │ │ : ValidatedTkt │
└─────────────────┘ └─────────────────┘
┌─────────────────┐
│ <<inbound>> │
│ExhibitController│
│ Port │
├─────────────────┤
│+admit_visitor │
│ (Token) : bool │
└─────────────────┘
▲
│ implements
│
┌─────────────────┐
│ WebController │
├─────────────────┤
│+admit_visitor │
│ (Token) : bool │
└─────────────────┘
Controller UseCase DevicePort VendorA SDK
│ │ │ │
│ execute │ │ │
├──────────▶│ │ │
│ │ set_luma │ │
│ ├──────────▶│setBright │
│ │ ├───────────▶│
│ │ │ 504 ❌ │
│ │ │◄───────────┤
│ │ │ │
│ │ ⏰ Retry Logic │
│ │ │setBright │
│ │ ├───────────▶│
│ │ │ 200 ✅ │
│ │ │◄───────────┤
│ │ success │ │
│ │◄──────────┤ │
│ result=T │ │ │
│◄──────────┤ │ │
Adapter 層負責:重試 + 退避 + 冪等保證
[開始]
│
▼
┌─────────┐
│ Shadow │──────┐
│ (影子) │ │
└─────────┘ │
│ │
▼ │
┌─────────┐ │
│Mirrored │ │
│(鏡像流) │ │
└─────────┘ │
│ │
▼ │
┌─────────┐ │ errors > SLO
│ Canary │ │ │
│ (5%流量)│──────┼──────┘
└─────────┘ │
│ │
▼ │
┌─────────┐ │
│ Ramp │ │
│(50%流量)│──────┤
└─────────┘ │
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│ Full │ │Rollback │
│(100%流量)│ │(回退) │
└─────────┘ └─────────┘
│
│ fix & retest
└──────────┐
│
▼
[回到 Shadow]
狀態轉換:Shadow → Mirrored → Canary → Ramp → Full
失敗路徑:任何階段 errors > SLO → Rollback → Shadow