Go error 處理最佳實踐
今天分享 go 語言 error 處理的最佳實踐,瞭解當前 error 的缺點、妥協以及使用時注意事項。文章內容較長,乾貨也多,建義收藏
什麼是 error
大家都知道 error[1] 是源代碼內嵌的接口類型。根據導出原則,只有大寫的才能被其它源碼包引用,但是 error 屬於 predeclared identifiers 預定義的,並不是關鍵字,細節參考 int make 居然不是關鍵字?
// 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
}
一般我們創建 error 時只需要調用 errors.New("error from somewhere")
即可,底層就是一個字符串結構體 errorString
s
當前 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
的用法,先寫一堆邏輯,不處理異常,最後統一捕獲
let mut cfg = self.check_and_copy()?;
相比來說 rust Result 模式更簡潔,一個 ? 就代替了我們的操作。但是 error 的繁瑣判斷是當前的痛點嘛?顯然不是,尤其喜歡 c 語言的人,反而喜歡每次都做判斷
在我看來 go 的痛點不是缺少泛型,不是 error 太挫,而是 GC 太弱,尤其對大內存非常不友好,這方面可以參考真實環境下大內存 Go 服務性能優化一例
當前 error 的問題有兩點:
-
無法 wrap 更多的信息,比如調用棧,比如層層封裝的 error 消息
-
無法很好的處理類型信息,比如我想知道錯誤是 io 類型的,還是 net 類型的
1.Wrap 更多的消息
這方面有很多輪子,最著名的就是 https://github.com/pkg/errors
, 我司也重度使用,主要功能有三個:
-
Wrap
封裝底層 error, 增加更多消息,提供調用棧信息,這是原生 error 缺少的 -
WithMessage
封裝底層 error, 增加更多消息,但不提供調用棧信息 -
Cause
返回最底層的 error, 剝去層層的 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
...
*/
這是測試代碼,當用 %v
打印時只有原始錯誤信息,%+v
時打印完整調用棧。當 go1.13 後,標準庫 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
}
標準庫沒有提供增加調用棧的方法,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
錯誤處理強大,可以根據不同錯誤 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()
內容一樣,但是根 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
類型,遞歸調用層層查找,源碼後面再講解
另外一個判斷類型或是錯誤原因的就是 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 從網絡接受到的連接錯誤是 temporary
臨時的那麼可以 continue 重試,當然最好 backoff sleep 一下
當然現在新增加了 net.Error
類型,實現了 Temporary
接口,不過也要廢棄了,請參考#45729[5]
源碼實現
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(),
}
}
主要的函數就是 Wrap
, 代碼實現比較簡單,查看如何追蹤調用棧可以查看源碼
2.github.com/pkg/errors
庫 Cause
實現
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
遞歸調用,如果沒有實現 causer
接口,那麼就返回這個 err
3. 官方庫如何生成一個 wrapper error
官方沒有這樣的函數,而是 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
實現
func Unwrap(err error) error {
u, ok := err.(interface {
Unwrap() error
})
if !ok {
return nil
}
return u.Unwrap()
}
也是遞歸調用,否則接口斷言失敗,返回 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 結構體如上所示,Unwrap
直接返回底層 err
5. 官方庫 Is
As
實現
本段源碼分析來自 flysnow[6]
func Is(err, target error) bool {
if target == nil {
return err == target
}
isComparable := reflectlite.TypeOf(target).Comparable()
//for循環,把err一層層剝開,一個個比較,找到就返回true
for {
if isComparable && err == target {
return true
}
//這裏意味着你可以自定義error的Is方法,實現自己的比較代碼
if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
return true
}
//剝開一層,返回被嵌套的err
if err = Unwrap(err); err == nil {
return false
}
}
}
Is
函數比較簡單,遞歸層層檢查,如果是嵌套 err, 那就調用 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是一個接口或者實現了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 {
//關鍵部分,反射判斷是否可被賦予,如果可以就賦值並且返回true
//本質上,就是類型斷言,這是反射的寫法
if reflectlite.TypeOf(err).AssignableTo(targetType) {
val.Elem().Set(reflectlite.ValueOf(err))
return true
}
//這裏意味着你可以自定義error的As方法,實現自己的類型斷言代碼
if x, ok := err.(interface{ As(interface{}) bool }); ok && x.As(target) {
return true
}
//這裏是遍歷error鏈的關鍵,不停的Unwrap,一層層的獲取err
err = Unwrap(err)
}
return false
}
代碼同樣是遞歸調用 As
, 同時 Unwrap
最底層的 error, 然後用反射判斷是否可以賦值,如果可以,那麼說明是同一類型
ErrGroup 使用
提到 error 就必須要提一下 golang.org/x/sync/errgroup
, 適用如下場景:併發場景下,如果一個 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
是誰傳進來的?其它代碼會不會用到,cancel 只能執行一次,瞎比用會出問題 -
g.Go
不帶 recover 的,爲了程序的健壯,一定要自行 recover -
並行的 goroutine 有一個錯誤就返回,而不是普通的 fan-out 請求後收集結果
線上實踐注意的幾個問題
1.error 與 panic
查看 go 源代碼會發現,源碼很多地方寫 panic, 但是工程實踐,尤其業務代碼不要主動寫 panic
理論上 panic 只存在於 server 啓動階段,比如 config 文件解析失敗,端口監聽失敗等等,所有業務邏輯禁止主動 panic
根據 CAP
理論,當前 web 互聯網最重要的是 AP
, 高可用性才最關鍵 (非銀行金融場景),程序啓動時如果有部分詞表,元數據加載失敗,都不能 panic, 提供服務才最關鍵,當然要有報警,讓開發第一時間感知當前服務了的 QOS 己經降低
最後說一下,所有異步的 goroutine 都要用 recover
去兜底處理
2. 錯誤處理與資源釋放
func worker(done chan error) {
err := doSomething()
result := &result{}
if err != nil {
result.Err = err
}
done <- result
}
一般異步組裝數據,都要分別啓動 goroutine, 然後把結果通過 channel 返回,result 結構體擁有 err 字段表示錯誤
這裏要注意,main 函數中 done
channel 千萬不能 close, 因爲你不知道 doSomething
會超時多久返回,寫 closed channel 直接 panic
所以這裏有一個準則:數據傳輸和退出控制,需要用單獨的 channel 不能混, 我們一般用 context 取消異步 goroutine, 而不是直接 close channels
3.error 級聯使用問題
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)
}
}
這個問題非常經典,如果複用 err 變量的情況下, Call2 返回的 error 是自定義類型,此時 err 類型是不一樣的,導致經典的 error is not nil, but value is nil
非常經典的 Nil is not nil[7] 問題。解決方法就是 Call2
err 重新定義一個變量,當然最簡單就是統一 error 類型。有點難,尤其是大型項目
4. 併發問題
go 內置類型除了 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)
}
}
運行之前,大家先猜下會發生什麼???
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)
<autogenerated>:1 +0x86
main.main()
/Users/zerun.dong/code/gotest/panic.go:25 +0x82
exit status 2
上面是測試的例子,只要跑一會,就一定發生 panic, 本質就是 error 接口類型不是併發安全的
// 沒有方法的interface
type eface struct {
_type *_type
data unsafe.Pointer
}
// 有方法的interface
type iface struct {
tab *itab
data unsafe.Pointer
}
所以不要併發對 error 賦值
5.error 要不要忽略
func Test(){
_ = json.Marshal(xxxx)
......
}
有的同學會有疑問,error 是否一定要處理?其實上面的 Marshal
都有可能失敗的
如果換成其它函數,當前實現可以忽略,不能保證以後還是兼容的邏輯,一定要處理 error,至少要打日誌
6.errWriter
本例來自官方 blog[8], 有時我們想做 pipeline 處理,需要把 err 當成結構體變量
_, 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
的判斷,官方優化的寫法如下
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. 何時打印調用棧
官方庫無法 wrap 調用棧,所以 fmt.Errorf %w
不如 pkg/errors
庫實用,但是errors.Wrap
最好保證只調用一次,否則全是重複的調用棧
我們項目的使用情況是 log error 級別的打印棧,warn 和 info 都不打印,當然 case by case 還得看實際使用情況
8.Wrap 前做判斷
errors.Wrap(err, "failed")
通過查看源碼,如果 err 爲 nil 的時候,也會返回 nil. 所以 Wrap
前最好做下判斷,建議來自 xiaorui.cc
小結
上面提到的線上實踐注意的幾個問題,都是實際發生的坑,慘痛的教訓,大家一定要多體會下。錯誤處理涵蓋內容非常廣,本文不涉及分佈式系統的錯誤處理、gRPC 錯誤傳播以及錯誤管理
關於 error
大家有什麼看法,歡迎留言一起討論,大牛多留言 ^_^
參考資料
[1] 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,
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/XojOIIZfKm_wXul9eSU1tQ