📌 30 秒摘要(讀完用自己的話寫一句)

這套文件在講:Google 內部審 Go code 時到底依據什麼。它跟 Effective Go 的差別是——Effective Go 講「Go 為什麼長這樣」,這套講「兩種寫法都合法時,該選哪個、依據什麼排序」。最值得拿走的不是條目,而是它把可讀性拆成五個有明確優先序的屬性,以及把文件本身分成三種權威等級(哪些是鐵律、哪些只是建議)。

🗺 心智圖(Canvas)

Google Go 風格指南

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

Link to original

🎯 為什麼存這套文件 / 未來想拿它做什麼

  • Go 是我的主力語言。知識庫裡既有的 Go 卡多半在講「語言機制」(介面、切片、錯誤 API),缺的是做取捨時的裁決依據——這套補的正是這塊。
  • 它的文件分級設計本身可以偷:把團隊規範分成「不可違反/可討論/僅供參考」三層,比一份扁平的 500 條規則好用太多。
  • 錯誤處理與測試那兩章是可以直接照做的,而且是我既有的 工具-Go現代錯誤處理 沒有涵蓋的另一半(那張講「怎麼辨識錯誤」,這套講「該不該包、包什麼、誰負責記 log」)。

⚠️ 使用前要知道的三件事

  1. 它明說假設你已讀過 Effective Go:原文 “This guide assumes the reader is familiar with Effective Go”。所以它不重複講語言基礎,直接從取捨開始。
  2. 有一部分是 Google 內部專用,外面照抄會踩空:文中的 log(其實是 glog 變體,有 log.Exit、log.V,沒有 log.Panic)、flag(內部變體)、status/canonical codes、Bazel test filter、「同一目錄多個 go_library」等,都是 Google 內部環境。觀念可移植,API 不能直接抄。
  3. 三份文件權威等級不同,衝突時有明確優先序(見下方「文件涵蓋範圍」)。

🧰 這套文件給我的工具(連到 tools/)

介面的部分(consumer 端定義介面、accept interfaces return concrete types)與既有的 工具-Go介面小而隱式 高度重疊,不另開卡;Google 補的觀點寫在下方「關鍵重點 §6」。

🗂 文件涵蓋範圍(共 4 頁,全部讀過)

頁面受眾NormativeCanonical內容
Overview所有人——三份文件的定位、canonical/normative/idiomatic 的定義
Style Guide(guide)所有人✅✅五個可讀性原則、最少機制、gofmt/MixedCaps/行長/命名/區域一致性
Style Decisions(decisions)Readability mentor✅❌命名細則、註解、import、錯誤、語言特性、常用函式庫、測試失敗訊息、測試結構
Best Practices(best-practices)有興趣的人❌❌錯誤結構與 %w、文件撰寫、變數宣告、函式參數列、測試、字串串接、全域狀態、介面設計

三個詞的定義(原文自己下的):

  • Canonical:規定性、且不預期會變的規則。新舊程式碼都該遵守,門檻最高,所以 canonical 文件反而最短。
  • Normative:為了讓審查者之間一致而訂的。會隨時間改;不要求寫 code 的人熟記,是審查者的參考書。
  • Idiomatic:普遍且好認的慣例。同樣達成目的時,慣用的優於不慣用的。

衝突時 Style Guide 勝,且 Decisions 要被改成跟它一致。

✨ 關鍵重點

1. ⭐ 可讀性五屬性,有明確排序

原文是「依重要性排序」的五條:

  1. Clarity(清楚) — 讀者看得懂它在做什麼、以及為什麼這樣做
  2. Simplicity(簡單) — 用最簡單的方式達成目標
  3. Concision(精簡) — 訊噪比高
  4. Maintainability(可維護) — 容易被下一個人正確地改
  5. Consistency(一致) — 跟更大範圍的 codebase 一致

關鍵在最後一條的定位:一致性不凌駕前面任何一條,但當前四條打平時,往一致性那邊倒。 這一句就解掉大部分「要不要跟既有爛寫法保持一致」的爭論。

另外它把 Clarity 拆成兩個獨立面向——「在做什麼」(靠命名、切分函式、留白)與**「為什麼這樣做」**(靠註解)。而且明講註解該解釋 why 而不是 what,因為重述 what 的註解會變成雜訊與維護負擔。

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

同一件事有多種表達方式時,用最標準的工具。順序是:

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

理由原文寫得很好:「要加複雜度隨時可以加,但發現複雜度沒必要之後要拿掉就難得多。」

兩個具體例子:

  • 要在測試裡覆蓋 flag 綁定的變數,直接改那個變數,不要用 flag.Set(除非你測的就是 CLI 本身)。
  • 需要集合成員檢查,map[string]bool 通常就夠了;只有在需要 map 做不到或做起來很醜的操作時才引入 set 函式庫。

3. 區域一致性(local consistency)有明確邊界

風格指南沒規定的地方,可以照你喜歡的寫——除非附近的程式碼(通常同檔案/同 package)已經有一致的立場。

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

而且有個止血條款:如果這次改動會讓既有偏差惡化、擴散到更多檔案、露出到更多 API 表面,或真的引入 bug,「跟旁邊一致」就不再是正當理由。

4. 錯誤:%v 與 %w 是一個 API 決策,不是格式選擇

  • %v:把錯誤壓成字串,丟掉結構。用在「單純加註說明」「要記 log 或顯示給人看」「在系統邊界(RPC/IPC/儲存)把內部錯誤翻譯成對外的標準錯誤空間」。
  • %w:建立 Unwrap() 鏈,讓呼叫端能用 errors.Is/errors.As。一旦用了 %w,被包住的錯誤就成為你套件契約的一部分——原文明說這該是「你明確文件化並測試過要暴露的錯誤」。

還有一條很少見但很實用的排版規則:%w 放在錯誤字串結尾("...: %w"),這樣印出來的順序才會跟錯誤鏈的方向一致(新 → 舊)。例外是哨兵錯誤:fmt.Errorf("%w: invalid header", ErrParse) 放開頭,讓最重要的分類資訊最先被看到。

以及兩條幾乎每個 codebase 都在犯的:

  • 不要重複底層已有的資訊:os 的錯誤本來就含路徑,再寫 "could not open settings.txt: %v" 會印出兩次檔名。
  • return fmt.Errorf("failed: %v", err) 是純雜訊——「有錯誤」這件事本身就表示失敗了,直接 return err。

→ 決策細節整理在 工具-Go錯誤該包還是該轉。

5. ⭐ 測試:失敗訊息是第一公民

整章的出發點只有一句:不看測試原始碼就要能診斷失敗。由此推出的一整套慣例(函式名、輸入、got 在 want 前、t.Error 讓測試跑完、用 cmp.Diff 並標明方向)整理在 工具-讓測試失敗訊息有用。

另外兩條值得單獨記:

  • 不要自製 assertion library。它把「驗證」跟「產生失敗訊息」綁在一起,結果通常是訊息更少、又因為內部呼叫 t.Fatalf 讓測試提早死掉。要共用邏輯就回傳值或 error,讓測試自己組訊息。
  • 不要用錯誤字串比對來判斷錯誤種類,那會把單元測試變成 change detector。要嘛 errors.Is,要嘛在 table 裡只放一個 wantErr bool。

6. 介面:消費端定義,且先別急著定義

  • 介面由使用它的那一方定義,只放它真正用到的方法。
  • 三種例外(由提供方匯出介面):介面本身就是產品(io.Writer、hash.Hash、protobuf 生成的 server 介面);大型系統中為了避免每個 client 都要 import 整個實作包;避免多個 package 各自複製同一份介面造成的維護負擔。
  • Accept interfaces, return concrete types——回傳具體型別讓呼叫端拿得到完整能力,要當介面用隨時可以傳進去。回傳介面的正當理由只有:封裝(限制 API 表面,例:error)、runtime 決定回傳哪個實作(factory/strategy/chaining)、打破循環依賴。
  • 不要為了測試而把 RPC client 包一層手寫介面,也不要為了測試匯出 test double——原文的理由很犀利:那會讓讀者從理解一個東西變成理解三個(介面、真實實作、測試替身)。

→ 與既有的 工具-Go介面小而隱式 對照著看。

7. Context 的兩條硬規則

  • context.Context 永遠是第一個參數,而且不要放進 struct(唯一例外是簽章必須符合外部介面)。
  • 不要自訂 context 型別,「這條規則沒有例外」。理由是如果每個團隊都有自己的 context,任兩個 package 之間呼叫都要做轉換,自動化重構也會變成不可能。

(Go 1.24 起測試裡優先用 (testing.TB).Context() 而不是 context.Background()。)

8. 幾條零成本就能記住的小規則

  • 錯誤字串不要大寫開頭、不要加句號(因為它會被嵌進別的句子裡);但完整顯示的訊息(log、測試失敗、UI)該大寫。
  • 不要用 in-band error(回傳 -1/空字串表示失敗)。多回傳一個 ok bool 或 error,順帶讓 Parse(Lookup(key)) 這種錯誤寫法編譯不過。
  • 錯誤流程往外推:if err != nil { ...; return } 之後直接寫正常流程,不要用 else 把正常路徑縮排進去。
  • 不要用 math/rand 產金鑰,即使是用完就丟的。未 seed 完全可預測;用 time.Nanoseconds() seed 也只有幾個 bit 的熵。用 crypto/rand。
  • 不要為了省幾個 byte 而傳指標。如果函式全程只用 *x,那參數就不該是指標(*string、*io.Reader 是常見誤用)。大 struct 與 protobuf 訊息例外。
  • package 不要叫 util/helper/common。判準是看呼叫端讀起來像什麼:spannertest.NewDatabaseFromFile(...) vs test.NewDatabaseFromFile(...)。
  • 泛型別急著用:「Write code, don’t design types.」只有一種型別會被實例化時,就先別泛型化——之後要加多型很簡單,要拿掉沒必要的抽象很難。

💬 原文摘錄

  • 可讀性的視角歸屬:

    “Clarity is to be viewed through the lens of the reader, not the author of the code. It is more important that code be easy to read than easy to write.”

  • 一致性的定位(我認為全套文件最有用的一句):

    “Consistency concerns do not override any of the principles above, but if a tie must be broken, it is often beneficial to break it in favor of consistency.”

  • 最少機制的理由:

    “It is easy to add complexity to code as needed, whereas it is much harder to remove existing complexity after it has been found to be unnecessary.”

  • 測試章的總綱:

    “It should be possible to diagnose a test’s failure without reading the test’s source.”

  • 無例外的規則(全套文件唯一一句這樣講的):

    “Do not create custom context types or use interfaces other than context.Context in function signatures. There are no exceptions to this rule.”

  • 介面歸屬:

    “The consumer of the interface should define it (not the package implementing the interface), ensuring it includes only the methods they actually use.”

  • 沒資訊的錯誤包裝:

    “Don’t add an annotation if its sole purpose is to indicate a failure without adding new information.”

  • 全域狀態:

    “Global state should be approached with extreme scrutiny.”

  • 泛型:

    “Write code, don’t design types.”

🔗 相關

  • Effective Go — 這套文件明說以它為前提。兩者分工:Effective Go 講「Go 為什麼長這樣」,這套講「合法的寫法之間怎麼選」
  • Modern Go Guidelines — 補的是「哪個 API 是現在的寫法」;這套不談 API 新舊,只談取捨
  • Go Blog 經典六篇 — 那六篇的 Errors 章已過時,這套的錯誤章可以當作它的現代替代
  • Go — 主題索引:16 張 Go 工具卡與四份來源的分工