🎯 什麼情境該想到我
當你「有一份任何角落都能存取、也都能修改的全域資料,出問題時完全查不出是誰改的」的時候——包括全域變數、類別的靜態欄位、單例(singleton)裡的可變狀態。
⚙️ 怎麼用(步驟 / 公式)
這味道是什麼徵兆:全域資料的問題在於「從任何地方都能被改,卻沒有任何機制能看出是哪段碼改的」。它像看不見的暗流,讓 bug 極難重現與定位,且會在整份程式碼裡製造隱形耦合。作者稱它為「最刺鼻的味道之一」。
通常用哪些重構手法對治:
- 封裝變數(Encapsulate Variable):把全域資料藏到一個函式(或一小組函式)後面,所有存取都得經過它。這樣你就有了單一的攔截點,能控管、記錄、限制修改,日後也更容易搬移或改結構。
封裝後,進一步可縮小可見範圍、把「誰能改」限制到最少。
★ Go 裡的五種全域資料,危險程度不同
// ① ★★ 最危險:可變的匯出變數
package cfg
var Timeout = 30 * time.Second // ★ 任何 package、任何 goroutine 都能改
// → 沒有鎖 = 資料競爭;有鎖也還是「誰改了它」無法追蹤
// ② 可變的套件私有變數
var cache = map[string]*User{} // ★ 至少邊界是 package,但併發問題一樣
// ③ ★ init() 建立的全域單例
var db *sql.DB
func init() { db, _ = sql.Open(…) } // ★ 測試無法替換;init 的執行順序跨檔案不保證直觀
// ④ 不可變的全域(✅ 通常沒問題)
var ErrNotFound = errors.New("not found") // ★ sentinel error 是慣例
const MaxRetries = 3
// ⑤ ★ 隱性全域:sync.Once 包起來的延遲初始化
var once sync.Once // ★ 好一點,但仍然是全域狀態# golangci-lint 相關 linter(2026-09-24 查證的官方清單)
# gochecknoglobals —— 檢查沒有全域變數
# gochecknoinits —— 檢查沒有 init 函式
# reassign —— ★ 檢查套件變數沒有被重新賦值(抓別人改你的全域)★★ 為什麼會痛:四個具體情境
// ① ★ 資料競爭 —— go test -race 會抓到,但只在有測試覆蓋時
go func() { cfg.Timeout = time.Second }()
go func() { use(cfg.Timeout) }() // ★ DATA RACE
// ② ★★ 測試互相污染
func TestA(t *testing.T) { cfg.Timeout = time.Second; … } // ★ 沒還原
func TestB(t *testing.T) { … } // ★ 拿到 TestA 留下的值
// 更糟:go test 預設同套件內循序執行,但加了 t.Parallel() 就會爆
// ③ ★ 無法平行測試
// 全域狀態讓 t.Parallel() 變成不可用 —— 測試變慢,而且慢得無法改善
// ④ ★ 「誰改了它」無法回答
// 出事時你有一個錯的值,和整個 codebase 的可能來源★ 對治的順序(不要一步到位)
第 1 步 ─▶ [[封裝變數]]:改小寫 + 存取方法。★ 這一步的價值是「有地方放鎖與驗證」
第 2 步 ─▶ 加鎖或改成不可變(設定只在啟動時讀一次)
第 3 步 ─▶ ★ 變成明確的依賴:從全域變成 struct 欄位,由 main 注入
// ★ 第 3 步的樣子 —— 這是真正的解法
type Service struct {
cfg Config // ★ 不再是全域;每個測試可以給不同的值
db *sql.DB
logger *slog.Logger
now func() time.Time // ★ 連時間都注入 —— 測試可以控制「現在」
}
func New(cfg Config, db *sql.DB, logger *slog.Logger) *Service { … }★ Go 社群的共識:依賴用參數或 struct 欄位傳,不要用全域。
這不是 DI 框架的意思——在main()裡手動組裝就是 Go 的 DI。
★★ 什麼時候全域是對的
| 情況 | 例子 |
|---|---|
| ★ 不可變的 sentinel error | var ErrNotFound = errors.New(…) —— Go 的標準慣例,不要改 |
| 常數 | const MaxRetries = 3 |
| ★ 編譯期就決定的查表/正則 | var emailRe = regexp.MustCompile(…) —— 建立昂貴、之後唯讀 |
| ★ 標準庫自己就這樣做的事 | http.DefaultClient、slog.Default() —— ⚠️ 但函式庫不該依賴它們,要讓呼叫端傳入 |
| ★ driver 註冊表 | sql.Register、image.RegisterFormat —— 這是 Go 的既定模式(配 import _) |
flag / 環境變數解析結果 | 啟動時寫一次、之後唯讀 |
★★ 判準:它在啟動後還會被寫入嗎?
不會(唯讀)→ 全域通常沒問題。
會 → 幾乎一定要處理,不管它看起來多方便。
⚠️ 兩個容易忽略的細節
// ① ★ 「唯讀」的全域 map 其實可寫
var defaults = map[string]string{"a": "1"} // ★ 別的 package 可以 delete、可以改值
// → map 沒有不可變版本。要真的唯讀就包成函式:func Default(k string) string
// ② ★ 全域 slice 的底層陣列會被共用
var Tags = []string{"a", "b"}
func Get() []string { return Tags } // ★ 呼叫端可以 Get()[0] = "x"
// → 見 [[封裝集合]]🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
全域的「常數」(不可變)沒有這個問題,反而是好東西——味道來自「可變」。
-
封裝能大幅降低風險,但無法完全消除全域帶來的耦合;能縮小作用域就縮小。
-
★ 不可變的 sentinel error(
var ErrX = errors.New(…))是 Go 的標準慣例,不是味道。 -
★ 判準是「啟動後還會被寫入嗎」。唯讀的全域通常沒問題;可寫的幾乎一定要處理。
-
★ 「唯讀」的全域 map/slice 其實可寫。Go 沒有不可變集合;要真的唯讀必須包成函式。
-
★ 函式庫不該依賴
http.DefaultClient/slog.Default()。應用程式可以,函式庫要讓呼叫端傳入。 -
★ 全域狀態讓
t.Parallel()不可用,測試會永久地慢。 -
加鎖只解決競爭,不解決「誰改了它」。真正的解法是變成注入的依賴。
-
不要引入 DI 框架。Go 的 DI 就是在
main()裡手動組裝。 -
init()的跨檔案執行順序不直觀,而且測試無法介入。能用New()就不要用init()。
🔗 相關工具
- 可變資料 Mutable Data(全域資料的危害核心正是「可變」)
- 中間人 Middle Man(過度封裝也可能走向另一個極端)
- 重構
- 封裝變數 —— 第一步:至少有地方放鎖與驗證
- 封裝集合 —— 全域 slice/map 的洩漏
- 可變資料 —— 同一根因
- 封裝全域引用 —— 遺留碼裡先封裝再打破依賴
- 工具-打破依賴以便測試 —— 把全域換成注入
- 工具-好萊塢原則與依賴反轉 —— 依賴該往哪個方向