IThome 鐵人賽
設計模式
Facade
Codetopia
Codetopia 晚會的喧囂剛剛散去,清晨的第一縷陽光還沒照進辦公室,市政廳前台主管 Ava 的電話就已經被打爆了。媒體、交通單位、活動承辦商… 合作夥伴們像一群迷路的羔羊,在電話那頭哀嚎遍野。
「Ava 主管,我想發個公告,到底要先呼叫『內容組包 API』還是先跑『安全簽章』?」
「那個… LED 看板的驅動 Vendor-A 說他們要維護,我們是不是得改接 Vendor-B 的 API?」
「天啊,我只是想把三則短訊打包成一個夜間套餐,為什麼要我懂你們的巢狀結構、加密順序和通路派遣…」
砰!Ava 揉了揉太陽穴。她意識到,我們不能再讓合作夥伴拿著一份「IKEA 家具組裝說明書」來跟市政廳打交道了。他們需要的不是一堆零件,而是一個開箱即用的櫃子。
於是,Ava 拍板定案:啟動「一站式市民快辦 API」專案!在市政廳內部拉起一道俐落的門面,把所有複雜的流程——組包、加密、簽章、多通路派遣——全都藏在門後。對外,只留一個清爽無比的窗口。
【驗收標準】 (Ava 對技術團隊下的軍令狀)
Given:三則公告打包成一個
NightPack
,安全政策為「先加密再簽章」,目標通路是 LED 看板和 App 推播。When:外部合作夥伴只需呼叫一個方法:
CivicBroadcastFacade.publish(...)
。Then:Facade 內部要能完美協調組合 (Composite)、裝飾 (Decorator) 與橋接 (Bridge) 三大子系統。最重要的是,當 LED 供應商從 Vendor-A 換成 Vendor-B 時,外部夥伴的程式碼一個字都不用改!
Facade (外觀/門面):為一群複雜的子系統提供一個單一、簡潔的統一入口。它自己不處理業務,只做「轉接」和「協調」。
Composite (組合):將物件組合成樹狀結構,讓客戶端能以一致的方式處理單一物件和物件群組。(Day 9 的老朋友)
Decorator (裝飾):在不改變物件結構的情況下,動態地為物件增加新功能。(Day 10 的老朋友)
Bridge (橋接):將抽象部分與它的實現部分分離,使它們都可以獨立地變化。(未來會遇到的重要角色)
讓我們把時間倒回專案啟動前的那個混亂早晨。鏡頭轉到合作單位一位菜鳥工程師 Andy 身上,他的螢幕上正是這段令人落淚的「五連招」腳本:
# ❌ 反例:合作夥伴的工程師 Andy 必須親手編排所有內部細節
# (第一招:拜託內容組包系統)
pack = build_night_pack()
# (第二招:手動展開巢狀結構...為什麼我要懂這個?)
payloads = expand_to_leafs(pack)
# (第三招:呼叫安全模組 A 進行加密)
payloads = encrypt(payloads)
# (第四招:呼叫安全模組 B 進行簽章...順序反了會怎樣?天曉得!)
payloads = sign(payloads)
# (第五招:自己寫 if-else 切換通路...供應商一換我就得加班)
for p in payloads:
if channel == "LED":
led_driver_vendor_A.send(p) # 啊,聽說要換 Vendor B 了...
elif channel == "APP":
app_driver.send(p)
# Andy (內心獨白): 「我只是想發個公告,為什麼感覺像在手動組裝火箭?」
# 任何一個子系統的微小改動,都會讓 Andy 的週末泡湯。
災難現場分析:
高度耦合:客戶端 Andy 必須知道所有子系統的名稱、順序,甚至是供應商的細節。
脆弱不堪:市政廳內部任何一次重構或升級,都可能引發外部合作夥伴的連鎖性崩潰。
重複的惡夢:每個合作夥伴都得複製貼上這段樣板程式碼,形成一片廣大的「技術債沼澤」。
一句話概括:Facade (外觀/門面) 模式就是「把複雜留在門後,給世界一個微笑」。它就像一家五星級飯店的萬能前台,你只需要說「我要去機場」,前台就會在幕後協調門僮、禮賓部、計程車調度中心,你完全不必操心這些細節。
簡化介面:當你有一堆複雜的子系統,但只想對外提供一組固定、簡單的操作時。
建立防腐層 (Anti-corruption Layer):當你想隔離一個老舊、混亂或第三方系統時,用 Facade 包起來,保護你的新系統不被污染。
分層解耦:當你希望系統的外部介面保持穩定,而內部可以自由地進行重構和演進時。Facade 就是那道穩固的防火牆。
只是想翻譯介面:如果只是 A 介面轉 B 介面,沒有簡化流程的需求,考慮更輕巧的 Adapter (轉接器) 模式。
想分離不同維度的變化:如果你的問題是「形狀」和「顏色」兩種屬性在交叉組合,導致類別爆炸,那應該考慮 Bridge (橋接) 模式。
需要控制存取權限:如果你想做的是權限控管、延遲載入或遠端代理,那主角應該是 Proxy (代理人) 模式。
導播,鏡頭拉一下!讓我們用三個不同的縮放層級,看看 Ava 的設計在 Codetopia 的架構中是如何定位的。
視角 | 觀念/模式 | 在 Codetopia 的說法 |
---|---|---|
微觀 (GoF) | Facade 對多個 Subsystems 提供單一 API | CivicBroadcastFacade 負責協調 Composer 、Policy 與 Gateway 三大局處。 |
中觀 (EIP/EDA) | Gateway / Aggregator / Translator | 「市民快辦 API Gateway」作為統一入口,內部透過事件路由與訊息轉換,完成複雜任務。 |
宏觀 (MAS) | FrontDeskAgent 協調 Specialist Agents | 「前台代理人」接到任務後,查詢黃頁服務(DF),找到「公告聚合代理」、「安全策略代理」、「通路派遣代理」並指揮它們協同作戰。 |
Mermaid|類圖 (微觀 GoF 結構)
Mermaid|時序圖 (中/宏觀流程)
現在,讓我們看看 Ava 的團隊是如何將這個優雅的設計化為可落地的工程現實。注意看,Facade 本身依然只負責協調和委派,但它的契約變得更強固了。
from typing import Protocol, List, Dict, Any, Tuple
import time, random
# --- 核心型別定義,作為公開契約 ---
class Payload(dict): ...
Ack = Tuple[bool, Dict[str, Any]] # (ok, meta) 以 type alias 取代繼承 typing 型別
class RetryableError(Exception): ...
# --- 子系統的介面 (Protocols) ---
class Policy(Protocol):
def apply(self, items: List[Payload]) -> List[Payload]: ...
class Composer(Protocol):
def compose(self, pack: Any) -> List[Payload]: ...
class Gateway(Protocol):
def dispatch(self, items: List[Payload], channels: List[str], *, idempotency_key: str) -> Ack: ...
# --- 統一的錯誤型別 ---
class FacadeError(Exception):
def __init__(self, code: str, message: str, cause: Any = None):
super().__init__(message)
self.code, self.cause = code, cause
# --- 我們的主角:堅固的 Facade ---
class CivicBroadcastFacade:
def __init__(self, composer: Composer, policy: Policy, gateway: Gateway):
# 依賴注入!這些專家都是從外部請來的。
self.composer = composer # Day 9 的老朋友 (Composite) 負責組包
self.policy = policy # Day 10 的老朋友 (Decorator) 負責加工
self.gateway = gateway # 未來之星 (Bridge) 負責派遣
def publish(self, pack: Any, channels: List[str], *, idempotency_key: str) -> Ack:
# 觀察點:在這裡啟動一個 trace span
try:
items = self.composer.compose(pack)
staged = self.policy.apply(items)
ok, meta = self.gateway.dispatch(
staged, channels, idempotency_key=idempotency_key
)
if not ok:
raise FacadeError(
code="DISPATCH_FAILED",
message="One or more channels failed",
cause=meta.get("errors")
)
return (ok, meta)
except Exception as e:
# 觀察點:記錄結構化日誌
if not isinstance(e, FacadeError):
raise FacadeError(code="INTERNAL_ERROR", message=str(e)) from e
raise e
一份穩定的 Facade,必然伴隨著一份清晰的 API 契約。
# OpenAPI (excerpt)
paths:
/civic/publish:
post:
summary: Publish a NightPack to multiple channels via Facade
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [pack, channels, idempotencyKey]
properties:
pack: { $ref: '#/components/schemas/NightPack' }
channels: { type: array, items: { type: string, enum: [LED, APP, WEBWALL] } }
idempotencyKey: { type: string, minLength: 8 }
responses:
"200":
description: Ack
content:
application/json:
schema:
type: object
properties:
ok: { type: boolean }
meta: { type: object }
"4XX":
description: FacadeError
content:
application/json:
schema:
$ref: '#/components/schemas/FacadeError'
"500":
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/FacadeError'
components:
schemas:
FacadeError:
type: object
required: [error]
properties:
error:
type: object
required: [code, message, traceId]
properties:
code: { type: string, enum: [BAD_REQUEST, DISPATCH_FAILED, OVER_CAPACITY, INTERNAL_ERROR] }
message: { type: string }
traceId: { type: string, format: uuid }
版控策略:路徑固定、以Header 版號(X-Api-Version: 2025-09-01
)或 /v2
演進,向後相容 6 個月後才公告移除期。
Facade 雖然好用,但一不小心就會養出怪獸。看到以下這些「壞味道」,就該亮起紅旗了:
🚩 萬能的上帝 Facade:什麼邏輯都往 Facade 裡塞,最後它變成了另一個難以維護的巨大泥球。記住,Facade 是協調者,不是實幹家。
🚩 洩漏的抽象:Facade 的方法簽名或回傳值中,出現了內部子系統的特定類型。
🚩 薄如紙的門面:Facade 只是簡單地將內部方法一對一轉發,完全沒有簡化任何流程。
🚩 外部的順序依賴:如果外部依然需要先呼叫 facade.stepA()
再呼叫 facade.stepB()
,那這個 Facade 就失去了「一站式」的意義。
從城市的制高點俯瞰,Facade 模式的 DNA 可以在更宏大的架構中找到它的影子,但請注意,它們角色不同,不能劃上等號。
名稱 | 解決層級 | 典型職責 | 不是它的事 |
---|---|---|---|
Facade | 程式內部物件層 | 對多個子系統提供簡化介面與協作編排 | 認證、流量限制、跨服務路由 |
API Gateway | 系統邊界層 | 驗證、路由、聚合、轉換、風控、流量治理 | 內部物件協作細節 |
Facade 對外簡化既有子系統的使用;Mediator 協調同級物件之間的互動以避免彼此直接耦合。本文的
CivicBroadcastFacade
不擔任業務規則的中心(那會是 Mediator 的職責),而是把固定順序的跨子系統流程包在門內,對外暴露最小介面。
模式 | 關注點 | 對象 | 對外介面 |
---|---|---|---|
Facade | 對外簡化 | 異質子系統 | 穩定、極簡 |
Mediator | 物件互動 | 同層同事物件 | 事件式 / 回呼 |
預設情況下,
publish()
以 best-effort 模式運作,這意味著每個通路(LED、APP)的發送是獨立的,一個失敗不影響另一個。如果業務需要 all-or-nothing(全成功或全失敗),則需啟用dispatchMode="atomic"
,這會由ChannelGateway
內部啟用更複雜的二階段提交或補償交易(Saga)模式來保證最終一致性。
併發度:ChannelGateway.dispatch()
以 max_inflight_per_channel=N
控制單通路併發;超過則排隊。
重試退避:對可重試錯誤(5xx/網路)採指數退避+抖動,上限 R
次;對非可重試錯誤(4xx/合規)不重試。
背壓:當隊列超過 Q
時,Facade 回 429 Too Many Requests,錯誤碼 OVER_CAPACITY
,避免雪崩。
批量派送:相同通路可批量聚合(例如 LED 允許一次送多筆),減少往返。
調參建議(起步):
N=8, R=3, Q=1000
;觀測p95 latency / 成功率 / 429 比例
後滾動調整。
讓我們回到開頭的驗收場景:
Given:還是那個 NightPack
。但現在,ChannelGateway
內部的 LED 驅動悄悄從 Vendor-A 換成了 Vendor-B。
When:合作夥伴 Andy 的程式碼完全沒動,依然是那一行清爽的呼叫:facade.publish(pack, channels=["LED", "APP"], idempotency_key="req-123")
。
Then:公告被正確地組包、上鍊、並成功發送到了 LED 看板和 App 上。Andy 甚至不知道供應商換了。Ava 滿意地點點頭,端起了她那杯應得的咖啡。驗收通過!
要確保這道漂亮的門面堅固可靠,我們的測試策略也必須跟著升級:
契約測試 (Contract Testing):驗證 API 的成功與錯誤回傳格式是否符合 OpenAPI 規格。
冪等性測試:使用相同的 idempotency_key
重複呼叫 publish()
,驗證 gateway.dispatch
只被實際執行了一次。
模擬與注入 (Mocking & Injection):注入假的 FakeComposer
、FakePolicy
和 FakeGateway
,驗證 Facade 是否以正確的順序、用正確的參數呼叫了協作者。
抗變測試 (Mutation Testing):故意替換 ChannelGateway
內部的驅動,重新執行契約測試,確保外部行為不變。
Metrics (建議最少):
facade_publish_total{channel, vendor, outcome}
facade_dispatch_latency_ms{channel, vendor}
(p50/p95/p99)
facade_retry_total{channel, vendor, reason}
idempotency_hits_total
Logs (結構化欄位):traceId
, idempotencyKey
, channels[]
, vendorMap
, retryCount
, errorCode
.
Traces:一個 root span (Facade.publish
),子 span:compose
/ apply(policy)
/ dispatch(channel=...)
。
總設計師,藍圖已經就位,現在輪到你來為這座城市添磚加瓦了!
小試身手:請為 ChannelGateway
新增一個 WebWall
(網路電視牆) 通路。你的目標是,在不修改 CivicBroadcastFacade
任何一行程式碼的前提下完成擴充。
責任思辨:目前公告的「摘要模式」是在 NoticeComposer
中完成的。有人提議,不如在 Policy
中新增一個 SummaryPolicy
來做。請比較這兩種做法在職責劃分上的優劣。
靈魂拷問 (小投票):某天,一個「超級用戶」希望能自訂發送流程。你會選擇:
A: 為 Facade 新增一個 publishWithCustomSteps(...)
的「後門」方法。
B: 堅決捍衛 Facade 的簡潔性,拒絕這個需求。
請在留言區留下你的選擇 (A/B) 和一句話理由!
回魂一問:請回顧「笑中帶淚」中 Andy 的腳本。現在,請你只用一行 facade.publish(...)
呼叫來改寫它,徹底拯救 Andy 的週末。
二十字摘要:複雜留門內,對外招式簡。門後勤換將,門外不知曉。
明日預告:全城的圖示、字型、共享單車… 數量一多,記憶體就拉警報!明天,我們來看看 Flyweight (享元) 模式,如何用「共享經濟」的思路,為 Codetopia 的 RAM 減負!
為了確保在不支援 Mermaid 渲染的環境中也能正常閱讀,以下提供文中圖表的 ASCII 替代版本:
+---------------------------+ 聚合 +--------------------+
| CivicBroadcastFacade |<>------------->| NoticeComposer |
+---------------------------+ +--------------------+
| +publish(pack, channels, | 聚合 +--------------------+
| idempotency_key): Ack |<>------------->| Policy |
+---------------------------+ +--------------------+
| +--------------------+
| 聚合 | ChannelGateway |
+--------------------------->+--------------------+
合作夥伴Andy 市民快辦Facade 內容組包處 安全策略局 通路派遣中心
| | | | |
|--publish()--------->| | | |
| | | | |
| |---compose()------>| | |
| |<--payloads[]------| | |
| | | | |
| |---apply()-----------------------> | |
| |<--secured_payloads[]---------------| |
| | | | |
| |---dispatch()---------------------------->| |
|<--ack---------------| | |<-----------------|
| | | | |
+-------------------------------------------------------+
| |
| +-----------+ +-----------+ +--------------+ |
| | | | | | | |
| | Composer | | Policy | | Gateway | |
-->| F | (組合子系統) | | (策略子系統) | | (通路子系統) | |
| A | | | | | | |
| C +-----------+ +-----------+ +--------------+ |
| A |
| D +-----------------------------------+ |
| E | | |
-->| | 協調與流程控制 (只做轉接不做業務) | |
| | | |
| +-----------------------------------+ |
| |
+-------------------------------------------------------+