🎯 什麼情境該想到我

當你「需要一個大家都找得到的地方,來取得共用的服務或物件」的時候。

⚙️ 怎麼用(步驟 / 公式)

意圖:用一個眾所周知的物件當全域入口,讓程式碼找到共用的服務或物件。

做法要點:

  • 提供一個公認的登錄器,透過它查找共用的服務、物件或資料。
  • 取代把依賴一路當參數傳遞的做法,需要時直接向登錄器索取。
  • 依需求可做成每執行緒(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()。

  • 不要用「避免傳太多參數」當理由。 參數多是設計訊號(見 過長參數列),不是引入全域狀態的理由。

🔗 相關工具