時間:上午 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)的標準,再來談誰有資格插。」
一場圍繞「契約」與「方向」的架構手術, 即將展開。
LSP (里氏替換原則):任何子類別物件,必須可以在不影響程式正確性的前提下,替換掉父類別物件;(人話:龍生龍,鳳生鳳,老鼠的兒子會打洞,你不能換了個燈泡,房子就跳電)。
ISP (介面隔離原則):客戶端不應該被迫依賴它用不到的方法;(人話:我只想開門,你別逼我還得會開飛機)。
DIP (依賴反轉原則):高階模組不該依賴低階模組,兩者都該依賴於抽象;(人話:老闆關心的是「報表」,而不是「Excel 的哪個版本」)。
讓我們把時間倒回事故發生前一小時,外包商代表 Moss 正在電話裡向他的主管邀功:
「報告經理!我把 open()
方法優化了!之前回傳比例 0.6
多不直觀,我改成了回傳開啟的台數!客戶肯定喜歡!還有 close()
,我加了我們公司專有的安全協議,現在呼叫可能會拋出一個安全例外,多安全啊!(殊不知,原來的契約保證絕不拋錯)」
Moss 的「優化」,正是這場災難的導-線:
LSP 契約公然被毀:EcoGateV2
覆寫了 open(percent: float)
,卻擅自將參數的語義從比例扭曲成台數。更糟的是,它改變了 close()
的後置條件(從不拋錯變成可能拋錯)。這直接破壞了上層 SOP 演算法賴以為生的不變量(0.0 <= ratio <= 1.0
),導致整個回歸測試集體炸裂。
ISP 介面過度肥胖:舊的 DeviceManager
介面,像個什麼都管的里長伯,暴露了 17 個方法。新來的設備驅動工程師 Niko 只是想寫個巡檢腳本,讀取設備心跳,卻被迫引用了整個肥大的介面。當 Moss 為 reboot()
方法加上強制權限後,連 Niko 的唯讀小工具都被迫跟著升級權限,簡直是無妄之災。
DIP 依賴方向錯亂:指揮中心的 PolicyEval
(策略評估)模組,程式碼裡赫然寫著 gate_client = new VendorXClient()
。這種高階決策(策略)直接寫死低階實作(廠商 SDK)的作法,意味著供應商一換,我們尊貴的核心模組就得跟著降級,陪著一起重新編譯部署。這完全違背了「對修改封閉」的初衷。
面對一片混亂,Livia、Niko 和 Daria 三位英雄決定聯手,為 Codetopia 的「城市憲法」補上關鍵的 L/I/D 三條修正案,並且這次,要讓憲法可被機器自動驗證。
核心思想:建立一份可被 CI/CD 自動驗證的「可替換契約」,任何供應商的 Adapter 都必須通過測試,才能獲得准入許可。
做法:Livia 不只定義了介面,更定義了契約的邊界與性質:
強化的契約定義:使用 dataclass
定義穩定的 GateStatus
資料結構,取代鬆散的 dict
,杜絕廠商回傳的雜訊污染核心。
明確的例外模型:close()
方法既然保證不拋錯,就不需要回傳 bool
,直接回傳 None
。所有硬體層的錯誤都被吸收,並反映在 GateStatus.is_operational
旗標中。(旁批:若抽象層非得拋錯,也必須是固定的 Port 級錯誤如 PortViolationError
,絕不允許供應商自訂例外穿透 Adapter!)
邊界與性質測試:契約明確規定 ratio
的邊界條件(0.0
到 1.0
),並以「夾逼策略」處理 None
/NaN
/inf
等非法輸入。同時透過單調性測試(open(0.8)
的結果必須大於等於 open(0.6)
)與冪等性測試,確保所有供應商的行為一致。
顆粒度說明:「近似規則:回傳為設備顆粒度量化後的實際比例,僅保證單調與【0,1】範圍,不保證與輸入相等。」
何時用 (When to Use) ✅
當你有一個穩定的抽象,且未來可能有多種不同的實作(例如:不同的資料庫、支付閘道、感測器供應商)。
當你需要確保系統的核心演算法,不會因為底層實作的更動而行為異常時。
在建立可擴充的插件式(Plugin)架構時。
何時不要用 (When NOT to Use) ⚠️
當類別繼承關係非常簡單,且未來不可能有其他子類時,過度強調 LSP 可能會增加不必要的抽象層。
如果子類與父類的行為本質上就不同,試圖強行符合 LSP 反而是錯誤的設計。此時應該考慮組合而非繼承。
核心思想:不只把介面變瘦,更要遵循 CQS (命令查詢分離) 原則,讓客戶端只依賴它真正需要的能力面向,並對齊權限模型。
做法:Niko 這次不只切分,還做了分類。他將原先的 GatePort
分離成兩個職責更單一的 Protocol
:
GateCommander
:只包含改變狀態的命令方法,如 open()
和 close()
。供 SOPRunner
這類需要寫入權限的執行單位使用。
GateMonitor
:只包含讀取狀態的查詢方法,如 status()
。供巡檢工具、儀表板這類只需要唯讀憑證的監控單位使用。
一句話到位:「Monitor 僅需唯讀憑證,Commander 需變更權限;任何巡檢工具不得依賴 Commander。」
何時用 (When to Use) ✅
當你發現一個類別的不同使用者,都只關心其中一小部分方法時。
當命令與查詢有不同的權限、效能或快取需求時,分離它們是個好主意。
在設計需要考慮不同安全等級或權限的模組時。
何時不要用 (When NOT to Use) ⚠️
如果一個介面的所有方法總是被客戶端一起使用,強行拆分反而會讓客戶端需要依賴多個介面,增加複雜度。
對於功能極度內聚、不可再分的小類別,ISP 不是優先考量。
核心思想:依賴方向必須指向穩定。我們不只透過 IoC 容器實現依賴反轉,更要透過 CI 檢查強制這個規則。
做法:Daria 在架構圖旁加了一條紅線,並在 CI/CD 管線中加入了一位「依賴圖守門員」。
自動化規則檢查:在 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 框架可能過於笨重(殺雞用牛刀)。
當你的應用程式只依賴一個極度穩定、幾乎不可能更換的底層服務時。
導播,鏡頭拉一下!讓我們看看這三大原則在 Codetopia 的不同尺度下是如何體現的。
視角 | 觀念/模式 | 在 Codetopia 的說法 |
---|---|---|
微觀 (GoF/SOLID) | LSP / ISP / DIP | 契約不破 / 瘦介面 (CQS) / 抽象優先 |
中觀 (EIP/EDA) | Ports/Adapters (穩定端口) | 核心服務只對 GateCommander /GateMonitor 說話,供應商的 Adapter 負責翻譯 |
宏觀 (MAS) | DF (黃頁服務) / 協作協定 | 黃頁登錄「支援 GatePort v2」的代理;協作語言(ACL)不變 |
微觀結構圖 (Class Diagram with CQS):
宏觀依賴方向圖 (Dependency Rule):
這裡是一段虛擬碼,完美重現了 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()
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 各司其職,毫無感知地跑完了標準流程。這一次,我們換掉的是插頭,而不是牆。
英雄換你當:回到「笑中帶淚」的場景,如果你是 Moss 的同事,你會如何利用 GateStatus
這個新設計,向他解釋為什麼「把錯誤包裝成狀態」比「直接拋出廠商自訂例外」對系統更友善、更符合 LSP?
CQS 大掃除:檢查一下你手邊的專案,有沒有哪個類別或服務,同時混合了大量「改變狀態」和「讀取狀態」的方法?試著用 Commander
和 Monitor
的思路,將它分離成兩個更乾淨的介面。
反模式紅旗 🚩:除了 import vendor_*
,還有哪些常見的寫法,會悄悄破壞 DIP?(提示:思考一下工廠模式 (Factory Pattern) 如果使用不當,會如何變成依賴的溫床?)
今天我們談的 L/I/D,是通往現代軟體架構(如六邊形架構、乾淨架構)的基石。它們的核心思想,都是透過「抽象」來定義穩定的核心,並透過「依賴反轉」來保護這個核心。而我們加入的錯誤地圖(將供應商錯誤碼映射到固定的領域錯誤)、觀測性約束(要求 Adapter 暴露固定的遙測指標如 gate_open_latency_ms
),則是讓這套架構在真實世界中能被有效維運的關鍵保險。
今日核心:契約不破、介面變瘦、依賴反轉;供應商可換,核心不痛。
明日預告:城市憲法 SOLID 已立,但光有法律還不夠!明天,讓我們來聊聊 Codetopia 的城市座右銘——如何用 KISS/DRY/YAGNI/CUPID,讓我們的城市不只強大,更可愛又可維護!
為了確保在不支援 Mermaid 渲染的環境中也能正常閱讀,以下提供文中圖表的 ASCII 替代版本:
┌─────────────────┐ ┌─────────────────────┐
│ 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_* 命名空間
┌─────────────────────────────────────────────┐
│ CI/CD 檢查範圍 │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ 核心模組 │ ────▶ │ 抽象端口 │ │
│ │ core │ │ ports │ │
│ └─────────────┘ └─────────────┘ │
│ ▲ │
│ │ │
│ │ 實作 │
│ │ │
│ ┌─────────────────────────────┴───┐ │
│ │ 供應商適配器 │ │
│ │ vendor_eco_v2 │ │
│ └─────────────────────────────────┘ │
│ │
│ ╳ ← 禁止這個方向的依賴! │
└─────────────────────────────────────────────┘
依賴規則:
✓ 核心模組 → 抽象端口 (允許)
✓ 供應商 → 抽象端口 (允許)
✗ 核心模組 → 供應商 (禁止)
✗ 供應商 → 核心模組 (禁止)
┌──────────────────┬─────────────────────┬──────────────────────────────────┐
│ 視角 │ 觀念/模式 │ 在 Codetopia 的說法 │
├──────────────────┼─────────────────────┼──────────────────────────────────┤
│ 微觀 │ LSP / ISP / DIP │ 契約不破 / 瘦介面 (CQS) / │
│ (GoF/SOLID) │ │ 抽象優先 │
├──────────────────┼─────────────────────┼──────────────────────────────────┤
│ 中觀 │ Ports/Adapters │ 核心服務只對 GateCommander/ │
│ (EIP/EDA) │ (穩定端口) │ GateMonitor 說話,供應商的 │
│ │ │ Adapter 負責翻譯 │
├──────────────────┼─────────────────────┼──────────────────────────────────┤
│ 宏觀 │ DF (黃頁服務) / │ 黃頁登錄「支援 GatePort v2」的 │
│ (MAS) │ 協作協定 │ 代理;協作語言(ACL)不變 │
└──────────────────┴─────────────────────┴──────────────────────────────────┘
供應商新設備上線流程
┌─────────────┐
│ 新供應商 │
│ 設備到貨 │
└──────┬──────┘
│
▼
┌─────────────┐
│ 實作 Gate- │
│ Commander & │
│ GateMonitor │
└──────┬──────┘
│
▼
┌─────────────┐ 通過 ┌─────────────┐
│ 執行 LSP │ ─────────▶ │ 部署到正式 │
│ 契約測試 │ │ 環境 │
└──────┬──────┘ └─────────────┘
│
│ 失敗
▼
┌─────────────┐
│ 修正 Adapter│
│ 重新測試 │
└──────┬──────┘
│
└─────────────────────┘
│
▲ 回到測試