🎯 什麼情境該想到我
當你「需要一個大家都找得到的地方,來取得共用的服務或物件」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:用一個眾所周知的物件當全域入口,讓程式碼找到共用的服務或物件。
做法要點:
- 提供一個公認的登錄器,透過它查找共用的服務、物件或資料。
- 取代把依賴一路當參數傳遞的做法,需要時直接向登錄器索取。
- 依需求可做成每執行緒(thread-scoped)或每會話範圍,避免共用衝突。
★★ 誠實的評價:這是 PoEAA 裡最該避免的一個模式
// ★ 登錄器 = 一個全域的查找點
var registry = map[string]any{}
func Register(name string, v any) { registry[name] = v }
func Get(name string) any { return registry[name] }
// ★ 呼叫端
db := registry.Get("db").(*sql.DB) // ★★ 型別斷言 + 全域狀態 + 隱藏依賴★★ 它就是 Service Locator,而 Service Locator 被公認為反模式,理由很具體:
★ 依賴變成隱形的——從函式簽章看不出它需要什麼,
你必須讀完整個實作才知道它會去登錄器拿什麼。
★★ 三個具體的傷害
① ★★ 依賴不可見 ⇒ 無法從簽章推理
func Process(o Order) error { db := registry.Get("db") … }
→ ★ 呼叫端不知道這個函式需要資料庫;★★ 測試前要先猜「該註冊什麼」
② ★★ 測試互相污染
TestA 註冊了 fake db,TestB 沒註冊 → ★ 它拿到 TestA 的 fake
→ ★★ 而且加了 t.Parallel() 之後是資料競爭(見 [[全域資料]])
③ ★★ 執行期才失敗
忘記註冊 → ★ nil 或型別斷言 panic,而且是在第一個用到它的請求上
→ ★ 相對的:★★ 建構函式參數缺了 = 編譯錯誤
★★ Go 的答案:在 main() 裡手動組裝
// ★★ 這就是 Go 的 DI —— 不需要框架
func main() {
cfg := loadConfig()
db, err := sql.Open("pgx", cfg.DSN)
if err != nil { log.Fatal(err) }
defer db.Close()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
q := db.New(db)
orders := orderrepo.New()
svc := order.NewService(q, orders, logger) // ★★ 依賴全部在簽章上
h := httpapi.New(svc, logger)
srv := &http.Server{Handler: h}
…
}★★ 這段程式碼的價值:
① ★★ 「這個系統由什麼組成」全部在一個地方,一眼看完
② ★ 依賴在簽章上 ⇒ 忘記傳 = 編譯錯誤
③ ★★ 測試直接呼叫 NewService(fakeQ, fakeRepo, logger) —— 零全域狀態
④ ★ 啟動順序與關閉順序明確(★ defer 的順序就是關閉順序)
★ main() 變長是正常的。一個 200 行的 main() 比一個隱形的登錄器好得多——
它是「系統組裝圖」,而那份圖值得被看見。
★★ 那 PoEAA 為什麼收錄它?它有幾個正當的形態
// ★★ ① 外掛/driver 的註冊表(★ 這是 Go 標準庫自己的做法)
sql.Register("pgx", &pgx.Driver{}) // database/sql
image.RegisterFormat("png", …) // image
// ★ 為什麼正當:① 註冊在 init() 一次完成、之後唯讀
// ② ★★ 目的就是「讓外部套件能加入」——這是 [[外掛]] 的機制
// ③ 配 import _ "…/pgx" 的慣例,依賴在 import 清單裡可見
// ★ ② 無狀態、純函式的查表
var currencyPrecision = map[string]int{"TWD": 0, "USD": 2, "JPY": 0}
// ★ 這其實只是一個常數表,叫它「登錄器」是言重了
// ★★ ③ 指標與 metrics 的註冊(prometheus.MustRegister)
// ★ 為什麼可接受:它是橫切的可觀測性,而且註冊在啟動時完成★★ 共同特徵:啟動時寫入一次,之後唯讀,而且它的目的是「讓外部加入」。
→ ★ 一旦它變成「執行期互相查找業務依賴」,它就是反模式。
★★ 如果你接手了一個用登錄器的系統
★ 遷移路徑(每一步可停):
第 1 步 ★ 不要新增登錄器的使用。新程式碼一律用建構函式注入
第 2 步 ★★ 把「從登錄器拿」的那一行推到最外層
func Process(o Order) error { db := reg.Get("db") … }
→ func Process(db *sql.DB, o Order) error { … }
→ ★ 呼叫端改成 Process(reg.Get("db").(*sql.DB), o)
→ ★★ 依賴變可見了,而且這一步是純機械的
第 3 步 ★ 逐層往上推,最後登錄器只出現在 main()
第 4 步 ★ 刪掉登錄器
★ 見 工具-打破依賴以便測試、封裝全域引用。
★ 兄弟辨析
| 差別 | |
|---|---|
| 識別對應 | ★ 請求作用域的快取;登錄器是全域的 |
| 外掛 | ★ 註冊表是它的機制(★ 這是登錄器的正當用途) |
| 分離介面 | ★ 用介面 + 注入取代全域查找 |
| 單例模式 | ★ 近親;同樣的問題(隱藏依賴、測試困難) |
| 全域資料 | ★ 登錄器就是它的一種形態 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
本質是受控的全域資料,過度使用會造成隱性相依、難測試。
-
多執行緒下要小心可見性與競態,通常需要適當的範圍隔離。
-
能用明確傳參/依賴注入時,往往比登錄器更清楚可測。
-
★★ 這就是 Service Locator,而它被公認為反模式:依賴變成隱形的,從簽章看不出函式需要什麼。
-
★★ Go 的答案是「在
main()裡手動組裝」。不需要 DI 框架,而且main()變長是正常的——它是系統組裝圖。 -
★★ 忘記註冊 = 執行期 panic;忘記傳建構函式參數 = 編譯錯誤。 這是最實質的差別。
-
★★ 登錄器讓測試互相污染,而且加了
t.Parallel()之後是資料競爭。 -
★ 正當的形態只有一種:啟動時寫入一次、之後唯讀、目的是「讓外部套件加入」(
sql.Register、image.RegisterFormat、prometheus 註冊)。 -
★ 一旦它變成「執行期互相查找業務依賴」,它就是反模式。
-
★ 遷移路徑是「把
reg.Get()那一行往外推」,純機械、每一步可停,最後登錄器只出現在main()。 -
不要用「避免傳太多參數」當理由。 參數多是設計訊號(見 過長參數列),不是引入全域狀態的理由。
🔗 相關工具
- 分離介面 Separated Interface(登錄器常回傳介面型別)
- 外掛 Plugin(用設定決定登錄器裡放哪個實作)
- 企業應用架構模式
- 外掛 —— ★ 註冊表的正當用途
- 識別對應 —— 請求作用域 vs 全域
- 分離介面 —— 用注入取代全域查找
- 單例模式 —— 近親,同樣的問題
- 全域資料 —— 登錄器是它的一種形態
- 封裝全域引用、工具-打破依賴以便測試 —— 遷移路徑