Go error 處理最佳實踐
今天分享 go 語言 error 處理的最佳實踐,了解當(dāng)前 error 的缺點、妥協(xié)以及使用時注意事項。文章內(nèi)容較長,干貨也多,建義收藏
什么是 error
大家都知道 error[1] 是源代碼內(nèi)嵌的接口類型。根據(jù)導(dǎo)出原則,只有大寫的才能被其它源碼包引用,但是 error 屬于 predeclared identifiers 預(yù)定義的,并不是關(guān)鍵字,細(xì)節(jié)參考int make 居然不是關(guān)鍵字?
//?The?error?built-in?interface?type?is?the?conventional?interface?for
//?representing?an?error?condition,?with?the?nil?value?representing?no?error.
type?error?interface?{
?Error()?string
}
error 只有一個方法 Error() string 返回錯誤消息
//?New?returns?an?error?that?formats?as?the?given?text.
//?Each?call?to?New?returns?a?distinct?error?value?even?if?the?text?is?identical.
func?New(text?string)?error?{
?return?&errorString{text}
}
//?errorString?is?a?trivial?implementation?of?error.
type?errorString?struct?{
?s?string
}
func?(e?*errorString)?Error()?string?{
?return?e.s
}
一般我們創(chuàng)建 error 時只需要調(diào)用 errors.New("error from somewhere") 即可,底層就是一個字符串結(jié)構(gòu)體 errorStrings
當(dāng)前 error 有哪些問題
func?Test()?error?{
?if?err?:=?func1();?err?!=?nil?{
??return?err
?}
??......
}
這是常見的用法,也最被人詬病,很多人覺得不如 try-catch 用法簡潔,有人戲稱 go 源碼錯誤處理占一半
import?sys
try:
????f?=?open('myfile.txt')
????s?=?f.readline()
????i?=?int(s.strip())
except?OSError?as?err:
????print("OS?error:?{0}".format(err))
except?ValueError:
????print("Could?not?convert?data?to?an?integer.")
except?BaseException?as?err:
????print(f"Unexpected?{err=},?{type(err)=}")
????raise
比如上面是 python try-catch 的用法,先寫一堆邏輯,不處理異常,最后統(tǒng)一捕獲
let?mut?cfg?=?self.check_and_copy()?;
相比來說 rust Result 模式更簡潔,一個 ? 就代替了我們的操作。但是 error 的繁瑣判斷是當(dāng)前的痛點嘛?顯然不是,尤其喜歡 c 語言的人,反而喜歡每次都做判斷
在我看來 go 的痛點不是缺少泛型,不是 error 太挫,而是 GC 太弱,尤其對大內(nèi)存非常不友好,這方面可以參考真實環(huán)境下大內(nèi)存 Go 服務(wù)性能優(yōu)化一例
當(dāng)前 error 的問題有兩點:
無法 wrap 更多的信息,比如調(diào)用棧,比如層層封裝的 error 消息 無法很好的處理類型信息,比如我想知道錯誤是 io 類型的,還是 net 類型的
1.Wrap 更多的消息
這方面有很多輪子,最著名的就是 https://github.com/pkg/errors, 我司也重度使用,主要功能有三個:
Wrap封裝底層 error, 增加更多消息,提供調(diào)用棧信息,這是原生 error 缺少的WithMessage封裝底層 error, 增加更多消息,但不提供調(diào)用棧信息Cause返回最底層的 error, 剝?nèi)訉拥?wrap
import?(
???"database/sql"
???"fmt"
???"github.com/pkg/errors"
)
func?foo()?error?{
???return?errors.Wrap(sql.ErrNoRows,?"foo?failed")
}
func?bar()?error?{
???return?errors.WithMessage(foo(),?"bar?failed")
}
func?main()?{
???err?:=?bar()
???if?errors.Cause(err)?==?sql.ErrNoRows?{
??????fmt.Printf("data?not?found,?%v\n",?err)
??????fmt.Printf("%+v\n",?err)
??????return
???}
???if?err?!=?nil?{
??????//?unknown?error
???}
}
/*Output:
data?not?found,?bar?failed:?foo?failed:?sql:?no?rows?in?result?set
sql:?no?rows?in?result?set
foo?failed
main.foo
????/usr/three/main.go:11
main.bar
????/usr/three/main.go:15
main.main
????/usr/three/main.go:19
runtime.main
????...
*/
這是測試代碼,當(dāng)用 %v 打印時只有原始錯誤信息,%+v 時打印完整調(diào)用棧。當(dāng) go1.13 后,標(biāo)準(zhǔn)庫 errors 增加了 Wrap 方法
func?ExampleUnwrap()?{
?err1?:=?errors.New("error1")
?err2?:=?fmt.Errorf("error2:?[%w]",?err1)
?fmt.Println(err2)
?fmt.Println(errors.Unwrap(err2))
?//?Output
?//?error2:?[error1]
?//?error1
}
標(biāo)準(zhǔn)庫沒有提供增加調(diào)用棧的方法,fmt.Errorf 指定 %w 時可以 wrap error, 但整體來講,并沒有 https://github.com/pkg/errors 庫好用
2.錯誤類型
這個例子來自 ITNEXT[2]
import?(
???"database/sql"
???"fmt"
)
func?foo()?error?{
???return?sql.ErrNoRows
}
func?bar()?error?{
???return?foo()
}
func?main()?{
???err?:=?bar()
???if?err?==?sql.ErrNoRows?{
??????fmt.Printf("data?not?found,?%+v\n",?err)
??????return
???}
???if?err?!=?nil?{
??????//?Unknown?error
???}
}
//Outputs:
//?data?not?found,?sql:?no?rows?in?result?set
有時我們要處理類型信息,比如上面例子,判斷 err 如果是 sql.ErrNoRows 那么視為正常,data not found 而己,類似于 redigo 里面的 redigo.Nil 表示記錄不存在
func?foo()?error?{
???return?fmt.Errorf("foo?err,?%v",?sql.ErrNoRows)
}
但是如果 foo 把 error 做了一層 wrap 呢?這個時候錯誤還是 sql.ErrNoRows 嘛?肯定不是,這點沒有 python try-catch 錯誤處理強(qiáng)大,可以根據(jù)不同錯誤 class 做出判斷。那么 go 如何解決呢?答案是 go1.13 新增的 Is[3] 和 As
import?(
???"database/sql"
???"errors"
???"fmt"
)
func?bar()?error?{
???if?err?:=?foo();?err?!=?nil?{
??????return?fmt.Errorf("bar?failed:?%w",?foo())
???}
???return?nil
}
func?foo()?error?{
???return?fmt.Errorf("foo?failed:?%w",?sql.ErrNoRows)
}
func?main()?{
???err?:=?bar()
???if?errors.Is(err,?sql.ErrNoRows)?{
??????fmt.Printf("data?not?found,??%+v\n",?err)
??????return
???}
???if?err?!=?nil?{
??????//?unknown?error
???}
}
/*?Outputs:
data?not?found,??bar?failed:?foo?failed:?sql:?no?rows?in?result?set
*/
還是這個例子,errors.Is 會遞歸的 Unwrap err, 判斷錯誤是不是 sql.ErrNoRows,這里個小問題,Is 是做的指針地址判斷,如果錯誤 Error() 內(nèi)容一樣,但是根 error 是不同實例,那么 Is 判斷也是 false, 這點就很扯
func?ExampleAs()?{
?if?_,?err?:=?os.Open("non-existing");?err?!=?nil?{
??var?pathError?*fs.PathError
??if?errors.As(err,?&pathError)?{
???fmt.Println("Failed?at?path:",?pathError.Path)
??}?else?{
???fmt.Println(err)
??}
?}
?//?Output:
?//?Failed?at?path:?non-existing
}
errors.As[4] 判斷這個 err 是否是 fs.PathError 類型,遞歸調(diào)用層層查找,源碼后面再講解
另外一個判斷類型或是錯誤原因的就是 https://github.com/pkg/errors 庫提供的 errors.Cause
switch?err?:=?errors.Cause(err).(type)?{
case?*MyError:
????????//?handle?specifically
default:
????????//?unknown?error
}
在沒有 Is As 類型判斷時,需要很惡心的去判斷錯誤自符串
func?(conn?*cendolConnectionV5)?serve()?{
?//?Buffer?needs?to?be?preserved?across?messages?because?of?packet?coalescing.
?reader?:=?bufio.NewReader(conn.Connection)
?for?{
??msg,?err?:=?conn.readMessage(reader)
??if?err?!=?nil?{
???if?netErr,?ok?:=?strings.Contain(err.Error(),?"temprary");?ok???{
?????continue
???}
??}
??conn.processMessage(msg)
?}
}
想必接觸 go 比較早的人一定很熟悉,如果 conn 從網(wǎng)絡(luò)接受到的連接錯誤是 temporary 臨時的那么可以 continue 重試,當(dāng)然最好 backoff sleep 一下
當(dāng)然現(xiàn)在新增加了 net.Error 類型,實現(xiàn)了 Temporary 接口,不過也要廢棄了,請參考#45729[5]
源碼實現(xiàn)
1.github.com/pkg/errors 庫如何生成 warapper error
//?Wrap?returns?an?error?annotating?err?with?a?stack?trace
//?at?the?point?Wrap?is?called,?and?the?supplied?message.
//?If?err?is?nil,?Wrap?returns?nil.
func?Wrap(err?error,?message?string)?error?{
?if?err?==?nil?{
??return?nil
?}
?err?=?&withMessage{
??cause:?err,
??msg:???message,
?}
?return?&withStack{
??err,
??callers(),
?}
}
主要的函數(shù)就是 Wrap, 代碼實現(xiàn)比較簡單,查看如何追蹤調(diào)用??梢圆榭丛创a
2.github.com/pkg/errors 庫 Cause 實現(xiàn)
type?withStack?struct?{
?error
?*stack
}
func?(w?*withStack)?Cause()?error?{?return?w.error?}
func?Cause(err?error)?error?{
?type?causer?interface?{
??Cause()?error
?}
?for?err?!=?nil?{
??cause,?ok?:=?err.(causer)
??if?!ok?{
???break
??}
??err?=?cause.Cause()
?}
?return?err
}
Cause 遞歸調(diào)用,如果沒有實現(xiàn) causer 接口,那么就返回這個 err
3.官方庫如何生成一個 wrapper error
官方?jīng)]有這樣的函數(shù),而是 fmt.Errorf 格式化時使用 %w
e?:=?errors.New("this?is?a?error")
w?:=?fmt.Errorf("more?info?about?it?%w",?e)
func?Errorf(format?string,?a?...interface{})?error?{
?p?:=?newPrinter()
?p.wrapErrs?=?true
?p.doPrintf(format,?a)
?s?:=?string(p.buf)
?var?err?error
?if?p.wrappedErr?==?nil?{
??err?=?errors.New(s)
?}?else?{
??err?=?&wrapError{s,?p.wrappedErr}
?}
?p.free()
?return?err
}
func?(p?*pp)?handleMethods(verb?rune)?(handled?bool)?{
?if?p.erroring?{
??return
?}
?if?verb?==?'w'?{
??//?It?is?invalid?to?use?%w?other?than?with?Errorf,?more?than?once,
??//?or?with?a?non-error?arg.
??err,?ok?:=?p.arg.(error)
??if?!ok?||?!p.wrapErrs?||?p.wrappedErr?!=?nil?{
???p.wrappedErr?=?nil
???p.wrapErrs?=?false
???p.badVerb(verb)
???return?true
??}
??p.wrappedErr?=?err
??//?If?the?arg?is?a?Formatter,?pass?'v'?as?the?verb?to?it.
??verb?=?'v'
?}
??......
}
代碼也不難,handleMethods 時特殊處理 w, 使用 wrapError 封裝一下即可
4.官方庫 Unwrap 實現(xiàn)
func?Unwrap(err?error)?error?{
?u,?ok?:=?err.(interface?{
??Unwrap()?error
?})
?if?!ok?{
??return?nil
?}
?return?u.Unwrap()
}
也是遞歸調(diào)用,否則接口斷言失敗,返回 nil
type?wrapError?struct?{
?msg?string
?err?error
}
func?(e?*wrapError)?Error()?string?{
?return?e.msg
}
func?(e?*wrapError)?Unwrap()?error?{
?return?e.err
}
上文 fmt.Errof 時生成的 error 結(jié)構(gòu)體如上所示,Unwrap 直接返回底層 err
5.官方庫 Is As 實現(xiàn)
本段源碼分析來自 flysnow[6]
func?Is(err,?target?error)?bool?{
?if?target?==?nil?{
??return?err?==?target
?}
?isComparable?:=?reflectlite.TypeOf(target).Comparable()
?
?//for循環(huán),把err一層層剝開,一個個比較,找到就返回true
?for?{
??if?isComparable?&&?err?==?target?{
???return?true
??}
??//這里意味著你可以自定義error的Is方法,實現(xiàn)自己的比較代碼
??if?x,?ok?:=?err.(interface{?Is(error)?bool?});?ok?&&?x.Is(target)?{
???return?true
??}
??//剝開一層,返回被嵌套的err
??if?err?=?Unwrap(err);?err?==?nil?{
???return?false
??}
?}
}
Is 函數(shù)比較簡單,遞歸層層檢查,如果是嵌套 err, 那就調(diào)用 Unwrap 層層剝開找到最底層 err, 最后判斷指針是否相等
var?errorType?=?reflectlite.TypeOf((*error)(nil)).Elem()
func?As(err?error,?target?interface{})?bool?{
????//一些判斷,保證target,這里是不能為nil
?if?target?==?nil?{
??panic("errors:?target?cannot?be?nil")
?}
?val?:=?reflectlite.ValueOf(target)
?typ?:=?val.Type()
?
?//這里確保target必須是一個非nil指針
?if?typ.Kind()?!=?reflectlite.Ptr?||?val.IsNil()?{
??panic("errors:?target?must?be?a?non-nil?pointer")
?}
?
?//這里確保target是一個接口或者實現(xiàn)了error接口
?if?e?:=?typ.Elem();?e.Kind()?!=?reflectlite.Interface?&&?!e.Implements(errorType)?{
??panic("errors:?*target?must?be?interface?or?implement?error")
?}
?targetType?:=?typ.Elem()
?for?err?!=?nil?{
?????//關(guān)鍵部分,反射判斷是否可被賦予,如果可以就賦值并且返回true
?????//本質(zhì)上,就是類型斷言,這是反射的寫法
??if?reflectlite.TypeOf(err).AssignableTo(targetType)?{
???val.Elem().Set(reflectlite.ValueOf(err))
???return?true
??}
??//這里意味著你可以自定義error的As方法,實現(xiàn)自己的類型斷言代碼
??if?x,?ok?:=?err.(interface{?As(interface{})?bool?});?ok?&&?x.As(target)?{
???return?true
??}
??//這里是遍歷error鏈的關(guān)鍵,不停的Unwrap,一層層的獲取err
??err?=?Unwrap(err)
?}
?return?false
}
代碼同樣是遞歸調(diào)用 As, 同時 Unwrap 最底層的 error, 然后用反射判斷是否可以賦值,如果可以,那么說明是同一類型
ErrGroup 使用
提到 error 就必須要提一下 golang.org/x/sync/errgroup, 適用如下場景:并發(fā)場景下,如果一個 goroutine 有錯誤,那么就要提前返回,并取消其它并行的請求
func?ExampleGroup_justErrors()?{
?g?:=?new(errgroup.Group)
?var?urls?=?[]string{
??"http://www.golang.org/",
??"http://www.google.com/",
??"http://www.somestupidname.com/",
?}
?for?_,?url?:=?range?urls?{
??//?Launch?a?goroutine?to?fetch?the?URL.
??url?:=?url?//?https://golang.org/doc/faq#closures_and_goroutines
??g.Go(func()?error?{
???//?Fetch?the?URL.
???resp,?err?:=?http.Get(url)
???if?err?==?nil?{
????resp.Body.Close()
???}
???return?err
??})
?}
?//?Wait?for?all?HTTP?fetches?to?complete.
?if?err?:=?g.Wait();?err?==?nil?{
??fmt.Println("Successfully?fetched?all?URLs.")
?}
}
上面是官方給的例子,底層使用 context 來 cancel 其它請求,同步使用 WaitGroup, 原理非常簡單,代碼量非常少,感興趣的可以看源碼
這里一定要注意三點:
context是誰傳進(jìn)來的?其它代碼會不會用到,cancel 只能執(zhí)行一次,瞎比用會出問題g.Go不帶 recover 的,為了程序的健壯,一定要自行 recover并行的 goroutine 有一個錯誤就返回,而不是普通的 fan-out 請求后收集結(jié)果
線上實踐注意的幾個問題
1.error 與 panic
查看 go 源代碼會發(fā)現(xiàn),源碼很多地方寫 panic, 但是工程實踐,尤其業(yè)務(wù)代碼不要主動寫 panic
理論上 panic 只存在于 server 啟動階段,比如 config 文件解析失敗,端口監(jiān)聽失敗等等,所有業(yè)務(wù)邏輯禁止主動 panic
根據(jù) CAP 理論,當(dāng)前 web 互聯(lián)網(wǎng)最重要的是 AP, 高可用性才最關(guān)鍵(非銀行金融場景),程序啟動時如果有部分詞表,元數(shù)據(jù)加載失敗,都不能 panic, 提供服務(wù)才最關(guān)鍵,當(dāng)然要有報警,讓開發(fā)第一時間感知當(dāng)前服務(wù)了的 QOS 己經(jīng)降低
最后說一下,所有異步的 goroutine 都要用 recover 去兜底處理
2.錯誤處理與資源釋放
func?worker(done?chan?error)?{
????err?:=?doSomething()
????result?:=?&result{}
????if?err?!=?nil?{
????????result.Err?=?err
????}
????done?<-?result
}
一般異步組裝數(shù)據(jù),都要分別啟動 goroutine, 然后把結(jié)果通過 channel 返回,result 結(jié)構(gòu)體擁有 err 字段表示錯誤
這里要注意,main 函數(shù)中 done channel 千萬不能 close, 因為你不知道 doSomething 會超時多久返回,寫 closed channel 直接 panic
所以這里有一個準(zhǔn)則:數(shù)據(jù)傳輸和退出控制,需要用單獨的 channel 不能混, 我們一般用 context 取消異步 goroutine, 而不是直接 close channels
3.error 級聯(lián)使用問題
package?main
import?"fmt"
type?myError?struct?{
?string
}
func?(i?*myError)?Error()?string?{
?return?i.string
}
func?Call1()?error?{
?return?nil
}
func?Call2()?*myError?{
?return?nil
}
func?main()?{
?err?:=?Call1()
?if?err?!=?nil?{
??fmt.Printf("call1?is?not?nil:?%v\n",?err)
?}
?err?=?Call2()
?if?err?!=?nil?{
??fmt.Printf("call2?err?is?not?nil:?%v\n",?err)
?}
}
這個問題非常經(jīng)典,如果復(fù)用 err 變量的情況下, Call2 返回的 error 是自定義類型,此時 err 類型是不一樣的,導(dǎo)致經(jīng)典的 error is not nil, but value is nil
非常經(jīng)典的 Nil is not nil[7] 問題。解決方法就是 Call2 err 重新定義一個變量,當(dāng)然最簡單就是統(tǒng)一 error 類型。有點難,尤其是大型項目
4.并發(fā)問題
go 內(nèi)置類型除了 channel 大部分都是非線程安全的,error 也不例外,先看一個例子
package?main
import?(
???"fmt"
???"github.com/myteksi/hystrix-go/hystrix"
???"time"
)
var?FIRST?error?=?hystrix.CircuitError{Message:"timeout"}
var?SECOND?error?=?nil
func?main()?{
???var?err?error
???go?func()?{
??????i?:=?1
??????for?{
?????????i?=?1?-?i
?????????if?i?==?0?{
????????????err?=?FIRST
?????????}?else?{
????????????err?=?SECOND
?????????}
?????????time.Sleep(10)
??????}
???}()
???for?{
??????if?err?!=?nil?{
?????????fmt.Println(err.Error())
??????}
??????time.Sleep(10)
???}
}
運行之前,大家先猜下會發(fā)生什么???
zerun.dong$?go?run?panic.go
hystrix:?timeout
panic:?value?method?github.com/myteksi/hystrix-go/hystrix.CircuitError.Error?called?using?nil?*CircuitError?pointer
goroutine?1?[running]:
github.com/myteksi/hystrix-go/hystrix.(*CircuitError).Error(0x0,?0xc0000f4008,?0xc000088f40)
?:1?+0x86
main.main()
?/Users/zerun.dong/code/gotest/panic.go:25?+0x82
exit?status?2
上面是測試的例子,只要跑一會,就一定發(fā)生 panic, 本質(zhì)就是 error 接口類型不是并發(fā)安全的
//?沒有方法的interface
type?eface?struct?{
????_type?*_type
????data??unsafe.Pointer
}
//?有方法的interface
type?iface?struct?{
????tab??*itab
????data?unsafe.Pointer
}
所以不要并發(fā)對 error 賦值
5.error 要不要忽略
func?Test(){
?_?=?json.Marshal(xxxx)
?......
}
有的同學(xué)會有疑問,error 是否一定要處理?其實上面的 Marshal 都有可能失敗的
如果換成其它函數(shù),當(dāng)前實現(xiàn)可以忽略,不能保證以后還是兼容的邏輯,一定要處理 error,至少要打日志
6.errWriter
本例來自官方 blog[8], 有時我們想做 pipeline 處理,需要把 err 當(dāng)成結(jié)構(gòu)體變量
_,?err?=?fd.Write(p0[a:b])
if?err?!=?nil?{
????return?err
}
_,?err?=?fd.Write(p1[c:d])
if?err?!=?nil?{
????return?err
}
_,?err?=?fd.Write(p2[e:f])
if?err?!=?nil?{
????return?err
}
//?and?so?on
上面是原始例子,需要一直做 if err != nil 的判斷,官方優(yōu)化的寫法如下
type?errWriter?struct?{
????w???io.Writer
????err?error
}
func?(ew?*errWriter)?write(buf?[]byte)?{
????if?ew.err?!=?nil?{
????????return
????}
????_,?ew.err?=?ew.w.Write(buf)
}
//?使用時
ew?:=?&errWriter{w:?fd}
ew.write(p0[a:b])
ew.write(p1[c:d])
ew.write(p2[e:f])
//?and?so?on
if?ew.err?!=?nil?{
????return?ew.err
}
清晰簡潔,大家平時寫代碼可以多考濾一下
7.何時打印調(diào)用棧
官方庫無法 wrap 調(diào)用棧,所以 fmt.Errorf %w 不如 pkg/errors 庫實用,但是errors.Wrap 最好保證只調(diào)用一次,否則全是重復(fù)的調(diào)用棧
我們項目的使用情況是 log error 級別的打印棧,warn 和 info 都不打印,當(dāng)然 case by case 還得看實際使用情況
8.Wrap前做判斷
errors.Wrap(err,?"failed")
通過查看源碼,如果 err 為 nil 的時候,也會返回 nil. 所以 Wrap 前最好做下判斷,建議來自 xiaorui.cc
小結(jié)
上面提到的線上實踐注意的幾個問題,都是實際發(fā)生的坑,慘痛的教訓(xùn),大家一定要多體會下。錯誤處理涵蓋內(nèi)容非常廣,本文不涉及分布式系統(tǒng)的錯誤處理、gRPC 錯誤傳播以及錯誤管理
寫文章不容易,如果對大家有所幫助和啟發(fā),請大家?guī)兔c擊在看,點贊,分享 三連
關(guān)于 error 大家有什么看法,歡迎留言一起討論,大牛多留言 ^_^
參考資料
builting.go error interface: https://github.com/golang/go/blob/master/src/builtin/builtin.go#L260,
[2]ITNEXT: https://itnext.io/golang-error-handling-best-practice-a36f47b0b94c,
[3]errors.Is: https://github.com/golang/go/blob/master/src/errors/wrap.go#L40,
[4]errors.As example: https://github.com/golang/go/blob/master/src/errors/wrap_test.go#L255,
[5]#45729: https://github.com/golang/go/issues/45729,
[6]flysnow error 分析: https://www.flysnow.org/2019/09/06/go1.13-error-wrapping.html,
[7]Nil is not nil: https://yourbasic.org/golang/gotcha-why-nil-error-not-equal-nil/,
[8]errors are values: https://blog.golang.org/errors-are-values,
