uber go code 規範(指導原則)

前言

從接觸 Golang 到現在, 感覺到的很深的一點是, go 的代碼無論是大佬還是菜鳥寫出的代碼, 都有着大體統一的 格式/流程, 這也是 Go 被開發者喜愛的一個原因, 但是還有一些, 比如變量的命名方式等, 可以稱之爲 風格 的東西, 卻不盡相同, 我在開發中, 其實也希望有一個相對權威的指導意見, 後來就找到了 uber 團隊出品的開發規範.
uber 是衆多公司中, 比較早使用 go 語言的了, 其本身也開源了一些優質的模塊, 有機會的話希望也能向大家展示一下, 而在 uber 內部開發中, 經過持續的迭代, 開源了自己的代碼規範, 這裏給大家解讀一下
需要特別指出的是, 下面的內容並不是一定需要遵守, 這裏你可以選擇自己認爲正確的可行的規範.
團隊內使用統一的風格, 可以提高代碼的可讀性
本篇記錄原則部分

原則

原則部分, 在 uber 內部是必須遵守的, 其目的是提高代碼的健壯性, 讓一些可能的錯誤能在編寫時就暴露出來

結構體中包含接口指針

接口可以包含任何類型的值, 但是, 將結構體的某個值的類型設置爲接口的指針則會出現問題, 例如:

package main

type Brace interface{} // 空接口

type Round struct { // 結構體
	prev  Brace  // 值 prev 的類型爲接口值
	prev_ *Brace // 值 prev_ 的類型爲接口指針
}

type Square struct{} // 空結構體

func main() {
	var r Round
	var s Square
	r.prev = s   // OK: 這裏 ok
	r.prev_ = &s // ERR: 想要將 s 的指針賦值給 prev_, 會報錯
}

開發者很少需要在結構體中設置某個值的類型爲接口的指針, 而應該將接口作爲值進行傳遞, 類似於上面的 prev, 如果你真的需要將接口指針設置爲結構體的某個值, 也不需要將其類型設置爲指針, 例如:

package main

type Brace interface{} // 空接口

type Round struct { // 結構體
	prev  Brace // 值 prev 的類型爲接口值
	prev_ Brace // 值 prev_ 的類型爲接口值
}

type Square struct{} // 空結構體

func main() {
	var r Round
	var s Square
	r.prev = s   // OK: 這裏 ok
	r.prev_ = &s // OK: 想要將 s 的指針賦值給 prev_, 可以賦值
}

而一般情況下, 我們對結構體的方法的結構體部分傳參, 大多數都是結構體的指針()指針方法, 此時可以使用結構體指針賦值給接口的方式, 例如:

package main

type Brace interface {
	Length()
}

type Round struct { // 結構體
}

func (r *Round) Length() {}

func main() {
	b := []Brace{&Round{}}  // OK: *Round 實現了 Brace 接口, 而不是 Round
}

interface 合理性驗證

對於接口的實現, 在我們編寫代碼時, 可能會因爲種種原因沒有實現好對應接口, 而這個錯誤只有在真正調用時纔會被發現, 例如:

package main

type Brace interface {
	Length()
}

type Round struct { // 結構體
}

func (r *Round) Long() {}

func main() {
	_ = []Brace{&Round{}} // ERR: 這裏會報錯, 因爲 &Round 沒有實現 Brace
}

所以, 我們可以在編寫時使用一個無用的空值, 來讓編譯器幫助我們判斷是否實現了接口, 例如:

package main

type Brace interface {
	Length()
}

type Round struct { // 結構體
}

var _ Brace = &Round{} // OK: 利用 var 一個無用的值, 讓編譯器檢測 &Round 是否實現了 Brace 接口

func (r *Round) Long() {}

func (r *Round) Length() {}

func main() {
}

接收器與接口

對於結構體的值來講, 結構體的指針方法與值方法不能一起調用, 例如:

package main

type S struct {
	data string
}

func (s S) Read() string { // 值方法
	return s.data
}

func (s *S) Write(str string) { // 指針方法
	s.data = str
}

func main() {
	sVals := map[int]S{1: {data: "A"}}

	// OK: 你只能通過值調用 Read
	sVals[1].Read()

	// ERR: 這裏會出現問題, 因爲方法爲指針方法
	sVals[1].Write("test")
}

而對於結構體的指針來講, 可以調用值方法和指針方法, 例如:

package main

type S struct {
	data string
}

func (s S) Read() string { // 值方法
	return s.data
}

func (s *S) Write(str string) { // 指針方法
	s.data = str
}

func main() {
	sPtrs := map[int]*S{1: {data: "A"}} // 存儲結構體的指針

	// OK: 通過指針既可以調用 Read(值方法),也可以調用 Write 方法(指針方法)
	sPtrs[1].Read()
	sPtrs[1].Write("test")
}

同樣的道理, 對於接口來講, 也可以使用指針接收器來實現接口

package main

type F interface {
	f()
}

type S1 struct{}

func (s S1) f() {} // 值方法

type S2 struct{}

func (s *S2) f() {} // 指針方法

func main() {
	s1Val := S1{}  // 結構體值
	s1Ptr := &S1{} // 結構體指針
	s2Val := S2{}  // 結構體值
	s2Ptr := &S2{} // 結構體指針

	var i F   // 接口
	i = s1Val // OK: S1的值實現了值方法
	i = s1Ptr // OK: S1的指針實現了值方法
	i = s2Ptr // OK: S2的指針實現了指針方法

	// ERR: 不行, 因爲S2是指針方法
	i = s2Val
}

零值 Mutex

對於sync 的鎖包, sync.Mutexsync.RWMuntex, 他的零值也是有效的, 不需要通過new關鍵字來生成指針

package main

import "sync"

func main() {
	mu := new(sync.Mutex) // ERR: 生成 Mutex 的指針, 多此一舉
	mu.Lock()

	var mu1 sync.Mutex // OK: Mutex 的零值也可以正常使用, 正確的用法
	mu1.Lock()
}

如果將 Mutex 作爲結構體中的一部分, 那麼其應該作爲值類型, 而不是指針類型.
並且, 結構體的 Mutex 應該由包內部控制, 不要被外部修改, 所以不要把 mutex 直接嵌入到結構體中(匿名字段的方式Go 編程語言規範 - Go 編程語言).
錯誤示例:

package main

import "sync"

type SMap struct {
	sync.Mutex // 沒有 key, 在 struct 中視爲匿名字段和提升字段, 提升字段會導致暴露方法給外部調用者
	data       map[string]string
}

func NewSMap() *SMap {
	return &SMap{
		// 因爲 Mutex 零值直接可以使用, 所以初始化時不需要初始化 Mutex
		data: make(map[string]string),
	}
}

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

	return m.data[k]
}

正確示例:

package main

import "sync"

type SMap struct {
	mu sync.Mutex // 設置爲普通字段, 設置爲私有的, 防止外部調用, 只能讓模塊內部調用
	data map[string]string
}

func NewSMap() *SMap {
	return &SMap{
		// 因爲 Mutex 零值直接可以使用, 所以初始化時不需要初始化 Mutex
		data: make(map[string]string),
	}
}

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

	return m.data[k]
}

拷貝 Slices 和 Maps

Slices 和 Maps 內部保存的事指向底層數據的指針, 因此涉及到他們的複製時, 需要特別的注意

將 Slices 作爲函數參數和返回值

當 map 和 slice 作爲函數參數使用時, 如果存儲了他們的引用, 則外部對他的修改, 也會造成內部的數據錯亂

package main

import "fmt"

type Driver struct {
	trips []int
}

func (d *Driver) SetTrips(trips []int) {
	// 直接將slice存儲進自身
	d.trips = trips // ERR: 存在外部修改可能
}

func (d *Driver) GetTrips() []int {
	// 直接返回 slice
	return d.trips // ERR: 存在外部修改可能
}

func main() {
	d := Driver{}
	gt := []int{0, 1, 2, 3}
	d.SetTrips(gt)
	fmt.Println(d.GetTrips()) // [0 1 2 3]
	gt[0] = 5                 // ERR: 在外部修改了 Driver 的數據, 這是你想要的嗎?
	fmt.Println(d.GetTrips()) // [5 1 2 3]
	rgt := d.GetTrips()       // 獲取內部的 slice
	rgt[0] = 6                // ERR: 在外部修改了 Driver 的數據, 這是你想要的嗎?
	fmt.Println(d.GetTrips()) // [6 1 2 3]
}

我們可以借用copy函數, 進行 copy, 防止引用出現

package main

import "fmt"

type Driver struct {
	trips []int
}

func (d *Driver) SetTrips(trips []int) {
	d.trips = make([]int, len(trips)) // 創建長度爲參數長度的新切片
	copy(d.trips, trips)              // OK: 使用 copy, 複製值而不是直接引用
}

func (d *Driver) GetTrips() []int {
	res := make([]int, len(d.trips)) // 創建長度爲參數長度的新切片
	copy(res, d.trips)               // 使用 copy, 複製內部值而不是直接返回內部引用
	return res                       // OK: 外部修改不會影響內部
}

func main() {
	d := Driver{}
	gt := []int{0, 1, 2, 3}
	d.SetTrips(gt)
	fmt.Println(d.GetTrips()) // [0 1 2 3]
	gt[0] = 5                 // OK: 在外部修改了 Driver 的數據, 不影響內部
	fmt.Println(d.GetTrips()) // [0 1 2 3]
	rgt := d.GetTrips()       // 獲取內部的 slice
	rgt[0] = 6                // OK: 在外部修改了 Driver 的數據, 不影響內部
	fmt.Println(d.GetTrips()) // [0 1 2 3]
}

將 map 作爲函數參數和返回值

同樣的, map 也有這個問題

package main

import "fmt"

type Driver struct {
	trips map[string]int
}

func (d *Driver) SetTrips(trips map[string]int) {
	// 直接將 map 存儲進自身
	d.trips = trips // ERR: 存在外部修改可能
}

func (d *Driver) GetTrips() map[string]int {
	// 直接返回 map
	return d.trips // ERR: 存在外部修改可能
}

func main() {
	d := Driver{}
	gt := make(map[string]int)
	gt["0"] = 0
	gt["1"] = 1
	d.SetTrips(gt)
	fmt.Println(d.GetTrips()) // map[0:0 1:1]
	gt["0"] = 5               // ERR: 在外部修改了 Driver 的數據, 這是你想要的嗎?
	fmt.Println(d.GetTrips()) // map[0:5 1:1]
	rgt := d.GetTrips()       // 獲取內部的 map
	rgt["0"] = 6              // ERR: 在外部修改了 Driver 的數據, 這是你想要的嗎?
	fmt.Println(d.GetTrips()) // map[0:6 1:1]
}

對於 map, 沒有內置的 copy 函數, 我們可以手動賦值達到效果

package main

import "fmt"

type Driver struct {
	trips map[string]int
}

func (d *Driver) SetTrips(trips map[string]int) {
	d.trips = make(map[string]int, len(trips)) // make
	for k, v := range trips {                  // 使用循環來賦值
		d.trips[k] = v
	}
}

func (d *Driver) GetTrips() map[string]int {
	res := make(map[string]int, len(d.trips))
	for k, v := range d.trips {
		res[k] = v
	}
	return res
}

func main() {
	d := Driver{}
	gt := make(map[string]int)
	gt["0"] = 0
	gt["1"] = 1
	d.SetTrips(gt)
	fmt.Println(d.GetTrips()) // map[0:0 1:1]
	gt["0"] = 5               // OK: 在外部修改了 Driver 的數據, 這是你想要的嗎?
	fmt.Println(d.GetTrips()) // map[0:0 1:1]
	rgt := d.GetTrips()       // 獲取內部的 map
	rgt["0"] = 6              // OK: 在外部修改了 Driver 的數據, 這是你想要的嗎?
	fmt.Println(d.GetTrips()) // map[0:0 1:1]
}

使用 defer 釋放資源

defer 在函數返回之前執行, 所以我們可以利用 defer 進行資源的釋放
錯誤示例

package main

import "sync"

func test(count int) int {
	mu := sync.Mutex{}
	mu.Lock()
	count++
	mu.Unlock() // ERR: 手動關閉, 很容易遺忘, 且針對多個分支處理, 容易遺忘
	// 當有多個 return 分支時,很容易遺忘 unlock
	return 1
}

正確示例

package main

import "sync"

func test(count int) int {
	mu := sync.Mutex{}
	mu.Lock()
	defer mu.Unlock()  // OK: 註冊 defer, 後續無需操心解鎖時機
	count++
	return 1
}

defer 對於程序的開銷非常小, 只有確定真的對函數的執行時間控制爲納秒單位時, 纔不使用 defer. 普通情況下, 使用 defer 來保持代碼整潔性是十分推薦的.

channel 的 size 設置爲無緩衝或者1

channel 的 size 通常是1或者是無緩衝的, 默認情況下, channel 應該是無緩衝的, 因爲 channel 的大小是無法改變的, 所以一般我們儘可能的希望其中不要存儲數據, 只作爲傳輸. 可以設置爲 1 做一個最小的冗餘, 而設置爲其他大小時, 必須要考慮是什麼讓你必須選擇有其他緩衝長度的通道? 是否可以通過別的方式解決?
錯誤示例

package main

func test(count int) {
	c := make(chan int, 1024)  // ERR: 爲什麼要這樣做?
}

正確示例

package main

func test(count int) {
	c := make(chan int, 1) // OK: 只設置1個冗餘
	c1 := make(chan int)   // OK: 無緩衝
}

枚舉從 1 開始

go 中使用枚舉的方式是聲明一個自定義的類型和一個iotaconst組, 因爲變量默認值爲0, 因此枚舉的一組通常以0值開始, 但是有時候, 0 有着特殊的意義, 比如 int 的默認值就爲0, 因此將枚舉設置爲1開始可以防止可能出現的錯誤值進行枚舉
錯誤示例

package main

import "fmt"

type Operation int // int 類型枚舉

const (
	Add Operation = iota
	Subtract
	Multiply
)

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

func (o Operation) ToString() string {
	res := ""
	switch o {
	case Add:
		res = "Add"
	case Subtract:
		res = "Subtract"
	case Multiply:
		res = "Multiply"
	}
	return res
}

func main() {
	var o Operation // 默認爲0
	// 這裏因爲遺漏, 沒有正確的對 o 進行賦值
	fmt.Println(o.ToString()) // ERR: 解出來卻是 Add, 只是因爲int 默認爲0
}

正確示例
從1開始

package main

import "fmt"

type Operation int // int 類型枚舉

const (
	Add Operation = iota + 1
	Subtract
	Multiply
)

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

func (o Operation) ToString() string {
	res := ""
	switch o {
	case Add:
		res = "Add"
	case Subtract:
		res = "Subtract"
	case Multiply:
		res = "Multiply"
	}
	return res
}

func main() {
	var o Operation // 默認爲0
	// 這裏因爲遺漏, 沒有正確的對 o 進行賦值
	fmt.Println(o.ToString()) // OK: 解出來是空, 代表錯誤了, 避免了 o 是默認值而錯誤的找到了枚舉
}

使用 time 類型處理時間

time package - time - pkg.go.dev
時間的處理與計算總是複雜的, 在開發者的認知中, 可能存在以下錯誤:

使用time.Time表示某個瞬間時間

使用time.Time類型表示某一刻的時間, 在 時間比較/計算 時使用內置的方法
錯誤示例

// 判斷時間是否在某個時間段內
func isActive(now, start, stop int) bool {
	return start <= now && now < stop
}

正確示例

// 判斷時間是否在某個時間段內
func isActive(now, start, stop time.Time) bool { // time.Time 類型
	// start.Before(now) 判斷 start 是否在 now 之前
	// start.Equal(now) 判斷 now 是否與 start 相同
	// now.Before(stop) 判斷 now 是否在 stop 之前
	return (start.Before(now) || start.Equal(now)) && now.Before(stop)
}

使用time.Duration表達時間段

使用time.Duration來表達某個時間段, 而不是其他數據類型
錯誤示例

package main

import "time"

func poll(delay int) {
	for {
		// sleep delay 毫秒
		time.Sleep(time.Duration(delay) * time.Millisecond)
	}
}

func main() {
	poll(10) // 調用者只能通過註釋和查看源代碼來確認參數 delay 代表毫秒還是秒
}

正確示例

package main

import "time"

func poll(delay time.Duration) {
	for {
		// ...
		time.Sleep(delay)
	}
}

func main() {
	poll(10 * time.Second) // 調用者自己決定 sleep 多久
}

時間加減

時間的加減一定不要自己實現, 需要考慮的情況太多了
對於日期的加減, 我們可以使用 time.TimeAddDate方法, 而對於時間的加減, 使用Time.Add
正確示例

package main

import (
	"fmt"
	"time"
)

func main() {
	t := time.Now() // 獲取當前時間
	fmt.Println(t)
	newDay := t.AddDate(0 /* years */, 1 /* months */, 1 /* days */) // +1月+1天
	fmt.Println(newDay)
	newDay1 := t.AddDate(0 /* years */, -1 /* months */, 1 /* days */) // +1月-1天
	fmt.Println(newDay1)
	maybeNewDay := t.Add(24 * time.Hour) // +24h
	fmt.Println(maybeNewDay)
	maybeNewDay1 := t.Add(-24 * time.Second) // -24s
	fmt.Println(maybeNewDay1)
}

輸出結果:

2022-05-17 16:27:33.394981 +0800 CST m=+0.000059114
2022-06-18 16:27:33.394981 +0800 CST
2022-04-18 16:27:33.394981 +0800 CST
2022-05-18 16:27:33.394981 +0800 CST m=+86400.000059114
2022-05-17 16:27:09.394981 +0800 CST m=-23.999940886

在對外部的系統中使用time.Timetime.Duration

儘可能的在與外部系統的交互中使用time.Timetime.Duration, 例如:

  • Command-line 標誌: flag 通過 time.ParseDuration 支持 time.Duration
  • JSON: encoding/json 通過其 UnmarshalJSON method 方法支持將 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.Time, 其他語言一般也都會支持解析, 因爲他是統一的標準, 而對於time.Duration, 如果不支持, 請使用int或者float64, 並且在字段名稱中包含單位.
    例如, json不支持time.Duration, 因此使用int替代, 並且將單位包含在名稱中, 提高可讀性
    錯誤示例
package main

import (
	"encoding/json"
	"log"
	"time"
)

type Task struct {
	StartTime time.Time `json:"start_time"`
	Timeout   int       `json:"timeout"` // 這裏是秒還是毫秒?
}

func main() {
	t := Task{
		StartTime: time.Now(),
		Timeout:   int((time.Second * 30).Seconds()),
	}
	s, err := json.Marshal(t)
	if err != nil {
		log.Fatalln(err)
	}
	log.Println(string(s))
}

2022/05/17 17:05:42 {"start_time":"2022-05-17T17:05:42.356961+08:00","timeout":30}

正確示例

package main

import (
	"encoding/json"
	"log"
	"time"
)

type Task struct {
	StartTime     time.Time `json:"start_time"`
	TimeoutSecond int       `json:"timeout_second"` // 字段名就可以明白是秒
}

func main() {
	t := Task{
		StartTime:     time.Now(),
		TimeoutSecond: int((time.Second * 30).Seconds()),
	}
	s, err := json.Marshal(t)
	if err != nil {
		log.Fatalln(err)
	}
	log.Println(string(s))
}

2022/05/17 17:07:26 {"start_time":"2022-05-17T17:07:26.147585+08:00","timeout_second":30}

當在這些交互中不能使用 time.Time 時, 除非達成一致, 否則使用 string 和 RFC 3339 中定義的格式時間戳. 默認情況下, Time.UnmarshalText 使用此格式, 並可通過 time.RFC3339 在 Time.Format 和 time.Parse 中使用

需要注意的是, "time" 包不支持解析閏秒時間戳8728, 也不在計算中考慮閏秒15190, 如果比較兩個時間瞬間,則差異將不包括這兩個瞬間之間可能發生的閏秒。

Errors

對於 error 的使用, 有幾種方式, 有各自的優缺點, 在選擇之前, 先考慮具體的情況:

  • 對於調用者, 是否需要匹配錯誤信息以便處理? 如果需要, 則必須通過聲明頂級的錯誤變量或者自定義類型來支持errors.Iserrors.As函數
  • 錯誤消息是靜態的字符串, 還是存儲有上下文信息的動態字符串? 如果是靜態字符串, 可以使用errors.New, 如果是動態, 必須使用fmt.Errorf或者自定義的錯誤類型
  • 錯誤是否是我們的下游返回的錯誤? 如果是, 參閱之後的錯誤包裝部分
是否需要錯誤匹配 錯誤類型 使用
NO 靜態 errors.New
NO 動態 fmt.Errorf
YES 靜態 errors.New 或者自定義頂級錯誤
YES 動態 自定義錯誤類型
不需要錯誤匹配的靜態錯誤
package main

import "errors"

// 假設這是你寫的一個包

func Open() error {
	return errors.New("could not open") // new 一個靜態的錯誤返回
}

func main() {
	// 假設這是調用者
	if err := Open(); err != nil {
		panic("unknown error")
	}
}

不需要錯誤匹配的動態錯誤

package main

import (
	"fmt"
)

// 假設這是你寫的一個包

func Open(file string) error {
	return fmt.Errorf("file %q not found", file) // 返回 format 後的錯誤
}

func main() {
	// 假設這是調用者
	if err := Open("demo.txt"); err != nil {
		// Can't handle the error.
		panic("unknown error")
	}
}

需要錯誤匹配的靜態錯誤

package main

import "errors"

// 假設這是你寫的一個包

var ErrCouldNotOpen = errors.New("could not open") // 定義一個靜態錯誤類型, 需要是可以導出的

func Open() error {
	return ErrCouldNotOpen // 返回指定的錯誤類型
}

func main() {
	// 假設這是調用者
	if err := Open(); err != nil {
		if errors.Is(err, ErrCouldNotOpen) { // errors.Is 判斷錯誤是否是指定的錯誤類型
			// handle the error
		} else {
			panic("unknown error")
		}
	}
}

需要錯誤匹配的動態錯誤

package main

import (
	"errors"
	"fmt"
)

// 假設這是你寫的一個包

type NotFoundError struct { // 定義一個結構體, 爲錯誤使用, 需要設置爲外部可使用
	File string // 動態部分
}

func (e *NotFoundError) Error() string { // error 方法, 傳出 format 後的錯誤信息
	return fmt.Sprintf("file %q not found", e.File) // 動態信息 format
}

func Open(file string) error {
	return &NotFoundError{File: file} // return時發現是 error類型, 會自動調 Error 方法
}

func main() {
	// 假設這是調用者
	if err := Open("demo.txt"); err != nil {
		var notFound *NotFoundError
		if errors.As(err, &notFound) { // errors.As 判斷錯誤是否是這個結構體的方法
			// handle the error
		} else {
			panic("unknown error")
		}
	}
}

錯誤包裝

當這個錯誤是我們的下游返回的錯誤, 我們需要將錯誤返回給更上級時, 我們有三種選擇:

  • 按照原樣返回錯誤
  • 使用 fmt.Errorf 搭配 %w 將錯誤添加進上下文後返回
  • 使用 fmt.Errorf 搭配 %v 將錯誤添加進上下文後返回
    如果你沒有需要添加的其他上下文, 則直接原樣返回錯誤即可, 這樣保留了原始錯誤類型和消息, 適合上游進行錯誤追蹤, 非常適合底層的錯誤
    否則, 則需要儘可能的在錯誤消息裏添加上下文, 這樣可以防止模糊的錯誤信息, 比如connection refused之類的, 他應該是更詳細的, 例如call service foo: connection refused
    此時你需要使用fmt.Errorf來生成一個包含上下文的錯誤, 那麼如何選擇%w%v?
  • 如果調用者可以訪問底層的錯誤, 使用%w, %w可以在傳遞之後, 外部的調用者依舊可以使用errors.Is來進行錯誤的匹配, 更多情況下, %w更推薦使用
  • %v會將下游錯誤進行混淆,導致上游無法進行錯誤匹配, 如果可以修改, 將他切換到%w
    在生成錯誤信息時, 記得避免加上failed to 之類的描述來保證錯誤信息的簡潔, 因爲他在返回時, 就已經默認是錯誤信息, 不需要特別的指出, 另外當錯誤通過堆棧一層層向上返回時, 加入過多的描述會導致錯誤信息錯亂不堪, 無法辨認

%v導致的錯誤示例:

package main

import (
	"errors"
	"fmt"
)

// 假設這是最下游的一個包

var ErrCouldNotOpen = errors.New("could not open") // 定義一個靜態錯誤類型, 需要是可以導出的

func Open() error {
	return ErrCouldNotOpen // 返回指定的錯誤類型
}

// 這是中層的包

func Demo() error {
	if err := Open(); err != nil {
		return fmt.Errorf("open: %v", err) // 返回給上層, %v 將錯誤信息覆蓋
	}
	return nil
}

func main() {
	// 假設這是調用者
	if err := Demo(); err != nil {
		if errors.Is(err, ErrCouldNotOpen) { // errors.Is 判斷錯誤是否是指定的錯誤類型, %v 覆蓋了錯誤類型, 導致判斷失敗, Panic
			// handle the error
		} else {
			panic("unknown error")
		}
	}
}

正確示例

package main

import (
	"errors"
	"fmt"
)

// 假設這是最下游的一個包

var ErrCouldNotOpen = errors.New("could not open") // 定義一個靜態錯誤類型, 需要是可以導出的

func Open() error {
	return ErrCouldNotOpen // 返回指定的錯誤類型
}

// 這是中層的包

func Demo() error {
	if err := Open(); err != nil {
		// 加入的上下文只有 open: 讓調用者知道是 open 時的錯誤即可
		return fmt.Errorf("open: %w", err) // 返回給上層, %v 將錯誤信息帶入返回
	}
	return nil
}

func main() {
	// 假設這是調用者
	if err := Demo(); err != nil {
		if errors.Is(err, ErrCouldNotOpen) { // errors.Is 判斷錯誤是否是指定的錯誤類型, 判斷成功
			// handle the error
		} else {
			panic("unknown error")
		}
	}
}

需要注意的是, 如果錯誤信息需要傳送到另一個系統, 例如日誌收集, 就需要明確告訴這是個錯誤信息
另外, 遇到錯誤, 不要選擇忽略他不要只是檢查錯誤,而是優雅地處理它們|戴夫·切尼 (cheney.net) // TODO, 這裏有空翻譯一下

錯誤命名

對於存儲爲全局變量的錯誤類型, 根據是否需要導出, 統一加入前綴Err或者err

var (
	// 導出以下兩個錯誤,以便此包的用戶可以將它們與 errors.Is 進行匹配。
	// 統一使用 Err 作爲前綴

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

	// 這個錯誤沒有被導出,因爲我們不想讓它成爲我們公共 API 的一部分。 我們可能仍然在帶有錯誤的包內使用它。
	// 統一使用 err 作爲前綴

	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)
}

斷言處理失敗

go 的類型斷言會在失敗時, 以單一返回值形式返回 panic, 因此, 使用 , ok 方式防止 panic
錯誤示例

package main

import "fmt"

func main() {
	var s interface{}
	s = 1
	t := s.(string)  // panic
	fmt.Println(t)
}

正確示例

package main

import (
	"fmt"
	"log"
)

func main() {
	var s interface{}
	s = 1
	t, ok := s.(string) // !ok, 不會 panic
	if !ok {
		log.Fatalln("error")
	}
	fmt.Println(t)
}

不要使用 panic

在生產環境運行的代碼必須避免出現 panic, panic 會導致整個程序崩潰, 如果發生錯誤, 函數必須捕捉並返回錯誤, 讓調用方來進行處理
錯誤示例

func run(args []string) {
  if len(args) == 0 {
    panic("an argument is required")  // panic, 程序崩潰
  }
  // ...
}

func main() {
  run(os.Args[1:])
}

正確示例

func run(args []string) error {
  if len(args) == 0 {
    return errors.New("an argument is required")  // 不符合預期的邏輯, 捕捉以 error 方式返回, 而不是 panic
  }
  // ...
  return nil
}

func main() {
  if err := run(os.Args[1:]); err != nil {  // 調用方處理錯誤
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
  }
}

panic/recover不是經常使用的錯誤處理策略, 僅僅在發生不可恢復的事情(比如空指針)時才 panic, 有一個例外: 程序的初始化時發生某些致命錯誤可能會 panic(比如數據庫連接解析錯誤)
即使在測試代碼中, 也不要使用 panic, 應該使用t.Fatal或者t.FailNow來確保失敗被標記
錯誤示例

// func TestFoo(t *testing.T)

f, err := ioutil.TempFile("", "test")
if err != nil {
  panic("failed to set up test")
}

正確示例

// func TestFoo(t *testing.T)

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

使用 go.uber.org/atomic

go 語言內置了一部分原始數據類型的原子操作功能, 實現在包  sync/atomic 中, 原子操作可以防止資源競爭導致可能出現的錯誤, 但是開發者很容易忘記使用這些原子操作.
go.uber.org/atomic 通過隱藏基礎類型爲這些操作增加了類型安全性。此外,它包括一個方便的atomic.Bool類型

避免可變的全局變量

在初始化完成後, 應該儘量避免改變全局變量, 這樣會導致可能會出現的, 其他地方修改這個全局變量, 從而發生預期值外的錯誤.
正確示例

// sign.go

var _timeNow = time.Now  // 設置一個全局變量

func sign(msg string) string {
  now := _timeNow()  // 函數中使用這個全局變量
  return signWithTime(msg, now)
}

// main.go
func Sign(t *testing.T) {
  oldTimeNow := _timeNow
  _timeNow = func() time.Time {
	// 覆蓋了全局變量
	// 此時其他地方調用 sign 會導致出現問題
    return someFixedTime  
  }
  defer func() { _timeNow = oldTimeNow }()
}

錯誤示例

// sign.go

// 設定結構體, 將原本的全局變量設置爲結構體的某一個字段
type signer struct {
  now func() time.Time
}

func newSigner() *signer {
  // 新建一個新的 singer, 而不是全局變量, 是這個對象私有的屬性
  return &signer{
    now: time.Now,
  }
}

func (s *signer) Sign(msg string) string {
  // 調用時, 只使用自己的私有的屬性
  now := s.now()
  return signWithTime(msg, now)
}

// main.go

func Signer(t *testing.T) {
  s := newSigner()  // 創建一個新的 singer
  s.now = func() time.Time {
    // 對屬性進行修改, 不影響其他使用
    return someFixedTime
  }
}

避免在公共結構中嵌入類型

直接在公共結構體中嵌入類型會導致這個類型的實現細節暴露出去, 導致分層失敗, 同時還會對以後可能的迭代產生阻礙, 同時不利於文檔的編寫
假設有一個結構體 AbstractList, 實現了AddRemove方法

type AbstractList struct{}

func (l *AbstractList) Add(s string) {
	// ...
}

func (l *AbstractList) Remove(s string) {
	// ...
}


func (l *AbstractList) Clean() {
	// ...
}

當開發者需要在上游的結構體中使用該類型時, 注意不要直接嵌入這個類型, 例如
錯誤示例

package main

type AbstractList struct{}

func (l *AbstractList) Add(s string) {
	// ...
}

func (l *AbstractList) Remove(s string) {
	// ...
}


func (l *AbstractList) Clean() {
	// ...
}


// ConcreteList 是一個實體列表。
// ConcreteList 是公開的結構體
type ConcreteList struct {
	*AbstractList  // 直接嵌入類型
}

func main(){
	c := ConcreteList{}
	c.Add("1")  // 外部可以直接調用 *AbstractList 的方法, 導致分層失敗
	c.Remove("1")
	c.Clean()
}

正確示例
正確的做法應該是作爲結構體的某一個字段使用

package main

type AbstractList struct{}

func (l *AbstractList) Add(s string) {
	// ...
}

func (l *AbstractList) Remove(s string) {
	// ...
}


func (l *AbstractList) Clean() {
	// ...
}


// ConcreteList 是一個實體列表。
// ConcreteList 是公開的結構體
type ConcreteList struct {
	list *AbstractList // 直接嵌入類型
}

// 分層
func (l *ConcreteList) Add(s string) {
	// 做一些其他事情, 例如校驗
	l.list.Add(s)
}

// 分層
func (l *ConcreteList) Remove(s string) {
	// 做一些其他事情, 例如校驗
	l.list.Remove(s)
}

func main() {
	c := ConcreteList{}
	c.Add("1") // 調用的是 *ConcreteList 本身的方法
	c.Remove("1")
	c.Clean()  // 調用失敗, 因爲我不希望你使用
}

分層可以爲之後可能出現的其他邏輯留下空間, 避免之後新的需求到來之時對現有的代碼進行結構上的破壞性改動, 同時也可以避免將某些其他的方法暴露出來
即使AbstractList是接口, 也應該保持同樣的做法, 道理是一樣的

避免使用內置的名稱

Go 語言規範 概述了幾個內置的, 不應在 Go 項目中使用的 預先聲明的標識符
根據上下文的不同,將這些標識符作爲名稱重複使用, 將在當前作用域(或任何嵌套作用域)中隱藏原始標識符,或者混淆代碼。 在最好的情況下,編譯器會報錯;在最壞的情況下,這樣的代碼可能會引入潛在的、難以恢復的錯誤。
錯誤示例

var error string  // 覆蓋了 error
// `error` 本身的作用域隱式覆蓋

// 在函數裏 error 也被覆蓋
func handleErrorMessage(error string) {
    // `error` 作用域隱式覆蓋
}

type Foo struct {
    // 雖然這些字段在技術上不構成隱式覆蓋,但`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
}

正確示例

var errorMessage string
// `error` 不會被覆蓋


func handleErrorMessage(msg string) {
    // `error` 不會被覆蓋
}

type Foo struct {
    // `error` and `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()函數的順序或結果. 雖然此刻多個init()順序是明確的, 但代碼可能被更改, 因此init()函數之間的關係可能會使代碼變得脆弱和容易出錯.
    • 避免訪問或操作全局或環境狀態,如機器信息、環境變量、工作目錄、程序參數/輸入等
  • 避免I/O,包括文件系統、網絡和系統調用
    錯誤示例
// package a
type Foo struct {
    // ...
}
var _defaultFoo Foo
func init() {
	// init 中初始化變量
    _defaultFoo = Foo{
        // ...
    }
}

// package b
type Config struct {
    // ...
}
var _config Config
func init() {
    // 獲取當前目錄
    cwd, _ := os.Getwd()
    // 讀取目錄下文件
    raw, _ := ioutil.ReadFile(
        path.Join(cwd, "config", "config.yaml"),
    )
    yaml.Unmarshal(raw, &_config)
}

正確示例

var _defaultFoo = Foo{
    // ...
}
// 使用函數來進行初始化
var _defaultFoo = defaultFoo()
func defaultFoo() Foo {
    return Foo{
        // ...
    }
}

type Config struct {
    // ...
}
// 開發者手動調用相關函數而不是讓其自動執行
func loadConfig() Config {
    cwd, err := os.Getwd()
    // handle err
    raw, err := ioutil.ReadFile(
        path.Join(cwd, "config", "config.yaml"),
    )
    // handle err
    var config Config
    yaml.Unmarshal(raw, &config)
    return config
}

考慮到上述情況,在某些情況下,init()可能更可取或是必要的,可能包括:

  • 不能表示爲單個賦值的複雜表達式。
  • 可插入的鉤子,如database/sql、編碼類型註冊表等。
  • 對 Google Cloud Functions 和其他形式的確定性預計算的優化, 例如regexp.MustCompile(編譯正則表達式)

切片追加時優先指定容量

在切片需要追加時, 儘可能的預先估算出最大容量, 並在 make 時就指定其容量
目的是減少切片動態擴容帶來的時間損耗
錯誤示例

package main

import (
	"fmt"
	"time"
)

func main() {
	s := time.Now()
	size := 100000000
	data := make([]int, 0)
	fmt.Println(cap(data))
	for k := 0; k < size; k++ {
		data = append(data, k)
	}
	fmt.Println(cap(data))
	fmt.Println(time.Since(s)) // 所需時長
}

0
114748416
1.532827648s

正確示例

package main

import (
	"fmt"
	"time"
)

func main() {
	s := time.Now()
	size := 100000000
	data := make([]int, 0, size) // 指定容量爲 size
	fmt.Println(cap(data))
	for k := 0; k < size; k++ {
		data = append(data, k)
	}
	fmt.Println(cap(data))
	fmt.Println(time.Since(s)) // 所需時長
}

100000000
100000000
333.793275ms

主函數的退出方式

go 程序使用os.Exit或者log.Fatal來進行立即退出, 永遠記住, 不要使用panic來進行退出
並且, 只在main()中調用os.Exitlog.Fatal, 對於其他函數的退出, 要將錯誤信息返回出來
錯誤示例

package main

import (
	"fmt"
	"io/ioutil"
	"log"
	"os"
)

func main() {
	body := readFile("a.txt")
	fmt.Println(body)
}

func readFile(path string) string {
	defer func() {
		fmt.Println("假如這裏進行一些其他清理操作")
	}()
	f, err := os.Open(path)
	if err != nil {
		log.Fatal(err)
	}
	b, err := ioutil.ReadAll(f)
	if err != nil {
		// 發送錯誤, 使用 log.Fatal 退出
		log.Fatal(err)
	}
	return string(b)
}

運行後, 發現, defer 中註冊的操作無法執行

2022/06/13 19:21:11 open a.txt: no such file or directory
exit status 1

在其他函數中通過以上兩種方式直接退出程序有幾個隱患:

  • 不明顯的控制流: 任何函數都可以導致程序退出, 因此很難對處理邏輯進行控制和分析
  • 難以測試: 如果你的test 測試代碼調用了函數, 而在函數內導致程序退出, 同樣導致整個測試流程退出, 無法繼續進行
  • 跳過清理: 一般的, 我們使用 defer 來進行一些資源清理操作, 例如連接的關閉, 文件句柄關閉等, 但是當函數直接退出時, defer 中的代碼不會被執行
    正確示例
package main

import (
	"errors"
	"fmt"
	"io/ioutil"
	"log"
	"os"
)

func main() {
	if err := run(); err != nil {
		// 主函數進行退出
		log.Fatal(err)
	}
}
func run() error {
	defer func() {
		fmt.Println("資源回收")
	}()
	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 := ioutil.ReadAll(f)
	if err != nil {
		return err
	}
	// ...
	fmt.Println(b)
	return nil
}

資源回收
2022/06/13 19:30:29 missing file
exit status 1

一次性退出

如果可以的話, 在每個main()中最多調用一次os.Exit或者log.Fatal, 如果有多個錯誤場景, 應該將程序結束, 此時應該將邏輯單獨放置在單獨的錯誤函數中, 通過返回錯誤來讓 main 來進行退出, 這樣會縮短 main 函數, 同時將關鍵業務邏輯放置在了單獨的, 可以進行測試的函數中
錯誤示例

package main

import (
	"fmt"
	"io/ioutil"
	"log"
	"os"
)

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 {
		// fatal
		log.Fatal(err)
	}
	defer f.Close()
	defer func() {
		fmt.Println("清理")
	}()
	b, err := ioutil.ReadAll(f)
	if err != nil {
		// defer 同樣並不會被執行
		log.Fatal(err)
	}
	// ...
	fmt.Println(b)
}

正確示例

package main

import (
	"errors"
	"fmt"
	"io/ioutil"
	"log"
	"os"
)

func main() {
	if err := run(); err != nil {  // 統一進行判斷
		log.Fatal(err)
	}
}
func run() error {
	args := os.Args[1:]
	if len(args) != 1 {
		// err 0
		return errors.New("missing file")
	}
	name := args[0]
	f, err := os.Open(name)
	if err != nil {
		// err 1
		return err
	}
	defer f.Close()
	b, err := ioutil.ReadAll(f)
	if err != nil {
		// err 2
		return err
	}
	// ...
	fmt.Println(b)
	return nil
}

在序列化的結構體中使用 tag

任何序列化到 json/YAML 或者其他支持基於 tag 來進行字段命名的格式, 都應該使用 tag 來進行註釋
因爲, 結構的序列化方式, 是不同系統之間交流的約定, 而對字段的修改會導致破壞約定. 使用加入 tag 的方式, 可以使約定更加明確和易讀. 並且在重構和重命名字段時, 只要不動 tag, 就無需重新約定結構
錯誤示例

package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	type Stock struct {
		// json 在沒有 tag 時默認按照字段名
		// 當後續字段名有調整導致 json 結構發生變化
		Price int
		Name  string
	}
	bytes, err := json.Marshal(Stock{
		Price: 137,
		Name:  "UBER",
	})
	fmt.Println(err)
	fmt.Println(string(bytes))
}

正確示例

package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	type Stock struct {
		// json 根據 json tag 來進行命名
		// 當後續字段名調整, 只要 tag 不動, 則無需重新約定 json 結構
		Price int    `json:"price"`
		Name  string `json:"name"`
	}
	bytes, err := json.Marshal(Stock{
		Price: 137,
		Name:  "UBER",
	})
	fmt.Println(err)
	fmt.Println(string(bytes))
}

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章