Google Go 風格指南

取捨與原則

🎯 什麼情境該想到我

當你「兩種寫法都對、但不知道該選哪個」的時候——尤其是 review 時兩個人各有道理、吵不出結論的時候。

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

1. 用這個排序當裁決器

可讀性不是一個模糊的整體,是五個有優先序的屬性:

序屬性判準
1Clarity 清楚讀者看得懂它在做什麼、以及為什麼
2Simplicity 簡單用最簡單的方式達成目標(行為與效能上都是)
3Concision 精簡訊噪比高,重點不被雜訊蓋掉
4Maintainability 可維護下一個人容易正確地改
5Consistency 一致跟更大範圍的 codebase 一致

吵架時就問:「這兩個寫法,在第幾條上分出勝負?」 排名靠前的那條贏。

2. ⭐ 一致性是打平時的 tie-breaker,不是理由

原文寫得很精準:

一致性的考量不凌駕上面任何一條原則;但如果必須打破平手,往一致性那邊倒通常是有益的。

所以「旁邊都這樣寫」不足以壓過「這樣寫更清楚」。但如果兩個寫法在清楚/簡單/精簡/可維護上都分不出高下,那就跟旁邊一致。

一致性的優先範圍:同 package 內的一致性最要緊(同一個問題在一個 package 裡有三種解法、同一個概念在一個檔案裡有三個名字,都很刺眼),但仍不能凌駕已文件化的風格原則。

3. Clarity 要分兩個問題問

  • 「這段在做什麼?」 → 用更描述性的變數名、切成獨立函式、用空行與註解分段。
  • 「為什麼這樣做?」 → 這個只能靠註解。特別是當程式碼帶有讀者不熟的眉角時:語言層的眉角(閉包捕捉了很遠之外的迴圈變數)、業務層的眉角(權限檢查要分辨「本人」與「模擬本人的管理員」)、為了效能而寫得難懂的地方。

註解解釋 why,不要重述 what。 重述 what 的註解會變成雜訊、跟程式碼矛盾、還多一份要維護的東西。

4. ⭐ 最少機制(least mechanism):抽象的升級階梯

同一件事有多種表達方式時,用最標準的工具,按這個順序往上爬,能停就停:

  1. 核心語言構件 — channel、slice、map、loop、struct
  2. 標準函式庫 — HTTP client、template engine…
  3. 專案/公司既有的核心庫
  4. 才考慮新依賴,或自己造一個

理由:要加複雜度隨時可以加,但發現複雜度沒必要之後要移除就難得多。

實例:

// 需要集合成員檢查 → map 就夠了,不要為此引入 set 套件
seen := map[string]bool{}
 
// 測試要覆蓋 flag 綁定的值 → 直接改變數,不要用 flag.Set
// (除非你測的就是 CLI 本身)
pollInterval = 5 * time.Second

5. 刻意加的複雜度要留下說明

這條原則不是「不准寫複雜的 code」。它要的是:codebase 裡沒有無謂的複雜度,於是複雜度出現時本身就是一個訊號——告訴讀者「這裡需要小心」。

所以複雜的地方要附上:為什麼需要(通常是效能)、要注意什麼、以及示範正確用法的測試或 example。典型情境是預先配置一塊 buffer 在整個 goroutine 生命週期重複使用——維護者看到就知道這段是效能敏感的。

6. 區域一致性(local consistency)的邊界

風格指南沒規定的地方,照你喜歡的寫;但如果附近的程式碼已經有一致立場,就跟它一致。

  • ✅ 算有效的在地風格:錯誤格式化用 %s 還是 %v;用 buffered channel 取代 mutex。
  • ❌ 不算的:自訂行長限制;使用 assertion 風格的測試函式庫。

止血條款(很重要):如果這次改動會讓既有的偏差惡化、擴散到更多檔案、露出到更多 API 表面,或引入真正的 bug,「跟旁邊一致」就不再是正當理由。 這時該在同一個 CL 順手清乾淨、或先做一次前置重構。

🧪 我實際套用的紀錄

  • 2026-09-20:(待填)

⚠️ 注意 / 什麼時候不適用

  • 這五條是排序,不是評分表。 不要拿來幫每個寫法打分數加總;它的用途是在爭執時指出「你們其實是在不同層級上爭論」。
  • 排序不代表可以犧牲下面的。 只有在真的衝突時才動用排序。
  • 「簡單」指的是讀的人簡單,不是寫的人簡單。 有時候要讓實作變複雜,好讓 API 的使用者更難用錯——這是划算的。反過來也成立,要看哪邊的讀者多。
  • 原文自己承認:風格議題本質上是主觀的,這套排序的價值跟 gofmt 一樣,大半來自「統一」而不是「正確」。

🔗 相關工具

錯誤處理

🎯 什麼情境該想到我

當你手上有一個 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. 處理錯誤只有三個合法選項

  1. 當場處理掉
  2. 回傳給呼叫端
  3. 例外情況下 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。

🔗 相關工具

錯誤字串小寫無句號 不要 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")

原文的理由,三個都很實在:

  1. 這類函式內部通常呼叫 t.Fatalf,讓測試提早死掉,其他問題看不到。
  2. 它看不到測試函式裡的上下文,產出的訊息反而更少。
  3. 每個團隊一套 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 裡。

🔗 相關工具

🎯 什麼情境該想到我

當你正在貼第四、第五個長得幾乎一樣的測試函式,或是表格測試的迴圈裡開始長出 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函式參數太多怎麼辦),或是這個表格該拆了。

🔗 相關工具

API 與狀態設計

🎯 什麼情境該想到我

當你正要寫下 var somethingGlobal = ... 或 func Register(name string, ...) 的時候——在動手前跑一次下面的 litmus test。

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

1. 先認出四種常見的「套件層級狀態」

它們都是同一個問題的不同外表:

  1. 頂層變數(不管有沒有匯出)
    // Bad
    package logger
    var Sinks []Sink
  2. Service locator 定義成全域(pattern 本身沒問題,全域才有問題)
  3. 註冊表/callback 清單
    // Bad
    package health
    var unhealthyFuncs []func
    func OnUnhealthy(f func()) { unhealthyFuncs = append(unhealthyFuncs, f) }
  4. 厚 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. 真的要提供方便的預設實例,有四個條件

原文的立場是「不建議,但可接受」,前提是:

  1. 套件必須同時提供建立獨立實例的能力。
  2. 用到全域狀態的公開 API 必須是前者的薄包裝(標竿:http.Handle 只是去呼叫 http.DefaultServeMux 的 Handle)。
  3. 這組 package 層級 API 只能被 binary target 使用,不能被函式庫使用(正在重構往依賴傳遞走的除外)。
  4. 必須文件化並強制它的不變量(什麼生命週期階段可以呼叫、能不能併發使用),而且要提供一個把全域狀態重置回已知良好預設值的 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。

🔗 相關工具

🎯 什麼情境該想到我

當函式簽章長到一行放不下,或呼叫端出現 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)。

🔗 相關工具

介面由消費端定義 accept interfaces, return concrete types → [[工具-Go介面小而隱式]]

Google 內部專屬 (照抄會踩空)

log 是 glog 變體 flag 是內部變體 status / canonical codes Bazel test filter