• Stars
    star
    104
  • Rank 330,604 (Top 7 %)
  • Language
  • License
    Apache License 2.0
  • Created over 2 years ago
  • Updated over 1 year ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

The Uber Go Style Guide 繁體中文版本

uber-go/guide 的繁體中文翻譯

English

Uber Go 語言撰寫規範

Uber 是一家美國矽谷的科技公司,同時也是 Go 語言的早期 adoptor。它開源了許多 Golang 相關的專案,像是 Gopher 圈熟知的 zapjaeger 等。2018 年年末 Uber 將內部的 Go 語言撰寫規範 開源到 GitHub 上,經過一年多的累積與貢獻,該規範已經初具規模,並且廣受 Gopher 們的關注。本文是該規範的繁體中文版本,內容會以原版為參考盡快更新。

原翻譯參考並修改自 uber-go/guide 的中文翻譯,部分段落有進行重寫使語意通順。在這邊感謝開源社群的貢獻,讓眾多開發者可以免費使用這些學習資源。

版本

  • 目前版本:[2023-03-22] commit:#176
  • 如果讀者發現原版本有任何更新、問題與內容的改進,歡迎大家幫忙貢獻到此專案中

目錄

介紹

樣式 (style) 是支配我們程式碼的慣例。實際上由於這些約定不僅僅只涵蓋程式碼格式化的部分(gofmt 能替我們處理),術語「樣式」不是很恰當的說法。

本指南的目的是透過詳細描述在 Uber 撰寫 Go 程式碼的注意事項來梳理這種複雜的規則。這些規則的存在是為了使函式庫易於管理,同時也能讓工程師能更有效地運用 Go 語言的功能。

該指南最初由 Prashant VaranasiSimon Newton 編寫,目的是使一些同事能快速使用 Go。多年來,這份指南已根據其他人的回饋進行數次修訂。

本文件記錄了我們在 Uber 遵循的 Go 程式碼中的慣用約定。其中許多是 Go 的通則,而其他準則是延伸自這些外部資源:

  1. Effective Go
  2. Go Common Mistakes
  3. Go Code Review Comments

程式碼範例部分,主要目標是能使最新兩個小版本的 Go 得以準確執行。

所有程式碼都應該透過 golintgo vet 的檢查並無錯誤。我們建議您將編輯器設定為:

  • 儲存時使用 goimports
  • 使用 golintgo vet 檢查錯誤

您可以在以下 Go 編輯器工具的相關頁面中找到更為詳細的資訊: https://github.com/golang/go/wiki/IDEsAndTextEditorPlugins

指導準則

指向介面的指標

大部分情況下都不需要指向介面 (interface) 類型的指標 (pointer)。您應該將介面作為數值進行傳遞,在這樣的傳遞過程中,實質上傳遞的基底 (underlying) 資料仍然可以是指標。

介面由兩個欄位構成:

  1. 指向某個特定類型資訊的指標。您可以將其視為「類型 (type)」。
  2. 資料指標。如果儲存的資料是指標,則直接儲存;如果儲存的資料是數值,則儲存指向這個數值的指標。

如果想要介面方法修改基底資料,則必須使用指標傳遞(將物件指標賦值給介面變數)。

type F interface {
  f()
}

type S1 struct{}

func (s S1) f() {}

type S2 struct{}

func (s *S2) f() {}

// f1.f() 無法修改基底資料
// f2.f() 可以修改基底資料,給介面變數 f2 賦值時使用的是物件指標
var f1 F = S1{}
var f2 F = &S2{}

介面合理性驗證

可以的話,在編譯時驗證介面的合理性,包括:

  • 需要用來實作特定介面以滿足 API 契約的匯出類型
  • 實作同個介面,屬於一整組的匯出或非匯出類型
  • 會打斷使用者編譯的其它違反介面原則情況

補充1:上面 3 點是編譯器對介面的檢查機制,大致意思是錯誤使用介面會在編譯期間報錯。所以可以利用這個機制讓部分問題在編譯期間暴露。

不妥做法 較佳做法
// 如果 Handler 沒有實作 http.Handler,會在運作時報錯
type Handler struct {
  // ...
}
func (h *Handler) ServeHTTP(
  w http.ResponseWriter,
  r *http.Request,
) {
  ...
}
type Handler struct {
  // ...
}
// 用於觸發編譯時期的介面合理性檢查機制
// 如果 Handler 沒有實作 http.Handler,會在編譯期間報錯
var _ http.Handler = (*Handler)(nil)
func (h *Handler) ServeHTTP(
  w http.ResponseWriter,
  r *http.Request,
) {
  // ...
}

如果 *Handlerhttp.Handler 的介面不同,那麼語句 var _ http.Handler = (*Handler)(nil) 將無法編譯通過。

賦值的右邊應該是斷言類型的零值——指標類型(如 *Handler)、切片和 map (映射) 是 nil;結構體類型是空結構體 (structName{})。

type LogHandler struct {
  h   http.Handler
  log *zap.Logger
}
var _ http.Handler = LogHandler{}
func (h LogHandler) ServeHTTP(
  w http.ResponseWriter,
  r *http.Request,
) {
  // ...
}

接收器 (receiver) 與介面

使用數值接收器的方法,既可以透過數值呼叫,也可以透過指標呼叫。

具指標接收器的方法,只能透過指標或 可定址數值 (addressable values) 呼叫。

例如:

type S struct {
  data string
}

func (s S) Read() string {
  return s.data
}

func (s *S) Write(str string) {
  s.data = str
}

// 我們無法取得儲存在 map 中的值的指標,因為它們不是可尋址的數值
sVals := map[int]S{1: {"A"}}

// 我們可以對儲存在 map 中的值呼叫 Read 方法,
// 因為 Read 方法是使用數值接受器,其不要求
// 數值是可以尋址的。
sVals[1].Read()

// 我們不能對儲存在 map 中的值呼叫 Write 方法,
// 因為 Write 方法是使用指標接收器,其無法
// 取得儲存在 map 中的值的指標。
//
//  sVals[1].Write("test")

sPtrs := map[int]*S{1: {"A"}}

// 如果 map 儲存的是指標,則可以呼叫 Read 和 Write,
// 因為指標本身就可以尋址。
sPtrs[1].Read
sPtrs[1].Write("test")

類似的,即使方法有數值接收器,也同樣可以用指標接收器來滿足介面。

type F interface {
  f()
}

type S1 struct{}

func (s S1) f() {}

type S2 struct{}

func (s *S2) f() {}

s1Val := S1{}
s1Ptr := &S1{}
s2Val := S2{}
s2Ptr := &S2{}

var i F
i = s1Val
i = s1Ptr
i = s2Ptr

//  下面程式碼無法透過編譯。因為 s2Val 是數值,而 S2 的 f 方法中沒有使用數值接收器
//   i = s2Val

Effective Go 中有一段關於 Pointers vs. Values 的精闢講解。

補充1

  • 一個類型可以有數值接收器方法集和指標接收器方法集
    • 數值接收器方法集是指標接收器方法集的子集,反之不是
  • 規則
    • 數值只能使用數值接收器方法集
    • 指標可以使用 數值接收器方法集 + 指標接收器方法集
  • 介面的匹配(或稱「實作」)
    • 類型實作了介面的所有方法,叫匹配
    • 具體來說,可以是類型的數值方法集匹配介面,或者是指標方法集匹配介面

具體的匹配分兩種:

  • 數值方法集和介面匹配
    • 給介面變數賦值的,不管是數值還是指標都 ok,因為都包含數值方法集
  • 指針方法集和介面匹配
    • 只能將指標賦值給介面變數,因為只有指標方法集和介面匹配
    • 如果將數值賦值給介面變數,會在編譯期間報錯(會觸發介面合理性檢查機制)

i = s2Val 會報錯的本因,是數值方法集和介面不匹配。

零值 Mutex 是有效的

sync.Mutexsync.RWMutex 的零值是有效的。所以指向 mutex 的指標基本是不必要的。

不妥做法 較佳做法
mu := new(sync.Mutex)
mu.Lock()
var mu sync.Mutex
mu.Lock()

如果你使用結構體 (struct) 指標,mutex 應該作為結構體的非指標成員。即使不匯出結構體,也不要直接把 mutex 嵌入 (embed) 到結構體中。

不妥做法 較佳做法
type SMap struct {
  sync.Mutex

  data map[string]string
}

func NewSMap() *SMap {
  return &SMap{
    data: make(map[string]string),
  }
}

func (m *SMap) Get(k string) string {
  m.Lock()
  defer m.Unlock()

  return m.data[k]
}
type SMap struct {
  mu sync.Mutex

  data map[string]string
}

func NewSMap() *SMap {
  return &SMap{
    data: make(map[string]string),
  }
}

func (m *SMap) Get(k string) string {
  m.mu.Lock()
  defer m.mu.Unlock()

  return m.data[k]
}

Mutex 欄位以及 LockUnlock 方法,是 SMap 匯出 (export) 的 API 中沒有刻意說明的部分。

mutex 及其方法是 SMap 的實作,對其呼叫者來說是不可見的。

在邊界處複製切片和 maps

切片 (slices) 和 maps(映射、對應)包含了指向基底資料的指標,因此在需要複製它們時要特別注意。

接收切片和 maps

請記住,當切片或 map 作為函式參數傳入時,如果您儲存了對它們的引用,則使用者可以對其進行修改。

不妥做法 較佳做法
func (d *Driver) SetTrips(trips []Trip) {
  d.trips = trips
}

trips := ...
d1.SetTrips(trips)

// 你是要修改 d1.trips 嗎?
trips[0] = ...
func (d *Driver) SetTrips(trips []Trip) {
  d.trips = make([]Trip, len(trips))
  copy(d.trips, trips)
}

trips := ...
d1.SetTrips(trips)

// 這裡我們修改 trips[0],但不會影響到 d1.trips
trips[0] = ...

補充1:這是 Go 語言中很基本的深淺複製概念,我們必須要熟記哪些資料結構屬於淺複製,哪些又屬於深複製。在不清楚結構體特性的情況下,很有可能會修改到不應受影響的資料,造成非預期行為發生。

回傳切片或 maps

同樣,留意使用者可能會對暴露內部狀態的切片或 map 進行修改。

不妥做法較佳做法
type Stats struct {
  mu sync.Mutex

  counters map[string]int
}

// Snapshot 回傳目前狀態。
func (s *Stats) Snapshot() map[string]int {
  s.mu.Lock()
  defer s.mu.Unlock()

  return s.counters
}

// snapshot 不再受互斥鎖保護
// 因此對 snapshot 的任何存取都將受到 data races 影響
// 影響 stats.counters
snapshot := stats.Snapshot()
type Stats struct {
  mu sync.Mutex

  counters map[string]int
}

func (s *Stats) Snapshot() map[string]int {
  s.mu.Lock()
  defer s.mu.Unlock()

  result := make(map[string]int, len(s.counters))
  for k, v := range s.counters {
    result[k] = v
  }
  return result
}

// snapshot 現在是一個複本
snapshot := stats.Snapshot()

使用 defer 釋放資源

使用 defer 釋放資源,諸如檔案和鎖。

不妥做法 較佳做法
p.Lock()
if p.count < 10 {
  p.Unlock()
  return p.count
}

p.count++
newCount := p.count
p.Unlock()

return newCount

// 當有多個 return 分支時,很容易遺忘 unlock
p.Lock()
defer p.Unlock()

if p.count < 10 {
  return p.count
}

p.count++
return p.count

// 更可讀

Defer 的開銷非常小,除非您可以證明函式執行時間在奈秒級的程度,否則都應該用 Defer。因為使用 defer 的成本微不足道,因此值得使用 defer 提升可讀性。這尤其適用於那些不單單進行簡單記憶體存取的規模較大方法——在這些方法中,其他計算的資源消耗遠超過 defer

Channel 的 size 要麼是 1,不然就是無暫存的

channel 通常 size 應為 1 或是無暫存的。預設情況下,channel 是無暫存的,其 size 為 0。任何其他大小都必須經過嚴格的檢查。我們需要考慮如何確定大小,考慮是什麼阻止了 channel 在高負載下和阻塞型寫入時的寫入,以及當這種情況發生時系統邏輯有哪些變化。(翻譯解釋1:按照原文意思是需要界定通道邊界、競爭條件,以及邏輯上下文的梳理)

不妥做法 較佳做法
// 應該足以滿足任何情況!
c := make(chan int, 64)
// 大小:1
c := make(chan int, 1) // 或者
// 無暫存 channel,大小為 0
c := make(chan int)

列舉從 1 開始

在 Go 中引入列舉的標準方法是宣告一個自訂類型和一個使用了 iota 的 const 組。由於變數的預設值為 0,因此通常應以非零值開頭列舉。

不妥做法 較佳做法
type Operation int

const (
  Add Operation = iota
  Subtract
  Multiply
)

// Add=0, Subtract=1, Multiply=2
type Operation int

const (
  Add Operation = iota + 1
  Subtract
  Multiply
)

// Add=1, Subtract=2, Multiply=3

在某些情況下,使用零值是有意義的(列舉從零開始)。例如當零值是理想的預設行為時。

type LogOutput int

const (
  LogToStdout LogOutput = iota
  LogToFile
  LogToRemote
)

// LogToStdout=0, LogToFile=1, LogToRemote=2

使用 time 處理時間

時間處理很複雜。關於時間的錯誤假設通常包括以下幾點。

  1. 一天有 24 小時
  2. 一小時有 60 分鐘
  3. 一週有七天
  4. 一年 365 天
  5. 還有更多

例如 第一點(在瞬時時間加上 24 小時)並不總是產生一個新的日曆日。

因此,在處理時間時始終使用 "time" 套件,因為它有助於以更安全、更準確的方式處理這些不正確的假設。

使用 time.Time 表達瞬時時間

使用 time.Time 處理瞬時時間 (instant of time),以及使用 time.Time 中的方法比較、加入或減去時間。

不妥做法 較佳做法
func isActive(now, start, stop int) bool {
  return start <= now && now < stop
}
func isActive(now, start, stop time.Time) bool {
  return (start.Before(now) || start.Equal(now)) && now.Before(stop)
}

使用 time.Duration 表達時間區段

在處理時間區段時使用 time.Duration .

不妥做法 較佳做法
func poll(delay int) {
  for {
    // ...
    time.Sleep(time.Duration(delay) * time.Millisecond)
  }
}
poll(10) // 是幾秒鐘還是幾毫秒?
func poll(delay time.Duration) {
  for {
    // ...
    time.Sleep(delay)
  }
}
poll(10*time.Second)

回到「往瞬時時間加上 24 小時」的例子,該使用什麼方法,取決於意圖是什麼。如果我們想要下一個日曆日(目前天的下一天)的同一個時間點,我們應該使用 Time.AddDate。但是,如果我們想保證某一時刻比前一時刻晚 24 小時,我們應該使用 Time.Add

newDay := t.AddDate(0 /* years */, 0 /* months */, 1 /* days */)
maybeNewDay := t.Add(24 * time.Hour)

對外部系統使用 time.Timetime.Duration

盡可能在與外部系統的互動中使用 time.Durationtime.Time。例如 :

當不能在這些操作中使用 time.Duration,請改用 intfloat64,並在欄位名稱中加上單位。

例如,由於 encoding/json 不支援 time.Duration,因此在欄位名稱中加上單位:

不妥做法 較佳做法
// {"interval": 2}
type Config struct {
  Interval int `json:"interval"`
}
// {"intervalMillis": 2000}
type Config struct {
  IntervalMillis int `json:"intervalMillis"`
}

如果這些操作不能使用 time.Time,除非達成一致,否則請使用 stringRFC 3339 中定義的格式時間戳。預設情況下,Time.UnmarshalText 使用此格式,並可透過 time.RFC3339Time.Formattime.Parse 中使用。

儘管這在實務中並不成問題,但請記住,"time" 套件不支援解析閏秒時戳(8728),也不在計算中考慮閏秒問題(15190)。如果您比較兩個瞬時時間 (instant time),則差異將不包括這兩個瞬間之間可能發生的閏秒。

錯誤

錯誤的類型

宣告錯誤的選項很少。在選擇最適合您用例的選項之前,請思考:

  • 呼叫者是否需要匹配錯誤,方便呼叫者進行處置?如果需要,則必須透過宣告頂層錯誤變數或自訂類型,方可支援 errors.Iserrors.As 函式
  • 錯誤訊息是靜態字串,還是需要上下文 (context) 資訊的動態字串?如果是靜態字串,我們可以使用 errors.New;但對於後者,則必須使用 fmt.Errorf 或自訂錯誤的類型
  • 我們是否正在傳遞由下游函式回傳的新錯誤?如果是這樣,請參考〈錯誤封裝〉
錯誤匹配? 錯誤訊息 建議
不用 靜態字串 errors.New
不用 動態字串 fmt.Errorf
需要 靜態字串 頂層 var 搭配 errors.New
需要 動態字串 自訂 error 類型

舉例來說:使用 errors.New 表示帶有靜態字串的錯誤。如果呼叫者需要匹配並處理此錯誤,則將此錯誤匯出為變數,這樣子才能將其與 errors.Is 匹配。

無錯誤匹配有錯誤匹配
// package foo

func Open() error {
  return errors.New("could not open")
}

// package bar

if err := foo.Open(); err != nil {
  // 無法處置錯誤
  panic("unknown error")
}
// package foo

var ErrCouldNotOpen = errors.New("could not open")

func Open() error {
  return ErrCouldNotOpen
}

// package bar

if err := foo.Open(); err != nil {
  if errors.Is(err, foo.ErrCouldNotOpen) {
    // 處置錯誤
  } else {
    panic("unknown error")
  }
}

對於動態字串的錯誤,如果呼叫者不需要匹配它,則使用 fmt.Errorf;如果呼叫者需要匹配它,則自訂 error

無錯誤匹配有錯誤匹配
// package foo

func Open(file string) error {
  return fmt.Errorf("file %q not found", file)
}

// package bar

if err := foo.Open("testfile.txt"); err != nil {
  // 無法處置錯誤
  panic("unknown error")
}
// package foo

type NotFoundError struct {
  File string
}

func (e *NotFoundError) Error() string {
  return fmt.Sprintf("file %q not found", e.File)
}

func Open(file string) error {
  return &NotFoundError{File: file}
}


// package bar

if err := foo.Open("testfile.txt"); err != nil {
  var notFound *NotFoundError
  if errors.As(err, &notFound) {
    // 處置錯誤
  } else {
    panic("unknown error")
  }
}

請注意,如果您從套件中匯出錯誤變數或類型,它們將成為套件公用 API 的一部分。

錯誤封裝

如果呼叫其他方法時出現錯誤,通常有三種處理方式可以選擇:

  • 將原始錯誤原樣回傳
  • 使用 fmt.Errorf 搭配 %w 加入上下文
  • 使用 fmt.Errorf 搭配 %v 加入上下文

如果沒有要加入其他上下文,則按原樣回傳原始錯誤。這將保留原始錯誤的類型和訊息。如果基底 (undelying) 錯誤訊息已有足夠資訊追蹤其來源,則十分適合使用這種做法。

否則,請盡量往錯誤訊息加入上下文,這樣就不會出現諸如「連線被拒」之類的模糊錯誤。加入上下文可以得到更多實用的錯誤,例如「呼叫服務 foo:連接被拒絕。」

使用 fmt.Errorf 為你的錯誤加入上下文,並根據呼叫者是否應該可以匹配和擷取基底原因,來在 %w%v 動詞間抉擇。

  • 如果呼叫者應當可以存取基底錯誤,請使用 %w。對於大多數封裝型錯誤,可以直接採用這種方式,但注意呼叫者可能會開始依賴此行為。因此,針對封裝型錯誤是已知變數或類型的情況,請將其作為函式契約的一部分進行記錄和測試
  • 使用 %v 混淆基底錯誤。呼叫者將無法匹配它,但如果需要,未來您可以切換到 %w

在往回傳錯誤加上上下文時,請不要加上「無法……」(failed to…) 之類的短語,以維持上下文的簡潔。當錯誤透過堆疊向上增加時,它會被一層一層的堆疊起來:

不妥做法 較佳做法
s, err := store.New()
if err != nil {
    return fmt.Errorf(
        "failed to create new store: %w", err)
}
s, err := store.New()
if err != nil {
    return fmt.Errorf(
        "new store: %w", err)
}
failed to x: failed to y: failed to create new store: the error
x: y: new store: the error

然而,一旦錯誤被寄送到另一個系統,應該寫明訊息是一個錯誤(例如加上 err 標籤,或在記錄檔 (logs) 中加上 Failed 前綴)。

另見 不要只檢查錯誤,優雅地處理它們

錯誤命名

對於儲存為全域變數的錯誤值,根據是否匯出 (exported) 使用前綴 Errerr。請參閱指南 對於未匯出的上層常數和變數,使用 _ 作為前綴

var (
  // 下面兩個錯誤會被匯出,以便此套件的
  // 使用者可以使用 `errors.Is` 匹配錯誤。

  ErrBrokenLink = errors.New("link is broken")
  ErrCouldNotOpen = errors.New("could not open")

  // 這個錯誤沒有被匯出,因為我們不想讓它
  // 成為我們公用 API 的一部分。我們依然可以
  // 在套件中藉由 errors.As 使用到它。
  errNotFound = errors.New("not found")
)

對於自訂錯誤的類型,請改用後綴 Error

// 同樣,下面這個錯誤會被匯出,以便此套件
// 的使用者可以使用 `errors.Is` 匹配錯誤。

type NotFoundError struct {
  File string
}

func (e *NotFoundError) Error() string {
  return fmt.Sprintf("file %q not found", e.File)
}

// 而這個錯誤沒有被匯出,因為我們不想讓它
// 成為我們公用 API 的一部分。我們依然可以
// 在套件中藉由 errors.As 使用到它。
type resolveError struct {
  Path string
}

func (e *resolveError) Error() string {
  return fmt.Sprintf("resolve %q", e.Path)
}

處理斷言失敗

類型斷言 (type assertions) 的單一回傳值形式,會在遇到錯誤類型時 panic。因此,請始終使用「逗號 ok」習語。

不妥做法 較佳做法
t := i.(string)
t, ok := i.(string)
if !ok {
  // 優雅地處理錯誤
}

不要使用 panic

在生產環境中運作的程式碼必須避免出現 panic。panic 是 級聯失敗 的主要原因。如果發生錯誤,該函式必須回傳錯誤,並允許呼叫方決定如何處理它。

不妥做法 較佳做法
func run(args []string) {
  if len(args) == 0 {
    panic("an argument is required")
  }
  // ...
}

func main() {
  run(os.Args[1:])
}
func run(args []string) error {
  if len(args) == 0 {
    return errors.New("an argument is required")
  }
  // ...
  return nil
}

func main() {
  if err := run(os.Args[1:]); err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
  }
}

panic/recover 不是個錯誤處理策略。僅當發生不可復原的事情(例如:nil 引用)時,程式才必須 panic。程式初始化是一個例外:程式啟動時應使程式中止的不良情況可能會引起 panic。

var _statusTemplate = template.Must(template.New("name").Parse("_statusHTML"))

即使在測試程式碼中,也優先使用 t.Fatal 或者 t.FailNow 而不是 panic 確保失敗能被標記。

不妥做法 較佳做法
// func TestFoo(t *testing.T)

f, err := os.CreateTemp("", "test")
if err != nil {
  panic("failed to set up test")
}
// func TestFoo(t *testing.T)

f, err := os.CreateTemp("", "test")
if err != nil {
  t.Fatal("failed to set up test")
}

使用 go.uber.org/atomic

sync/atomic 套件的原子操作函式是在原始類型 (int32int64 等)進行操作的,因此很容易忘記用原子操作函式來讀取或修改變數。

go.uber.org/atomic 透過隱藏基底類型 (underlying type),增加了這些操作的類型安全性。此外,它附帶方便的 atomic.Bool 類型。

不妥做法 較佳做法
type foo struct {
  running int32  // 原子變數
}

func (f* foo) start() {
  if atomic.SwapInt32(&f.running, 1) == 1 {
     // 已經在執行了……
     return
  }
  // 啟動 Foo
}

func (f *foo) isRunning() bool {
  return f.running == 1  // race!
}
type foo struct {
  running atomic.Bool
}

func (f *foo) start() {
  if f.running.Swap(true) {
     // 已經在執行了……
     return
  }
  // 啟動 Foo
}

func (f *foo) isRunning() bool {
  return f.running.Load()
}

避免可變的全域變數

不要改變全域變數,而是採用依賴注入。這條規則不僅適用於函式指標,也適用於其他類型的值。

不妥做法 較佳做法
// sign.go
var _timeNow = time.Now
func sign(msg string) string {
  now := _timeNow()
  return signWithTime(msg, now)
}
// sign.go
type signer struct {
  now func() time.Time
}
func newSigner() *signer {
  return &signer{
    now: time.Now,
  }
}
func (s *signer) Sign(msg string) string {
  now := s.now()
  return signWithTime(msg, now)
}
// sign_test.go
func TestSign(t *testing.T) {
  oldTimeNow := _timeNow
  _timeNow = func() time.Time {
    return someFixedTime
  }
  defer func() { _timeNow = oldTimeNow }()
  assert.Equal(t, want, sign(give))
}
// sign_test.go
func TestSigner(t *testing.T) {
  s := newSigner()
  s.now = func() time.Time {
    return someFixedTime
  }
  assert.Equal(t, want, s.Sign(give))
}

避免在公用結構體中嵌入類型

嵌入這些類型會洩漏實作細節,阻擾類型的演化 (type evolution) ,亦使文件混亂、晦澀難懂。

假設你使用共用的 AbstractList 實作了多種 list 類型,請避免在具體的 list 實作中嵌入 AbstractList。正確的做法是將方法手寫進具體的 list,再使這些方法委託 (delegate) 到 AbstractList 上。

type AbstractList struct {}
// Add 將實體加入到 list 中。
func (l *AbstractList) Add(e Entity) {
  // ...
}
// Remove 從 list 中移除實體。
func (l *AbstractList) Remove(e Entity) {
  // ...
}
不妥做法 較佳做法
// ConcreteList 是一個實體 list。
type ConcreteList struct {
  *AbstractList
}
// ConcreteList 是一個實體 list。
type ConcreteList struct {
  list *AbstractList
}
// Add 將實體加入到 list 中。
func (l *ConcreteList) Add(e Entity) {
  l.list.Add(e)
}
// Remove 從 list 中移除實體。
func (l *ConcreteList) Remove(e Entity) {
  l.list.Remove(e)
}

Go 允許使用 類型嵌入,是繼承和組合取捨後的折衷做法。外層類型獲取嵌入類型的方法的隱式複本。預設情況下,這些方法委託 (delegate) 給嵌入實例的同名方法。

結構體亦得到與類型同名的欄位。因此,倘若嵌入類型是公用的 (public),那麼欄位也會公用的。為了保持向後相容性,外部類型的每個後續版本,都得保留嵌入類型。

嵌入類型通常不會是「缺一不可」的——它只是方便您節省撰寫冗長委託方法的時間。

雖然不嵌入結構體,改嵌入相容的 AbstractList 介面,可以讓開發者在未來有更大的修改空間,不過這仍然會洩漏出「具體 list 使用抽象實作」的細節。

不妥做法 較佳做法
// AbstractList 是各種實體 list 的通用實作。
type AbstractList interface {
  Add(Entity)
  Remove(Entity)
}
// ConcreteList 是一個實體 list 。
type ConcreteList struct {
  AbstractList
}
// ConcreteList 是一個實體 list 。
type ConcreteList struct {
  list AbstractList
}
// Add 將 Entity 加進 list。
func (l *ConcreteList) Add(e Entity) {
  l.list.Add(e)
}
// Remove 從 list 中移除 Entity。
func (l *ConcreteList) Remove(e Entity) {
  l.list.Remove(e)
}

無論是使用嵌入結構還是嵌入介面,都會限制類型的演化。

  • 向嵌入介面加入方法會導致破壞性更動
  • 從嵌入結構體刪除方法會導致破壞性更動
  • 刪除嵌入類型會導致破壞性的更動
  • 即使使用滿足相同介面的類型取代嵌入類型,也會導致破壞性更動

儘管撰寫這些委託方法乏味無趣,但這些多做的工作不僅可以隱藏實作,留下了更多的更改機會,還消除了在文件中間接發現完整 List 介面的可能。

避免使用內建名稱

Go 語言規範 概述了幾個內建的,不應在 Go 專案中使用的 預先宣告的識別元

根據情境的不同,將這些識別元作為名稱重複使用,可能會在目前的語意範圍(或任何巢套的語意範圍)中隱藏原始識別元,或者使受影響的程式碼變得令人困惑。在最好的情況下,編譯器會報錯;在最壞的情況下,這樣的程式碼可能會引入潛在的、難以搜尋 (grep) 的錯誤。

不妥做法 較佳做法
var error string
// `error` 蓋住內建識別元

// 或

func handleErrorMessage(error string) {
    // `error` 蓋住內建識別元
}
var errorMessage string
// `error` 指向內建識別元

// 或

func handleErrorMessage(msg string) {
    // `error` 指向內建識別元
}
type Foo struct {
  // 雖然技術上來說,這些欄位不會蓋住內建識別元,
  // 但搜尋 (grep) `error` 或 `string` 字串,
  // 現在得到的結果很容易造成混淆。
  error  error
  string string
}

func (f Foo) Error() error {
  // `error` 和 `f.error` 看起來相仿
  return f.error
}

func (f Foo) String() string {
  // `string` and `f.string` 看起來相仿
  return f.string
}
type Foo struct {
    // `error` 和 `string` 現在十分明確。
    err error
    str string
}

func (f Foo) Error() error {
    return f.err
}

func (f Foo) String() string {
    return f.str
}

注意,編譯器不會因為使用預先宣告的識別元而產生錯誤,但像 go vet 這樣的工具,應能正確指出這些和其他情況的蓋住情況。

避免使用 init()

盡量避免使用 init()。如果無法避開使用 init(),或者確實需要使用 init(),程式碼應先嘗試:

  1. 無論程式環境或呼叫方式如何,結果都要是完全可以確定的。
  2. 避免仰賴其他 init() 函式的順序或副作用。雖然 init() 順序現在是明確的,但程式碼是會更動的——init() 函式之間的關係可能會使程式碼變得脆弱且容易出錯。
  3. 避免存取或操作全域或環境狀態,如機器資訊、環境變數、工作目錄、程式引數和輸入等。
  4. 避免進行 I/O 操作,包括檔案系統、網路和系統呼叫。

無法滿足這些要求的程式碼,可能更屬於要讓 main()(或程式生命週期中的其他地方)呼叫的輔助函式,或者寫進 main() 裡頭。尤其是打算讓其他程式使用的函式庫,應該要更加注意「完全確定性」而不應搞「init 魔法」。

不妥做法 較佳做法
type Foo struct {
    // ...
}
var _defaultFoo Foo
func init() {
    _defaultFoo = Foo{
        // ...
    }
}
var _defaultFoo = Foo{
    // ...
}
// 或為了更好的可測試性:
var _defaultFoo = defaultFoo()
func defaultFoo() Foo {
    return Foo{
        // ...
    }
}
type Config struct {
    // ...
}
var _config Config
func init() {
    // 不妥做法:基於目前目錄
    cwd, _ := os.Getwd()
    // 不妥做法:I/O
    raw, _ := os.ReadFile(
        path.Join(cwd, "config", "config.yaml"),
    )
    yaml.Unmarshal(raw, &_config)
}
type Config struct {
    // ...
}
func loadConfig() Config {
    cwd, err := os.Getwd()
    // 處理 err
    raw, err := os.ReadFile(
        path.Join(cwd, "config", "config.yaml"),
    )
    // 處理 err
    var config Config
    yaml.Unmarshal(raw, &config)
    return config
}

考量上述要求,某些情況下 init() 可能更能勝任,或者是必要的。這些情況可能包括:

  • 不能表示為單個賦值的複雜表示式
  • 可插入的掛勾,如 database/sql 延伸物、編碼類型登錄檔等
  • 最佳化 Google Cloud Functions 和其他形式的確定性預計算

附加 (append) 時優先指定切片大小

在初始化要附加的切片時,盡量向 make() 傳入大小參數。

不妥做法 較佳做法
for n := 0; n < b.N; n++ {
  data := make([]int, 0)
  for k := 0; k < size; k++{
    data = append(data, k)
  }
}
for n := 0; n < b.N; n++ {
  data := make([]int, 0, size)
  for k := 0; k < size; k++{
    data = append(data, k)
  }
}
BenchmarkBad-4    100000000    2.48s
BenchmarkGood-4   100000000    0.21s

主函式結束方式

Go 程式使用 os.Exit 或者 log.Fatal* 立即結束(使用 panic 不是結束程式的好方法,請不要使用 panic。)

只在 main() 中呼叫 os.Exitlog.Fatal*。其他所有函式應回傳錯誤以表示失敗。

不妥做法 較佳做法
func main() {
  body := readFile(path)
  fmt.Println(body)
}
func readFile(path string) string {
  f, err := os.Open(path)
  if err != nil {
    log.Fatal(err)
  }
  b, err := os.ReadAll(f)
  if err != nil {
    log.Fatal(err)
  }
  return string(b)
}
func main() {
  body, err := readFile(path)
  if err != nil {
    log.Fatal(err)
  }
  fmt.Println(body)
}
func readFile(path string) (string, error) {
  f, err := os.Open(path)
  if err != nil {
    return "", err
  }
  b, err := os.ReadAll(f)
  if err != nil {
    return "", err
  }
  return string(b), nil
}

基本上,有多個函式會觸發 exit 的程式,可能會有一些問題:

  • 不明瞭的控制流程:任何函式都可以結束程式,因此很難推斷控制流程
  • 難以測試:結束程式的函式也會結束呼叫它的測試。這使得函式難以測試,並且可能導致 go test 還沒進行其他測試,測試程序就先結束的問題
  • 跳過清理:函式結束程式時,會跳過已經排入 defer 佇列的函式呼叫。這增加了跳過重要清理任務的風險

只結束一次

可以的話,建議 main() 中最多只呼叫一次 os.Exitlog.Fatal*。如果有多個錯誤場景會停止程式執行,請將邏輯放在單獨的函式,並從中回傳錯誤。

這能縮短 main() 函式的列數,同時也能將所有關鍵的商業邏輯放入單獨的、可測試的函式中。

不妥做法 較佳做法
package main
func main() {
  args := os.Args[1:]
  if len(args) != 1 {
    log.Fatal("missing file")
  }
  name := args[0]
  f, err := os.Open(name)
  if err != nil {
    log.Fatal(err)
  }
  defer f.Close()
  // 在本列後呼叫 log.Fatal,
  // f.Close 就不能被呼叫到。
  b, err := os.ReadAll(f)
  if err != nil {
    log.Fatal(err)
  }
  // ...
}
package main
func main() {
  if err := run(); err != nil {
    log.Fatal(err)
  }
}
func run() error {
  args := os.Args[1:]
  if len(args) != 1 {
    return errors.New("missing file")
  }
  name := args[0]
  f, err := os.Open(name)
  if err != nil {
    return err
  }
  defer f.Close()
  b, err := os.ReadAll(f)
  if err != nil {
    return err
  }
  // ...
}

在序列化結構中使用成員標籤

任何會序列化到 JSON、YAML 或其他支援以標籤為基礎的欄位命名格式的結構體欄位,都應使用相關的標籤進行註解。

不妥做法 較佳做法
type Stock struct {
  Price int
  Name  string
}
bytes, err := json.Marshal(Stock{
  Price: 137,
  Name:  "UBER",
})
type Stock struct {
  Price int    `json:"price"`
  Name  string `json:"name"`
  // 可以無慮地將 Name 重新命名成 Symbol。
}
bytes, err := json.Marshal(Stock{
  Price: 137,
  Name:  "UBER",
})

基本上:結構的序列化形式是不同系統間的契約。對序列化格式結構(包括欄位名稱)的更動會破壞這個契約。在標籤中指定欄位名稱,使得契約更為明確,也能防止因重構或重新命名欄位而意外破壞契約。

要對建立的 goroutine 負責

Goroutines 很輕量,但並非完全不需要開銷。至少排程堆疊和 CPU 資源的過程中會消耗記憶體資源。雖然這些成本對 goroutines 來說很小,但當它們不受生命週期的管控,在大量的情況下會拖累整體效能。此外,不受管理的 goroutines 可能會造成其他問題,像是讓 GC 無法回收不再使用的資源。

因此,不要在生產環境中的程式內洩漏 goroutines。使用 go.uber.org/goleak 來測試可能產生 goroutine 的洩漏問題。

一般來說,每個 goroutine 必須:

  • 具備可預測的停止運作時間;或者
  • 具有一個方法可以使 goroutine 在接收訊號時停止執行

在這兩種情況下,必須要有一個方法阻止程式繼續執行,等待 goroutine 完成工作。

舉例來說:

不妥做法 較佳做法
go func() {
  for {
    flush()
    time.Sleep(delay)
  }
}()
var (
  stop = make(chan struct{}) // 告訴 goroutine 停止
  done = make(chan struct{}) // 告訴我們 goroutine 結束了
)
go func() {
  defer close(done)
  ticker := time.NewTicker(delay)
  defer ticker.Stop()
  for {
    select {
    case <-ticker.C:
      flush()
    case <-stop:
      return
    }
  }
}()
// 其它……
close(stop)  // 指示 goroutine 停止
<-done       // 並且等待它停止後離開

無法停止這個 goroutine。它將運作到應用程式結束。

這個 goroutine 可以使用 close(stop) 結束,而我們可以使用 <-done 等待它結束。

等待 goroutines 結束

給定一個由系統產生的 goroutine,必須有一種方式能等待 goroutine 結束。這邊有兩種常見的方式:

  • 使用 sync.WaitGroup。如果您要等待多個 goroutine,請採用此方式:

    var wg sync.WaitGroup
    for i := 0; i < N; i++ {
      wg.Add(1)
      go func() {
        defer wg.Done()
        // ...
      }()
    }
    
    // 等待所有程序完成:
    wg.Wait()
  • 新增另一個 chan struct{},goroutine 結束執行時會關閉它。如果只有一個 goroutine,請採用此方式:

    done := make(chan struct{})
    go func() {
      defer close(done)
      // ...
    }()
    
    // 若要等待 goroutine 完成:
    <-done

不要在 init() 使用 goroutines

init() 函式不應該產生 goroutines。可以參考〈避免使用 init()〉。

如果套件需要在背景執行的 goroutine,它必須公開一個負責管理 goroutine 生命週期的物件。該物件必須提供一個方法(CloseStopShutdown 等)來指示背景 goroutine 停止動作並等待其結束。

不妥做法 較佳做法
func init() {
  go doWork()
}
func doWork() {
  for {
    // ...
  }
}
type Worker struct{ /* ... */ }
func NewWorker(…) *Worker {
  w := &Worker{
    stop: make(chan struct{}),
    done: make(chan struct{}),
    // ...
  }
  go w.doWork()
}
func (w *Worker) doWork() {
  defer close(w.done)
  for {
    // ...
    case <-w.stop:
      return
  }
}
// Shutdown 告訴 worker 停止
// 並等待它完成。
func (w *Worker) Shutdown() {
  close(w.stop)
  <-w.done
}

當使用者匯出這個套件時,將無條件的產生背景 goroutine。使用者無法控制 goroutine 或是停止它。

只在使用者請求時產生 worker,另外提供一種關閉 worker 的方式,方便使用者釋放 worker 使用的資源。

請注意,如果 worker 管理多個 goroutines,則應該使用 WaitGroup。請參考 等待 goroutines 結束

效能

效能方面的特定準則只適用於最忙碌路徑 (hot path)。

優先使用 strconv 而不是 fmt

將基本類型轉換為字串或反過來轉換時,strconv 速度比 fmt 快。

不妥做法 較佳做法
for i := 0; i < b.N; i++ {
  s := fmt.Sprint(rand.Int())
}
for i := 0; i < b.N; i++ {
  s := strconv.Itoa(rand.Int())
}
BenchmarkFmtSprint-4    143 ns/op    2 allocs/op
BenchmarkStrconv-4    64.2 ns/op    1 allocs/op

避免字串到字元的轉換

不要反覆從固定字串建立字元切片。相反,請只做一次轉換並擷取其結果。

不妥做法 較佳做法
for i := 0; i < b.N; i++ {
  w.Write([]byte("Hello world"))
}
data := []byte("Hello world")
for i := 0; i < b.N; i++ {
  w.Write(data)
}
BenchmarkBad-4   50000000   22.2 ns/op
BenchmarkGood-4  500000000   3.25 ns/op

指定容器容量

盡可能指定容器容量,以便為容器預先分配記憶體。這能最大減少加入元素造成的後續分配(透過複製和調整容器大小)。

指定 Map 容量提示

盡量在使用 make() 初始化的時候提供容量提示。

make(map[T1]T2, hint)

make() 提供容量提示,會使之嘗試在初始化時調整 map 至正確大小。這將減少元素加入到 map 時,擴增 map 和分配記憶體的需要。

注意,與切片不同,map 的容量提示不保證完全的搶斷式分配,而是用於估計所需的 hashmap bucket 的數量。因此,將元素加入到 map 時,即使個數還沒到指定的容量,仍可能會進行分配。

不妥做法 較佳做法
m := make(map[string]os.FileInfo)

files, _ := os.ReadDir("./files")
for _, f := range files {
    m[f.Name()] = f
}
files, _ := os.ReadDir("./files")

m := make(map[string]os.FileInfo, len(files))
for _, f := range files {
    m[f.Name()] = f
}

m 建立時未給予容量提示,運作時分配的次數可能會比較多。

m 建立時有給予容量提示,運作時分配的次數可能會比較少。

指定切片容量

盡量在使用 make() 初始化切片 (slice) 時提供容量參數,尤其是在附加切片時。

make([]T, length, capacity)

和 map 不同,切片容量不單單只是提示:編譯器將為提供給 make() 的切片容量,分配足夠的記憶體。這意味著後續的 append() 操作將不需要進行分配(直到切片的長度和容量相同,在此之後,任何 append 都可能調整大小以容納其他元素)。

不妥做法 較佳做法
for n := 0; n < b.N; n++ {
  data := make([]int, 0)
  for k := 0; k < size; k++{
    data = append(data, k)
  }
}
for n := 0; n < b.N; n++ {
  data := make([]int, 0, size)
  for k := 0; k < size; k++{
    data = append(data, k)
  }
}
BenchmarkBad-4    100000000    2.48s
BenchmarkGood-4   100000000    0.21s

規範

列內文字不要過長

不要寫出需要使用者橫向捲動,或需要大幅度轉動頭部的程式列。

我們建議將軟性 (soft) 列長度限制在 99 個字元。作者應該在達到這個限制之前換列,但這不是硬性限制。程式碼可以超過這個限制。

一致性

本文中概述的一些準則可以客觀評估,而其他準則需因地、場景或主觀因素進行判斷。

最重要的是 保持一致

一致的程式碼更容易維護、更加容易安排、學習成本更少,而且更容易在新的約定出現時進行移植,或在類別的錯誤修正後進行更新。

相反,在一個函式庫中包含多個完全不同或衝突的程式碼風格,會增加維護成本、製造不確定性並導致認知上的偏誤,而這些全會直接導致速度的降低、程式碼檢查上的痛苦,並造出 bugs。

當在程式碼庫 (codebase) 中套用這些準則時,建議在套件(或更大)的層級上進行更動:子套件層級的應用程式在同一份程式碼中引入多種風格,是違反上述原則的。

相似的宣告放在一組

Go 語言支援將相似的宣告放在一個組內。

不妥做法 較佳做法
import "a"
import "b"
import (
  "a"
  "b"
)

這同樣適用於常數、變數和類型宣告:

不妥做法 較佳做法
const a = 1
const b = 2

var a = 1
var b = 2

type Area float64
type Volume float64
const (
  a = 1
  b = 2
)

var (
  a = 1
  b = 2
)

type (
  Area float64
  Volume float64
)

僅將相關的宣告放在一組。不要將不相關的宣告放在一組。

不妥做法 較佳做法
type Operation int

const (
  Add Operation = iota + 1
  Subtract
  Multiply
  EnvVar = "MY_ENV"
)
type Operation int

const (
  Add Operation = iota + 1
  Subtract
  Multiply
)

const EnvVar = "MY_ENV"

沒有規定應該在程式碼何處分組。例如:你可以在函式內部進行分組:

不妥做法 較佳做法
func f() string {
  red := color.New(0xff0000)
  green := color.New(0x00ff00)
  blue := color.New(0x0000ff)

  ...
}
func f() string {
  var (
    red   = color.New(0xff0000)
    green = color.New(0x00ff00)
    blue  = color.New(0x0000ff)
  )

  ...
}

例外:如果變數宣告與其他變數相鄰,則應將變數宣告(尤其是函式內部的宣告)分在同個群組。即使變數間沒有關聯,也應當如此宣告。

不妥做法 較佳做法
func (c *client) request() {
  caller := c.name
  format := "json"
  timeout := 5*time.Second
  var err error
  // ...
}
func (c *client) request() {
  var (
    caller  = c.name
    format  = "json"
    timeout = 5*time.Second
    err error
  )
  // ...
}

import 分組

匯入 (import) 應該分為兩組:

  • 標準函式庫
  • 其他函式庫

預設情況下,這是 goimports 套用的分組方式。

不妥做法 較佳做法
import (
  "fmt"
  "os"
  "go.uber.org/atomic"
  "golang.org/x/sync/errgroup"
)
import (
  "fmt"
  "os"

  "go.uber.org/atomic"
  "golang.org/x/sync/errgroup"
)

套件名稱

當命名套件時,請按下面規則選擇一個名稱:

  • 全部小寫。沒有大寫或底線
  • 大部分呼叫情境都不需要使用「命名匯入」重新命名套件
  • 簡短而簡潔。請謹記:每個呼叫套件的地方,都會完整呈現這個名稱
  • 不用複數。例如 net/url,而不是 net/urls
  • 不要用 commonutilsharedlib。這些是不好的,資訊量不足的名稱

另請參考 Go 套件命名規則Go 套件樣式指南

函式名稱

我們遵循 Go 社群關於使用 MixedCaps 作為函式名 的約定。有一個例外,為了對相關的測試用例進行分組,函式名可能包含底線,如:TestMyFunction_WhatIsBeingTested.

匯入別名

如果套件名稱與匯入路徑的最後一個元素不匹配,則必須使用匯入別名。

import (
  "net/http"

  client "example.com/client-go"
  trace "example.com/trace/v2"
)

在所有其他情況下,除非匯入之間有直接衝突,否則應避免匯入別名。

不妥做法 較佳做法
import (
  "fmt"
  "os"

  nettrace "golang.net/x/trace"
)
import (
  "fmt"
  "os"
  "runtime/trace"

  nettrace "golang.net/x/trace"
)

函式分組與順序

  • 函式應按粗略的呼叫順序排序
  • 同一文件中的函式應按接收者分組

因此,匯出的函式應先出現在檔案中,放在 structconstvar 定義的後面。

在定義類型之後,但在接收者的其餘方法之前,可能會放一個 newXYZ()NewXYZ()

由於函式是依照接收者分組的,因此純工具函式應放在檔案末尾。

不妥做法 較佳做法
func (s *something) Cost() {
  return calcCost(s.weights)
}

type something struct{ ... }

func calcCost(n []int) int {...}

func (s *something) Stop() {...}

func newSomething() *something {
    return &something{}
}
type something struct{ ... }

func newSomething() *something {
    return &something{}
}

func (s *something) Cost() {
  return calcCost(s.weights)
}

func (s *something) Stop() {...}

func calcCost(n []int) int {...}

減少巢套(Nesting)

程式碼應當盡量減少巢套 (Nesting) 的數量。可以先處理錯誤情況或特殊情況並盡早回傳或繼續循環來減少巢套。減少在多層級巢套之內程式碼的列數。

不妥做法 較佳做法
for _, v := range data {
  if v.F1 == 1 {
    v = process(v)
    if err := v.Call(); err == nil {
      v.Send()
    } else {
      return err
    }
  } else {
    log.Printf("Invalid v: %v", v)
  }
}
for _, v := range data {
  if v.F1 != 1 {
    log.Printf("Invalid v: %v", v)
    continue
  }

  v = process(v)
  if err := v.Call(); err != nil {
    return err
  }
  v.Send()
}

不必要的 else

如果在 if 的兩個分支中都設定了變數,則可以將其取代為單個 if。

不妥做法 較佳做法
var a int
if b {
  a = 100
} else {
  a = 10
}
a := 10
if b {
  a = 100
}

上層變數宣告

在上層,使用標準 var 關鍵字。除非它與表示式的類型不同,否則不要指定類型。

不妥做法 較佳做法
var _s string = F()

func F() string { return "A" }
var _s = F()
// 由於 F() 已經明確回傳一個字串類型,
// 因此不用再次指定 _s 的類型

func F() string { return "A" }

如果表示式的類型與所需的類型不完全匹配,請指定類型。

type myError struct{}

func (myError) Error() string { return "error" }

func F() myError { return myError{} }

var _e error = F()
// F 回傳一個 myError 類型的實例,但是我們要 error 類型

對於未匯出的上層常數和變數,使用 _ 作為前綴

在未匯出的頂層 varsconsts 前面加上前綴 _,以使它們在使用時明確表示它們是全域符號。

基本上:頂層變數和常數具有套件範圍作用域。使用通用名稱,很容易在其他檔案中意外使用錯誤的值。

不妥做法 較佳做法
// foo.go

const (
  defaultPort = 8080
  defaultUser = "user"
)

// bar.go

func Bar() {
  defaultPort := 9090
  ...
  fmt.Println("Default port", defaultPort)

  // 把 Bar() 的第一列程式碼刪掉後,
  // 會發現沒有編譯錯誤。
}
// foo.go

const (
  _defaultPort = 8080
  _defaultUser = "user"
)

例外:未匯出的錯誤值可以使用不帶底線的前綴 err。 參見錯誤命名

結構體中的嵌入

嵌入式類型(例如 mutex)應位於結構體內的成員 list 的頂部,並且必須有一個空行將嵌入式成員與一般成員分隔開。

不妥做法 較佳做法
type Client struct {
  version int
  http.Client
}
type Client struct {
  http.Client

  version int
}

要有切實的好處,比如以語義上合適的方式加入或增強功能,才建議內嵌。它應該在對使用者沒有任何不利影響的情況下使用(另請參見:避免在公用結構體中嵌入類型)。

例外:即使在未匯出類型中,Mutex 也不應該作為內嵌成員。另請參見:零值 Mutex 是有效的

嵌入 不應該

  • 純粹是為了美觀或方便
  • 使外部類型更難建構或使用
  • 影響外部類型的零值。如果外部類型有一個有用的零值,則在嵌入內部類型之後應依然有一個有用的零值
  • 作為嵌入內部類型的副作用,從外部類型公開不相關的函式或成員
  • 公開未匯出的類型
  • 影響外部類型的複製語意
  • 更改外部類型的 API 或類型語義
  • 嵌入內部類型的非規範形式
  • 公開外部類型的實作詳情
  • 允許使用者觀察或控制類型內部
  • 透過包裝的方式改變內部函式的一般行為,這種包裝方式會給使用者帶來一些意料之外的情況

簡單地說:要有意識且有目的性的嵌入。有個很好的檢驗準則:「是否想要把所有這些匯出的內部方法或欄位,直接加入到外部類型?」如果答案是「只有一些」或「不想」,則不要嵌入內部類型,而是改用欄位。

不妥做法 較佳做法
type A struct {
    // 不妥做法:現在可以使用 A.Lock() and A.Unlock()
    // 但這些方法不提供任何功能性好處,並允許使用者控制
    // 有關 A 的內部細節。
    sync.Mutex
}
type countingWriteCloser struct {
    // 較佳做法:Write() 在外層提供用於特定目的,
    // 並且委託工作到內部類型的 Write() 中。
    io.WriteCloser
    count int
}
func (w *countingWriteCloser) Write(bs []byte) (int, error) {
    w.count += len(bs)
    return w.WriteCloser.Write(bs)
}
type Book struct {
    // 不妥做法:指標更改零值的可用性
    io.ReadWriter
    // 其它欄位
}
// 稍後
var b Book
b.Read(…)  // panic: nil pointer
b.String()   // panic: nil pointer
b.Write(…) // panic: nil pointer
type Book struct {
    // 較佳做法:包含可用的零值
    bytes.Buffer
    // 其它欄位
}
// 稍後
var b Book
b.Read(…)  // ok
b.String()   // ok
b.Write(…) // ok
type Client struct {
    sync.Mutex
    sync.WaitGroup
    bytes.Buffer
    url.URL
}
type Client struct {
    mtx sync.Mutex
    wg  sync.WaitGroup
    buf bytes.Buffer
    url url.URL
}

本地變數宣告

如果將變數明確設定為某個值,則應使用短變數宣告形式 (:=)。

不妥做法 較佳做法
var s = "foo"
s := "foo"

但是,在某些情況下,使用 var 關鍵字能使預設值更清晰,例如宣告空切片

不妥做法 較佳做法
func f(list []int) {
  filtered := []int{}
  for _, v := range list {
    if v > 10 {
      filtered = append(filtered, v)
    }
  }
}
func f(list []int) {
  var filtered []int
  for _, v := range list {
    if v > 10 {
      filtered = append(filtered, v)
    }
  }
}

nil 是一個有效的 slice

nil 是一個有效的長度為 0 的 slice,這意味著,

  • 您不應明確回傳長度為零的切片。應該改回傳 nil

    不妥做法 較佳做法
    if x == "" {
      return []int{}
    }
    if x == "" {
      return nil
    }
  • 要檢查切片是否為空,請始終使用 len(s) == 0。而非 nil

    不妥做法 較佳做法
    func isEmpty(s []string) bool {
      return s == nil
    }
    func isEmpty(s []string) bool {
      return len(s) == 0
    }
  • 零值切片(用 var 宣告的切片)可立即使用,無需呼叫 make() 建立

    不妥做法 較佳做法
    nums := []int{}
    // or, nums := make([]int)
    
    if add1 {
      nums = append(nums, 1)
    }
    
    if add2 {
      nums = append(nums, 2)
    }
    var nums []int
    
    if add1 {
      nums = append(nums, 1)
    }
    
    if add2 {
      nums = append(nums, 2)
    }

記住,雖然 nil 切片是有效的切片,但它不等於長度為 0 的切片(一個為 nil,另一個不是),並且在不同的情況下(例如序列化),這兩個切片的處理方式可能不同。

縮小變數作用域

如果有可能,盡量縮小變數作用範圍。除非它與 減少巢套 的規則衝突。

不妥做法 較佳做法
err := os.WriteFile(name, data, 0644)
if err != nil {
 return err
}
if err := os.WriteFile(name, data, 0644); err != nil {
 return err
}

如果需要在 if 之外使用函式呼叫的結果,則不應嘗試縮小範圍。

不妥做法 較佳做法
if data, err := os.ReadFile(name); err == nil {
  err = cfg.Decode(data)
  if err != nil {
    return err
  }

  fmt.Println(cfg)
  return nil
} else {
  return err
}
data, err := os.ReadFile(name)
if err != nil {
   return err
}

if err := cfg.Decode(data); err != nil {
  return err
}

fmt.Println(cfg)
return nil

避免參數語義不明確 (Avoid Naked Parameters)

函式呼叫中的 意義不明確的參數 可能會損害可讀性。當參數名稱的含義不明顯時,請為參數加入 C 樣式註解 (/* ... */)

不妥做法 較佳做法
// func printInfo(name string, isLocal, done bool)

printInfo("foo", true, true)
// func printInfo(name string, isLocal, done bool)

printInfo("foo", true /* isLocal */, true /* done */)

對於上面的範例程式碼,還有一種更好的處理方式,是將上面的 bool 類型換成自訂類型。之後這個參數就不會被兩元狀態 (true/false) 所局限。

type Region int

const (
  UnknownRegion Region = iota
  Local
)

type Status int

const (
  StatusReady Status= iota + 1
  StatusDone
  // 之後還可以有個 StatusInProgress。
)

func printInfo(name string, region Region, status Status)

使用原始字串字面值,避免轉義

Go 支援使用 原始字串字面值 (raw string literal),不僅可以跨多個程式列,還可以包含引號(")。使用這些字串可以避免寫出很難閱讀的手寫轉義字串。

不妥做法 較佳做法
wantError := "unknown name:\"test\""
wantError := `unknown error:"test"`

初始化結構體

使用欄位名稱初始化結構體

初始化結構時,絕大多數情況下應指定欄位名稱。現在 go vet 會強制檢查這方面的問題。

不妥做法 較佳做法
k := User{"John", "Doe", true}
k := User{
    FirstName: "John",
    LastName: "Doe",
    Admin: true,
}

例外:當有 3 個或更少的成員時,測試表中的成員名 或許 可以省略。

tests := []struct{
  op Operation
  want string
}{
  {Add, "add"},
  {Subtract, "subtract"},
}

省略結構體中的零值欄位

初始化具有成員名稱的結構體時,除非這些欄位能引出有意義的上下文 (context),否則不要手寫,讓 Go 自動設定這些零值。

不妥做法 較佳做法
user := User{
  FirstName: "John",
  LastName: "Doe",
  MiddleName: "",
  Admin: false,
}
user := User{
  FirstName: "John",
  LastName: "Doe",
}

省略這個上下文中的預設值,可以減少讀者閱讀程式碼的障礙。只指定有意義的數值。

如果欄位名稱能引出有意義的上下文,則請寫出其零值。比如 表格驅動測試 中的測試用例,即使欄位的數值是零值,但可以從欄位名稱中獲益。

tests := []struct{
  give string
  want int
}{
  {give: "0", want: 0},
  // ...
}

對零值結構體使用 var

如果在宣告中省略了結構體的所有成員,請使用 var 宣告結構。

不妥做法 較佳做法
user := User{}
var user User

這將零值結構與那些具有類似於為 map 初始化 建立的,區別於非零值成員的結構區分開來,並與我們更偏好的 宣告空切片 方式相匹配。

初始化 Struct 引用

在初始化結構引用時,請使用 &T{} 代替 new(T),以使其與結構體初始化一致。

不妥做法 較佳做法
sval := T{Name: "foo"}

// 不一致
sptr := new(T)
sptr.Name = "bar"
sval := T{Name: "foo"}

sptr := &T{Name: "bar"}

初始化 Maps

空 map 請使用 make(…) 初始化,並且 map 是透過程式填充的。這使得 map 初始化在表現上不同於宣告,並且它還可以方便地在 make 後加入大小提示。

不妥做法 較佳做法
var (
  // m1 讀寫安全;
  // m2 在寫入時會 panic
  m1 = map[T1]T2{}
  m2 map[T1]T2
)
var (
  // m1 讀寫安全;
  // m2 在寫入時會 panic
  m1 = make(map[T1]T2)
  m2 map[T1]T2
)

宣告和初始化在視覺上容易混淆。

宣告和初始化能在視覺上進行區分。

盡量在初始化時提供 map 容量大小,詳細請看 指定 Map 容量提示

另外,如果 map 包含固定的元素列表 (list),則使用 map 字面值初始化 map。

不妥做法 較佳做法
m := make(map[T1]T2, 3)
m[k1] = v1
m[k2] = v2
m[k3] = v3
m := map[T1]T2{
  k1: v1,
  k2: v2,
  k3: v3,
}

基本準則是:在初始化時使用 map 字面值加入一組固定的元素。否則使用 make(如果可以,最好指定 map 容量。)

字串 string format

如果你在函式外宣告 Printf 風格函式的格式字串,請將其設定為 const 常數。

這有助於 go vet 對格式字串執行靜態分析。

不妥做法 較佳做法
msg := "unexpected values %v, %v\n"
fmt.Printf(msg, 1, 2)
const msg = "unexpected values %v, %v\n"
fmt.Printf(msg, 1, 2)

命名 Printf 樣式的函式

宣告 Printf 風格的函式時,請確保 go vet 可以檢測到它並檢查格式字串。

這意味著您應盡可能使用預定義的 Printf 風格函式名稱。go vet 將預設檢查這些函式。更多資訊請參見 Printf 系列

如果不能使用預定義的名稱,請以 f 結束選擇的名稱:Wrapf,而不是 Wrap。可以要求 go vet 檢查特定的 Printf 樣式名稱,但名稱必須以 f 結尾。

go vet -printfuncs=wrapf,statusf

另請參考 go vet: Printf family check

編程模式

表格驅動測試

當測試邏輯重複時,搭配 subtests 使用表格驅動的方式可以減少重複程式碼。

不妥做法 較佳做法
// func TestSplitHostPort(t *testing.T)

host, port, err := net.SplitHostPort("192.0.2.0:8000")
require.NoError(t, err)
assert.Equal(t, "192.0.2.0", host)
assert.Equal(t, "8000", port)

host, port, err = net.SplitHostPort("192.0.2.0:http")
require.NoError(t, err)
assert.Equal(t, "192.0.2.0", host)
assert.Equal(t, "http", port)

host, port, err = net.SplitHostPort(":8000")
require.NoError(t, err)
assert.Equal(t, "", host)
assert.Equal(t, "8000", port)

host, port, err = net.SplitHostPort("1:8")
require.NoError(t, err)
assert.Equal(t, "1", host)
assert.Equal(t, "8", port)
// func TestSplitHostPort(t *testing.T)

tests := []struct{
  give     string
  wantHost string
  wantPort string
}{
  {
    give:     "192.0.2.0:8000",
    wantHost: "192.0.2.0",
    wantPort: "8000",
  },
  {
    give:     "192.0.2.0:http",
    wantHost: "192.0.2.0",
    wantPort: "http",
  },
  {
    give:     ":8000",
    wantHost: "",
    wantPort: "8000",
  },
  {
    give:     "1:8",
    wantHost: "1",
    wantPort: "8",
  },
}

for _, tt := range tests {
  t.Run(tt.give, func(t *testing.T) {
    host, port, err := net.SplitHostPort(tt.give)
    require.NoError(t, err)
    assert.Equal(t, tt.wantHost, host)
    assert.Equal(t, tt.wantPort, port)
  })
}

「測試表格」(test table) 不僅方便加上錯誤訊息的上下文、減少重複的邏輯,還簡化了加入新測試案例的流程。

我們遵循這樣的約定:將這種結構體切片稱為 tests,每個測試用例稱為tt。此外,我們建議使用 givewant 前綴說明每個測試用例的輸入和輸出值。

tests := []struct{
  give     string
  wantHost string
  wantPort string
}{
  // ...
}

for _, tt := range tests {
  // ...
}

平行測試,比如一些特化的循環(例如產生 goroutine、或者會捕捉循環體中引用的循環)必須注意在循環的範圍內顯式地指派循環變數,以確保它們保持預期的值。

tests := []struct{
  give string
  // ...
}{
  // ...
}
for _, tt := range tests {
  tt := tt // 指派給 t.Parallel
  t.Run(tt.give, func(t *testing.T) {
    t.Parallel()
    // ...
  })
}

在上面的例子中,由於下面使用了 t.Parallel(),我們必須宣告一個作用域為循環疊代的 tt 變數。如果我們不這樣做,大多數或所有測試會收到一個非預期的 tt 值,或者一個在運作時會發生變化的值。

函式型選項

「函式型選項」是一種模式,您可以在其中宣告一個不透明 Option 類型,在特定內部結構中記錄資訊。您接受不定個數的這些選項,並根據內部結構體上選項記錄的完整資訊採取行動。

這個模式請用在您需要擴充的建構子和其他公用 API 中的可選參數,尤其是在這些功能上已經具有三個或更多參數的情況下。

不妥做法 較佳做法
// package db

func Open(
  addr string,
  cache bool,
  logger *zap.Logger
) (*Connection, error) {
  // ...
}
// package db

type Option interface {
  // ...
}

func WithCache(c bool) Option {
  // ...
}

func WithLogger(log *zap.Logger) Option {
  // ...
}

// Open creates a connection.
func Open(
  addr string,
  opts ...Option,
) (*Connection, error) {
  // ...
}

必須始終提供快取和記錄器參數,即使使用者希望使用預設值。

db.Open(addr, db.DefaultCache, zap.NewNop())
db.Open(addr, db.DefaultCache, log)
db.Open(addr, false /* cache */, zap.NewNop())
db.Open(addr, false /* cache */, log)

只有在需要時才提供選項。

db.Open(addr)
db.Open(addr, db.WithLogger(log))
db.Open(addr, db.WithCache(false))
db.Open(
  addr,
  db.WithCache(false),
  db.WithLogger(log),
)

我們建議實作此模式的方法是使用一個 Option 介面,該介面儲存一個未匯出 (unexported) 的方法,並在一個未匯出的 options 結構體上記錄選項。

type options struct {
  cache  bool
  logger *zap.Logger
}

type Option interface {
  apply(*options)
}

type cacheOption bool

func (c cacheOption) apply(opts *options) {
  opts.cache = bool(c)
}

func WithCache(c bool) Option {
  return cacheOption(c)
}

type loggerOption struct {
  Log *zap.Logger
}

func (l loggerOption) apply(opts *options) {
  opts.logger = l.Log
}

func WithLogger(log *zap.Logger) Option {
  return loggerOption{Log: log}
}

// Open creates a connection.
func Open(
  addr string,
  opts ...Option,
) (*Connection, error) {
  options := options{
    cache:  defaultCache,
    logger: zap.NewNop(),
  }

  for _, o := range opts {
    o.apply(&options)
  }

  // ...
}

注意:還有一種使用閉包實作這個模式的方法,但是我們相信上面的模式為作者提供了更多的靈活性,並且更容易對使用者進行除錯和測試。特別是,閉包不可能進行比較,而我們的模式可以在測試和模擬中對選項進行比較。此外,它還允許選項實作其他介面,包括 fmt.Stringer,允許使用者讀取選項的字串表示形式。

還可以參考下面資料:

Linting

比任何「豐富」linter 集更重要的是,lint 規則在一個程式碼庫始終保持一致。

我們建議至少使用以下 linters,因為我們認為它們有助於發現最常見的問題,並在不需要規定的情況下為程式碼品質建立一個高標準:

  • errcheck 以確保錯誤得到處理
  • goimports 格式化程式碼和管理 imports
  • golint 指出常見的語法錯誤
  • govet 分析程式碼中的常見錯誤
  • staticcheck 各種靜態分析檢查

Lint Runners

我們推薦將 golangci-lint 作為 go-to lint 的運作程式,主要是因為它在較大函式庫中有著優異效能,以及能夠同時設定和使用許多規範。這個 repo 有一個範例組態檔案 .golangci.yml 以及建議的 linters 和設定。

golangci-lint 有 多種 linters 可供使用。建議將上述的 linters 當作基礎設定,而我們鼓勵團隊往專案中加入其他對專案有意義的 linters。

Stargazers over time

Stargazers over time

Footnotes

  1. 這個補充來源自簡體中文翻譯 2 3 4