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 添加鍵值等
-
人爲製造被自動觸發的異常,比如:數組越界,向空 map 添加鍵值對等。
-
手工觸發異常並終止異常,比如:連接數據庫失敗主動 panic。
1.3 panic
對於真正意外的情況,那些表示不可恢復的程序錯誤,不可恢復才使用 panic。對於其他的錯誤情況,我們應該是期望使用 error 來進行判定
go 源代碼很多地方寫 panic, 但是工程實踐業務代碼不要主動寫 panic,理論上 panic 只存在於 server 啓動階段,比如 config 文件解析失敗,端口監聽失敗等等,所有業務邏輯禁止主動 panic,所有異步的 goroutine 都要用 recover 去兜底處理。
1.4 總結
-
理解了錯誤和異常的真正含義,我們就能理解 Go 的錯誤和異常處理的設計意圖。傳統的 try...catch... 結構,很容易讓開發人員把錯誤和異常混爲一談,甚至把業務錯誤處理的一部分當做異常來處理,於是你會在程序中看到一大堆的 catch...
-
Go 開發團隊認爲錯誤應該明確地當成業務的一部分,任何可以預見的問題都需要做錯誤處理,於是在 Go 代碼中,任何調用者在接收函數返回值的同時也需要對錯誤進行處理,以防遺漏任何運行時可能的錯誤
-
異常則是意料之外的,甚至你認爲在編碼中不可能發生的,Go 遇到異常會自動觸發 panic(恐慌),觸發 panic 程序會自動退出。除了程序自動觸發異常,一些你認爲不可允許的情況你也可以手動觸發異常
-
另外,在 Go 中除了觸發異常,還可以終止異常並可選的對異常進行錯誤處理,也就是說,錯誤和異常是可以相互轉換的
二、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 保存到對象內部,處理邏輯交給每個方法,本質上仍是順序執行。標準庫的
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
}
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 以外的處理邏輯。
-
case 1: 如果業務邏輯不是很清楚,比較推薦 case1
-
case 2: 代碼很少去改動,類似標準庫,可以使用 case2
-
case 3: 比較複雜的場景,複雜到抽象成一種設計模式
三、分層下的 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 的重點參考對象。
-
錯誤要被日誌記錄。
-
應用程序處理錯誤,保證 100% 完整性。
-
之後不再報告當前錯誤(錯誤只被處理一次)。
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
以常見的一個三層架構爲例:
- Dao 層使用 Wrap 上拋錯誤
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)
}
- Service 層追加信息
bills, err := a.Dao.GetName(ctx, param)
if err != nil {
return result, errors.WithMessage(err, "GetName failed")
}
- MiddleWare 統一打印錯誤日誌
// 請求響應組裝
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)
}
- 包內如果調用其他包內的函數,通常簡單的直接 return err
最終效果樣例:
3.4 關鍵點總結
-
MyError 作爲全局 error 的底層實現,保存具體的錯誤碼和錯誤信息;
-
MyError 向上返回錯誤時,第一次先用 Wrap 初始化堆棧,後續用 WithMessage 增加堆棧信息;
-
要判斷 error 是否爲指定的錯誤時,可以使用 errors.Cause 獲取 root error,再進行和 sentinel error 判定;
-
github.com/pkg/errors 和標準庫的 error 完全兼容,可以先替換、後續改造歷史遺留的代碼;
-
打印 error 的堆棧需要用 %+v,而原來的 %v 依舊爲普通字符串方法;同時也要注意日誌採集工具是否支持多行匹配;
-
log error 級別的打印棧,warn 和 info 可不打印堆棧;
-
可結合統一錯誤碼使用: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,我們需要再業務代碼裏嵌入以下邏輯,但這種方式存在問題:
-
每個請求都開啓 goroutinue,會有一定的性能開銷。
-
野生的 goroutinue,生命週期管理比較困難。
-
收到類似 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 結構體
-
通過 WithContext 可以創建一個帶取消的 Group
-
當然除此之外也可以零值的 Group 也可以直接使用,但是出錯之後就不會取消其他的 goroutine 了
-
Go 方法傳入一個 func() error 內部會啓動一個 goroutine 去處理
-
Wait 類似 WaitGroup 的 Wait 方法,等待所有的 goroutine 結束後退出,返回的錯誤是一個出錯的 err
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 站的結構多出了一個函數簽名管道和一個函數簽名切片, 並把 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:
-
B 站拓展包主要解決了官方 ErrGroup 的幾個痛點 1. 控制併發量。2.Recover 住協程的 Panic 並打出堆棧信息。
-
Go 方法併發的去調用在量很多的情況下會產生死鎖,因爲他的切片不是線程安全的,如果要併發,併發數量一定不能過大,一旦動用了任務切片,那麼很有可能就在 wait 方法那裏 hold 住了。這個可以加個鎖來優化。
-
Wg watigroup 只在 Go 方法中進行 Add(),並沒有控制消費者的併發,Wait 的邏輯就是分發者都分發完成,直接關閉管道,讓消費者併發池自行銷燬,不去管控,一旦邏輯中有完全 hold 住的方法那麼容易產生內存泄漏。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/EvkMQCPwg-B0fZonpwXodg