App下載

Uber 的 Go 規(guī)范

來源: W3cschool 2022-12-08 10:11:44 瀏覽數(shù) (2990)
反饋

Uber公司推出的Go語言規(guī)范,建議沒看過的同學看一遍,里面的規(guī)范很多,不見得每一條都采納,不現(xiàn)實,選一些適合的可以落地執(zhí)行的拿來參考就行。

介紹

本指南的目的是通過詳細描述在Uber編寫Go代碼的注意事項來管理這種復雜性。這些規(guī)則的存在是為了保持代碼庫的可管理性,同時還允許工程師有效地使用Go語言的特性。

本指南最初是由Prashant Varanasi和Simon Newton創(chuàng)建的,是為了讓一些同事盡快掌握Go的使用方法。多年來,我們根據(jù)其他人的反饋對它進行了修改。

這記錄了我們在 Uber 所遵循的 Go 代碼中的習慣性約定。其中很多是Go里面的一般準則,而其他的則是根據(jù)外部資源進行擴展:

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

我們的目標是使代碼樣本準確地適用于Go的兩個最新的次要版本。

所有代碼在通過golint和go vet運行時應該是沒有錯誤的。我們建議將您的編輯器設置為:

  • 保存時運行 goimports
  • 運行 golint 和 go vet 檢查錯誤

你可以在這里找到編輯器支持Go工具的信息:https://github.com/golang/go/wiki/IDEsAndTextEditorPlugins

指南

指向interface的指針

你幾乎不需要一個指向 interface 的指針,interface類型數(shù)據(jù)應該直接傳遞,但實際上 interface 底層是一個指針。

interface 類型包括兩部分:

  1. 一個指向特定類型的指針。可以將其視為 "類型"。
  2. 數(shù)據(jù)指針。如果底層數(shù)據(jù)是指針,會被直接存儲。如果底層數(shù)據(jù)是值,那會存儲這個數(shù)據(jù)的指針。

如果你想要接口方法修改基礎數(shù)據(jù),那必須使用指針。

驗證接口合法性

在編譯期驗證接口的合法性,需要驗證的有:

  • 驗證導出類型在作為API時是否實現(xiàn)了特定接口
  • 實現(xiàn)一個接口的導出和非導出類型是集合的一部分
  • 違反接口合理性無法編譯通過,通知用戶
Bad Good
type Handler struct {
  // ...
}



func (h *Handler) ServeHTTP(
  w http.ResponseWriter,
  r *http.Request,
) {
  ...
}
type Handler struct {
  // ...
}

var _ http.Handler = (*Handler)(nil)

func (h *Handler) ServeHTTP(
  w http.ResponseWriter,
  r *http.Request,
) {
  // ...
}

如果*Handler沒有實現(xiàn)http.Handler接口,那么var _ http.Handler = (*Handler)(nil)語句在編譯期就會報錯;

賦值語句的右邊部門應是斷言類型的零值。對于指針類型(像*Handler)、slice和map類型,零值為nil,對于結構體類型,零值為空結構體,下面是空結構體的例子。

type LogHandler struct {
  h   http.Handler
  log *zap.Logger
}

// LogHandler{}是空結構體
var _ http.Handler = LogHandler{}

func (h LogHandler) ServeHTTP(
  w http.ResponseWriter,
  r *http.Request,
) {
  // ...
}

接收者和接口

使用 值類型 接收者的方法既可以通過值調用,也可以通過指針調用。

使用 指針類型 接收者的方法只能通過指針或者 addressable values調用。

例如:

type S struct {
  data string
}

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

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

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

// 值類型可以調用Read()
sVals[1].Read()

// 值類型調用Write方法會報編譯錯誤
//  sVals[1].Write("test")

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

// 指針類型 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的方法里接收者是指針類型.
//   i = s2Val

Effective Go 有一段關于 Pointers vs. Values 的優(yōu)秀講解

Mutexes的零值是有效的

sync.Mutex 和 sync.RWMutex 的零值是有效的,所以不需要實例化一個Mutex的指針。

Bad Good
mu := new(sync.Mutex)
mu.Lock()
var mu sync.Mutex
mu.Lock()

如果結構體中包含mutex,在使用結構體的指針時,mutex應該是結構體的非指針字段,也不要把mutex內嵌到結構體中,即使結構體是非導出類型。

Bad Good
type SMap struct {
  sync.Mutex

  data map[string]string
}

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

func (m *SMap) Get(k stringstring {
  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 stringstring {
  m.mu.Lock()
  defer m.mu.Unlock()

  return m.data[k]
}

隱式嵌入Mutex, 其LockUnlock方法是SMap公開API中不明確說明的一部分。

mutex和它SMap方法的實現(xiàn)細節(jié)對調用方屏蔽。

在邊界拷貝Slices和Maps

slice 和 map 類型包含指向data數(shù)據(jù)的指針,所以當你需要復制時應格外注意。

接收 Slices 和 Maps

如果在函數(shù)調用中傳遞 map 或 slice, 請記住這個函數(shù)可以修改它。

Bad Good
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)

// 這樣賦值不會影響 d1.trips(因為 SetTrips 內部有copy).
trips[0] = ...

返回 Slices 和 Maps

同樣,請注意用戶對 maps 或 slices 的修改暴露了內部狀態(tài)。

Bad Good
type Stats struct {
  mu sync.Mutex
  counters map[string]int
}

// Snapshot 返回當前的 stats.
func (s *Stats) Snapshot() map[string]int {
  s.mu.Lock()
  defer s.mu.Unlock()

  return s.counters
}

// snapshot 變量不在受 mutex鎖保護,任何對 snapshot 的訪問會受數(shù)據(jù)竟態(tài)的影響
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]intlen(s.counters))
  for k, v := range s.counters {
    result[k] = v
  }
  return result
}

// snapshot 現(xiàn)在只是個copy。
snapshot := stats.Snapshot()

使用Defer釋放資源

在讀寫文件、使用鎖時,使用 defer 釋放資源

Bad Good
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在一個地方 Unlock, 代碼可讀性更好

調用 defer 的性能開銷非常小,但如果你需要納秒級別的函數(shù)調用,那可能需要避免使用 defer。使用 defer 帶來的可讀性 勝過引入其它帶來的性能開銷。defer 尤其適用于適用于那些不僅是內存 放在的行數(shù)較多、邏輯較為復雜的大方法,這些方法中其他代碼邏輯的執(zhí)行成本比 defer 執(zhí)行成本更大。

Channel 大小應為 0 或 1

Channels 的大小應該是1或無緩沖的。默認情況下,channels 是無緩沖的,size為0。其他size需經(jīng)過嚴格的審查??紤] channel 的size 是如何定義的,是什么造成了 channel 在負荷情況下被寫滿而無法寫入,以及無法寫入會發(fā)生什么。

Bad Good
// 這個 size 對任何操作都夠了!
c := make(chan int, 64)
// Size 是1
c := make(chan int, 1// or
// 無緩沖 channel
c := make(chan int)

枚舉類型值從1開始

在Go中聲明枚舉值的標準方法是使用const包iota。由于變量的默認值為0,因此枚舉類型的值需要從1開始。

Bad Good
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

當你需要將0值視為默認行為時,枚舉類型從0開始是有意義的。

type LogOutput int

const (
  LogToStdout LogOutput = iota
  LogToFile
  LogToRemote
)

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

使用time包來處理時間

時間處理很復雜,關于時間錯誤預估有以下這些點。

  1. 一天有24小時
  2. 一小時有60分鐘
  3. 一周有7天
  4. 一年有365天
  5. 其他易錯點

舉例來說, 1 表示在一個時間點加上24小時并不一定會產(chǎn)生新的一天。

因此,在處理時間時應始終使用"time"包,因為它會用更安全、準確的方式來處理這些不正確的假設。

用 time.Time 表示瞬時時間

需要瞬時時間語義時,使用time.Time ,在進行比較、增加或減少時間段時,使用time.Time包里的方法。

Bad Good
func isActive(now, start, stop intbool {
  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 來表示時間段

Bad Good
func poll(delay int) {
  for {
    // ...
    time.Sleep(time.Duration(delay) * time.Millisecond)
  }
}

poll(10) // 這里單位是秒還是毫秒
func poll(delay time.Duration) { // 使用 time.Duration 表示時間段
  for {
    // ...
    time.Sleep(delay)
  }
}

poll(10*time.Second) // 明確單位

回到剛剛的例子,在一個瞬時時間加上24小時,怎么加這個 "24小時" 取決于我們的意圖。如果我們想獲取 下一天的當前時間,我們應該使用 Time.AddDate。如果我們想獲取比當前時間晚24小時的瞬時時間, 我們應該應該使用 Time.Add。

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

對外交互 使用 time.Time 和 time.Duration

在對外交互時盡可能使用 time.Duration 和 time.Time,例如:

  • Command-line 標記: flag 通過支持 time.ParseDuration 來支持 time.Duration
  • JSON: encoding/json 通過  [UnmarshalJSON 方法] 支持把 time.Time 解碼為 RFC 3339 字符串
  • SQL: database/sql 支持把 DATETIME 或 TIMESTAMP 類型轉化為 time.Time
  • YAML: gopkg.in/yaml.v2 支持把 time.Time 作為一個 RFC 3339 字符串, 通過支持time.ParseDuration 來支持time.Duration。

如果交互中不支持使用time.Duration,那字段名中應包含單位,類型應為int或float64。

例如, 由于 encoding/json 不支持 time.Duration 類型, 因此字段名中應包含時間單位。

Bad Good
// {"interval": 2}
type Config struct {
  Interval int `json:"interval"`
}
// {"intervalMillis": 2000}
type Config struct { // Millis 是單位
  IntervalMillis int `json:"intervalMillis"`
}

如果在交互中不能使用 time.Time,除非有額外約定,否則應該使用 string 和 RFC 3339定義的 時間戳格式。默認情況下, Time.UnmarshalText 使用這種格式,并可以通過time.RFC3339在Time.Format 和 time.Parse 中使用。

盡管在實踐中這不是什么問題,但是你需要記住"time"不能解析閏秒時間戳(8728),在計算中也不考慮閏秒(15190)。因此如果你要比較兩個瞬時時間,比較結果不會包含這兩個瞬時時間可能會出現(xiàn)的閏秒。

錯誤

錯誤類型

聲明錯誤的選項很少。在為你的代碼選擇合適的用例之前,考慮這些事項:

  • 調用方需要匹配錯誤嗎,還是調用方需要自己處理錯誤。如果需要匹配錯誤,那應該聲明頂級 錯誤類型或自定義類型 來讓 errors.Is 或 errors.As 匹配。
  • 錯誤信息是靜態(tài)字符串嗎,還是說錯誤信息是需要上下文的動態(tài)字符串。如果是靜態(tài)字符串, 我們可以使用 errors.New,如果是動態(tài)字符串,我們應該使用fmt.Errorf來 自定義錯誤類型。
  • 我們是否正在傳遞由下游返回的新的錯誤類型,如果是這樣,參考section on error wrapping.
錯誤匹配? 錯誤信息 使用
靜態(tài) errors.New
動態(tài) fmt.Errorf
靜態(tài) errors.New聲明頂級錯誤類型
動態(tài) 定制 error 類型

舉例,使用 errors.New 表示一個靜態(tài)字符串錯誤。如果調用方需要匹配并處理這個錯誤, 就把這個錯誤聲明為變量來支持和 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")
  }
}

對于動態(tài)字符串的錯誤,如果調用方不需要匹配就是用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 {
  // Can't handle the error.
  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) {
    // handle the error
  } else {
    panic("unknown error")
  }
}

注意,如果你的包里導出了錯誤變量或錯誤類型,那這個錯誤將變成你包里公共API的一部分。

錯誤包裝

調用函數(shù)失敗時,有三種選擇供你選擇:

  • 返回原始錯誤
  • 用 fmt.Errorf 和 %w 包裝上下文信息
  • 用 fmt.Errorf 和 %v 包裝上下文信息

返回原始錯誤不會附加上下文信息,這樣就保持了原始錯誤類型和信息。比較適用于lib庫類型代碼 展示底層錯誤信息。

如果不是lib庫,就需要增加所需的上下文信息,不然就會出現(xiàn) "connection refused" 這樣非常 模糊的錯誤,理論上應該添加上下文,來得到這樣的報錯信息:"call service foo: connection refused"。

在錯誤類型上使用 fmt.Errorf 來添加上下文信息,根據(jù)調用方不同的使用方式,可以選擇 %w 或 %v 動詞。

  • 如果調用方需要訪問底層錯誤,使用%w動詞,這是一個用來包裝錯誤的動詞,如果你在代碼中使用到了它,請注意 調用方會對此產(chǎn)生依賴,所以當你的包裝的錯誤是用var聲明的已知類型,需要在你的代碼里對其進行測試。
  • 使用 %v 會混淆你的底層錯誤類型,調用方將無法進行匹配,如果有匹配需求,應該使用%w動詞。

當為返回錯誤增加上下文信息時,避免在上下文中增加像 "failed to" 這樣的沒啥用的短語,這樣沒用的短語在錯誤 堆棧中堆積起來的話,反而不利于你定位bug。

Bad Good
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

然而當你的錯誤傳給別的系統(tǒng)時,錯誤信息應該足夠清晰。(比如, 錯誤信息在日志中以 "Failed" 開頭)

其他參考信息:Don't just check errors, handle them gracefully.

錯誤命名

對于全局變量類型,根據(jù)是否導出使用  Err 或 err 前綴。詳情參考:Prefix Unexported Globals with _.

var (
  // 以下兩個錯誤是導出類型,所以他們的命名以 Err 作為開頭,用戶可以使用 errors.Is 來匹配錯誤類型
  ErrBrokenLink = errors.New("link is broken")
  ErrCouldNotOpen = errors.New("could not open")

  // 這個錯誤是非導出類型,不會作為我們公共API的一部分,但是你可以在包內使用errors.Is匹配它。
  errNotFound = errors.New("not found")
)

對于自定義錯誤類型,請使用Error后綴。

// 這個錯誤是被導出的,用戶可以使用errors.As去匹配它。
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)
}

todo:errors.Is和errors.As的區(qū)別

處理斷言失敗

在不正確的類型斷言上 使用單返回值來處理會導致 panic, 因此請使用 "comma ok" 習俗.

Bad Good
t := i.(string)
t, ok := i.(string)
if !ok {
  // 在這里優(yōu)雅處理錯誤
}

不要使用Panic

在生產(chǎn)環(huán)境的業(yè)務代碼避免使用panic。Panics 是級聯(lián)問題cascading failures的主要來源。如果發(fā)生錯誤,函數(shù)必須返回錯誤,讓調用方?jīng)Q定如何處理這種情況。

Bad Good
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 不是錯誤處理策略。當系統(tǒng)發(fā)生像空指針異常這種 不可恢復的 Fatal 異常時,才需要使用Panic。唯一的意外情況是項目啟動:如果程序啟動階段出現(xiàn)問題需要拋出異常。

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

即使在測試中,優(yōu)先使用t.Fatal 或 t.FailNow 而不是異常,來確保失敗情況被記錄。

Bad Good
// 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 包的原子操作對數(shù)據(jù)類型進行操作(int32, int64, 等.) 。因為自己操作容易忘記使用原子操作對變量進行讀取和修改。

go.uber.org/atomic 通過隱藏基礎類型為這些操作增加了類型安全性,它還包括一個很方便的 atomic.Bool 類型.

Bad Good
type foo struct {
  running int32  // atomic
}

func (f* foo) start() {
  if atomic.SwapInt32(&f.running, 1) == 1 {
     // already running…
     return
  }
  // start the 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) {
     // already running…
     return
  }
  // start the Foo
}

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

避免使用全局可變對象

Avoid mutating global variables, instead opting for dependency injection. This applies to function pointers as well as other kinds of values.

Bad Good
// sign.go

var _timeNow = time.Now

func sign(msg stringstring {
  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 stringstring {
  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))
}

避免在公共結構體中內嵌類型

嵌入類型會暴露實現(xiàn)細節(jié),無法類型演化,讓文檔也變得模糊。

假設你用AbstractList結構體實現(xiàn)了公共的 list 方法,避免在其他實現(xiàn)中內嵌AbstractList類型。而是應該在其他結構體中顯式聲明list,并在方法實現(xiàn)中調用list的方法。

type AbstractList struct {}

// Add adds an entity to the list.
func (l *AbstractList) Add(e Entity) {
  // ...
}

// Remove removes an entity from the list.
func (l *AbstractList) Remove(e Entity) {
  // ...
}
Bad Good
// ConcreteList is a list of entities.
type ConcreteList struct {
  *AbstractList
}
// ConcreteList is a list of entities.
type ConcreteList struct {
  list *AbstractList
}

// Add adds an entity to the list.
func (l *ConcreteList) Add(e Entity) {
  l.list.Add(e)
}

// Remove removes an entity from the list.
func (l *ConcreteList) Remove(e Entity) {
  l.list.Remove(e)
}

Go允許內嵌類型type embedding作為組合和繼承的折中方案。外部的結構體會獲得內嵌類型的隱式拷貝。默認情況下,內嵌類型的方法會嵌入實例的同一方法。

外部的結構體會獲取嵌入類型的同名字段。如果嵌入類型的字段是公開(public)的,那嵌入后也是公開的。為保證向后兼容性,外部結構體未來每個版本都需要保留嵌入類型。

很少場景需要嵌入類型,雖然嵌入類型很方便,讓你避免編寫冗長的方法。

即使是用interface而不是結構體來嵌入方法,這是給開發(fā)人員帶來了一定的靈活性,但是仍然暴露了具體實現(xiàn)列表的抽象細節(jié)。

Bad Good
// AbstractList is a generalized implementation
// for various kinds of lists of entities.
type AbstractList interface {
  Add(Entity)
  Remove(Entity)
}

// ConcreteList is a list of entities.
type ConcreteList struct {
  AbstractList
}
// ConcreteList is a list of entities.
type ConcreteList struct {
  list AbstractList
}

// Add adds an entity to the list.
func (l *ConcreteList) Add(e Entity) {
  l.list.Add(e)
}

// Remove removes an entity from the list.
func (l *ConcreteList) Remove(e Entity) {
  l.list.Remove(e)
}

不管是嵌入結構體還是嵌入接口,都會限制類型的演化。

  • 若嵌入接口,當你增加一個方法是一種破壞性改變
  • 若嵌入結構體,當你刪除一個方法是一種破壞性改變
  • 刪除嵌入類型是一種破壞性改變
  • 即使用滿足接口約束的類型去替換嵌入類型,也是一種破壞性改變

盡管編寫內嵌類型已實現(xiàn)的方法是乏味的。但是這些工作隱藏了實現(xiàn)細節(jié),留下了更多更改的機會, 并消除了在文檔中發(fā)現(xiàn)完整List接口的間接方法。

避免使用內建命名

Go語言的spec中列舉了一些內建命名,在你的Go程序中應該避免使用預聲明的標識符;

根據(jù)上下文的不同,用預聲明標識符命名變量可能會在當前作用域下覆蓋官方標識符,讓你的代碼變得難以理解。最好的情況下,編譯器會直接報錯,最糟糕的情況下,這樣的代碼會引入難以排查的bug。

Bad Good
var error string
// `error` 覆蓋了內建的error

// or

func handleErrorMessage(error string) {
    // `error` 覆蓋了內建的error
}
var errorMessage string
// `error` 指向內置的 error 

// or

func handleErrorMessage(msg string) {
    // `error` 指向內置的 error
}
type Foo struct {
    // While these fields technically don't
    // constitute shadowing, grepping for
    // `error` or `string` strings is now
    // ambiguous.
    error  error
    string string
}

func (f Foo) Error() error {
    // `error` and `f.error` are
    // visually similar
    return f.error
}

func (f Foo) String() string {
    // `string` and `f.string` are
    // visually similar
    return f.string
}
type Foo struct {
    // `error` and `string` strings are
    // now unambiguous.
    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(),可以使用以下方式:

  1. 不管程序環(huán)境或調用方式如何,初始化要完全確定。
  2. 避免依賴其他init()函數(shù)的順序或者產(chǎn)生的結果。雖然init()順序是明確的,但是代碼可以更改。init()函數(shù)之間的關系會讓代碼變得易錯和脆弱。
  3. 避免讀寫全局變量、環(huán)境變量,比如機器信息、環(huán)境變量、工作目錄,程序的參數(shù)和輸入等等。
  4. 避免 I/O 操作,比如文件系統(tǒng),網(wǎng)絡和系統(tǒng)調用。

如果代碼不能滿足這些需求,那可能屬于幫助代碼,需要作為main()函數(shù)的一部分進行調用(或者封裝初始化邏輯,讓main 函數(shù)去調用)。需要注意的是,被其他模塊依賴的代碼應該完全指定初始化順序的確定性,而不是依賴"初始化魔法"。

Bad Good
type Foo struct {
    // ...
}

var _defaultFoo Foo

func init() {
    _defaultFoo = Foo{
        // ...
    }
}
var _defaultFoo = Foo{
    // ...
}

// or, better, for testability:

var _defaultFoo = defaultFoo()

func defaultFoo() Foo {
    return Foo{
        // ...
    }
}
type Config struct {
    // ...
}

var _config Config

func init() {
    // Bad: based on current directory
    cwd, _ := os.Getwd()

    // Bad: 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()
    // handle err

    raw, err := os.ReadFile(
        path.Join(cwd, "config""config.yaml"),
    )
    // handle err

    var config Config
    yaml.Unmarshal(raw, &config)

    return config
}

但是在某些情況下,init()函數(shù)可能更具優(yōu)勢:

  • 單個賦值語句中無法表示的復雜表達式
  • 插件鉤子,比如 database/sql,編碼信息注冊表等
  • 對 Google Cloud Functions 和其他形式確定性預計算的優(yōu)化

優(yōu)雅退出主函數(shù)

Go程序使用os.Exit 或 log.Fatal*來立即退出。(Panic 不是優(yōu)雅的程序退出方式,可以參考 don't panic)

應該只在main()函數(shù)里調用os.Exit 或 log.Fatal*函數(shù)。其他函數(shù)應該返回錯誤來表示失敗,在main中進行退出。

Bad Good
func main() {
  body := readFile(path)
  fmt.Println(body)
}

func readFile(path stringstring {
  f, err := os.Open(path)
  if err != nil {
    log.Fatal(err)
  }

  b, err := io.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 := io.ReadAll(f)
  if err != nil {
    return "", err
  }

  return string(b), nil
}

程序中多個函數(shù)都能退出的話會有一些問題:

  • 不明顯的控制流:多個函數(shù)都能退出的話,找出程序的控制流會變得困難。
  • 測試困難:如果一個函數(shù)讓程序退出,那它也會讓測試退出。這樣會讓函數(shù)難以測試。而且可能會讓go text無法測試其他函數(shù)。
  • 跳過清理:當一個函數(shù)退出程序時,會跳過已經(jīng)進入defer隊列的函數(shù)調用。這樣會增加跳過清理任務的風險。

一次性退出

有條件的情況下,main()函數(shù)中最好只調用os.Exit 或 log.Fatal 一次。如果有多種錯誤情況會停止 程序的執(zhí)行,將這些錯誤放在一個獨立的函數(shù)中,并返回錯誤,main()中處理錯誤并退出。

把所有的關鍵邏輯放在一個獨立的可測試的函數(shù)中,會讓你的main()函數(shù)變得簡短。

Bad Good
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()

  // If we call log.Fatal after this line,
  // f.Close will not be called.

  b, err := io.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 := io.ReadAll(f)
  if err != nil {
    return err
  }

  // ...
}

在序列化結構體中使用字段標簽。

要編碼成JSON、YAML或其他支持tag格式的結構體字段應該用指定對應項tag標簽進行注釋。

Bad Good
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"`
  // Safe to rename Name to Symbol.
}

bytes, err := json.Marshal(Stock{
  Price: 137,
  Name:  "UBER",
})

Rationale: 結構體的序列化方式是不同系統(tǒng)通信的契約。修改結構體的結構和字段會破壞這個契約。在結構體中聲明tag 可以防止重構結構體中意外違反約定。

性能

性能方面的指導準則只適用于高頻調用場景。

使用 strconv 而不是 fmt

當需要原始類型和字符串互相轉化時,strconvfmt性能更好。

Bad Good
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

避免 字符串到字節(jié)的轉化

不要在for循環(huán)中創(chuàng)建[]byte類型,應該在for循環(huán)開始前把[]byte數(shù)據(jù)準備好。

Bad Good
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

預先指定容器類型的容量

盡可能指定容器類型變量的容量來預先分配容器類型所需的內存大小。這樣可以預防由于后續(xù)由分配元素 (由于拷貝或重新指定容器大小)而導致的內存分配。

指定Map容量

如果有可能,用make來初始化map類型,并指定map的大小。

make(map[T1]T2, hint)

使用 make() 初始化map時,提供一個容量來執(zhí)行size,這樣會減少后續(xù)將給map添加元素時引起的內存分配。

注意,和 slice 不同,給map指定容量不意味著搶占式內存分配完成,而是會用于預估的哈希表內部 buckets。因此,當你給 map 添加元素,或者給 map 指定值時,仍有可能發(fā)生內存分配。

Bad Good
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.DirEntry, len(files))
for _, f := range files {
    m[f.Name()] = f
}

m 沒有指定內存大小,因此在運行期間可能會有更多的內存分配。

m 指定了內存大小,因此在運行期間可能會有較少的內存分配。

指定Slice容量

如果有可能的話,在使用make()初始化slice的時候提供容量大小,尤其是后面需要 append 操作時。

make([]T, length, capacity)

和 map 不同,slice的容量不是一個提示:編譯器會根據(jù) make() 提供的容量信息申請足夠的內存, 這意味著后續(xù)的 append() 操作不會申請內存(除非slice的長度和容量相等,這樣的話后續(xù)添加元素 會申請內存來調整 slice 的大小)。

Bad Good
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

規(guī)范

避免代碼過長

避免由于長度過長而需要水平滾動或者太需要轉動頭部的代碼。

我們建議一行代碼長度為 99個字符,如果代碼超過了這個限制就應該換行。但是這也不是絕對的,代碼 可以超過這個限制。

一致性

本文的一些指導準則可以被客觀評估,其他準則可以根據(jù)實際情況進行選擇。

但是最重要是,在你的代碼中要保持一致。

一致性的代碼更易于維護,更容易合理化,需要的認知開銷較少;當新的管理出現(xiàn)時或 bug 被修復后也更易于 遷移和更新。

與之相反,如果單個代碼庫中有多種沖突的風格,會讓維護成本升高、不確定性增高、認知不協(xié)調,這些問題會 導致開發(fā)效率降低,code review 困難,且容易產(chǎn)生 bug。

當你在代碼庫中實施標準時,建議最低在包層面進行修改:在子包層面進行應用違反了上述約定,因為在一種代碼 里面引入了多種風格。

相似聲明放一組

Go語言支持組引用。

Bad Good
import "a"
import "b"
import (
  "a"
  "b"
)

組聲明同樣適用于常量、變量和類型聲明。

Bad Good

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
)

注意只把相關的變量聲明到一個組里,不想管的聲明放在多個組里。

Bad Good
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"

組聲明不限制在哪使用。比如,你可以在函數(shù)中使用組聲明。

Bad Good
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)
  )

  // ...
}

例外:對于變量聲明,尤其是函數(shù)中的變量聲明,不管他們之間是否有關系,都應該被放在一個組內。

Bad Good
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
  )

  // ...
}

包導入順序

包中應該有兩種導入順序:

  • 標準庫
  • 其他庫

默認情況下,應該使用 goimports 的導入順序。

Bad Good
import (
  "fmt"
  "os"
  "go.uber.org/atomic"
  "golang.org/x/sync/errgroup"
)
import (
  "fmt"
  "os"

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

包命名

當命名包時,應按照下面原則命名:

  • 全小寫字母。無大寫字母或下劃線。
  • 大多數(shù)導入包的情況下,不需要對包重新命名。
  • 簡短而簡潔,因為當你使用包名時你都需要完成輸入包名稱。
  • 不要使用復數(shù)。比如:命名為 net/url, 而不是 net/urls。
  • 不要使用"common", "util", "shared", 或 "lib"。這些包含有信息太少了。

可以參考 Package Names 和 Style guideline for Go packages.

函數(shù)命名

我們遵守 Go 社區(qū) MixedCaps for function names 約定。一種其他情況是使用測試函數(shù)。測試函數(shù) 命名可以包含下劃線以便于相關測試函數(shù)進行分組。比如:TestMyFunction_WhatIsBeingTested

導入別名

如果包名稱和導入路徑最后一個元素不匹配,就需要使用導入別名。

import (
  "net/http"

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

其他情況下,除非幾個包之間有導入沖突,否則應該避免使用導入別名。

Bad Good
import (
  "fmt"
  "os"


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

  nettrace "golang.net/x/trace"
)

函數(shù)分組和排序

  • 函數(shù)應該按照大概調用順序排序。
  • 一個文件中的函數(shù)應該按照接收者分組。

因此,導入的函數(shù)時,應該放在 structconstvar 的下面。

像 newXYZ()/NewXYZ() 這樣的函數(shù)可能會出現(xiàn)在類型定義下、接收者的其他方法之上。

由于函數(shù)是按照接收者進行分組的,普通的工具函數(shù)應該放在文件末尾。

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

type something struct{ ... }

func calcCost(n []intint {...}

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 []intint {...}

減少嵌套

代碼應通過盡早處理錯誤/特殊情況盡早處理/循環(huán)中使用 continue 等手段,來減少嵌套代碼過多問題。

Bad Good
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

如啊a變量在兩個if分支中都進行賦值操作,則可以被替換為只在一個if分支中聲明。

Bad Good
var a int
if b {
  a = 100else {
  a = 10
}
a := 10
if b {
  a = 100
}

頂層變量聲明

在頂層使用var來聲明變量。不要指定類型,除非它和表達式的類型不同。

Bad Good
var _s string = F()

func F() string { return "A" }
var _s = F()
// Since F already states that it returns a string, we don't need to specify
// the type again.

func F() string { return "A" }

如果表達式的類型和所需類型不一樣,需要指定類型。

type myError struct{}

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

func F() myError { return myError{} }

var _e error = F()
// F returns an object of type myError but we want error.

非導出變量使用_前綴

對于非導出類型的變量,在用vars and const聲明時加上_前綴,來表示他們是全局符號。

原因:頂層聲明的變量作用域一般是包范圍。用一個常見的名字可能會導致在其他包中被意外修改。

Bad Good
// foo.go

const (
  defaultPort = 8080
  defaultUser = "user"
)

// bar.go

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

  // We will not see a compile error if the first line of
  // Bar() is deleted.
}
// foo.go

const (
  _defaultPort = 8080
  _defaultUser = "user"
)

異常:非導出的錯誤類型一般使用不帶 _ 的 err 前綴。參考Error Naming.

結構體內嵌類型

嵌入類型應該放在結構體的最上面,應該和結構體的常規(guī)字段用一個空行分隔開。

Bad Good
type Client struct {
  version int
  http.Client
}
type Client struct {
  http.Client

  version int
}

內嵌類型會帶來足夠的好處,比如在語義上會增加或增強功能。但應該在對用戶沒有影響的情況下使用內嵌。(參考: 避免在公共結構中嵌入類型).

例外:即使是未導出類型,Mutex 也不應該被內嵌。參考:: Mutex的零值是有效的.

這些情況下避免內嵌:

  • 單純?yōu)榱吮憷兔烙^。
  • 讓外部類型構造起來或使用起來更困難。
  • 影響了外部的零值。如果外部類型的零值是有用的,嵌入類型應該也有一個有用的零值。
  • 作為嵌入類型的副作用,公開外部類型的不相關函數(shù)或字段。
  • 公開非導出類型。
  • 影響外部類型的復制語義。
  • 影響外部類型的API或類型語義。
  • 影響內部類型的非規(guī)范形式。
  • 公開外部類型的詳細實現(xiàn)信息。
  • 允許用戶觀察和控制內部類型。
  • 通過包裝的形式改變了內部函數(shù)的行為,這種包裝的方式會給用戶造成意外觀感。

簡單概括,使用嵌入類型時要明確目的。一個不錯的方式是:"這些嵌入的字段/方法是否需要被直接添加到外部 類型",如果答案是"一些"或者"No",不要使用內嵌類型,而是使用命名字段。

Bad Good
type A struct {
    // Bad: A.Lock() and A.Unlock() are
    //      now available, provide no
    //      functional benefit, and allow
    //      users to control details about
    //      the internals of A.
    sync.Mutex
}
type countingWriteCloser struct {
    // Good: Write() is provided at this
    //       outer layer for a specific
    //       purpose, and delegates work
    //       to the inner type's 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 {
    // Bad: pointer changes zero value usefulness
    io.ReadWriter

    // other fields
}

// later

var b Book
b.Read(...)  // panic: nil pointer
b.String()   // panic: nil pointer
b.Write(...) // panic: nil pointer
type Book struct {
    // Good: has useful zero value
    bytes.Buffer

    // other fields
}

// later

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
}

本地變量聲明

如果將變量聲明為某個值,應該使用短變量命名方式::=。

Bad Good
var s = "foo"
s := "foo"

然而,有些情況下用 var 會讓聲明語句更加清晰,比如聲明空slice.

Bad Good
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, 意味著:

  • 你不應該聲明一個長度為0的空slice,而是用nil來代替。

    Bad Good
    if x == "" {
      return []int{}
    }
    
    if x == "" {
      return nil
    }
    
  • 要檢查slice是否為空,不應該檢查 nil, 而是用長度判斷len(s) == 0。

    Bad Good
    func isEmpty(s []stringbool {
      return s == nil
    }
    
    func isEmpty(s []stringbool {
      return len(s) == 0
    }
    
  • 用 var 聲明的零值slice是有效的,沒必要用 make 來創(chuàng)建。

    Bad Good
    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 的slice有效,但是它不等于長度為0的 slice。在一些情況下(比如說序列化), 這兩種slice的表現(xiàn)不同。

縮小變量作用域

盡可能減小變量的作用域。如果與 減少嵌套 沖突,就不要縮小。

Bad Good
err := os.WriteFile(name, data, 0644)
if err != nil {
 return err
}
if err := os.WriteFile(name, data, 0644); err != nil {
 return err
}

但是如果作用域是 if 范圍之外,不應該減少作用域。

Bad Good
if data, err := os.ReadFile(name); err == nil {
  err = cfg.Decode(data)
  if err != nil {
    return err
  }

  fmt.Println(cfg)
  return nilelse {
  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

不面參數(shù)語義不明確

在函數(shù)中裸傳參數(shù)值會讓代碼語義不明確,可以添加 C 風格(/* ... */)的注釋。

Bad Good
// func printInfo(name string, isLocal, done bool)

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

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

當然,更好的處理方式將上面的 bool 換成自定義類型。因為未來可能不僅僅局限于兩個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中支持 字符串原始值,當需要 轉義時,盡量使用 "`" 來包裝字符串。

Bad Good
wantError := "unknown name:\"test\""
wantError := `unknown error:"test"`

初始化結構體

初始化結構體時聲明字段名

在你初始化結構時,幾乎應該始終指定字段名。目前由go vet強制執(zhí)行。

Bad Good
k := User{"John""Doe"true}
k := User{
    FirstName: "John",
    LastName: "Doe",
    Admin: true,
}

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

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

省略結構體中的零值字段

當初始化結構體字段時,除非需要提供一個有意義的上下文,否則需要忽略對零值字段進行賦值。因為Go 會自動給這些零值字段進行填充。

Bad Good
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 來聲明結構體。

Bad Good
user := User{}
var user User

這種 零值結構體 和具有非零值字段的結構體有所不同,和 map初始化 更相似, 和我們更想用的 [聲明空Slices][聲明空Slices] 更匹配。

初始化結構體引用

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

Bad Good
sval := T{Name: "foo"}

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

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

初始化Map

優(yōu)先使用make來創(chuàng)建空map,這樣使得map的初始化不同于聲明,而且你還可以在 make 中添加map的大小提示。

Bad Good
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
)

聲明和初始化在形式上相似

聲明和初始化在形式上隔離

盡可能在 make() 中制定map的初始化容量,可以參考:Specifying Map Capacity Hints。

另外,如果map初始化的時候需要賦值固定信息,使用 map literals 方式來初始化。

Bad Good
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時增加一組固定的元素,就使用map literals。否則就使用 make(如果可以, 盡可能指定map的容量)。

在Printf外面格式化字符串

如果你在函數(shù)外聲明 Printf 風格 函數(shù)的格式字符串,請將其設置為 const 常量。

這有助于go vet對格式字符串執(zhí)行靜態(tài)分析。

Bad Good
msg := "unexpected values %v%v\n"
fmt.Printf(msg, 12)
const msg = "unexpected values %v%v\n"
fmt.Printf(msg, 12)

命名Printf樣式函數(shù)

使用Printf函數(shù)時,應保證go vet可以檢測到他的格式化字符串。

這意味著你需要使用預定義的Printf函數(shù)名稱,go vet會默認檢查這些。更多信息,請參考:Printf family

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

go vet -printfuncs=wrapf,statusf

參考 go vet: Printf family check.

Patterns

Test Tables

當你的測試用例形式上重復時,用 subtests 方式編寫case會讓測試用例看起來更加簡潔。

Bad Good
// 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的方式,在拓展測試用例時也會顯得更加清晰。

我們遵守這樣的準則:搞一個slice類型的struct測試用例,每個測試case叫做tt。然后使用givewant說明測試用例的輸入和輸出。

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

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

對于并行測試,比如一些特殊的循環(huán) (比如那些生產(chǎn) goroutine 或 在循環(huán)中捕獲引用的循環(huán)), 需要 注意在循環(huán)中明確分配循環(huán)變量來確保不會產(chǎn)生閉包。

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

for _, tt := range tests {
  tt := tt // for t.Parallel
  t.Run(tt.give, func(t *testing.T) {
    t.Parallel()
    // ...
  })
}

在上面的例子中,由于循環(huán)中使用了 t.Parallel(),我們必須在外部循環(huán)中聲明一個 tt 變量。如果不這么做,大多數(shù)測試用例都會收到一個非預期的 tt,或是一個在運行期改變的值。

函數(shù)功能選項API

功能選項是一種模式,你可以聲明一個對用戶不透明的 Option 類型,在一些內部結構中記錄信息。函數(shù)接收不定長的參數(shù)選項,并根據(jù)參數(shù)做不同的行為。

對于需要拓展參數(shù)的構造方法或是其他需要可選參數(shù)的公共API可以考慮這種模式,對于參數(shù)在三個及以上 的函數(shù)更應該考慮。

Bad Good
// 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 創(chuàng)建一個連接
func Open(
  addr string,
  opts ...Option,
) (*Connection, error) {
  // ...
}

即使用戶默認不需要 cache 和 logger,也需要提供這倆參數(shù)。

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)

Options 只在需要時才被提供。

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

我們建議這種模式的實現(xiàn)方式是 提供一個 Option 接口,里面有一個非導出類型方法,在一個非 導出類型的 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 創(chuàng)建一個連接
func Open(
  addr string,
  opts ...Option,
) (*Connection, error) {
  options := options{
    cache:  defaultCache,
    logger: zap.NewNop(),
  }

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

  // ...
}

還有一種用閉包實現(xiàn)這種方法的模式,但我們認為上面提供的這種模式給作者提供了更高的靈活性,更 易于調試和測試。這種方式可以在測試和mock中進行比較,而閉包方式難以做到。此外,它允許 option 實現(xiàn)其他接口,比如 fmt.Stringer,會 string 類型的可讀性更高。

還可以參考:

  • Self-referential functions and the design of options

  • Functional options for friendly APIs

Linting

比其他任何 "神圣" linter 工具更重要的是,在你的代碼庫里使用一致性的 lint 工具。

我們建議最少要使用下面這些 linters 工具嗎,因為我們認為這些工具可以幫你捕獲最常見的問題,有助于 在沒有規(guī)定的前提下提高代碼質量:

  • errcheck 確保錯誤被處理

  • goimports 格式化代碼和管理包引用

  • golint 指出常見的文本錯誤

  • govet 分析代碼中的常見錯誤

  • staticcheck 各種靜態(tài)分析檢查

Lint Runners

由于優(yōu)秀的性能表現(xiàn),我們推薦 golangci-lint 作為Go代碼的首選 lint 工具。這個倉庫有在一個.golangci.yml 例子,里面有配置的 linters 工具和設置。

golangci-lint 有一系列 various linters 可供使用。建議將這些 linters 作為基礎集合, 我們鼓勵團隊內部將其他有意義的 linters 工具在他們的項目中進行使用。


3 人點贊