3種方式!Go Error處理最佳實(shí)踐
導(dǎo)語?|
?
錯(cuò)誤處理一直以一是編程必需要面對(duì)的問題,錯(cuò)誤處理如果做的好的話,代碼的穩(wěn)定性會(huì)
很好。不同的語言有不同的出現(xiàn)處理的方式。Go語言也一樣,在本篇文章中,我們來討論一下Go語言的錯(cuò)誤處理方式。
一、錯(cuò)誤與異常
(一) Error
錯(cuò)誤是程序中可能出現(xiàn)的問題,比如連接數(shù)據(jù)庫失敗,連接網(wǎng)絡(luò)失敗等,在程序設(shè)計(jì)中,錯(cuò)誤處理是業(yè)務(wù)的一部分。
Go內(nèi)建一個(gè)error接口類型作為go的錯(cuò)誤標(biāo)準(zhǔn)處理
http://golang.org/pkg/builtin/#error
// 接口定義
type error interface {
Error() string
}
http://golang.org/src/pkg/errors/errors.go
// 實(shí)現(xiàn)
func New(text string) error {
return &errorString{text}
}
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
(二)Exception
異常是指在不該出現(xiàn)問題的地方出現(xiàn)問題,是預(yù)料之外的,比如空指針引用,下標(biāo)越界,向空map添加鍵值等。
-
人為制造被自動(dòng)觸發(fā)的異常,比如:數(shù)組越界,向空map添加鍵值對(duì)等。
-
手工觸發(fā)異常并終止異常,比如:連接數(shù)據(jù)庫失敗主動(dòng)panic。
(三)panic
對(duì)于真正意外的情況,那些表示不可恢復(fù)的程序錯(cuò)誤,不可恢復(fù)才使用panic。對(duì)于其他的錯(cuò)誤情況,我們應(yīng)該是期望使用error來進(jìn)行判定。
go源代碼很多地方寫panic, 但是工程實(shí)踐業(yè)務(wù)代碼不要主動(dòng)寫panic,理論上panic只存在于server啟動(dòng)階段,比如config文件解析失敗,端口監(jiān)聽失敗等等,所有業(yè)務(wù)邏輯禁止主動(dòng)panic,所有異步的goroutine都要用recover去兜底處理。
(四)總結(jié)
理解了錯(cuò)誤和異常的真正含義,我們就能理解Go的錯(cuò)誤和異常處理的設(shè)計(jì)意圖。傳統(tǒng)的try...catch...結(jié)構(gòu),很容易讓開發(fā)人員把錯(cuò)誤和異常混為一談,甚至把業(yè)務(wù)錯(cuò)誤處理的一部分當(dāng)做異常來處理,于是你會(huì)在程序中看到一大堆的catch...
Go開發(fā)團(tuán)隊(duì)認(rèn)為錯(cuò)誤應(yīng)該明確地當(dāng)成業(yè)務(wù)的一部分,任何可以預(yù)見的問題都需要做錯(cuò)誤處理,于是在Go代碼中,任何調(diào)用者在接收函數(shù)返回值的同時(shí)也需要對(duì)錯(cuò)誤進(jìn)行處理,以防遺漏任何運(yùn)行時(shí)可能的錯(cuò)誤。
異常則是意料之外的,甚至你認(rèn)為在編碼中不可能發(fā)生的,Go遇到異常會(huì)自動(dòng)觸發(fā)panic(恐慌),觸發(fā)panic程序會(huì)自動(dòng)退出。除了程序自動(dòng)觸發(fā)異常,一些你認(rèn)為不可允許的情況你也可以手動(dòng)觸發(fā)異常。
另外,在Go中除了觸發(fā)異常,還可以終止異常并可選的對(duì)異常進(jìn)行錯(cuò)誤處理,也就是說,錯(cuò)誤和異常是可以相互轉(zhuǎn)換的。
二、Go處理錯(cuò)誤的三種方式
(一)經(jīng)典Go邏輯
直觀的返回error:
type ZooTour interface {
Enter() error
VisitPanda(panda *Panda) error
Leave() error
}
// 分步處理,每個(gè)步驟可以針對(duì)具體返回結(jié)果進(jìn)行處理
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保存到對(duì)象內(nèi)部,處理邏輯交給每個(gè)方法,本質(zhì)上仍是順序執(zhí)行。標(biāo)準(zhǔn)庫的bufio、database/sql包中的Rows等都是這樣實(shí)現(xiàn)的,有興趣可以去看下源碼:
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()
// 集中編寫業(yè)務(wù)邏輯代碼,最后統(tǒng)一處理error
if err := t.Err(); err != nil {
return errors.WithMessage(err, "ZooTour failed")
}
return nil
}
(三)利用函數(shù)式編程延遲運(yùn)行
分離關(guān)注點(diǎn)-遍歷訪問用數(shù)據(jù)結(jié)構(gòu)定義運(yùn)行順序,根據(jù)場景選擇,如順序、逆序、二叉樹樹遍歷等。運(yùn)行邏輯將代碼的控制流邏輯抽離,靈活調(diào)整。kubernetes中的visitor對(duì)此就有很多種擴(kuò)展方式,分離了數(shù)據(jù)和行為,有興趣可以去擴(kuò)展閱讀:
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 {
// 遇到錯(cuò)誤break或者continue繼續(xù)執(zhí)行
}
}
}
(四)三種方式對(duì)比
上面這三個(gè)例子,是Go項(xiàng)目處理錯(cuò)誤使用頻率最高的三種方式,也可以應(yīng)用在error以外的處理邏輯。
-
case1: 如果業(yè)務(wù)邏輯不是很清楚,比較推薦case1;
-
case2: 代碼很少去改動(dòng),類似標(biāo)準(zhǔn)庫,可以使用case2;
-
case3: 比較復(fù)雜的場景,復(fù)雜到抽象成一種設(shè)計(jì)模式。
三、分層下的Error Handling
(一)一個(gè)常見的三層調(diào)用
在工程實(shí)踐中,以一個(gè)常見的三層架構(gòu)(dao->service->controller)為例,我們常見的錯(cuò)誤處理方式大致如下:
// 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)
}
(二)問題總結(jié)
-
分層開發(fā)導(dǎo)致的處處打印日志;
-
難以獲取詳細(xì)的堆棧關(guān)聯(lián);
-
根因丟失。
(三)Wrap erros
Go相關(guān)的錯(cuò)誤處理方法很多,但大多為過渡方案,這里就不一一分析了(類似 github.com/juju/errors 庫,有興趣可以了解)。這里我以 github.com/pkg/errors 為例,這個(gè)也是官方Proposal的重點(diǎn)參考對(duì)象。
-
錯(cuò)誤要被日志記錄;
-
應(yīng)用程序處理錯(cuò)誤,保證100%完整性;
-
之后不再報(bào)告當(dāng)前錯(cuò)誤(錯(cuò)誤只被處理一次)。
github.com/pkg/errors包主要包含以下幾個(gè)方法,如果我們要新生成一個(gè)錯(cuò)誤,可以使用New函數(shù),生成的錯(cuò)誤,自帶調(diào)用堆棧信息。如果有一個(gè)現(xiàn)成的error ,我們需要對(duì)他進(jìn)行再次包裝處理,這時(shí)候有三個(gè)函數(shù)可以選擇(WithMessage/WithStack/Wrapf)。其次,如果需要對(duì)源錯(cuò)誤類型進(jìn)行自定義判斷可以使用Cause,可以獲得最根本的錯(cuò)誤原因。
// 新生成一個(gè)錯(cuò)誤, 帶堆棧信息
func New(message string) error
// 只附加新的信息
func WithMessage(err error, message string) error
// 只附加調(diào)用堆棧信息
func WithStack(err error) error
// 同時(shí)附加堆棧和信息
func Wrapf(err error, format string, args ...interface{}) error
// 獲得最根本的錯(cuò)誤原因
func Cause(err error) error
以常見的一個(gè)三層架構(gòu)為例:

-
Dao層使用Wrap上拋錯(cuò)誤
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統(tǒng)一打印錯(cuò)誤日志
// 請(qǐng)求響應(yīng)組裝
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
}
}
// 獲取錯(cuò)誤碼
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)
}
-
和其他庫進(jìn)行協(xié)作
如果和其他庫進(jìn)行協(xié)作,考慮使用errors.Wrap或者errors.Wrapf保存堆棧信息。同樣適用于和標(biāo)準(zhǔn)庫協(xié)作的時(shí)候。
_, err := os.Open(path)
if err != nil {
return errors.Wrapf(err, "Open failed. [%s]", path)
}
-
包內(nèi)如果調(diào)用其他包內(nèi)的函數(shù),通常簡單的直接return err
最終效果樣例:

關(guān)鍵點(diǎn)總結(jié):
-
MyError作為全局error的底層實(shí)現(xiàn),保存具體的錯(cuò)誤碼和錯(cuò)誤信息;
-
MyError向上返回錯(cuò)誤時(shí),第一次先用Wrap初始化堆棧,后續(xù)用WithMessage增加堆棧信息;
-
要判斷error是否為指定的錯(cuò)誤時(shí),可以使用errors.Cause獲取root error,再進(jìn)行和sentinel error判定;
-
github.com/pkg/errors和標(biāo)準(zhǔn)庫的error完全兼容,可以先替換、后續(xù)改造歷史遺留的代碼;
-
打印error的堆棧需要用%+v,而原來的%v依舊為普通字符串方法;同時(shí)也要注意日志采集工具是否支持多行匹配;
-
log error級(jí)別的打印棧,warn和info可不打印堆棧;
-
可結(jié)合統(tǒng)一錯(cuò)誤碼使用:
https://google-cloud.gitbook.io/api-design-guide/errors
四、errgroup集中錯(cuò)誤處理
官方的ErrGroup非常簡單,其實(shí)就是解決小型多任務(wù)并發(fā)任務(wù)。基本用法golang.org/x/sync/errgroup包下定義了一個(gè)Group struct,它就是我們要介紹的ErrGroup并發(fā)原語,底層也是基于WaitGroup實(shí)現(xiàn)的。在使用ErrGroup時(shí),我們要用到三個(gè)方法,分別是WithContext、Go和Wait。
(一)背景
通常,在寫業(yè)務(wù)代碼性能優(yōu)化時(shí)經(jīng)常將一個(gè)通用的父任務(wù)拆成幾個(gè)小任務(wù)并發(fā)執(zhí)行。此時(shí)需要將一個(gè)大的任務(wù)拆成幾個(gè)小任務(wù)并發(fā)執(zhí)行,來提高QPS,我們需要再業(yè)務(wù)代碼里嵌入以下邏輯,但這種方式存在問題:
-
每個(gè)請(qǐng)求都開啟goroutinue,會(huì)有一定的性能開銷。
-
野生的goroutinue,生命周期管理比較困難。
-
收到類似SIGQUIT信號(hào)時(shí),無法平滑退出。
(二)errgroup函數(shù)簽名
type Group
func WithContext(ctx context.Context) (*Group, context.Context)
func (g *Group) Go(f func() error)
func (g *Group) Wait() error
整個(gè)包就一個(gè)Group結(jié)構(gòu)體:
-
通過WithContext可以創(chuàng)建一個(gè)帶取消的Group;
-
當(dāng)然除此之外也可以零值的Group也可以直接使用,但是出錯(cuò)之后就不會(huì)取消其他的goroutine了;
-
Go方法傳入一個(gè)func() error內(nèi)部會(huì)啟動(dòng)一個(gè)goroutine去處理;
-
Wait類似WaitGroup的Wait方法,等待所有的goroutine結(jié)束后退出,返回的錯(cuò)誤是一個(gè)出錯(cuò)的err。
(三)使用案例
注意這里有一個(gè)坑,在后面的代碼中不要把ctx當(dāng)做父 context又傳給下游,因?yàn)閑rrgroup取消了,這個(gè)context就沒用了,會(huì)導(dǎo)致下游復(fù)用的時(shí)候出錯(cuò)
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)
相比官方的結(jié)構(gòu),B站的結(jié)構(gòu)多出了一個(gè)函數(shù)簽名管道和一個(gè)函數(shù)簽名切片,并把Context直接放入了返回的Group結(jié)構(gòu),返回僅返回一個(gè)Group結(jié)構(gòu)指針。
?
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方法可以看出并不是直接起協(xié)程的(如果管道已經(jīng)初始化好了),而是優(yōu)先將函數(shù)簽名放入管道,管道如果滿了就放入切片。
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函數(shù)其實(shí)是起了一個(gè)并發(fā)池來控制協(xié)程數(shù)量,傳入最大協(xié)程數(shù)量進(jìn)行并發(fā)消費(fèi)管道里的函數(shù)簽名:
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)
}
}()
}
})
}
整個(gè)流程梳理下來其實(shí)就是啟動(dòng)一個(gè)固定數(shù)量的并發(fā)池消費(fèi)任務(wù),Go函數(shù)其實(shí)是向管道中發(fā)送任務(wù)的生產(chǎn)者,這個(gè)設(shè)計(jì)中有意思的是他的協(xié)程生命周期的控制,他的控制方式是每發(fā)送一個(gè)任務(wù)都進(jìn)行WaitGroup加一,在最后結(jié)束時(shí)的wait函數(shù)中進(jìn)行等待,等待所有的請(qǐng)求都處理完才會(huì)關(guān)閉管道,返出錯(cuò)誤。
tips :
-
B站拓展包主要解決了官方ErrGroup的幾個(gè)痛點(diǎn): 控 制并發(fā)量、 Recover住協(xié)程的Panic并打出堆棧信息。
-
Go方法并發(fā)的去調(diào)用在量很多的情況下會(huì)產(chǎn)生死鎖,因?yàn)樗那衅皇蔷€程安全的,如果要并發(fā),并發(fā)數(shù)量一定不能過大,一旦動(dòng)用了任務(wù)切片,那么很有可能就在wait方法那里hold住了。這個(gè)可以加個(gè)鎖來優(yōu)化。
-
Wg watigroup只在Go方法中進(jìn)行Add(),并沒有控制消費(fèi)者的并發(fā),Wait的邏輯就是分發(fā)者都分發(fā)完成,直接關(guān)閉管道,讓消費(fèi)者并發(fā)池自行銷毀,不去管控,一旦邏輯中有完全hold住的方法那么容易產(chǎn)生內(nèi)存泄漏。
?作者簡介

李森林
騰訊后臺(tái)工程師
騰訊后臺(tái)工程師,目前負(fù)責(zé)騰訊游戲內(nèi)容平臺(tái)的設(shè)計(jì)、開發(fā)和維護(hù)工作。
推薦閱讀
我為大家整理了一份 從入門到進(jìn)階的Go學(xué)習(xí)資料禮包 ,包含學(xué)習(xí)建議:入門看什么,進(jìn)階看什么。 關(guān)注公眾號(hào) 「polarisxu」,回復(fù)? ebook ?獲取;還可以回復(fù)「進(jìn)群」,和數(shù)萬 Gopher 交流學(xué)習(xí)。
