iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
Software Development

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

Day 26:城市憲法 II (L/I/D):插頭要能換,契約不能破!

  • 分享至 

  • xImage
  •  

Codetopia 創城記 (26)|城市憲法 II (L/I/D):插頭要能換,契約不能破!

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

時間:上午 09:10;地點:Codetopia 市中心賽道管制中心。

一年一度的「Codetopia 城市馬拉松」即將在週末鳴槍起跑,預計將有數十萬名跑者與加油民眾湧入市中心賽道。為了確保賽道沿線數十個管制站的人流安全與即時疏導,市府下達了死命令:智慧閘門整合系統必須做到零失誤。

幸好,團隊昨天才剛完成一次漂亮的架構重構,將核心的調度邏輯與硬體控制徹底分離。整個指揮中心的核心服務,此刻正像一顆穩定跳動的心臟,優雅而自信。今天的任務,看起來更像是錦上添花:將一家號稱性能更強、反應更快的供應商 EcoGateV2 的智慧閘門,無縫接入系統,作為本次馬拉松的主力。

工程師接上線路,滿懷信心地敲下啟動指令。他甚至還有時間端起咖啡,欣賞窗外的街景。

就在下一秒,沒有任何巨響或警告。監控儀表板上代表「歲月靜好」的整片綠色,像是被抽走了靈魂,同步閃爍了一下,瞬間轉為一片令人心悸的血紅。系統日誌的最後一行還停在「Driver Initialized」,緊接著的,卻是海嘯般刷屏而來的 FATAL_ERROR

  • 人流引導演算法當場精神錯亂:我們的人流 SOP 明確指示閘門「開啟 60%」,它期待的是一個 0.6 的比例值。但新閘門的驅動程式,竟驕傲地回傳了一個整數 6,代表「6 台閘門已開啟」。演算法收到這個數字,瞬間把它理解成「開啟 600%」,差點下令封鎖整個賽道!

  • 夜間維護流程直接罷工:昨晚還在正常運作的警報靜音 API,在新驅動裡被外包商代表 Moss「貼心」地加上了一個「必填安全密鑰」作為前置條件。結果,自動化的夜間演練腳本在呼叫時,因為沒給密鑰,直接被一個冷冰冰的 Exception 打臉,當場崩潰。

  • 剛蓋好的防火牆被自己人拆了:最慘的是,為了緊急修復問題,指揮中心的核心服務(高階模組)被迫在程式碼裡寫下 import EcoGateV2_SDK 這種恥辱的印記,依賴鏈瞬間倒掛。昨天才慶祝的「對修改封閉」,今天就成了最大的笑話。

盯著滿江紅的儀表板,剛到場的 Livia|替換合規官 眼神冰冷,只說了一句:「我們不是要換牆面,是要換插頭。」她身旁的 Daria|依賴反轉架構師 推了推眼鏡,接口道:「那就得先立好插座(Ports)的標準,再來談誰有資格插。

一場圍繞「契約」與「方向」的架構手術, 即將展開。

2) 術語卡 🧭

  • LSP (里氏替換原則):任何子類別物件,必須可以在不影響程式正確性的前提下,替換掉父類別物件;(人話:龍生龍,鳳生鳳,老鼠的兒子會打洞,你不能換了個燈泡,房子就跳電)。

  • ISP (介面隔離原則):客戶端不應該被迫依賴它用不到的方法;(人話:我只想開門,你別逼我還得會開飛機)。

  • DIP (依賴反轉原則):高階模組不該依賴低階模組,兩者都該依賴於抽象;(人話:老闆關心的是「報表」,而不是「Excel 的哪個版本」)。

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

讓我們把時間倒回事故發生前一小時,外包商代表 Moss 正在電話裡向他的主管邀功:

「報告經理!我把 open() 方法優化了!之前回傳比例 0.6 多不直觀,我改成了回傳開啟的台數!客戶肯定喜歡!還有 close(),我加了我們公司專有的安全協議,現在呼叫可能會拋出一個安全例外,多安全啊!(殊不知,原來的契約保證絕不拋錯)」

Moss 的「優化」,正是這場災難的導-線:

  1. LSP 契約公然被毀EcoGateV2 覆寫了 open(percent: float),卻擅自將參數的語義從比例扭曲成台數。更糟的是,它改變了 close() 的後置條件(從不拋錯變成可能拋錯)。這直接破壞了上層 SOP 演算法賴以為生的不變量0.0 <= ratio <= 1.0),導致整個回歸測試集體炸裂。

  2. ISP 介面過度肥胖:舊的 DeviceManager 介面,像個什麼都管的里長伯,暴露了 17 個方法。新來的設備驅動工程師 Niko 只是想寫個巡檢腳本,讀取設備心跳,卻被迫引用了整個肥大的介面。當 Moss 為 reboot() 方法加上強制權限後,連 Niko 的唯讀小工具都被迫跟著升級權限,簡直是無妄之災。

  3. DIP 依賴方向錯亂:指揮中心的 PolicyEval(策略評估)模組,程式碼裡赫然寫著 gate_client = new VendorXClient()。這種高階決策(策略)直接寫死低階實作(廠商 SDK)的作法,意味著供應商一換,我們尊貴的核心模組就得跟著降級,陪著一起重新編譯部署。這完全違背了「對修改封閉」的初衷。

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

面對一片混亂,Livia、Niko 和 Daria 三位英雄決定聯手,為 Codetopia 的「城市憲法」補上關鍵的 L/I/D 三條修正案,並且這次,要讓憲法可被機器自動驗證

4.1) Livia 的替換契約 (LSP)

核心思想:建立一份可被 CI/CD 自動驗證的「可替換契約」,任何供應商的 Adapter 都必須通過測試,才能獲得准入許可。

  • 做法:Livia 不只定義了介面,更定義了契約的邊界與性質

    1. 強化的契約定義:使用 dataclass 定義穩定的 GateStatus 資料結構,取代鬆散的 dict,杜絕廠商回傳的雜訊污染核心。

    2. 明確的例外模型close() 方法既然保證不拋錯,就不需要回傳 bool,直接回傳 None。所有硬體層的錯誤都被吸收,並反映在 GateStatus.is_operational 旗標中。(旁批:若抽象層非得拋錯,也必須是固定的 Port 級錯誤如 PortViolationError,絕不允許供應商自訂例外穿透 Adapter!)

    3. 邊界與性質測試:契約明確規定 ratio 的邊界條件(0.01.0),並以「夾逼策略」處理 None/NaN/inf 等非法輸入。同時透過單調性測試open(0.8) 的結果必須大於等於 open(0.6))與冪等性測試,確保所有供應商的行為一致。

    4. 顆粒度說明:「近似規則:回傳為設備顆粒度量化後的實際比例,僅保證單調與【0,1】範圍,不保證與輸入相等。」

  • 何時用 (When to Use)

    • 當你有一個穩定的抽象,且未來可能有多種不同的實作(例如:不同的資料庫、支付閘道、感測器供應商)。

    • 當你需要確保系統的核心演算法,不會因為底層實作的更動而行為異常時。

    • 在建立可擴充的插件式(Plugin)架構時。

  • 何時不要用 (When NOT to Use) ⚠️

    • 當類別繼承關係非常簡單,且未來不可能有其他子類時,過度強調 LSP 可能會增加不必要的抽象層。

    • 如果子類與父類的行為本質上就不同,試圖強行符合 LSP 反而是錯誤的設計。此時應該考慮組合而非繼承。

4.2) Niko 的介面手術刀 (ISP)

核心思想:不只把介面變瘦,更要遵循 CQS (命令查詢分離) 原則,讓客戶端只依賴它真正需要的能力面向,並對齊權限模型。

  • 做法:Niko 這次不只切分,還做了分類。他將原先的 GatePort 分離成兩個職責更單一的 Protocol

    • GateCommander:只包含改變狀態的命令方法,如 open()close()。供 SOPRunner 這類需要寫入權限的執行單位使用。

    • GateMonitor:只包含讀取狀態的查詢方法,如 status()。供巡檢工具、儀表板這類只需要唯讀憑證的監控單位使用。

    • 一句話到位:「Monitor 僅需唯讀憑證,Commander 需變更權限;任何巡檢工具不得依賴 Commander。」

  • 何時用 (When to Use)

    • 當你發現一個類別的不同使用者,都只關心其中一小部分方法時。

    • 當命令與查詢有不同的權限、效能或快取需求時,分離它們是個好主意。

    • 在設計需要考慮不同安全等級或權限的模組時。

  • 何時不要用 (When NOT to Use) ⚠️

    • 如果一個介面的所有方法總是被客戶端一起使用,強行拆分反而會讓客戶端需要依賴多個介面,增加複雜度。

    • 對於功能極度內聚、不可再分的小類別,ISP 不是優先考量。

4.3) Daria 的依賴反轉魔法 (DIP)

核心思想:依賴方向必須指向穩定。我們不只透過 IoC 容器實現依賴反轉,更要透過 CI 檢查強制這個規則。

  • 做法:Daria 在架構圖旁加了一條紅線,並在 CI/CD 管線中加入了一位「依賴圖守門員」。

    1. 自動化規則檢查:在 CI 設定檔中加入靜態檢查規則,嚴格禁止核心 (core) 依賴任何廠商實作 (vendor_*),並確保 vendor_* 只依賴抽象 (ports)。

      # importlinter.ini
      [importlinter]
      root_package = codetopia
      
      [contract:core-does-not-depend-on-vendors]
      name = Core does not depend on vendors
      type = forbidden
      source_modules =
          codetopia.core
      forbidden_modules =
          codetopia.vendors
      
      [contract:vendors-depend-on-ports-only]
      name = Vendors depend on ports only
      type = layers
      layers =
          codetopia.core
          codetopia.ports
          codetopia.vendors
      
  • 何時用 (When to Use)

    • 當你希望應用的核心業務邏輯,與外部依賴(如資料庫、UI、第三方服務)完全解耦時。

    • 當系統需要頻繁更換底層技術或供應商時。

    • 這是實現「對擴展開放,對修改封閉」原則的關鍵武器。

  • 何時不要用 (When NOT to Use) ⚠️

    • 對於非常簡單的 CRUD 應用或腳本,引入完整的 DIP 和 IoC 框架可能過於笨重(殺雞用牛刀)。

    • 當你的應用程式只依賴一個極度穩定、幾乎不可能更換的底層服務時。

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

導播,鏡頭拉一下!讓我們看看這三大原則在 Codetopia 的不同尺度下是如何體現的。

視角 觀念/模式 在 Codetopia 的說法
微觀 (GoF/SOLID) LSP / ISP / DIP 契約不破 / 瘦介面 (CQS) / 抽象優先
中觀 (EIP/EDA) Ports/Adapters (穩定端口) 核心服務只對 GateCommander/GateMonitor 說話,供應商的 Adapter 負責翻譯
宏觀 (MAS) DF (黃頁服務) / 協作協定 黃頁登錄「支援 GatePort v2」的代理;協作語言(ACL)不變

微觀結構圖 (Class Diagram with CQS):

https://ithelp.ithome.com.tw/upload/images/20251010/20178500tCrGRTiVE6.png

宏觀依賴方向圖 (Dependency Rule):

https://ithelp.ithome.com.tw/upload/images/20251010/20178500plUUA0Q4iG.png

6) 最小實作 (Python 虛擬碼) 💻

這裡是一段虛擬碼,完美重現了 L/I/D 原則如何拯救這一天。(神經串連:類別名稱與故事角色緊密關聯!)

import math
import threading
from dataclasses import dataclass
from numbers import Real
from typing import Protocol, runtime_checkable, Optional

# --- 4.1 Livia: 更強的契約 (資料型) ---
@dataclass(frozen=True)
class GateStatus:
    ratio: float          # 0.0..1.0
    is_operational: bool  # 供應商內部故障也要歸一成此旗標
    last_error: Optional[str] = None  # 不拋錯時,錯誤資訊落此

# --- 4.2 Niko: ISP + CQS 分離 ---
@runtime_checkable
class GateCommander(Protocol):
    def open(self, ratio: float) -> float: ...
    def close(self) -> None: ...  # 不拋錯 -> 不需回傳 bool

@runtime_checkable
class GateMonitor(Protocol):
    def status(self) -> GateStatus: ...

# 模擬的廠商錯誤
class VendorTimeout(Exception): pass
ERROR_MAP = {"E_TIMEOUT": "TEMPORARY", "E_AUTH": "SECURITY"}

# --- 供應商提供的 Adapter (同時實作兩個 Protocol) ---
class EcoGateV2_Adapter:
    def __init__(self, max_gates: int = 10):
        # 初始化不變量:閘門數必須大於等於1,避免除零
        assert isinstance(max_gates, int) and max_gates >= 1, "max_gates must be >= 1"
        self._MAX_GATES = max_gates
        self._current_open_gates = 0
        self._lock = threading.Lock() # 加上鎖保護狀態
        self._last_known_error = None

    def open(self, ratio: float) -> float:
        with self._lock:
            # 契約:健全化輸入,處理型別與邊界(夾逼策略)
            if not isinstance(ratio, Real) or not math.isfinite(ratio):
                safe_ratio = 0.0
            else:
                safe_ratio = min(max(0.0, float(ratio)), 1.0)

            # 契約:比例->台數採用 ceil 策略,以保證單調性
            target_gates = math.ceil(self._MAX_GATES * safe_ratio)

            try:
                # 實務上這裡會呼叫廠商 SDK,並應加上結構化日誌與遙測
                # print(f"...") -> logger.info("...", extra={"latency_ms": ..., "retry_count": ...})
                print(f"EcoGateV2: 收到比例 {ratio:.2f}, 轉換為開啟 {target_gates} 台閘門...")
                self._current_open_gates = target_gates
                self._last_known_error = None # 成功後清除錯誤
            except VendorTimeout:
                # 故障通道:吸收廠商例外,映射為狀態
                self._last_known_error = ERROR_MAP["E_TIMEOUT"]

            return self._current_open_gates / self._MAX_GATES

    def close(self) -> None:
        with self._lock:
            print("EcoGateV2: 收到關閉指令。")
            self._current_open_gates = 0

    def status(self) -> GateStatus:
        with self._lock:
            # 實現故障通道,映射供應商內部狀態
            is_ok = self._last_known_error is None
            return GateStatus(
                ratio=self._current_open_gates / self._MAX_GATES,
                is_operational=is_ok,
                last_error=self._last_known_error
            )

# --- 高階模組只依賴抽象 ---
class SOPRunner:
    def __init__(self, gate: GateCommander):
        self._gate_commander = gate # 只拿命令權

    def execute_nightly_drill(self):
        print("\n--- SOPRunner 開始夜間演練 ---")
        result_ratio = self._gate_commander.open(0.8)
        print(f"SOPRunner: 閘門已開啟至 {result_ratio:.0%}")
        self._gate_commander.close()
        print("SOPRunner: 閘門已安全關閉。演練成功!")

class PatrolScript:
    def __init__(self, monitor: GateMonitor):
        self._gate_monitor = monitor # 只拿監控權

    def check_status(self):
        print("\n--- 巡檢腳本開始回報 ---")
        current_status = self._gate_monitor.status()
        print(f"PatrolScript: 目前閘門狀態 - 開啟比例: {current_status.ratio:.0%}, 運作正常: {current_status.is_operational}")

# --- 系統啟動與驗收 (DIP) ---
adapter = EcoGateV2_Adapter()
runner = SOPRunner(adapter) # IoC 注入 adapter
runner.execute_nightly_drill()
patrol = PatrolScript(adapter) # IoC 注入 adapter
patrol.check_status()

7) 城市憲法的守護者 (CI/CD 測試策略) 🛡️

Livia:「口說無憑,Code 會說話。」她提交了一份 pytest 測試套件,作為所有供應商的「入學考試」。

import math
import pytest
# 假設 VendorAAdapter 也已修正並遵循契約
from vendor_a import VendorAAdapter

# --- A) 契約/替換測試 (LSP) ---
@pytest.fixture(params=[VendorAAdapter, EcoGateV2_Adapter])
def fresh_adapter(request):
    """測試隔離:為每個測試提供全新的 Adapter 實例"""
    AdapterCls = request.param
    return AdapterCls()

@pytest.mark.parametrize("r", [0.0, 0.3, 0.6, 0.8, 1.0])
def test_open_ratio_contract(fresh_adapter, r):
    """驗證 open 的回傳值是否在契約範圍內"""
    y = fresh_adapter.open(r)
    assert 0.0 <= y <= 1.0 and math.isfinite(y)

@pytest.mark.parametrize("x", [None, "0.5", float("nan"), float("inf"), -0.1, 1.1])
def test_open_invalid_inputs_are_clamped(fresh_adapter, x):
    """驗證非法輸入是否被穩定處理(夾逼策略)"""
    y = fresh_adapter.open(x)
    assert 0.0 <= y <= 1.0

def test_monotonicity(fresh_adapter):
    """驗證單調性:開得更大,回傳比例不能變小"""
    a = fresh_adapter.open(0.6)
    b = fresh_adapter.open(0.8)
    assert b + 1e-6 >= a # 允許極小的浮點數誤差

# --- B) 進階:用 Hypothesis 進行性質測試 ---
from hypothesis import given, strategies as st

@given(st.lists(st.floats(min_value=-0.5, max_value=1.5, allow_nan=False, allow_infinity=False), min_size=2, max_size=20))
def test_monotonic_property(fresh_adapter, seq):
    """隨機序列驗證單調性"""
    seq.sort() # 確保輸入序列是遞增的
    outputs = [fresh_adapter.open(r) for r in seq]
    for i in range(len(outputs) - 1):
        assert outputs[i+1] + 1e-6 >= outputs[i]

中午 11:50,第二家閘門再次上線。CI/CD 管線裡的「替換測試套件」全線綠燈。SOP Runner 和 PatrolScript 各司其職,毫無感知地跑完了標準流程。這一次,我們換掉的是插頭,而不是牆。

8) 鄉民出題 (動手+反模式紅旗) 🧠

  1. 英雄換你當:回到「笑中帶淚」的場景,如果你是 Moss 的同事,你會如何利用 GateStatus 這個新設計,向他解釋為什麼「把錯誤包裝成狀態」比「直接拋出廠商自訂例外」對系統更友善、更符合 LSP?

  2. CQS 大掃除:檢查一下你手邊的專案,有沒有哪個類別或服務,同時混合了大量「改變狀態」和「讀取狀態」的方法?試著用 CommanderMonitor 的思路,將它分離成兩個更乾淨的介面。

  3. 反模式紅旗 🚩:除了 import vendor_*,還有哪些常見的寫法,會悄悄破壞 DIP?(提示:思考一下工廠模式 (Factory Pattern) 如果使用不當,會如何變成依賴的溫床?)

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

今天我們談的 L/I/D,是通往現代軟體架構(如六邊形架構乾淨架構)的基石。它們的核心思想,都是透過「抽象」來定義穩定的核心,並透過「依賴反轉」來保護這個核心。而我們加入的錯誤地圖(將供應商錯誤碼映射到固定的領域錯誤)、觀測性約束(要求 Adapter 暴露固定的遙測指標如 gate_open_latency_ms),則是讓這套架構在真實世界中能被有效維運的關鍵保險。

10) 結語 & 預告 ✨

今日核心:契約不破、介面變瘦、依賴反轉;供應商可換,核心不痛。

明日預告:城市憲法 SOLID 已立,但光有法律還不夠!明天,讓我們來聊聊 Codetopia 的城市座右銘——如何用 KISS/DRY/YAGNI/CUPID,讓我們的城市不只強大,更可愛又可維護!


附錄:ASCII 版圖示

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

微觀結構圖 (Class Diagram with CQS)

┌─────────────────┐         ┌─────────────────────┐
│   SOPRunner     │         │   PatrolScript      │
│                 │         │                     │
│ - gate: Gate-   │         │ - monitor: Gate-    │
│   Commander     │         │   Monitor           │
│ + executePlan() │         │ + checkStatus()     │
└─────────┬───────┘         └─────────┬───────────┘
          │                           │
          │ 依賴                       │ 依賴
          ▼                           ▼
┌─────────────────────┐     ┌─────────────────────┐
│   <<Protocol>>      │     │   <<Protocol>>      │
│   GateCommander     │     │   GateMonitor       │
│                     │     │                     │
│ + open(ratio: float)│     │ + status(): Gate-   │
│ + close()           │     │   Status            │
└─────────────────────┘     └─────────────────────┘
          ▲                           ▲
          │                           │
          └───────────┬───────────────┘
                      │ 實作
                      │
         ┌─────────────────────────────┐
         │   EcoGateV2_Adapter         │
         │                             │
         │ + open(ratio: float)        │
         │ + close()                   │
         │ + status(): GateStatus      │
         └─────────────────────────────┘

    註:核心模組只依賴 Protocol,
        不得依賴任何 vendor_* 命名空間

宏觀依賴方向圖 (Dependency Rule)

        ┌─────────────────────────────────────────────┐
        │              CI/CD 檢查範圍                  │
        │                                           │
        │  ┌─────────────┐         ┌─────────────┐  │
        │  │  核心模組    │  ────▶  │ 抽象端口     │  │
        │  │   core      │         │   ports     │  │
        │  └─────────────┘         └─────────────┘  │
        │                                ▲          │
        │                                │          │
        │                                │ 實作      │
        │                                │          │
        │  ┌─────────────────────────────┴───┐      │
        │  │        供應商適配器              │      │
        │  │     vendor_eco_v2              │      │
        │  └─────────────────────────────────┘      │
        │                                           │
        │         ╳ ← 禁止這個方向的依賴!            │
        └─────────────────────────────────────────────┘

依賴規則:
  ✓ 核心模組 → 抽象端口   (允許)
  ✓ 供應商   → 抽象端口   (允許)
  ✗ 核心模組 → 供應商     (禁止)
  ✗ 供應商   → 核心模組   (禁止)

L/I/D 三大原則表格

┌──────────────────┬─────────────────────┬──────────────────────────────────┐
│      視角        │    觀念/模式        │      在 Codetopia 的說法         │
├──────────────────┼─────────────────────┼──────────────────────────────────┤
│ 微觀             │ LSP / ISP / DIP     │ 契約不破 / 瘦介面 (CQS) /       │
│ (GoF/SOLID)      │                     │ 抽象優先                         │
├──────────────────┼─────────────────────┼──────────────────────────────────┤
│ 中觀             │ Ports/Adapters      │ 核心服務只對 GateCommander/     │
│ (EIP/EDA)        │ (穩定端口)          │ GateMonitor 說話,供應商的       │
│                  │                     │ Adapter 負責翻譯                │
├──────────────────┼─────────────────────┼──────────────────────────────────┤
│ 宏觀             │ DF (黃頁服務) /     │ 黃頁登錄「支援 GatePort v2」的   │
│ (MAS)            │ 協作協定            │ 代理;協作語言(ACL)不變        │
└──────────────────┴─────────────────────┴──────────────────────────────────┘

契約驗證流程圖

    供應商新設備上線流程

    ┌─────────────┐
    │ 新供應商    │
    │ 設備到貨    │
    └──────┬──────┘
           │
           ▼
    ┌─────────────┐
    │ 實作 Gate-  │
    │ Commander & │
    │ GateMonitor │
    └──────┬──────┘
           │
           ▼
    ┌─────────────┐      通過    ┌─────────────┐
    │ 執行 LSP    │ ─────────▶  │ 部署到正式  │
    │ 契約測試    │              │ 環境        │
    └──────┬──────┘              └─────────────┘
           │
           │ 失敗
           ▼
    ┌─────────────┐
    │ 修正 Adapter│
    │ 重新測試    │
    └──────┬──────┘
           │
           └─────────────────────┘
                   │
                   ▲ 回到測試

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

尚未有邦友留言

立即登入留言