3種方式!Go Error處理最佳實踐
https://mp.weixin.qq.com/s/rn51V4VwXYb46h8_ugafeg
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新增鍵值等。
-
人為製造被自動觸發的異常,比如:陣列越界,向空map新增鍵值對等。
-
手工觸發異常並終止異常,比如:連線資料庫失敗主動panic。
(三)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以外的處理邏輯。
-
case1: 如果業務邏輯不是很清楚,比較推薦case1;
-
case2: 程式碼很少去改動,類似標準庫,可以使用case2;
-
case3: 比較複雜的場景,複雜到抽象成一種設計模式。
三、分層下的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的重點參考物件。
-
錯誤要被日誌記錄;
-
應用程式處理錯誤,保證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
最終效果樣例:
關鍵點總結:
-
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。
(一)背景
通常,在寫業務程式碼效能優化時經常將一個通用的父任務拆成幾個小任務併發執行。此時需要將一個大的任務拆成幾個小任務併發執行,來提高QPS,我們需要再業務程式碼裡嵌入以下邏輯,但這種方式存在問題:
-
每個請求都開啟goroutinue,會有一定的效能開銷。
-
野生的goroutinue,生命週期管理比較困難。
-
收到類似SIGQUIT訊號時,無法平滑退出。
(二)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。
(三)使用案例
注意這裡有一個坑,在後面的程式碼中不要把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:
-
B站拓展包主要解決了官方ErrGroup的幾個痛點:控制併發量、Recover住協程的Panic並打出堆疊資訊。
-
Go方法併發的去呼叫在量很多的情況下會產生死鎖,因為他的切片不是執行緒安全的,如果要併發,併發數量一定不能過大,一旦動用了任務切片,那麼很有可能就在wait方法那裡hold住了。這個可以加個鎖來優化。
-
Wg watigroup只在Go方法中進行Add(),並沒有控制消費者的併發,Wait的邏輯就是分發者都分發完成,直接關閉管道,讓消費者併發池自行銷燬,不去管控,一旦邏輯中有完全hold住的方法那麼容易產生記憶體洩漏。
作者簡介
李森林
騰訊後臺工程師
騰訊後臺工程師,目前負責騰訊遊戲內容平臺的設計、開發和維護工作。