Go Error 處理最佳實踐

作者:faberli,騰訊 IEG 後臺開發工程師

錯誤處理一直以一是編程必需要面對的問題,錯誤處理如果做的好的話,代碼的穩定性會很好。不同的語言有不同的出現處理的方式。Go 語言也一樣,在本篇文章中,我們來討論一下 Go 語言的錯誤處理方式。

一、Error vs Exception

1.1 Error

錯誤是程序中可能出現的問題,比如連接數據庫失敗,連接網絡失敗等,在程序設計中,錯誤處理是業務的一部分。

Go 內建一個 error 接口類型作爲 go 的錯誤標準處理

http://golang.org/pkg/builtin/#error

// 接口定義
type error interface {
   Error() string
}

http://golang.org/src/pkg/errors/errors.go

// 實現
func New(text string) error {
   return &errorString{text}
}

type errorString struct {
   s string
}

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

1.2 Exception

異常是指在不該出現問題的地方出現問題,是預料之外的,比如空指針引用,下標越界,向空 map 添加鍵值等

1.3 panic

對於真正意外的情況,那些表示不可恢復的程序錯誤,不可恢復才使用 panic。對於其他的錯誤情況,我們應該是期望使用 error 來進行判定

go 源代碼很多地方寫 panic, 但是工程實踐業務代碼不要主動寫 panic,理論上 panic 只存在於 server 啓動階段,比如 config 文件解析失敗,端口監聽失敗等等,所有業務邏輯禁止主動 panic,所有異步的 goroutine 都要用 recover 去兜底處理。

1.4 總結

二、Go 處理錯誤的三種方式

2.1 經典 Go 邏輯

直觀的返回 error

// ZooTour struct
type ZooTour interface {
    Enter() error
    VisitPanda(panda *Panda) error
    Leave() error
}

// 分步處理,每個步驟可以針對具體返回結果進行處理
func Tour(t ZooTour1, panda *Panda) error {
    if err := t.Enter(); err != nil {
        return errors.WithMessage(err, "Enter failed.")
    }
    if err := t.VisitPanda(); err != nil {
        return errors.WithMessage(err, "VisitPanda failed.")
    }
    // ...

    return nil
}

2.2 屏蔽過程中的 error 的處理

將 error 保存到對象內部,處理邏輯交給每個方法,本質上仍是順序執行。標準庫的bufiodatabase/sql包中的Rows等都是這樣實現的,有興趣可以去看下源碼

type ZooTour interface {
    Enter() error
    VisitPanda(panda *Panda) error
    Leave() error
    Err() error
}

func Tour(t ZooTour, panda *Panda) error {

    t.Enter()
    t.VisitPanda(panda)
    t.Leave()

    // 集中編寫業務邏輯代碼,最後統一處理error
    if err := t.Err(); err != nil {
        return errors.WithMessage(err, "ZooTour failed")
    }
    return nil
}

2.3 利用函數式編程延遲運行

分離關注點 - 遍歷訪問用數據結構定義運行順序,根據場景選擇,如順序、逆序、二叉樹樹遍歷等。運行邏輯將代碼的控制流邏輯抽離,靈活調整。kubernetes 中的 visitor 對此就有很多種擴展方式,分離了數據和行爲,有興趣可以去擴展閱讀

type Walker interface {
    Next MyFunc
}
type SliceWalker struct {
    index int
    funs []MyFunc
}

func NewEnterFunc() MyFunc {
    return func(t ZooTour) error {
        return t.Enter()
    }
}

func BreakOnError(t ZooTour, walker Walker) error {
    for {
        f := walker.Next()
        if f == nil {
            break
        }
        if err := f(t); err := nil {
          // 遇到錯誤break或者continue繼續執行
      }
    }
}

2.4 三種方式對比

上面這三個例子,是 Go 項目處理錯誤使用頻率最高的三種方式,也可以應用在 error 以外的處理邏輯。

三、分層下的 Error Handling

3.1 一個常見的三層調用

// controller
if err := mode.ParamCheck(param); err != nil {
    log.Errorf("param=%+v", param)
    return errs.ErrInvalidParam
}

return mode.ListTestName("")

// service
_, err := dao.GetTestName(ctx, settleId)
    if err != nil {
    log.Errorf("GetTestName failed. err: %v", err)
    return errs.ErrDatabase
}

// dao
if err != nil {
    log.Errorf("GetTestDao failed. uery: %s error(%v)", sql, err)
}

3.2 問題總結

3.3 Wrap erros

Go 相關的錯誤處理方法很多,但大多爲過渡方案,這裏就不一一分析了(類似 github.com/juju/errors 庫,有興趣可以瞭解)。這裏我以 github.com/pkg/errors 爲例,這個也是官方 Proposal 的重點參考對象。

  1. 錯誤要被日誌記錄。

  2. 應用程序處理錯誤,保證 100% 完整性。

  3. 之後不再報告當前錯誤(錯誤只被處理一次)。

github.com/pkg/errors 包主要包含以下幾個方法,如果我們要新生成一個錯誤,可以使用 New 函數, 生成的錯誤,自帶調用堆棧信息。如果有一個現成的 error ,我們需要對他進行再次包裝處理,這時候有三個函數可以選擇(WithMessage/WithStack/Wrapf)。其次,如果需要對源錯誤類型進行自定義判斷可以使用 Cause, 可以獲得最根本的錯誤原因。

// 新生成一個錯誤, 帶堆棧信息
func New(message string) error

// 只附加新的信息
func WithMessage(err error, message string) error

// 只附加調用堆棧信息
func WithStack(err error) error

// 同時附加堆棧和信息
func Wrapf(err error, format string, args ...interface{}) error

// 獲得最根本的錯誤原因
func Cause(err error) error

以常見的一個三層架構爲例:

    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, errors.Wrapf(ierror.ErrNotFound, "query:%s", query)
        }
        return nil, errors.Wrapf(ierror.ErrDatabase,
            "query: %s error(%v)", query, err)
    }
    bills, err := a.Dao.GetName(ctx, param)
    if err != nil {
        return result, errors.WithMessage(err, "GetName failed")
    }
// 請求響應組裝
func (Format) Handle(next ihttp.MiddleFunc) ihttp.MiddleFunc {
    return func(ctx context.Context, req *http.Request, rsp *ihttp.Response) error {
        format := &format{Time: time.Now().Unix()}
        err := next(ctx, req, rsp)
        format.Data = rsp.Data
        if err != nil {
            format.Code, format.Msg = errCodes(ctx, err)
        }
        rsp.Data = format
        return nil
    }
}

// 獲取錯誤碼
func errCodes(ctx context.Context, err error) (int, string) {
    if err != nil {
        log.CtxErrorf(ctx, "error: [%+v]", err)
    }
    var myError = new(erro.IError)
    if errors.As(err, &myError) {
        return myError.Code, myError.Msg
    }

    return code.ServerError, i18n.CodeMessage(code.ServerError)
}
_, err := os.Open(path)
if err != nil {
   return errors.Wrapf(err, "Open failed. [%s]", path)
}

最終效果樣例:

3.4 關鍵點總結

  1. MyError 作爲全局 error 的底層實現,保存具體的錯誤碼和錯誤信息;

  2. MyError 向上返回錯誤時,第一次先用 Wrap 初始化堆棧,後續用 WithMessage 增加堆棧信息;

  3. 要判斷 error 是否爲指定的錯誤時,可以使用 errors.Cause 獲取 root error,再進行和 sentinel error 判定;

  4. github.com/pkg/errors 和標準庫的 error 完全兼容,可以先替換、後續改造歷史遺留的代碼;

  5. 打印 error 的堆棧需要用 %+v,而原來的 %v 依舊爲普通字符串方法;同時也要注意日誌採集工具是否支持多行匹配;

  6. log error 級別的打印棧,warn 和 info 可不打印堆棧;

  7. 可結合統一錯誤碼使用:https://google-cloud.gitbook.io/api-design-guide/errors

四、errgroup 集中錯誤處理

官方的 ErrGroup 非常簡單,其實就是解決小型多任務併發任務。基本用法 golang.org/x/sync/errgroup 包下定義了一個 Group struct,它就是我們要介紹的 ErrGroup 併發原語,底層也是基於 WaitGroup 實現的。在使用 ErrGroup 時,我們要用到三個方法,分別是 WithContext、Go 和 Wait。

4.1 背景

通常,在寫業務代碼性能優化時經常將一個通用的父任務拆成幾個小任務併發執行。此時需要將一個大的任務拆成幾個小任務併發執行,來提高QPS,我們需要再業務代碼裏嵌入以下邏輯,但這種方式存在問題:

  1. 每個請求都開啓 goroutinue,會有一定的性能開銷。

  2. 野生的 goroutinue,生命週期管理比較困難。

  3. 收到類似 SIGQUIT 信號時,無法平滑退出。

4.2 errgroup函數簽名

type Group
    func WithContext(ctx context.Context) (*Group, context.Context)
    func (g *Group) Go(f func() error)
    func (g *Group) Wait() error

整個包就一個 Group 結構體

4.3 使用案例

注意這裏有一個坑,在後面的代碼中不要把 ctx 當做父 context 又傳給下游,因爲 errgroup 取消了,這個 context 就沒用了,會導致下游複用的時候出錯

func TestErrgroup() {
   eg, ctx := errgroup.WithContext(context.Background())
   for i := 0; i < 100; i++ {
      i := i
      eg.Go(func() error {
         time.Sleep(2 * time.Second)
         select {
         case <-ctx.Done():
            fmt.Println("Canceled:", i)
            return nil
         default:
            fmt.Println("End:", i)
            return nil
         }})}
   if err := eg.Wait(); err != nil {
      log.Fatal(err)
   }
}

4.4 errgroup拓展包

B 站拓展包

type Group struct {
   err     error
   wg      sync.WaitGroup
   errOnce sync.Once

   workerOnce sync.Once
   ch         chan func(ctx context.Context) error
   chs        []func(ctx context.Context) error

   ctx    context.Context
   cancel func()
}

func WithContext(ctx context.Context) *Group {
   return &Group{ctx: ctx}
}
func (g *Group) Go(f func(ctx context.Context) error) {
   g.wg.Add(1)
   if g.ch != nil {
      select {
      case g.ch <- f:
      default:
         g.chs = append(g.chs, f)
      }
      return
   }
   go g.do(f)
}
func (g *Group) GOMAXPROCS(n int) {
   if n <= 0 {
      panic("errgroup: GOMAXPROCS must great than 0")
   }
   g.workerOnce.Do(func() {
      g.ch = make(chan func(context.Context) error, n)
      for i := 0; i < n; i++ {
         go func() {
            for f := range g.ch {
               g.do(f)
            }
         }()
      }
   })
}

整個流程梳理下來其實就是啓動一個固定數量的併發池消費任務,Go 函數其實是向管道中發送任務的生產者,這個設計中有意思的是他的協程生命週期的控制,他的控制方式是每發送一個任務都進行 WaitGroup 加一,在最後結束時的 wait 函數中進行等待,等待所有的請求都處理完纔會關閉管道,返出錯誤。

tips:

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