3 種方式!Go Error 處理最佳實踐

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

一、錯誤與異常

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

(二)Exception

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

(三)panic

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

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

(四)總結

理解了錯誤和異常的真正含義,我們就能理解 Go 的錯誤和異常處理的設計意圖。傳統的 try...catch... 結構,很容易讓開發人員把錯誤和異常混爲一談,甚至把業務錯誤處理的一部分當做異常來處理,於是你會在程序中看到一大堆的 catch...

Go 開發團隊認爲錯誤應該明確地當成業務的一部分,任何可以預見的問題都需要做錯誤處理,於是在 Go 代碼中,任何調用者在接收函數返回值的同時也需要對錯誤進行處理,以防遺漏任何運行時可能的錯誤。

異常則是意料之外的,甚至你認爲在編碼中不可能發生的,Go 遇到異常會自動觸發 panic(恐慌),觸發 panic 程序會自動退出。除了程序自動觸發異常,一些你認爲不可允許的情況你也可以手動觸發異常。

另外,在 Go 中除了觸發異常,還可以終止異常並可選的對異常進行錯誤處理,也就是說,錯誤和異常是可以相互轉換的。

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

(一)經典 Go 邏輯

直觀的返回 error:

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
}

(二)屏蔽過程中的 error 的處理

將 error 保存到對象內部,處理邏輯交給每個方法,本質上仍是順序執行。標準庫的 bufio、database/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
}

(三)利用函數式編程延遲運行

分離關注點 - 遍歷訪問用數據結構定義運行順序,根據場景選擇,如順序、逆序、二叉樹樹遍歷等。運行邏輯將代碼的控制流邏輯抽離,靈活調整。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繼續執行  
      }
    }
}

(四)三種方式對比

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

三、分層下的 Error Handling

(一)一個常見的三層調用

在工程實踐中,以一個常見的三層架構(dao->service->controller)爲例,我們常見的錯誤處理方式大致如下:

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

(二)問題總結

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

如果和其他庫進行協作,考慮使用 errors.Wrap 或者 errors.Wrapf 保存堆棧信息。同樣適用於和標準庫協作的時候。

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

最終效果樣例:

關鍵點總結:

四、errgroup 集中錯誤處理

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

(一)背景

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

(二)errgroup 函數簽名

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

整個包就一個 Group 結構體:

(三)使用案例

注意這裏有一個坑,在後面的代碼中不要把 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)
   }
}

(四)errgroup 拓展包

B 站拓展包

(https://github.com/go-kratos/kratos/blob/v0.3.3/pkg/sync/errgroup/errgroup.go)

相比官方的結構,B 站的結構多出了一個函數簽名管道和一個函數簽名切片, 並把 Context 直接放入了返回的 Group 結構,返回僅返回一個 Group 結構指針。

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

Go 方法可以看出並不是直接起協程的(如果管道已經初始化好了),而是優先將函數簽名放入管道,管道如果滿了就放入切片。

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

GOMAXPROCS 函數其實是起了一個併發池來控制協程數量,傳入最大協程數量進行併發消費管道里的函數簽名:

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/ibGCUovoDHY2H9aa5jnDfA