iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
Software Development

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

Day 28:六角架構:當「乾濕分離」的衛浴設計,拯救了瀕臨崩潰的開幕夜!

  • 分享至 

  • xImage
  •  

Codetopia 創城記 (28)|六角架構:當「乾濕分離」的衛浴設計,拯救了瀕臨崩潰的開幕夜!

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

夜幕降臨,海風帶著一絲寒意掠過「港灣數據驛站」。這裡本該是互動影像節的璀璨明珠,但現在,控制室裡只有刺眼的紅色警報和工程師們越來越鐵青的臉色。

「怎麼回事?百面 LED 牆全部過曝,亮到快把人眼睛閃瞎了!」

「票務閘門瘋狂報錯!新的票務供應商升級了授權協議,回傳的 auth_v2 格式我們的系統根本不認!」

「UI 團隊還在改視覺稿,但現場沒一個畫面能正常顯示......」

開幕夜前 6 小時,主顯示器供應商臨時改了 SDK,亮度參數從 0–1 的浮點數變成了 0–100 的整數。另一家票務服務商,也悄悄更換了票券授權的協議版本。問題橫跨三家公司、四種通訊協議,各團隊像無頭蒼蠅一樣互相甩鍋。

這不是單純的 bug,這是一場完美的架構災難。所有東西都黏在一起,牽一髮而動全身。眼看著一場科技盛宴就要變成一場公關浩劫。

2) 術語卡 🧭

  • MVC (Model-View-Controller):一種將應用程式分為三層的設計模式,View 專注呈現,Controller 處理用戶輸入,Model 掌管資料與業務邏輯。

  • 分層架構 (Layered Architecture):將系統水平切割為表現層、應用層、領域層、基礎設施層,確保依賴方向單一。

  • 六角架構 (Hexagonal Architecture / Ports & Adapters):核心業務邏輯(Domain)位於中心,透過定義好的「插座 (Ports)」,與外部世界(如 UI、資料庫、第三方服務)的「轉接頭 (Adapters)」溝通。核心思想:「插頭能換,牆不動」。

  • ACL (Anti-Corruption Layer):反腐敗層,一個位於核心領域與外部系統之間的翻譯層,負責雙向轉換詞彙、單位與模型,保護核心領域不被外部「污染」。

分層架構與依賴方向

在深入之前,先用一張圖確立最重要的規則:所有依賴都必須指向核心。UI 層和基礎設施層 (Infrastructure) 只能向內依賴,核心的領域層 (Domain) 對外部一無所知。

https://ithelp.ithome.com.tw/upload/images/20251012/201785007O249xxthy.png

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

讓我們把時間倒帶回災難發生前。當時的「港灣數據驛站」系統,是個典型的「義大利麵式」傑作。

有個巨大的 ExhibitController 類,長達三千行,它像個控制狂:

  1. 直觸 SDK:程式碼裡充滿了 VendorA_SDK.setBrightness(0.8) 這種寫法。當 VendorA 把參數改成 setBrightness(80) 時,**砰!**全城最亮的探照燈誕生了。

  2. UI 邏輯混雜:Controller 裡有大量程式碼在計算按鈕的顏色、文字的跑馬燈效果,跟後端商業邏輯纏成一團。視覺團隊想換個皮?得先請後端工程師喝一週的咖啡。

  3. 供應商強耦合:驗證票券的邏輯,是照著 PayX 公司的 v1 版協議寫死的。當 PayYv2 新協議加入時,if-else 地獄就此展開,最終徹底崩潰。

(旁白:「看啊,一個把所有雞蛋放在同一個籃子裡,然後對著籃子玩雜耍的經典案例。」)

這種設計,改一處,壞十處。這就是今晚所有混亂的根源。

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

就在眾人瀕臨絕望之際,一支由幾位專家組成的緊急應變小組抵達了現場。他們沒有急著修 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 層,負責雙向翻譯,確保外部世界的術語被正規化為內部標準的領域物件,反之亦然,絕不污染核心。

ACL 雙向翻譯示意圖

https://ithelp.ithome.com.tw/upload/images/20251012/20178500XfJEZGJmz0.png

而 Luca (Luca|適配工程師) 則連夜為所有供應商開發了對應的 Adapter (轉接頭),並用契約測試確保每個轉接頭都符合插座標準。

何時用 (When to Use)

✅ 外部依賴多變:當你的系統需要與多個第三方服務(如金流、裝置 SDK、API 供應商)互動,而且這些服務經常變更時。

✅ 核心業務複雜且需保護:當你想保護珍貴的核心業務邏輯,不被外部技術細節或「髒資料」污染時。

✅ 多種前端或入口:當你的後端需要同時支援 Web、App、甚至指令列等多種使用者介面時,六角架構的 Inbound Port 能提供一致的入口。

✅ 追求高測試性:因為核心邏輯與外部世界解耦,你可以輕易地用「測試用的 Adapter (Test Double)」來取代真實的資料庫或 API,實現快速、可靠的單元測試。

何時不要用 (When NOT to Use)

⛔ 簡單的 CRUD 應用:如果你的應用只是一個簡單的資料庫前端,沒有複雜的業務規則,引入六角架構就像用牛刀殺雞,只會增加不必要的複雜度。

⛔ 專案初期或原型驗證:在需求極不穩定、連核心業務都還沒想清楚的階段,過早劃分邊界可能會成為一種束縛。

⛔ 團隊不熟悉:這不是一個能輕易上手的模式。如果團隊沒有相應的經驗,可能會創造出更難維護的「四不像」架構。

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

導播,鏡頭拉一下!讓我們用三個不同的縮放層級,看看這場救援行動背後的架構藍圖。

視角 關鍵觀念 在港灣數據驛站的對應
微觀(MVC) View / Controller / ViewModel ExhibitView 訂閱 DisplayViewModelWebController 實作 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。

Mermaid|宏觀六角架構總覽

這張圖展示了嚴格的「向內依賴」原則。

規則:Adapter 依賴 Port;Port 不認識 Adapter;所有箭頭只向核心內縮。

https://ithelp.ithome.com.tw/upload/images/20251012/20178500GdzvSUJ3hf.png

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

在看程式碼前,先用這張「Ports ↔ Adapters 目錄」來對照抽象與實作的關係。

https://ithelp.ithome.com.tw/upload/images/20251012/20178500G5r38DkwPA.png

這是校正後的 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)

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

  1. 情境題:回顧「3) 笑中帶淚」的災難現場。如果你是 Luca,需要緊急支援 PayXv1 協議,你會如何設計 PayX_Ticketing_Adapter?它和 PayY_Ticketing_Adapter 應該共享同一個 TicketingPort 嗎?為什麼?

  2. 實作題:請為你的個人專案畫一張簡單的六角架構圖。辨識出核心領域 (Domain),定義 1-2 個 Inbound/Outbound Ports,並列出對應的 Adapters (例如:REST API Controller 是一個 Inbound Adapter,它實作了 ControllerPort;資料庫 ORM 是一個 Outbound Adapter,它實作了 RepositoryPort)。

  3. 反模式紅旗

    • 滲漏的抽象 (Leaky Abstraction):當你的 Port 定義了方法 get_device_status_as_json_string(),這就是紅旗!Port 不該知道 JSON。你該如何修正它?(提示:返回一個 Domain Object)

    • Port 肥大症:一個 Port 定義了二十幾個方法。這違反了什麼設計原則?(提示:介面隔離原則 ISP)。你會如何將它拆分成更小的、依業務能力劃分的 Ports?

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

今天我們看到的是單體應用內的「乾濕分離」。當 Codetopia 規模擴大,這套思想會自然演進,並更專注於穩定性觀測性

失敗路徑與穩定性策略

Adapter 不僅是翻譯官,更是第一線的防衛兵。當外部供應商不穩定時,它必須處理失敗。

https://ithelp.ithome.com.tw/upload/images/20251012/20178500oWPB8MZdSW.png

遙測與觀測性

Adapter 是絕佳的觀測點。我們應在此處統一度量衡,例如使用以下結構化命名來監控供應商:

  • app.adapter.outbound.device.set_luma.attempt

  • app.adapter.outbound.device.set_luma.failed

  • app.adapter.outbound.device.set_luma.latency_ms

灰度釋出與供應商切換

有了清晰的邊界,我們可以安全地切換或灰度釋出新的 Adapter,而不用驚動核心業務。

https://ithelp.ithome.com.tw/upload/images/20251012/20178500YNFGGKO8Ld.png

9) 結語 & 預告 ✨

核心業務是嬌貴的,用 Ports 和 Adapters 為它打造一間「乾濕分離」的衛浴吧!

日出時分,港灣數據驛站的燈光柔和而穩定。遙測數據顯示 95% 的請求都已平穩地走在新架構上。昨晚的混亂,最終催生了一套更具韌性的城市基礎設施。

但故事還沒結束。當一個請求需要跨越多個服務才能完成,如果中途失敗了,該如何優雅地「反悔」?明天,我們來聊聊分佈式世界中的後悔藥:Saga 模式


附錄:ASCII 版圖示

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

分層架構與依賴方向

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   Presentation  │────▶│   Application   │────▶│     Domain      │
│  (Controllers/  │     │   (UseCases)    │     │ ▪ExhibitPolicy  │
│     Views)      │     │                 │     │ ▪DevicePort     │
└─────────────────┘     └─────────────────┘     │ ▪TicketingPort  │
                                                 └─────────────────┘
         ▲                                               ▲
         │                                               │
         │              ┌─────────────────┐              │
         └──────────────│ Infrastructure  │──────────────┘
                        │   (Adapters/    │
                        │   SDKs/DB/Net)  │
                        └─────────────────┘
                                │
                                ▼
                        ┌─────────────────┐
                        │  External SDKs  │
                        │   & Vendors     │
                        └─────────────────┘

依賴規則:所有箭頭都指向 Domain 核心

ACL 雙向翻譯示意圖

┌────────────────┐     ┌──────────────────────┐     ┌─────────────────┐
│    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            │ │ │
                        │ │ └─────────────────────────┘ │ │
                        │ └─────────────────────────────┘ │
                        └─────────────────────────────────┘

核心規則:外層依賴內層,內層不知外層存在

Ports ↔ Adapters 類別關係圖

┌─────────────────┐              ┌─────────────────┐
│  <<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


上一篇
Day 27:KISS/DRY/YAGNI/CUPID:城市座右銘,讓「耶誕城」從災難現場變回可愛天堂!
系列文
Codetopia 新手日記:設計模式與原則的 30 天學習之旅28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言