Google Go 風格指南
取捨與原則
🎯 什麼情境該想到我
當你「兩種寫法都對、但不知道該選哪個」的時候——尤其是 review 時兩個人各有道理、吵不出結論的時候。
⚙️ 怎麼用(步驟 / 公式)
1. 用這個排序當裁決器
可讀性不是一個模糊的整體,是五個有優先序的屬性:
| 序 | 屬性 | 判準 |
|---|---|---|
| 1 | Clarity 清楚 | 讀者看得懂它在做什麼、以及為什麼 |
| 2 | Simplicity 簡單 | 用最簡單的方式達成目標(行為與效能上都是) |
| 3 | Concision 精簡 | 訊噪比高,重點不被雜訊蓋掉 |
| 4 | Maintainability 可維護 | 下一個人容易正確地改 |
| 5 | Consistency 一致 | 跟更大範圍的 codebase 一致 |
吵架時就問:「這兩個寫法,在第幾條上分出勝負?」 排名靠前的那條贏。
2. ⭐ 一致性是打平時的 tie-breaker,不是理由
原文寫得很精準:
一致性的考量不凌駕上面任何一條原則;但如果必須打破平手,往一致性那邊倒通常是有益的。
所以「旁邊都這樣寫」不足以壓過「這樣寫更清楚」。但如果兩個寫法在清楚/簡單/精簡/可維護上都分不出高下,那就跟旁邊一致。
一致性的優先範圍:同 package 內的一致性最要緊(同一個問題在一個 package 裡有三種解法、同一個概念在一個檔案裡有三個名字,都很刺眼),但仍不能凌駕已文件化的風格原則。
3. Clarity 要分兩個問題問
- 「這段在做什麼?」 → 用更描述性的變數名、切成獨立函式、用空行與註解分段。
- 「為什麼這樣做?」 → 這個只能靠註解。特別是當程式碼帶有讀者不熟的眉角時:語言層的眉角(閉包捕捉了很遠之外的迴圈變數)、業務層的眉角(權限檢查要分辨「本人」與「模擬本人的管理員」)、為了效能而寫得難懂的地方。
註解解釋 why,不要重述 what。 重述 what 的註解會變成雜訊、跟程式碼矛盾、還多一份要維護的東西。
4. ⭐ 最少機制(least mechanism):抽象的升級階梯
同一件事有多種表達方式時,用最標準的工具,按這個順序往上爬,能停就停:
- 核心語言構件 — channel、slice、map、loop、struct
- 標準函式庫 — HTTP client、template engine…
- 專案/公司既有的核心庫
- 才考慮新依賴,或自己造一個
理由:要加複雜度隨時可以加,但發現複雜度沒必要之後要移除就難得多。
實例:
// 需要集合成員檢查 → map 就夠了,不要為此引入 set 套件
seen := map[string]bool{}
// 測試要覆蓋 flag 綁定的值 → 直接改變數,不要用 flag.Set
// (除非你測的就是 CLI 本身)
pollInterval = 5 * time.Second5. 刻意加的複雜度要留下說明
這條原則不是「不准寫複雜的 code」。它要的是:codebase 裡沒有無謂的複雜度,於是複雜度出現時本身就是一個訊號——告訴讀者「這裡需要小心」。
所以複雜的地方要附上:為什麼需要(通常是效能)、要注意什麼、以及示範正確用法的測試或 example。典型情境是預先配置一塊 buffer 在整個 goroutine 生命週期重複使用——維護者看到就知道這段是效能敏感的。
6. 區域一致性(local consistency)的邊界
風格指南沒規定的地方,照你喜歡的寫;但如果附近的程式碼已經有一致立場,就跟它一致。
- ✅ 算有效的在地風格:錯誤格式化用
%s還是%v;用 buffered channel 取代 mutex。 - ❌ 不算的:自訂行長限制;使用 assertion 風格的測試函式庫。
止血條款(很重要):如果這次改動會讓既有的偏差惡化、擴散到更多檔案、露出到更多 API 表面,或引入真正的 bug,「跟旁邊一致」就不再是正當理由。 這時該在同一個 CL 順手清乾淨、或先做一次前置重構。
🧪 我實際套用的紀錄
- 2026-09-20:(待填)
⚠️ 注意 / 什麼時候不適用
- 這五條是排序,不是評分表。 不要拿來幫每個寫法打分數加總;它的用途是在爭執時指出「你們其實是在不同層級上爭論」。
- 排序不代表可以犧牲下面的。 只有在真的衝突時才動用排序。
- 「簡單」指的是讀的人簡單,不是寫的人簡單。 有時候要讓實作變複雜,好讓 API 的使用者更難用錯——這是划算的。反過來也成立,要看哪邊的讀者多。
- 原文自己承認:風格議題本質上是主觀的,這套排序的價值跟
gofmt一樣,大半來自「統一」而不是「正確」。
🔗 相關工具
- 工具-簡單清楚至上 —— 語言無關的同一主張;這張是 Go 的落地版並且給了明確排序
- 工具-管理複雜度 —— 複雜度該怎麼量;跟「最少機制」互補
- 工具-註解是必要之惡 —— 對照:兩邊都主張註解寫 why 不寫 what
錯誤處理
🎯 什麼情境該想到我
當你手上有一個 err,要決定「往上丟的時候要加什麼、用什麼包、還是根本重造一個」的時候。
這張卡講決策(該不該包、包什麼、誰記 log);「怎麼辨識錯誤」的 API 機制(
errors.Is/As/Join)在 工具-Go現代錯誤處理。
⚙️ 怎麼用(步驟 / 公式)
1. ⭐ %v 還是 %w:先問「呼叫端需要程式化地判斷嗎」
%v | %w | |
|---|---|---|
| 做什麼 | 把錯誤壓成字串嵌進新錯誤,丟掉結構 | 建立 Unwrap() 鏈,保留原始錯誤 |
呼叫端能不能 errors.Is/As | ❌ | ✅ |
| 什麼時候用 | 只是加人看的說明;要記 log/顯示;在系統邊界重造錯誤 | 呼叫端需要判斷底層錯誤種類,而且這是你承諾的契約 |
// %v:加上這一層的意義,但不承諾底層錯誤型別
if err != nil {
return fmt.Errorf("launch codes unavailable: %v", err)
}
// %w:明確允許上層 errors.Is(err, fs.ErrNotExist)
if err != nil {
return fmt.Errorf("couldn't find remote file: %w", err)
}關鍵心態:用了 %w,被包住的錯誤就成為你套件 API 的一部分。 原文的判準是——只有當你「明確文件化並測試過」這些底層錯誤可以被解開檢查時,才用 %w。之後想換底層實作就是破壞性變更。
2. 在系統邊界要「翻譯」而不是「包裝」
RPC/IPC/儲存這種跨系統的邊界上,不要把內部錯誤原封不動 %w 往外拋。客戶端不在乎你內部是哪個檔案系統錯誤,他在乎的是標準化的結果(Internal/NotFound/PermissionDenied)。
// Good: 邊界上翻譯成標準錯誤空間
func (*FortuneTeller) SuggestFortune(ctx context.Context, req *pb.SuggestionRequest) (*pb.SuggestionResponse, error) {
if err != nil {
return nil, fmt.Errorf("couldn't find fortune database: %v", err)
}
}3. ⭐ %w 放在結尾(哨兵錯誤例外,放開頭)
包裝會形成錯誤鏈,每包一層就往鏈的前端加一個節點。%w 的位置不影響鏈的結構,但影響印出來的順序:
// Good: 印出來跟鏈的方向一致(新 → 舊)
err2 := fmt.Errorf("err2: %w", err1)
err3 := fmt.Errorf("err3: %w", err2)
fmt.Println(err3) // err3: err2: err1
// Bad: 鏈是新→舊,卻印成舊→新
err2 := fmt.Errorf("%w: err2", err1)
err3 := fmt.Errorf("%w: err3", err2)
fmt.Println(err3) // err1: err2: err3
// Bad: 夾在中間,印出來兩邊開花
err2 := fmt.Errorf("err2-1 %w err2-2", err1)
err3 := fmt.Errorf("err3-1 %w err3-2", err2)
fmt.Println(err3) // err3-1 err2-1 err1 err2-2 err3-2所以預設寫成 fmt.Errorf("做什麼的時候失敗: %w", err)。
例外:哨兵錯誤放開頭。 哨兵錯誤是「這次失敗的主要分類」(not found、invalid argument),把它放最前面,讀的人一眼就知道類別:
var ErrParse = fmt.Errorf("parse error")
var ErrParseInvalidHeader = fmt.Errorf("%w: invalid header", ErrParse)
func parseHeader() error {
err := checkHeader()
return fmt.Errorf("%w: invalid character in header: %v", ErrParseInvalidHeader, err)
}4. 加資訊的兩條反例(幾乎每個 codebase 都在犯)
// Bad: os 的錯誤本來就含路徑 → 印出來檔名出現兩次
return fmt.Errorf("could not open settings.txt: %v", err)
// could not open settings.txt: open settings.txt: no such file or directory
// Good: 只加這一層才知道的意義
return fmt.Errorf("launch codes unavailable: %v", err)
// launch codes unavailable: open settings.txt: no such file or directory
// Bad: 純雜訊,「有 error」本身就代表失敗了
return fmt.Errorf("failed: %v", err) // → 直接 return err判準:這一層加的字,是呼叫端與被呼叫端都不知道、只有我知道的資訊嗎? 不是就別加。
5. 要讓呼叫端分辨錯誤,就給它結構——絕不讓它比對字串
// Good: 哨兵值
var (
ErrDuplicate = errors.New("duplicate")
ErrMarsupial = errors.New("marsupials are not supported")
)
// 呼叫端
switch {
case errors.Is(err, ErrDuplicate): ...
case errors.Is(err, ErrMarsupial): ...
}
// Bad
if regexp.MatchString(`duplicate`, err.Error()) { ... }需要帶額外資料就給結構化型別(像 os.PathError 把路徑放在欄位讓人取用),而不是塞進訊息字串等人 parse。
6. 錯誤字串的格式:小寫、無句號
err := fmt.Errorf("something bad happened") // ✅ 它會被嵌進別人的句子裡
err := fmt.Errorf("Something bad happened.") // ❌例外是開頭本來就是匯出名稱、專有名詞或縮寫。反過來,完整顯示的訊息要大寫(log、測試失敗、API 回應、UI):
log.Errorf("Operation aborted: %v", err)
t.Errorf("Op(%q) failed unexpectedly; err=%v", args, err)7. ⭐ 回傳了就別自己記 log
如果你要回傳這個錯誤,通常就不要自己記 log,讓呼叫端決定。
呼叫端可以選擇記、可以限流(rate.Sometimes)、可以嘗試恢復、也可以直接結束程式。自己記一次又往上丟,就變成同一件事在 log 裡出現 N 次。
代價要知道:讓呼叫端記,log 的行號就是呼叫端的行號,不是出錯的那一行。
另外三條:
- 注意 PII,很多 log sink 不適合放使用者敏感資料。
log.Error要省著用:ERROR 級別會觸發 flush,比低級別貴得多。判準不是「比 warning 嚴重」,而是這條訊息是否可行動(actionable)。- 決定要不要 log 之前,先確認是不是該回傳。
8. 處理錯誤只有三個合法選項
- 當場處理掉
- 回傳給呼叫端
- 例外情況下
log.Fatal/(真的必要時)panic
不要用 _ 丟掉錯誤。 真的要忽略(例如文件明說不會失敗的 (*bytes.Buffer).Write),要附註解說明為什麼安全:
n, _ := b.Write(p) // never returns a non-nil error🧪 我實際套用的紀錄
- 2026-09-20:(待填)
⚠️ 注意 / 什麼時候不適用
- 不要每一層都包。 每層加一句很快就變成又臭又長的訊息。只在跨越有意義的邊界時加。
%w是單向承諾:加上去容易,拿掉是破壞性變更。不確定呼叫端需不需要時,預設%v。- 哨兵放開頭的例外只適用於哨兵。一般的上下文說明仍然放前面、
%w放最後。 - 原文的
status.Errorf/canonical codes 是 Google 內部與 gRPC 的做法;沒有 gRPC 的專案,對應概念是「在邊界上定義一組自己的對外錯誤分類」。 - 編排一組會一起失敗或一起取消的操作時,只取第一個錯誤是合理的——用
errgroup。
🔗 相關工具
- 工具-Go現代錯誤處理 —— 互補:那張講辨識錯誤的 API(
Is/As/Join),這張講包裝與傳遞的決策 - 工具-讓測試失敗訊息有用 —— 同一個原則的測試版:訊息要讓維護者知道發生什麼、且不要靠字串比對錯誤
- 工具-用例外處理錯誤 —— 對照:那是例外式語言的做法,Go 刻意選了回傳值
錯誤字串小寫無句號 不要 in-band error 錯誤流程往外推
測試
🎯 什麼情境該想到我
當你看到 CI 上一行 FAIL,卻得點進測試原始碼才知道到底哪裡不對的時候。
⚙️ 怎麼用(步驟 / 公式)
0. 整章只有一個標準
不看測試原始碼,就要能診斷失敗。
失敗訊息要回答四件事:什麼造成失敗、什麼輸入造成的、實際得到什麼、原本期望什麼。 下面全部都是從這一句推出來的。
1. 固定格式:YourFunc(%v) = %v, want %v
// Bad
t.Errorf("got %v, want %v", got, want)
// Good
t.Errorf("Compare(%q, %q) = %v, want %v", test.a, test.b, got, test.want)三個要素:
- 報出函式名,即使測試函式名看起來已經很明顯(同一個測試可能呼叫它好幾次)。
- 報出輸入,只要輸入夠短。太大或太不透明就給測試案例取個描述性的名字,把描述印進訊息裡。
- got 在 want 前,而且用 “got”/“want” 這兩個字,不要用 “actual”/“expected”。
2. ⭐ 用 t.Error 讓測試跑完,t.Fatal 只在「繼續下去沒意義」時用
測試應該盡量跑到底,一次把所有失敗都印出來——否則修 bug 的人得修一個跑一次。
// Good: 設定失敗 → Fatal;各項比對 → Error,全部都印出來
gotMean, gotVariance, err := MyDistribution(input)
if err != nil {
t.Fatalf("MyDistribution(%v) returned unexpected error: %v", input, err)
}
if diff := cmp.Diff(wantMean, gotMean); diff != "" {
t.Errorf("MyDistribution(%v) mean diff (-want +got):\n%s", input, diff)
}
if diff := cmp.Diff(wantVariance, gotVariance); diff != "" {
t.Errorf("MyDistribution(%v) variance diff (-want +got):\n%s", input, diff)
}t.Fatal 的正當時機是後續失敗會沒有意義、甚至誤導:
gotEncoded := Encode(input)
if gotEncoded != wantEncoded {
t.Fatalf("Encode(%q) = %q, want %q", input, gotEncoded, wantEncoded)
// 編碼就錯了,再去解碼它毫無意義
}表格測試裡的規則:
- 沒用
t.Run子測試 → 用t.Error後面接continue,跳到下一列。 - 有用
t.Run子測試 → 用t.Fatal,它只結束這個子測試,其他列照跑。 - ⚠️ 不要在另一個 goroutine 裡呼叫
t.Fatal(它靠runtime.Goexit,在別的 goroutine 裡不會終止測試)。
測試輔助函式(setup/cleanup 那種)裡失敗,該用 t.Fatalf 並先呼叫 t.Helper(),訊息要講清楚是 setup 失敗:
func mustAddGameAssets(t *testing.T, dir string) {
t.Helper()
if err := os.WriteFile(path.Join(dir, "pak0.pak"), pak0, 0644); err != nil {
t.Fatalf("Setup failed: could not write pak0 asset: %v", err)
}
}3. 結構比對用 cmp,不要手寫逐欄位比
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("AddPost() returned unexpected diff (-want +got):\n%s", diff)
}cmp.Equal判等、cmp.Diff產生人看得懂的差異。- 一定要在訊息裡標明方向:傳
(want, got)就寫(-want +got),傳(got, want)就寫(-got +want)——因為-/+會對應到 diff 每行開頭的符號。既有程式碼在這件事上很不一致,所以必須顯式寫出來。 - diff 前面要先換行,因為它跨多行。
- protobuf 要加
protocmp.Transform()。
為什麼不用別的:
reflect.DeepEqual不該用來判等——它對未匯出欄位與實作細節敏感。pretty會把「視覺上一樣」當成相等,分不出 nil slice 與空 slice,且預設比較未匯出欄位。- ⚠️
cmp是為測試設計的,不要用在 production:它懷疑你比對方式不對時會 panic。
回傳多個值時,不用先包成 struct 再比,分別比對並分別印就好。
4. ⭐ 不要自製 assertion library
// Bad
assert.IsNotNil(t, "obj", obj)
assert.StringEq(t, "obj.Type", obj.Type, "blogPost")原文的理由,三個都很實在:
- 這類函式內部通常呼叫
t.Fatalf,讓測試提早死掉,其他問題看不到。 - 它看不到測試函式裡的上下文,產出的訊息反而更少。
- 每個團隊一套 assertion 庫 → 大規模重構的人要面對 N 種輸出格式。
要共用比對邏輯,回傳值或 error,讓測試自己組訊息:
// Good
func postLength(p BlogPost) int { return len(p.Body) }
if got, want := postLength(post), 60; got != want {
t.Errorf("Length of post = %v, want %v", got, want)
}(順帶一提:如果 postLength 不只這麼簡單,它本身就該有自己的測試。)
5. 不要讓測試變成 change detector
- 不要用字串比對判斷錯誤種類。錯誤訊息被改寫,測試就壞——但行為根本沒變。要判斷就用
errors.Is,或cmp加cmpopts.EquateErrors。- 只在乎「有沒有錯」時,連
cmp都不用,wantErr用bool就好:err := f(test.input) if gotErr := err != nil; gotErr != test.wantErr { t.Errorf("f(%q) = %v, want error presence = %v", test.input, err, test.wantErr) } - 用字串比對來檢查「錯誤訊息包含某個參數名」這種性質,是可以的。
- 只在乎「有沒有錯」時,連
- 不要比對不是你擁有的套件的輸出穩定性。
json.Marshal的輸出 byte 變過;比對 JSON 字串相等的測試會因為標準庫的改動而壞。改成 parse 回來比對語義結構。
6. 詳細程度要分情況
- 複雜互動要講清楚是第幾次呼叫失敗、以及當時的系統狀態。
- 大 struct 可以只描述重點,但不要模糊到看不出資料。
- Setup 失敗不需要同等詳細:
t.Fatalf("Setup: Failed to set up test database: %s", err)通常就夠了。 - 印字串用
%q(一眼看出空白與怪字元),印小 struct 用%+v,大的用 diff。
實用招數:開發時故意讓它失敗一次,看看訊息長什麼樣、維護者能不能靠它解決問題。
🧪 我實際套用的紀錄
- 2026-09-20:(待填)
⚠️ 注意 / 什麼時候不適用
cmp不在標準函式庫(github.com/google/go-cmp),但由 Go team 維護。專案不想加依賴時,至少守住「格式固定、got 在 want 前、報函式名與輸入」這幾條。- 「不要 assertion library」不等於「不要 test helper」。 做 setup/cleanup 的 helper 是鼓勵的;被禁的是把「驗證」跟「產生失敗訊息」綁在一起的東西。
- 資料含不相關欄位、會蓋掉測試意圖時,才不做完整結構比對;這時用
cmpopts.IgnoreFields/IgnoreInterfaces而不是退回逐欄位手寫。 - Go 1.14 起有
t.Cleanup,比defer更適合寫在 helper 裡。
🔗 相關工具
- 工具-Go表格測試與子測試 —— 同一章的另一半:測試怎麼組織;那張的失敗訊息規則就是這張
- 工具-Go錯誤該包還是該轉 —— 「不要靠錯誤字串判斷種類」的另一面:所以錯誤要有結構
- 工具-特徵測試 —— 對照:特徵測試刻意要鎖住現況,change detector 在那裡反而是目的
🎯 什麼情境該想到我
當你正在貼第四、第五個長得幾乎一樣的測試函式,或是表格測試的迴圈裡開始長出 if 的時候。
⚙️ 怎麼用(步驟 / 公式)
1. 什麼時候該用表格測試
兩種情況:
- 驗證輸出等於預期(像
fmt.Sprintf的那堆測試)。 - 驗證輸出恆滿足同一組不變量(像
net.Dial的測試)。
共同前提是測試邏輯相同,只有資料不同。
最小結構就長這樣,不一定要子測試:
func TestCompare(t *testing.T) {
compareTests := []struct {
a, b string
want int
}{
{"", "", 0},
{"a", "", 1},
{"abc", "ab", 1},
// test runtime·memeq's chunked implementation
{"abcdefghi", "abcdefghj", -1},
}
for _, test := range compareTests {
got := Compare(test.a, test.b)
if got != test.want {
t.Errorf("Compare(%q, %q) = %v, want %v", test.a, test.b, got, test.want)
}
}
}注意失敗訊息報出了函式名與輸入,所以不需要再報「第幾列」。(訊息規則見 工具-讓測試失敗訊息有用。)
2. ⭐ 子測試名字要像識別字,不是像句子
t.Run 的第一個參數會進到測試輸出、也會被 -run 與 Bazel test filter 用來篩選。所以:
- 想成函式名,不是散文描述。
- 測試執行器會把空白換成底線、跳脫非可列印字元,所以避免這些字元,log 才對得回原始碼。
- 斜線最毒——它對 test filter 有特殊意義:
# 假設 TestTime 裡有 t.Run("America/New_York", ...) bazel test :mytest --test_filter="Time/New_York" # 什麼都沒跑! bazel test :mytest --test_filter="Time//New_York" # 對,但很難打
// Bad
t.Run("check that there is no mention of scratched records or hovercrafts", ...)
t.Run("AM/PM confusion", ...)
// Good
t.Run("hu=en_bug-1234", ...)需要長描述時,另開一個欄位,在失敗訊息裡印出來:
data := []struct {
name, desc, srcLang, dstLang, srcText, wantDstText string
}{
{
name: "hu=en_bug-1234",
desc: "regression test following bug 1234. contact: cleese",
srcLang: "hu",
srcText: "cigarettát és egy öngyújtót kérek",
dstLang: "en",
wantDstText: "cigarettes and a lighter please",
},
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
got := Translate(d.srcLang, d.dstLang, d.srcText)
if got != d.wantDstText {
t.Errorf("%s\nTranslate(%q, %q, %q) = %q, want %q",
d.desc, d.srcLang, d.dstLang, d.srcText, got, d.wantDstText)
}
})
}3. 子測試之間不可以互相依賴
子測試必須能被單獨執行(go test -run 或 Bazel filter 隨時會只跑其中一個),所以不能依賴別的案例先跑過、也不能依賴前一個案例留下的狀態。
4. ⭐ 迴圈裡開始長 if,就該拆成多個測試函式
這是最實用的判準。表格測試的價值來自所有列走同一條邏輯;一旦列的值開始決定迴圈裡走哪條分支,可讀性就開始崩。
可以容忍的程度(簡單的錯誤檢查、不製造分支邏輯):
for _, test := range tests {
got, err := Divide(test.dividend, test.divisor)
if (err != nil) != test.wantErr {
t.Errorf("Divide(%d, %d) error = %v, want error presence = %t",
test.dividend, test.divisor, err, test.wantErr)
}
if err != nil {
continue // 這裡只驗成功情況的值
}
if got != test.want {
t.Errorf("Divide(%d, %d) = %d, want %d", test.dividend, test.divisor, got, test.want)
}
}原文提醒:今天看起來很單純的東西,會有機地長成無法維護的東西。
該拆的時候就拆成兩個表格測試函式——一個測正常輸出、一個測錯誤輸入。若「邏輯不同但 setup 相同」,則改用一連串子測試會更好讀。
5. ⭐ 不要把「用哪個依賴」放進表格欄位
這是 data-driven 的經典陷阱:把 setup 的選擇塞進資料列,讀的人就得在腦中模擬才知道這一列跑的是哪個版本。
// Bad: codex 欄位讓每一列的 setup 都不一樣
type decodeCase struct {
name string
input string
codex testCodex // fake 還是 real?要回去查表
output string
err error
}正解是共用資料列型別,但寫兩個測試函式,各自決定依賴:
// Good
type decodeCase struct {
name string
input string
output string
err error
}
func TestDecode(t *testing.T) {
codex := setupCodex(t) // 真的(慢)
for _, test := range tests {
t.Run(test.name, func(t *testing.T) { /* ... */ })
}
}
func TestDecodeWithFake(t *testing.T) {
codex := newFakeCodex() // 假的(快)
for _, test := range tests {
t.Run(test.name, func(t *testing.T) { /* ... */ })
}
}重複兩段迴圈看起來不 DRY,但這份重複換來的清楚是值得的。
6. 子測試裡用 t.Fatal,沒子測試用 t.Error + continue
- 用了
t.Run:t.Fatal只結束當前子測試,其他列照跑。 - 沒用
t.Run:t.Fatal會終止整個測試函式,所以要t.Error後面接continue。 - 表格迴圈之前的整體 setup 失敗,用
t.Fatal是對的。
🧪 我實際套用的紀錄
- 2026-09-20:(待填)
⚠️ 注意 / 什麼時候不適用
- 子測試不是必需品。 原文明說「可以很有用,但不是強制的」。案例少、不需要個別篩選時,純表格就夠。
- 表格不是萬用錘。 測試邏輯本身不同的案例,本來就該是不同的測試函式。
- 斜線那條在純
go test -run下也成立(-run用/分隔子測試層級)。 - 表格列裡塞太多欄位本身就是訊號:可能是被測函式的參數太多(見 工具-Go函式參數太多怎麼辦),或是這個表格該拆了。
🔗 相關工具
- 工具-讓測試失敗訊息有用 —— 表格測試的失敗訊息規則全在那張
- 工具-Go全域狀態的判準 —— 「子測試不可互相依賴」失守最常見的原因就是全域狀態
- 工具-DRY原則 —— 對照:這裡刻意容許重複,因為清楚優先於不重複
API 與狀態設計
🎯 什麼情境該想到我
當你正要寫下 var somethingGlobal = ... 或 func Register(name string, ...) 的時候——在動手前跑一次下面的 litmus test。
⚙️ 怎麼用(步驟 / 公式)
1. 先認出四種常見的「套件層級狀態」
它們都是同一個問題的不同外表:
- 頂層變數(不管有沒有匯出)
// Bad package logger var Sinks []Sink - Service locator 定義成全域(pattern 本身沒問題,全域才有問題)
- 註冊表/callback 清單
// Bad package health var unhealthyFuncs []func func OnUnhealthy(f func()) { unhealthyFuncs = append(unhealthyFuncs, f) } - 厚 client 單例(backend、storage、DAL 等系統資源)
// Bad package useradmin var client pb.UserAdminServiceClientInterface func Client() *pb.UserAdminServiceClient { if client == nil { client = ... } return client }
2. ⭐ Litmus test:出現以下任一項就是不安全
- 兩個本來互不相干的函式(不同作者、不同目錄)因為全域狀態而互相影響。
- 獨立的測試案例透過全域狀態互相干擾。
- 使用者會想為了測試去抽換/取代這份全域狀態(換成 stub/fake/spy/mock)。
- 使用者必須考慮呼叫順序:要在
func init裡嗎?flag 解析完了沒?main之前還之後?
3. 反過來,符合以下任一項才算安全
- 全域狀態邏輯上是常數。
- 套件的可觀察行為是無狀態的。例如用私有全域變數當快取,但呼叫端分不出 cache hit 還是 miss——那就是無狀態。
- 狀態不外溢到程式之外(sidecar 行程、共享檔案系統…)。
- 本來就沒有可預期行為的期待。
標準庫的正面例子是 image.RegisterFormat:多次呼叫 image.Decode 彼此不會干擾;沒人想把 PNG decoder 換成測試替身;註冊撞名理論上可能但實務罕見;decoder 是無狀態、冪等、純的。
4. 看懂它為什麼會壞:順序相依的測試
func TestEndToEnd(t *testing.T) {
// 依賴「production 的 cloud logger 已經被註冊」
}
func TestRegression_NetworkUnavailability(t *testing.T) {
sidecar.Register("cloudlogger", cloudloggertest.UnavailableLogger) // 蓋掉了
}
func TestRegression_InvalidUser(t *testing.T) {
// 掛掉:上一個測試留下的 UnavailableLogger 還在
}Go 測試預設循序執行,於是第三個測試壞掉。後果是連鎖的:不能用 test filter 單跑、不能平行、不能分片。
5. 正解:讓呼叫端自己建實例,用參數傳進去
// Good
package sidecar
type Registry struct{ plugins map[string]*Plugin }
func New() *Registry { return &Registry{plugins: make(map[string]*Plugin)} }
func (r *Registry) Register(name string, p *Plugin) error { ... }// Good
func main() {
sidecars := sidecar.New()
if err := sidecars.Register("Cloud Logger", cloudlogger.New()); err != nil {
log.Exitf("Could not setup cloud logger: %v", err)
}
cfg := &myapp.Config{Sidecars: sidecars}
myapp.Run(context.Background(), cfg)
}遷移既有程式碼的主要手法就一種:把依賴當參數往下傳——建構函式參數、函式參數、方法參數、或 struct 欄位。
基礎設施提供者要特別注意:如果你的套件會被別的團隊 import,它不能依賴自己所 import 的那些套件的套件層級狀態。給別人用的形狀應該長這樣:
// Good
package cloudlogger
func New() *Logger { ... }
func Register(r *sidecar.Registry, l *Logger) { r.Register("Cloud Logging", l) }6. 真的要提供方便的預設實例,有四個條件
原文的立場是「不建議,但可接受」,前提是:
- 套件必須同時提供建立獨立實例的能力。
- 用到全域狀態的公開 API 必須是前者的薄包裝(標竿:
http.Handle只是去呼叫http.DefaultServeMux的Handle)。 - 這組 package 層級 API 只能被 binary target 使用,不能被函式庫使用(正在重構往依賴傳遞走的除外)。
- 必須文件化並強制它的不變量(什麼生命週期階段可以呼叫、能不能併發使用),而且要提供一個把全域狀態重置回已知良好預設值的 API,好讓測試能用。
7. 設計前先回答這七個問題
如果你回答不出來,就不該用全域狀態:
- 同一個行程裡要用兩組彼此獨立的 plugin(例如支援多台 server)怎麼辦?
- 測試想把某個 plugin 換成測試替身怎麼辦?
- 測試之間要互相隔離(hermetic)怎麼辦?
- 多個 client 用同一個名字
Register誰贏? - 錯誤怎麼處理?如果它 panic 或
log.Fatal,在所有呼叫場景都恰當嗎? - 呼叫端能不能事先檢查自己會不會做錯?
- 有沒有「只能在某個階段呼叫」的限制?呼叫時機錯了會怎樣?
最後一個有個隱藏後果:作者若假設「反正只在程式初始化時被呼叫」,就會把錯誤處理設計成 Must 那種直接中止程式的形狀——而中止程式對一個可以在任何階段被呼叫的通用函式庫並不恰當。
🧪 我實際套用的紀錄
- 2026-09-20:(待填)
⚠️ 注意 / 什麼時候不適用
- 標準庫裡有反例(
http.DefaultServeMux、flag的全域狀態),原文明說:遺留 API 違反這條,不構成繼續這樣做的先例。 - Sidecar 類的東西未必是 process-local ——它常被多個應用行程共用,還會連外部分散式系統。所以「不外溢到程式外」這條要照 sidecar 自己的程式碼再驗一次。
- 這條對函式庫作者最關鍵;只有自己團隊用的 binary 內部,壓力小得多(但測試互相干擾的痛一樣會來)。
- flag 也是全域狀態的一種:flag 只能定義在
package main或等價處,通用套件要用 Go API 設定,不要靠 import 副作用長出新 flag。
🔗 相關工具
- 工具-Go表格測試與子測試 —— 「子測試不可互相依賴」失守的頭號原因就是這張卡的問題
- 工具-打破依賴以便測試 —— 已經被全域狀態綁死的舊 code 怎麼拆開
- 工具-縮小變數作用域 —— 語言無關的同一方向;這張是 Go 套件層級的版本與明確判準
🎯 什麼情境該想到我
當函式簽章長到一行放不下,或呼叫端出現 Do(ctx, cfg, a, b, true, false, time.Hour, 100, w) 這種看不出哪個是哪個的時候。
⚙️ 怎麼用(步驟 / 公式)
0. 先問:是不是該拆成好幾個函式?
在跳到 options 之前,原文的第一建議是:把一個高度可設定、簽章不斷膨脹的函式,拆成數個比較簡單的函式,必要時共用一個未匯出的實作。
// Bad
func EnableReplication(ctx context.Context, config *replicator.Config,
primaryRegions, readonlyRegions []string, replicateExisting,
overwritePolicies bool, replicationInterval time.Duration,
copyWorkers int, healthWatcher health.Watcher) { ... }問題有三個:參數一多,每個參數的角色就變模糊;相鄰的同型別參數容易傳反(上面有兩個 []string 和兩個 bool 緊鄰);呼叫端讀起來記不住。
1. Option struct:把參數收進一個結構,當最後一個參數傳
// Good
type ReplicationOptions struct {
Config *replicator.Config
PrimaryRegions []string
ReadonlyRegions []string
ReplicateExisting bool
OverwritePolicies bool
ReplicationInterval time.Duration
CopyWorkers int
HealthWatcher health.Watcher
}
func EnableReplication(ctx context.Context, opts ReplicationOptions) { ... }// 複雜的呼叫
storage.EnableReplication(ctx, storage.ReplicationOptions{
Config: config,
PrimaryRegions: []string{"us-east1", "us-central2"},
OverwritePolicies: true,
ReplicationInterval: 1 * time.Hour,
CopyWorkers: 100,
})
// 簡單的呼叫
storage.EnableReplication(ctx, storage.ReplicationOptions{
Config: config,
PrimaryRegions: []string{"us-east1", "us-central2"},
})好處:欄位名讓呼叫端自我說明且難以傳反;不相關的欄位可以直接省略;呼叫端可以共用同一份 options 並寫 helper 去加工;godoc 上每個欄位有自己的說明;之後加欄位不影響現有呼叫端。
⚠️ context.Context 永遠不放進 option struct,它仍然是第一個參數。
⚠️ struct 只在被匯出的函式使用時才匯出。
什麼時候選它:
- 大部分呼叫端都得指定一個以上的選項。
- 很多呼叫端要指定很多選項。
- 這組選項會被多個函式共用。
2. Variadic options:匯出一組回傳 closure 的函式
// Good
type replicationOptions struct { // 未匯出
readonlyCells []string
replicateExisting bool
replicationInterval time.Duration
copyWorkers int
}
// A ReplicationOption configures EnableReplication.
type ReplicationOption func(*replicationOptions)
// ReadonlyCells adds additional cells that should additionally
// contain read-only replicas of the data.
//
// Passing this option multiple times will add additional read-only cells.
//
// Default: none
func ReadonlyCells(cells ...string) ReplicationOption {
return func(opts *replicationOptions) {
opts.readonlyCells = append(opts.readonlyCells, cells...)
}
}
var DefaultReplicationOptions = []ReplicationOption{
OverwritePolicies(true),
ReplicationInterval(12 * time.Hour),
CopyWorkers(10),
}
func EnableReplication(ctx context.Context, config *placer.Config,
primaryCells []string, opts ...ReplicationOption) {
var options replicationOptions
for _, opt := range DefaultReplicationOptions { opt(&options) }
for _, opt := range opts { opt(&options) }
}// 簡單的呼叫:完全不佔版面
storage.EnableReplication(ctx, config, []string{"po", "is", "ea"})好處:不需要設定時,呼叫端一個字都不用寫;選項仍是值,可以共用、累積、寫 helper;一個選項可以吃多個參數(cartesian.Translate(dx, dy int) TransformOption);有具名型別可以在 godoc 裡聚成一組;可以選擇讓或不讓第三方套件定義自己的選項(靠參數型別未匯出來控制)。
什麼時候選它(原文說「多數條件成立時」):
- 大部分呼叫端不需要指定任何選項。
- 多數選項很少被用到。
- 選項數量很多。
- 選項需要帶參數。
- 選項可能失敗或被設錯(這時 option function 可以回傳 error)。
- 選項需要大量文件,塞在 struct 欄位註解裡會很擠。
- 想讓使用者或其他套件提供自訂選項。
3. ⭐ 兩條容易踩的 variadic options 設計規則
(1)選項要吃參數,不要用「有沒有傳」來表達值。
rpc.FailFast(enable bool) // ✅
rpc.EnableFailFast() // ❌
log.Format(log.Capacitor) // ✅
log.CapacitorFormat() // ❌理由很實際:如果使用者要動態決定要不要開某個選項,前者只要改參數,後者得去改「要組哪幾個參數」的結構。不要假設所有使用者都在編譯期就知道完整的選項集合。
(2)依序處理,後者覆蓋前者。 有衝突、或非累加型的選項被傳多次時,最後一個贏。而且每個選項的文件要寫清楚它是累加(Passing this option multiple times will add additional...)還是覆蓋(Passing this option again will overwrite earlier values.),以及 Default: 是什麼。
4. 選哪一個:看呼叫端長什麼樣
原文給的首要判準是——在所有預期的使用情境下,呼叫端讀起來像什麼? 不是看實作端漂不漂亮。
粗略的分水嶺:「大家都要設一堆」→ option struct;「大部分人什麼都不用設」→ variadic options。
🧪 我實際套用的紀錄
- 2026-09-20:(待填)
⚠️ 注意 / 什麼時候不適用
- Variadic options 要寫的樣板碼很多(看上面那段就知道)。原文明說「只有在好處蓋過這份開銷時才用」——這正是最少機制原則的實例。
- 這些建議主要針對匯出的 API,標準比未匯出的高。套件內部的函式多半不需要這些機制。
- 第一步永遠是「能不能拆函式」,不是「用哪種 options」。
- Option struct 版本要注意零值即預設:欄位省略時拿到的是零值,如果某個選項的合理預設不是零值,就得在函式內部補,或改用 variadic options(它有明確的
DefaultXxxOptions)。
🔗 相關工具
- 工具-Go可讀性五原則 —— 「最少機制」正是判斷要不要上 variadic options 的依據
- 工具-函式短小且單一職責 —— 參數爆炸常常是職責過多的症狀,先治本
- 工具-介面設計原則 —— 同方向:API 要讓呼叫端難以用錯
介面由消費端定義 accept interfaces, return concrete types → [[工具-Go介面小而隱式]]
Google 內部專屬 (照抄會踩空)
log 是 glog 變體 flag 是內部變體 status / canonical codes Bazel test filter