Go error 處理最佳實踐

今天分享 go 語言 error 處理的最佳實踐,瞭解當前 error 的缺點、妥協以及使用時注意事項。文章內容較長,乾貨也多,建義收藏

什麼是 error

大家都知道 error[1] 是源代碼內嵌的接口類型。根據導出原則,只有大寫的才能被其它源碼包引用,但是 error 屬於 predeclared identifiers 預定義的,並不是關鍵字,細節參考 int make 居然不是關鍵字?

// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
 Error() string
}

error 只有一個方法 Error() string 返回錯誤消息

// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
func New(text string) error {
 return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
 s string
}

func (e *errorString) Error() string {
 return e.s
}

一般我們創建 error 時只需要調用 errors.New("error from somewhere") 即可,底層就是一個字符串結構體 errorStrings

當前 error 有哪些問題

func Test() error {
 if err := func1(); err != nil {
  return err
 }
  ......
}

這是常見的用法,也最被人詬病,很多人覺得不如 try-catch 用法簡潔,有人戲稱 go 源碼錯誤處理佔一半

import sys

try:
    f = open('myfile.txt')
    s = f.readline()
    i = int(s.strip())
except OSError as err:
    print("OS error: {0}".format(err))
except ValueError:
    print("Could not convert data to an integer.")
except BaseException as err:
    print(f"Unexpected {err=}, {type(err)=}")
    raise

比如上面是 python try-catch 的用法,先寫一堆邏輯,不處理異常,最後統一捕獲

let mut cfg = self.check_and_copy()?;

相比來說 rust Result 模式更簡潔,一個 ? 就代替了我們的操作。但是 error 的繁瑣判斷是當前的痛點嘛?顯然不是,尤其喜歡 c 語言的人,反而喜歡每次都做判斷

在我看來 go 的痛點不是缺少泛型,不是 error 太挫,而是 GC 太弱,尤其對大內存非常不友好,這方面可以參考真實環境下大內存 Go 服務性能優化一例

當前 error 的問題有兩點:

  1. 無法 wrap 更多的信息,比如調用棧,比如層層封裝的 error 消息

  2. 無法很好的處理類型信息,比如我想知道錯誤是 io 類型的,還是 net 類型的

1.Wrap 更多的消息

這方面有很多輪子,最著名的就是 https://github.com/pkg/errors, 我司也重度使用,主要功能有三個:

  1. Wrap 封裝底層 error, 增加更多消息,提供調用棧信息,這是原生 error 缺少的

  2. WithMessage 封裝底層 error, 增加更多消息,但不提供調用棧信息

  3. Cause 返回最底層的 error, 剝去層層的 wrap

import (
   "database/sql"
   "fmt"

   "github.com/pkg/errors"
)

func foo() error {
   return errors.Wrap(sql.ErrNoRows, "foo failed")
}

func bar() error {
   return errors.WithMessage(foo(), "bar failed")
}

func main() {
   err := bar()
   if errors.Cause(err) == sql.ErrNoRows {
      fmt.Printf("data not found, %v\n", err)
      fmt.Printf("%+v\n", err)
      return
   }
   if err != nil {
      // unknown error
   }
}
/*Output:
data not found, bar failed: foo failed: sql: no rows in result set
sql: no rows in result set
foo failed
main.foo
    /usr/three/main.go:11
main.bar
    /usr/three/main.go:15
main.main
    /usr/three/main.go:19
runtime.main
    ...
*/

這是測試代碼,當用 %v 打印時只有原始錯誤信息,%+v 時打印完整調用棧。當 go1.13 後,標準庫 errors 增加了 Wrap 方法

func ExampleUnwrap() {
 err1 := errors.New("error1")
 err2 := fmt.Errorf("error2: [%w]", err1)
 fmt.Println(err2)
 fmt.Println(errors.Unwrap(err2))
 // Output
 // error2: [error1]
 // error1
}

標準庫沒有提供增加調用棧的方法,fmt.Errorf 指定 %w 時可以 wrap error, 但整體來講,並沒有 https://github.com/pkg/errors 庫好用

2. 錯誤類型

這個例子來自 ITNEXT[2]

import (
   "database/sql"
   "fmt"
)

func foo() error {
   return sql.ErrNoRows
}

func bar() error {
   return foo()
}

func main() {
   err := bar()
   if err == sql.ErrNoRows {
      fmt.Printf("data not found, %+v\n", err)
      return
   }
   if err != nil {
      // Unknown error
   }
}
//Outputs:
// data not found, sql: no rows in result set

有時我們要處理類型信息,比如上面例子,判斷 err 如果是 sql.ErrNoRows 那麼視爲正常,data not found 而己,類似於 redigo 裏面的 redigo.Nil 表示記錄不存在

func foo() error {
   return fmt.Errorf("foo err, %v", sql.ErrNoRows)
}

但是如果 foo 把 error 做了一層 wrap 呢?這個時候錯誤還是 sql.ErrNoRows 嘛?肯定不是,這點沒有 python try-catch 錯誤處理強大,可以根據不同錯誤 class 做出判斷。那麼 go 如何解決呢?答案是 go1.13 新增的 Is[3] 和 As

import (
   "database/sql"
   "errors"
   "fmt"
)

func bar() error {
   if err := foo(); err != nil {
      return fmt.Errorf("bar failed: %w", foo())
   }
   return nil
}

func foo() error {
   return fmt.Errorf("foo failed: %w", sql.ErrNoRows)
}

func main() {
   err := bar()
   if errors.Is(err, sql.ErrNoRows) {
      fmt.Printf("data not found,  %+v\n", err)
      return
   }
   if err != nil {
      // unknown error
   }
}
/* Outputs:
data not found,  bar failed: foo failed: sql: no rows in result set
*/

還是這個例子,errors.Is 會遞歸的 Unwrap err, 判斷錯誤是不是 sql.ErrNoRows,這裏個小問題,Is 是做的指針地址判斷,如果錯誤 Error() 內容一樣,但是根 error 是不同實例,那麼 Is 判斷也是 false, 這點就很扯

func ExampleAs() {
 if _, err := os.Open("non-existing"); err != nil {
  var pathError *fs.PathError
  if errors.As(err, &pathError) {
   fmt.Println("Failed at path:", pathError.Path)
  } else {
   fmt.Println(err)
  }
 }

 // Output:
 // Failed at path: non-existing
}

errors.As[4] 判斷這個 err 是否是 fs.PathError 類型,遞歸調用層層查找,源碼後面再講解

另外一個判斷類型或是錯誤原因的就是 https://github.com/pkg/errors 庫提供的 errors.Cause

switch err := errors.Cause(err).(type) {
case *MyError:
        // handle specifically
default:
        // unknown error
}

在沒有 Is As 類型判斷時,需要很噁心的去判斷錯誤自符串

func (conn *cendolConnectionV5) serve() {
 // Buffer needs to be preserved across messages because of packet coalescing.
 reader := bufio.NewReader(conn.Connection)
 for {
  msg, err := conn.readMessage(reader)
  if err != nil {
   if netErr, ok := strings.Contain(err.Error(), "temprary"); ok   {
     continue
   }
  }

  conn.processMessage(msg)
 }
}

想必接觸 go 比較早的人一定很熟悉,如果 conn 從網絡接受到的連接錯誤是 temporary 臨時的那麼可以 continue 重試,當然最好 backoff sleep 一下

當然現在新增加了 net.Error 類型,實現了 Temporary 接口,不過也要廢棄了,請參考#45729[5]

源碼實現

1.github.com/pkg/errors 庫如何生成 warapper error

// Wrap returns an error annotating err with a stack trace
// at the point Wrap is called, and the supplied message.
// If err is nil, Wrap returns nil.
func Wrap(err error, message string) error {
 if err == nil {
  return nil
 }
 err = &withMessage{
  cause: err,
  msg:   message,
 }
 return &withStack{
  err,
  callers(),
 }
}

主要的函數就是 Wrap, 代碼實現比較簡單,查看如何追蹤調用棧可以查看源碼

2.github.com/pkg/errorsCause 實現

type withStack struct {
 error
 *stack
}

func (w *withStack) Cause() error { return w.error }

func Cause(err error) error {
 type causer interface {
  Cause() error
 }

 for err != nil {
  cause, ok := err.(causer)
  if !ok {
   break
  }
  err = cause.Cause()
 }
 return err
}

Cause 遞歸調用,如果沒有實現 causer 接口,那麼就返回這個 err

3. 官方庫如何生成一個 wrapper error

官方沒有這樣的函數,而是 fmt.Errorf 格式化時使用 %w

e := errors.New("this is a error")
w := fmt.Errorf("more info about it %w", e)
func Errorf(format string, a ...interface{}) error {
 p := newPrinter()
 p.wrapErrs = true
 p.doPrintf(format, a)
 s := string(p.buf)
 var err error
 if p.wrappedErr == nil {
  err = errors.New(s)
 } else {
  err = &wrapError{s, p.wrappedErr}
 }
 p.free()
 return err
}

func (p *pp) handleMethods(verb rune) (handled bool) {
 if p.erroring {
  return
 }
 if verb == 'w' {
  // It is invalid to use %w other than with Errorf, more than once,
  // or with a non-error arg.
  err, ok := p.arg.(error)
  if !ok || !p.wrapErrs || p.wrappedErr != nil {
   p.wrappedErr = nil
   p.wrapErrs = false
   p.badVerb(verb)
   return true
  }
  p.wrappedErr = err
  // If the arg is a Formatter, pass 'v' as the verb to it.
  verb = 'v'
 }
  ......
}

代碼也不難,handleMethods 時特殊處理 w, 使用 wrapError 封裝一下即可

4. 官方庫 Unwrap 實現

func Unwrap(err error) error {
 u, ok := err.(interface {
  Unwrap() error
 })

 if !ok {
  return nil
 }
 return u.Unwrap()
}

也是遞歸調用,否則接口斷言失敗,返回 nil

type wrapError struct {
 msg string
 err error
}

func (e *wrapError) Error() string {
 return e.msg
}

func (e *wrapError) Unwrap() error {
 return e.err
}

上文 fmt.Errof 時生成的 error 結構體如上所示,Unwrap 直接返回底層 err

5. 官方庫 Is As 實現

本段源碼分析來自 flysnow[6]

func Is(err, target error) bool {
 if target == nil {
  return err == target
 }

 isComparable := reflectlite.TypeOf(target).Comparable()
 
 //for循環,把err一層層剝開,一個個比較,找到就返回true
 for {
  if isComparable && err == target {
   return true
  }
  //這裏意味着你可以自定義error的Is方法,實現自己的比較代碼
  if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
   return true
  }
  //剝開一層,返回被嵌套的err
  if err = Unwrap(err); err == nil {
   return false
  }
 }
}

Is 函數比較簡單,遞歸層層檢查,如果是嵌套 err, 那就調用 Unwrap 層層剝開找到最底層 err, 最後判斷指針是否相等

var errorType = reflectlite.TypeOf((*error)(nil)).Elem()

func As(err error, target interface{}) bool {
    //一些判斷,保證target,這裏是不能爲nil
 if target == nil {
  panic("errors: target cannot be nil")
 }
 val := reflectlite.ValueOf(target)
 typ := val.Type()
 
 //這裏確保target必須是一個非nil指針
 if typ.Kind() != reflectlite.Ptr || val.IsNil() {
  panic("errors: target must be a non-nil pointer")
 }
 
 //這裏確保target是一個接口或者實現了error接口
 if e := typ.Elem(); e.Kind() != reflectlite.Interface && !e.Implements(errorType) {
  panic("errors: *target must be interface or implement error")
 }
 targetType := typ.Elem()
 for err != nil {
     //關鍵部分,反射判斷是否可被賦予,如果可以就賦值並且返回true
     //本質上,就是類型斷言,這是反射的寫法
  if reflectlite.TypeOf(err).AssignableTo(targetType) {
   val.Elem().Set(reflectlite.ValueOf(err))
   return true
  }
  //這裏意味着你可以自定義error的As方法,實現自己的類型斷言代碼
  if x, ok := err.(interface{ As(interface{}) bool }); ok && x.As(target) {
   return true
  }
  //這裏是遍歷error鏈的關鍵,不停的Unwrap,一層層的獲取err
  err = Unwrap(err)
 }
 return false
}

代碼同樣是遞歸調用 As, 同時 Unwrap 最底層的 error, 然後用反射判斷是否可以賦值,如果可以,那麼說明是同一類型

ErrGroup 使用

提到 error 就必須要提一下 golang.org/x/sync/errgroup, 適用如下場景:併發場景下,如果一個 goroutine 有錯誤,那麼就要提前返回,並取消其它並行的請求

func ExampleGroup_justErrors() {
 g := new(errgroup.Group)
 var urls = []string{
  "http://www.golang.org/",
  "http://www.google.com/",
  "http://www.somestupidname.com/",
 }
 for _, url := range urls {
  // Launch a goroutine to fetch the URL.
  url := url // https://golang.org/doc/faq#closures_and_goroutines
  g.Go(func() error {
   // Fetch the URL.
   resp, err := http.Get(url)
   if err == nil {
    resp.Body.Close()
   }
   return err
  })
 }
 // Wait for all HTTP fetches to complete.
 if err := g.Wait(); err == nil {
  fmt.Println("Successfully fetched all URLs.")
 }
}

上面是官方給的例子,底層使用 context 來 cancel 其它請求,同步使用 WaitGroup, 原理非常簡單,代碼量非常少,感興趣的可以看源碼

這裏一定要注意三點:

  1. context 是誰傳進來的?其它代碼會不會用到,cancel 只能執行一次,瞎比用會出問題

  2. g.Go 不帶 recover 的,爲了程序的健壯,一定要自行 recover

  3. 並行的 goroutine 有一個錯誤就返回,而不是普通的 fan-out 請求後收集結果

線上實踐注意的幾個問題

1.error 與 panic

查看 go 源代碼會發現,源碼很多地方寫 panic, 但是工程實踐,尤其業務代碼不要主動寫 panic

理論上 panic 只存在於 server 啓動階段,比如 config 文件解析失敗,端口監聽失敗等等,所有業務邏輯禁止主動 panic

根據 CAP 理論,當前 web 互聯網最重要的是 AP, 高可用性才最關鍵 (非銀行金融場景),程序啓動時如果有部分詞表,元數據加載失敗,都不能 panic, 提供服務才最關鍵,當然要有報警,讓開發第一時間感知當前服務了的 QOS 己經降低

最後說一下,所有異步的 goroutine 都要用 recover 去兜底處理

2. 錯誤處理與資源釋放

func worker(done chan error) {
    err := doSomething()
    result := &result{}
    if err != nil {
        result.Err = err
    }
    done <- result
}

一般異步組裝數據,都要分別啓動 goroutine, 然後把結果通過 channel 返回,result 結構體擁有 err 字段表示錯誤

這裏要注意,main 函數中 done channel 千萬不能 close, 因爲你不知道 doSomething 會超時多久返回,寫 closed channel 直接 panic

所以這裏有一個準則:數據傳輸和退出控制,需要用單獨的 channel 不能混, 我們一般用 context 取消異步 goroutine, 而不是直接 close channels

3.error 級聯使用問題

package main

import "fmt"

type myError struct {
 string
}

func (i *myError) Error() string {
 return i.string
}

func Call1() error {
 return nil
}

func Call2() *myError {
 return nil
}

func main() {
 err := Call1()
 if err != nil {
  fmt.Printf("call1 is not nil: %v\n", err)
 }

 err = Call2()
 if err != nil {
  fmt.Printf("call2 err is not nil: %v\n", err)
 }
}

這個問題非常經典,如果複用 err 變量的情況下, Call2 返回的 error 是自定義類型,此時 err 類型是不一樣的,導致經典的 error is not nil, but value is nil

非常經典的 Nil is not nil[7] 問題。解決方法就是 Call2 err 重新定義一個變量,當然最簡單就是統一 error 類型。有點難,尤其是大型項目

4. 併發問題

go 內置類型除了 channel 大部分都是非線程安全的,error 也不例外,先看一個例子

package main
import (
   "fmt"
   "github.com/myteksi/hystrix-go/hystrix"
   "time"
)
var FIRST error = hystrix.CircuitError{Message:"timeout"}
var SECOND error = nil
func main() {
   var err error
   go func() {
      i := 1
      for {
         i = 1 - i
         if i == 0 {
            err = FIRST
         } else {
            err = SECOND
         }
         time.Sleep(10)
      }
   }()
   for {
      if err != nil {
         fmt.Println(err.Error())
      }
      time.Sleep(10)
   }
}

運行之前,大家先猜下會發生什麼???

zerun.dong$ go run panic.go
hystrix: timeout
panic: value method github.com/myteksi/hystrix-go/hystrix.CircuitError.Error called using nil *CircuitError pointer

goroutine 1 [running]:
github.com/myteksi/hystrix-go/hystrix.(*CircuitError).Error(0x0, 0xc0000f4008, 0xc000088f40)
 <autogenerated>:1 +0x86
main.main()
 /Users/zerun.dong/code/gotest/panic.go:25 +0x82
exit status 2

上面是測試的例子,只要跑一會,就一定發生 panic, 本質就是 error 接口類型不是併發安全的

// 沒有方法的interface
type eface struct {
    _type *_type
    data  unsafe.Pointer
}
// 有方法的interface
type iface struct {
    tab  *itab
    data unsafe.Pointer
}

所以不要併發對 error 賦值

5.error 要不要忽略

func Test(){
 _ = json.Marshal(xxxx)
 ......
}

有的同學會有疑問,error 是否一定要處理?其實上面的 Marshal 都有可能失敗的

如果換成其它函數,當前實現可以忽略,不能保證以後還是兼容的邏輯,一定要處理 error,至少要打日誌

6.errWriter

本例來自官方 blog[8], 有時我們想做 pipeline 處理,需要把 err 當成結構體變量

_, err = fd.Write(p0[a:b])
if err != nil {
    return err
}
_, err = fd.Write(p1[c:d])
if err != nil {
    return err
}
_, err = fd.Write(p2[e:f])
if err != nil {
    return err
}
// and so on

上面是原始例子,需要一直做 if err != nil 的判斷,官方優化的寫法如下

type errWriter struct {
    w   io.Writer
    err error
}

func (ew *errWriter) write(buf []byte) {
    if ew.err != nil {
        return
    }
    _, ew.err = ew.w.Write(buf)
}

// 使用時
ew := &errWriter{w: fd}
ew.write(p0[a:b])
ew.write(p1[c:d])
ew.write(p2[e:f])
// and so on
if ew.err != nil {
    return ew.err
}

清晰簡潔,大家平時寫代碼可以多考濾一下

7. 何時打印調用棧

官方庫無法 wrap 調用棧,所以 fmt.Errorf %w 不如 pkg/errors 庫實用,但是errors.Wrap 最好保證只調用一次,否則全是重複的調用棧

我們項目的使用情況是 log error 級別的打印棧,warn 和 info 都不打印,當然 case by case 還得看實際使用情況

8.Wrap 前做判斷

errors.Wrap(err, "failed")

通過查看源碼,如果 err 爲 nil 的時候,也會返回 nil. 所以 Wrap 前最好做下判斷,建議來自 xiaorui.cc

小結

上面提到的線上實踐注意的幾個問題,都是實際發生的坑,慘痛的教訓,大家一定要多體會下。錯誤處理涵蓋內容非常廣,本文不涉及分佈式系統的錯誤處理、gRPC 錯誤傳播以及錯誤管理

關於 error 大家有什麼看法,歡迎留言一起討論,大牛多留言 ^_^

參考資料

[1] builting.go error interface: https://github.com/golang/go/blob/master/src/builtin/builtin.go#L260,

[2] ITNEXT: https://itnext.io/golang-error-handling-best-practice-a36f47b0b94c,

[3] errors.Is: https://github.com/golang/go/blob/master/src/errors/wrap.go#L40,

[4] errors.As example: https://github.com/golang/go/blob/master/src/errors/wrap_test.go#L255,

[5] #45729: https://github.com/golang/go/issues/45729,

[6] flysnow error 分析: https://www.flysnow.org/2019/09/06/go1.13-error-wrapping.html,

[7] Nil is not nil: https://yourbasic.org/golang/gotcha-why-nil-error-not-equal-nil/,

[8] errors are values: https://blog.golang.org/errors-are-values,

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/XojOIIZfKm_wXul9eSU1tQ