🎯 什麼情境該想到我
當你「想把對外部系統或資源的存取封裝起來、給應用一個好用的介面」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:用一個物件封裝對外部系統或資源的存取。
做法要點:
- 把外部 API、訊息系統、資料來源等的存取細節,包成一個對應用友善的簡單介面。
- 應用其餘部分只呼叫這個閘道,不直接碰外部系統的複雜性。
- 外部系統改變時,只要改閘道;測試時也能替換成假的實作。
★★ 它是 PoEAA 裡最常用、也最容易寫錯的基礎模式
// ★ 閘道 = 用「你自己的詞彙」封裝一個外部資源
type PaymentGateway interface { // ★ 你的詞彙
Charge(ctx context.Context, amount Money, token CardToken) (ChargeID, error)
Refund(ctx context.Context, id ChargeID, amount Money) error
}
// ★★ 外面是 Stripe 的 SDK、它的錯誤碼、它的 idempotency key 慣例 —— 全部藏在實作裡★★ 判準:閘道的介面裡出現外部系統的詞彙了嗎?
出現(StripeChargeParams、http.Response、*sql.Rows)→ 封裝失敗,
你只是搬了一層。
★★ 閘道必須翻譯的四件事(不只是「包一層」)
// ① ★★ 錯誤 —— 把外部錯誤翻成你的哨兵錯誤
func (g *stripeGateway) Charge(ctx context.Context, …) (ChargeID, error) {
res, err := g.client.Charges.New(params)
if err != nil {
var se *stripe.Error
if errors.As(err, &se) {
switch se.Code {
case stripe.ErrorCodeCardDeclined: return "", ErrCardDeclined // ★ 你的錯誤
case stripe.ErrorCodeRateLimit: return "", ErrTemporary // ★ 可重試
}
}
return "", fmt.Errorf("stripe charge: %w", err)
}
…
}
// ★★ 沒有這一步,呼叫端就要認識 stripe.Error ⇒ 封裝形同虛設
// ② ★ 型別 —— 外部的 int cents / string currency → 你的 Money
// ③ ★★ 重試與冪等 —— 外部 API 的 idempotency key 慣例藏在這裡
// ④ ★ 超時與斷路器 —— 見 [[工具-服務容錯設計]]★★ ① 是最常被跳過的,而它決定了「呼叫端能不能在不認識 Stripe 的情況下寫對」。
★★ 閘道的介面要多大:Go 的答案是「消費方定義」
// ❌★ 一個大介面放在閘道自己的 package
package payment
type Gateway interface { Charge; Refund; Capture; Void; ListDisputes; … } // ★ 十個方法
// ✅★★ 介面定義在使用它的一側,只放用到的方法
package checkout
type charger interface { // ★ 未匯出、一個方法
Charge(context.Context, Money, CardToken) (ChargeID, error)
}★ Go 的隱式滿足讓這件事免費:payment.StripeGateway 自動滿足 checkout.charger,
而 payment package 完全不知道它存在(見 工具-針對介面編程、分離介面)。
★★ 測試:閘道存在的最大理由
// ★ 有了閘道,測試 checkout 只需要一個十行的 fake
type fakeCharger struct{ err error; id ChargeID }
func (f fakeCharger) Charge(context.Context, Money, CardToken) (ChargeID, error) {
return f.id, f.err
}
// ★★ 可以輕鬆測:卡片被拒、限流、超時、部分成功 —— 這些用真的 Stripe 很難觸發★★ 但 fake 有一個致命限制:它只驗證「你假設的行為」。
★ 必須配兩種測試:
① ★ 契約測試:★★ 對真實的沙箱環境跑一組測試,驗證你的假設仍然成立
→ ★ 外部 API 改版時,這是唯一會告訴你的東西
② ★ 錄製/重播(VCR 式):錄一次真實回應,之後重播
→ ★ 比 fake 真實,比沙箱穩定;★★ 但錄的內容會過時
→ ★ 見 服務樁(它就是閘道的測試替身)。
★★ 什麼時候不需要閘道
① ★★ 標準庫或極穩定的依賴
★ 包一層 os.ReadFile 或 time.Now 通常是過度工程
⚠️ 但 time.Now 例外:★★ 測試需要控制時間 → 注入一個 func() time.Time 很值得
② ★★ 只有一個呼叫點,而且不需要測試替身
→ ★ 那是 [[冗員元素]]
③ ★ 外部 API 的語義就是你的語義
★ 一個內部服務的 gRPC client,而你的領域詞彙就是它的詞彙
→ ★ 包一層只是改名
★★ 判準:「你想在測試裡替換它」或「你想隔離它的變化」嗎?
兩者都不是 → 不要包。
★ 兄弟辨析
| 差別 | |
|---|---|
| 對應器 | ★ 兩邊互不認識的轉換層;閘道是「我認識外面,外面不認識我」 |
| 配接器模式 | ★ 把 A 的介面轉成 B 的介面(既有介面對既有介面) |
| 防腐層 | ★ DDD 的說法,範圍更大(★ 包含模型轉換,不只 API 封裝) |
| 服務樁 | ★ 閘道的測試替身 |
| 表格資料閘道 | ★ 閘道用在資料庫上的特化 |
| 訊息閘道 | 閘道用在訊息系統上的特化 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
只是薄薄一層轉接,別在裡面塞業務邏輯。
-
外部系統本就簡單好用時,多包一層反而是多餘。
-
介面應照「應用需要什麼」來設計,而非照外部系統長什麼樣。
-
★★ 判準:閘道的介面裡出現外部詞彙了嗎? 出現(
stripe.Error、http.Response、*sql.Rows)→ 封裝失敗,你只是搬了一層。 -
★★ 必須翻譯錯誤(外部錯誤碼 → 你的哨兵錯誤),否則呼叫端還是要認識外部函式庫。
-
★★ fake 只驗證「你假設的行為」。 必須配契約測試(對沙箱跑)或錄製重播,否則外部 API 改版時沒有東西會告訴你。
-
★ 介面定義在消費方、未匯出、只放用到的方法。 Go 的隱式滿足讓這件事免費。
-
★ 標準庫與極穩定的依賴不需要閘道(包
os.ReadFile是過度工程)——但time.Now例外,注入func() time.Time很值得。 -
★ 只有一個呼叫點且不需要測試替身 → 不要包,那是 冗員元素。
-
★ 外部 API 的語義就是你的語義時,包一層只是改名。
-
超時、重試、冪等 key、斷路器都屬於閘道內部,不要讓呼叫端處理。