目錄
- error 的困局
- 嘗試破局
- Errors are just values
- handle not just check errors
- Only handle errors once
- 小結
- 胎死腹中的 try 提案
- go 1.13 的改進
- fmt.Errorf
- Unwrap
- Is
- As
- 總結
- 參考資料
寫過 C 的同學知道,C 語言中常常回傳整數錯誤碼(errno)來表示函式處理出錯,通常用 -1 來表示錯誤,用 0 表示正確,
而在 Go 中,我們使用 error 型別來表示錯誤,不過它不再是一個整數型別,是一個介面型別:
type error interface {
Error() string
}
它表示那些能用一個字串就能說清的錯誤,
我們最常用的就是 errors.New() 函式,非常簡單:
// src/errors/errors.go
func New(text string) error {
return &errorString{text}
}
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
使用 New 函式創建出來的 error 型別實際上是 errors 包里未匯出的 errorString 型別,它包含唯一的一個欄位 s,并且實作了唯一的方法:Error() string,
通常這就夠了,它能反映當時“出錯了”,但是有些時候我們需要更加具體的資訊,例如:
func Sqrt(f float64) (float64, error) {
if f < 0 {
return 0, errors.New("math: square root of negative number")
}
// implementation
}
當呼叫者發現出錯的時候,只知道傳入了一個負數進來,并不清楚到底傳的是什么值,在 Go 里:
It is the error implementation's responsibility to summarize the context.
它要求回傳這個錯誤的函式要給出具體的“背景關系”資訊,也就是說,在 Sqrt 函式里,要給出這個負數到底是什么,
所以,如果發現 f 小于 0,應該這樣回傳錯誤:
if f < 0 {
return 0, fmt.Errorf("math: square root of negative number %g", f)
}
這就用到了 fmt.Errorf 函式,它先將字串格式化,再呼叫 errors.New 函式來創建錯誤,
當我們想知道錯誤型別,并且列印錯誤的時候,直接列印 error:
fmt.Println(err)
或者:
fmt.Println(err.Error)
fmt 包會自動呼叫 err.Error() 函式來列印字串,
通常,我們將 error 放到函式回傳值的最后一個,沒什么好說的,大家都這樣做,約定俗成,
參考資料【Tony Bai】這篇文章提到,構造 error 的時候,要求傳入的字串首字母小寫,結尾不帶標點符號,這是因為我們經常會這樣使用回傳的 error:
... err := errors.New("error example")
fmt.Printf("The returned error is %s.\n", err)
error 的困局
In Go, error handling is important. The language's design and conventions encourage you to explicitly check for errors where they occur (as distinct from the convention in other languages of throwing exceptions and sometimes catching them).
在 Go 語言中,錯誤處理是非常重要的,它從語言層面要求我們需要明確地處理遇到的錯誤,而不是像其他語言,類如 Java,使用 try-catch- finally 這種“把戲”,
這就造成代碼里 “error” 滿天飛,顯得非常冗長拖沓,
而為了代碼健壯性考慮,對于函式回傳的每一個錯誤,我們都不能忽略它,因為出錯的同時,很可能會回傳一個 nil 型別的物件,如果不對錯誤進行判斷,那下一行對 nil 物件的操作百分之百會引發一個 panic,
這樣,Go 語言中詬病最多的就是它的錯誤處理方式似憾訓到了上古 C 語言時代,
rr := doStuff1()
if err != nil {
//handle error...
}
err = doStuff2()
if err != nil {
//handle error...
}
err = doStuff3()
if err != nil {
//handle error...
}
Go authors 之一的 Russ Cox 對于這種觀點進行過駁斥:當初選擇回傳值這種錯誤處理機制而不是 try-catch,主要是考慮前者適用于大型軟體,后者更適合小程式,
在參考資料【Go FAQ】里也提到,try-catch 會讓代碼變得非常混亂,程式員會傾向將一些常見的錯誤,例如,failing to open a file,也拋到例外里,這會讓錯誤處理更加冗長繁瑣且易出錯,
而 Go 語言的多回傳值使得回傳錯誤例外簡單,對于真正的例外,Go 提供 panic-recover 機制,也使得代碼看起來非常簡潔,
當然 Russ Cox 也承認 Go 的錯誤處理機制對于開發人員的確有一定的心智負擔,
參考資料【Go 語言的錯誤處理機制是一個優秀的設計嗎?】是知乎上的一個回答,闡述了 Go 對待錯誤和例外的不同處理方式,前者使用 error,后者使用 panic,這樣的處理比較 Java 那種錯誤例外一鍋端的做法更有優勢,
【如何優雅的在Golang中進行錯誤處理】對于在業務上如何處理 error,給出了一些很好的示例,
嘗試破局
這部分的內容主要來自 Dave cheney GoCon 2016 的演講,參考資料可以直達原文,
經常聽到 Go 有很多“箴言”,說得很順口,但理解起來并不是太容易,因為它們大部分都是有故事的,例如,我們常說:
Don't communicating by sharing memory, share memory by communicating.
文中還列舉了很多,都很有意思:

下面我們講三條關于 error 的“箴言”,
Errors are just values
Errors are just values 的實際意思是只要實作了 Error 介面的型別都可以認為是 Error,重要的是要理解這些“箴言”背后的道理,
作者把處理 error 的方式分為三種:
- Sentinel errors
- Error Types
- Opaque errors
我們來挨個說,首先 Sentinel errors,Sentinel 來自計算機中常用的詞匯,中文意思是“哨兵”,以前在學習快排的時候,會有一個“哨兵”,其他元素都要和“哨兵”進行比較,它劃出了一條界限,
這里 Sentinel errors 實際想說的是這里有一個錯誤,暗示處理流程不能再進行下去了,必須要在這里停下,這也是一條界限,而這些錯誤,往往是提前約定好的,
例如,io 包里的 io.EOF,表示“檔案結束”錯誤,但是這種方式處理起來,不太靈活:
func main() {
r := bytes.NewReader([]byte("0123456789"))
_, err := r.Read(make([]byte, 10))
if err == io.EOF {
log.Fatal("read failed:", err)
}
}
必須要判斷 err 是否和約定好的錯誤 io.EOF 相等,
再來一個例子,當我想回傳 err 并且加上一些背景關系資訊時,就麻煩了:
func main() {
err := readfile(“.bashrc”)
if strings.Contains(error.Error(), "not found") {
// handle error
}
}
func readfile(path string) error {
err := openfile(path)
if err != nil {
return fmt.Errorf(“cannot open file: %v", err)
}
// ……
}
在 readfile 函式里判斷 err 不為空,則用 fmt.Errorf 在 err 前加上具體的 file 資訊,回傳給呼叫者,回傳的 err 其實還是一個字串,
造成的后果時,呼叫者不得不用字串匹配的方式判斷底層函式 readfile 是不是出現了某種錯誤,當你必須要這樣才能判斷某種錯誤時,代碼的“壞味道”就出現了,
順帶說一句,err.Error() 方法是給程式員而非代碼設計的,也就是說,當我們呼叫 Error 方法時,結果要寫到檔案或是列印出來,是給程式員看的,在代碼里,我們不能根據 err.Error() 來做一些判斷,就像上面的 main 函式里做的那樣,不好,
Sentinel errors 最大的問題在于它在定義 error 和使用 error 的包之間建立了依賴關系,比如要想判斷 err == io.EOF 就得引入 io 包,當然這是標準庫的包,還 Ok,如果很多用戶自定義的包都定義了錯誤,那我就要引入很多包,來判斷各種錯誤,麻煩來了,這容易引起回圈參考的問題,
因此,我們應該盡量避免 Sentinel errors,僅管標準庫中有一些包這樣用,但建議還是別模仿,
第二種就是 Error Types,它指的是實作了 error 介面的那些型別,它的一個重要的好處是,型別中除了 error 外,還可以附帶其他欄位,從而提供額外的資訊,例如出錯的行數等,
標準庫有一個非常好的例子:
// PathError records an error and the operation and file path that caused it.
type PathError struct {
Op string
Path string
Err error
}
PathError 額外記錄了出錯時的檔案路徑和操作型別,
通常,使用這樣的 error 型別,外層呼叫者需要使用型別斷言來判斷錯誤:
// underlyingError returns the underlying error for known os error types.
func underlyingError(err error) error {
switch err := err.(type) {
case *PathError:
return err.Err
case *LinkError:
return err.Err
case *SyscallError:
return err.Err
}
return err
}
但是這又不可避免地在定義錯誤和使用錯誤的包之間形成依賴關系,又回到了前面的問題,
即使 Error types 比 Sentinel errors 好一些,因為它能承載更多的背景關系資訊,但是它仍然存在引入包依賴的問題,因此,也是不推薦的,至少,不要把 Error types 作為一個匯出型別,
最后一種,Opaque errors,翻譯一下,就是“黑盒 errors”,因為你能知道錯誤發生了,但是不能看到它內部到底是什么,
譬如下面這段偽代碼:
func fn() error {
x, err := bar.Foo()
if err != nil {
return err
}
// use x
return nil
}
作為呼叫者,呼叫完 Foo 函式后,只用知道 Foo 是正常作業還是出了問題,也就是說你只需要判斷 err 是否為空,如果不為空,就直接回傳錯誤,否則,繼續后面的正常流程,不需要知道 err 到底是什么,
這就是處理 Opaque errors 這種型別錯誤的策略,
當然,在某些情況下,這樣做并不夠用,例如,在一個網路請求中,需要呼叫者判斷回傳的錯誤型別,以此來決定是否重試,這種情況下,作者給出了一種方法:
In this case rather than asserting the error is a specific type or value, we can assert that the error implements a particular behaviour.
就是說,不去判斷錯誤的型別到底是什么,而是去判斷錯誤是否具有某種行為,或者說實作了某個介面,
來個例子:
type temporary interface {
Temporary() bool
}
func IsTemporary(err error) bool {
te, ok := err.(temporary)
return ok && te.Temporary()
}
拿到網路請求回傳的 error 后,呼叫 IsTemporary 函式,如果回傳 true,那就重試,
這么做的好處是在進行網路請求的包里,不需要 import 參考定義錯誤的包,
handle not just check errors
這一節要說第二句箴言:“Don't just check errors, handle them gracefully”,
func AuthenticateRequest(r *Request) error {
err := authenticate(r.User)
if err != nil {
return err
}
return nil
}
上面這個例子中的代碼是有問題的,直接優化成一句就可以了:
func AuthenticateRequest(r *Request) error {
return authenticate(r.User)
}
還有其他的問題,在函式呼叫鏈的最頂層,我們得到的錯誤可能是:No such file or directory,
這個錯誤反饋的資訊太少了,不知道檔案名、路徑、行號等等,
嘗試改進一下,增加一點背景關系:
func AuthenticateRequest(r *Request) error {
err := authenticate(r.User)
if err != nil {
return fmt.Errorf("authenticate failed: %v", err)
}
return nil
}
這種做法實際上是先錯誤轉換成字串,再拼接另一個字串,最后,再通過 fmt.Errorf 轉換成錯誤,這樣做破壞了相等性檢測,即我們無法判斷錯誤是否是一種預先定義好的錯誤了,
應對方案是使用第三方庫:github.com/pkg/errors,提供了友好的界面:
// Wrap annotates cause with a message.
func Wrap(cause error, message string) error
// Cause unwraps an annotated error.
func Cause(err error) error
通過 Wrap 可以將一個錯誤,加上一個字串,“包裝”成一個新的錯誤;通過 Cause 則可以進行相反的操作,將里層的錯誤還原,
有了這兩個函式,就方便很多:
func ReadFile(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, errors.Wrap(err, "open failed")
}
defer f.Close()
buf, err := ioutil.ReadAll(f)
if err != nil {
return nil, errors.Wrap(err, "read failed")
}
return buf, nil
}
這是一個讀檔案的函式,先嘗試打開檔案,如果出錯,則回傳一個附加上了 “open failed” 的錯誤資訊;之后,嘗試讀檔案,如果出錯,則回傳一個附加上了 “read failed” 的錯誤,
當在外層呼叫 ReadFile 函式時:
func main() {
_, err := ReadConfig()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}
func ReadConfig() ([]byte, error) {
home := os.Getenv("HOME")
config, err := ReadFile(filepath.Join(home, ".settings.xml"))
return config, errors.Wrap(err, "could not read config")
}
這樣我們在 main 函式里就能列印出這樣一個錯誤資訊:
could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory
它是有層次的,非常清晰,而如果我們用 pkg/errors 庫提供的列印函式:
func main() {
_, err := ReadConfig()
if err != nil {
errors.Print(err)
os.Exit(1)
}
}
能得到更有層次、更詳細的錯誤:
readfile.go:27: could not read config
readfile.go:14: open failed
open /Users/dfc/.settings.xml: no such file or directory
上面講的是 Wrap 函式,接下來看一下 “Cause” 函式,以前面提到的 temporary 介面為例:
type temporary interface {
Temporary() bool
}
// IsTemporary returns true if err is temporary.
func IsTemporary(err error) bool {
te, ok := errors.Cause(err).(temporary)
return ok && te.Temporary()
}
判斷之前先使用 Cause 取出錯誤,做斷言,最后,遞回地呼叫 Temporary 函式,如果錯誤沒實作 temporary 介面,就會斷言失敗,回傳 false,
Only handle errors once
什么叫“處理”錯誤:
Handling an error means inspecting the error value, and making a decision.
意思是查看了一下錯誤,并且做出一個決定,
例如,如果不做任何決定,相當于忽略了錯誤:
func Write(w io.Writer, buf []byte) {? w.Write(buf)?
w.Write(buf)?
}
w.Write(buf)? 會回傳兩個結果,一個表示寫成功的位元組數,一個是 error,上面的例子中沒有對這兩個回傳值做任何處理,
下面這個例子卻又處理了兩次錯誤:
func Write(w io.Writer, buf []byte) error {?
?_, err := w.Write(buf)?
if err != nil {?
// annotated error goes to log file?
log.Println("unable to write:", err)
?
// unannotated error returned to caller? return err?
return err
}?
return nil
}
第一次處理是將錯誤寫進了日志,第二次處理則是將錯誤回傳給上層呼叫者,而呼叫者也可能將錯誤寫進日志或是繼續回傳給上層,
這樣一來,日志檔案中會有很多重復的錯誤描述,并且在最上層呼叫者(如 main 函式)看來,它拿到的錯誤卻還是最底層函式回傳的 error,沒有任何背景關系資訊,
使用第三方的 error 包就可以比較完美的解決問題:
func Write(w io.Write, buf []byte) error {?
_, err := w.Write(buf)?
return errors.Wrap(err, "write failed")?
}
回傳的錯誤,對于人和機器而言,都是友好的,
小結
這一部分主要講了處理 error 的一些原則,引入了第三方的 errors 包,使得錯誤處理變得更加優雅,
作者最后給出了一些結論:
- errors 就像對外提供的 API 一樣,需要認真對待,
- 將 errors 看成黑盒,判斷它的行為,而不是型別,
- 盡量不要使用 sentinel errors,
- 使用第三方的錯誤包來包裹 error(errors.Wrap),使得它更好用,
- 使用 errors.Cause 來獲取底層的錯誤,
胎死腹中的 try 提案
之前已經出現用 “check & handle” 關鍵字和 “try 內置函式”改進錯誤處理流程的提案,目前 try 內置函式的提案已經被官方提前拒絕,原因是社區里一邊倒地反對聲音,
關于這兩個提案的具體內容見參考資料【check & handle】和【try 提案】,
go 1.13 的改進
有一些 Go 語言失敗的嘗試,比如 Go 1.5 引入的 vendor 和 internal 來管理包,最后被濫用而引發了很多問題,因此 Go 1.13 直接拋棄了 GOPATH 和 vendor 特性,改用 module 來管理包,
柴大在《Go 語言十年而立,Go2 蓄勢待發》一文中表示:
比如最近 Go 語言之父之一 Robert Griesemer 提交的通過 try 內置函式來簡化錯誤處理就被否決了,失敗的嘗試是一個好的現象,它表示 Go 語言依然在一些新興領域的嘗試 —— Go 語言依然處于活躍期,
今年 9 月 3 號,Go 發布 1.13 版本,除了 module 特性轉正之外,還改進了數字字面量,比較重要的還有 defer 性能提升 30%,將更多的物件從堆上移動到堆疊上以提升性能,等等,
還有一個重大的改進發生在 errors 標準庫中,errors 庫增加了 Is/As/Unwrap三個函式,這將用于支持錯誤的再次包裝和識別處理,為 Go 2 中新的錯誤處理改進提前做準備,
1.13 支持了 error 包裹(wrapping):
An error e can wrap another error w by providing an Unwrap method that returns w. Both e and w are available to programs, allowing e to provide additional context to w or to reinterpret it while still allowing programs to make decisions based on w.
為了支持 wrapping,fmt.Errorf 增加了 %w 的格式,并且在 error 包增加了三個函式:errors.Unwrap,errors.Is,errors.As,
fmt.Errorf
使用 fmt.Errorf 加上 %w 格式符來生成一個嵌套的 error,它并沒有像 pkg/errors 那樣使用一個 Wrap 函式來嵌套 error,非常簡潔,
Unwrap
func Unwrap(err error) error
將嵌套的 error 決議出來,多層嵌套需要呼叫 Unwrap 函式多次,才能獲取最里層的 error,
原始碼如下:
func Unwrap(err error) error {
// 判斷是否實作了 Unwrap 方法
u, ok := err.(interface {
Unwrap() error
})
// 如果不是,回傳 nil
if !ok {
return nil
}
// 呼叫 Unwrap 方法回傳被嵌套的 error
return u.Unwrap()
}
對 err 進行斷言,看它是否實作了 Unwrap 方法,如果是,呼叫它的 Unwrap 方法,否則,回傳 nil,
Is
func Is(err, target error) bool
判斷 err 是否和 target 是同一型別,或者 err 嵌套的 error 有沒有和 target 是同一型別的,如果是,則回傳 true,
原始碼如下:
func Is(err, target error) bool {
if target == nil {
return err == target
}
isComparable := reflectlite.TypeOf(target).Comparable()
// 無限回圈,比較 err 以及嵌套的 error
for {
if isComparable && err == target {
return true
}
// 呼叫 error 的 Is 方法,這里可以自定義實作
if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
return true
}
// 回傳被嵌套的下一層的 error
if err = Unwrap(err); err == nil {
return false
}
}
}
通過一個無限回圈,使用 Unwrap 不斷地將 err 里層嵌套的 error 解開,再看被解開的 error 是否實作了 Is 方法,并且呼叫它的 Is 方法,當兩者都回傳 true 的時候,整個函式回傳 true,
As
func As(err error, target interface{}) bool
從 err 錯誤鏈里找到和 target 相等的并且設定 target 所指向的變數,
原始碼如下:
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 必須是一個非空指標
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
}
// 不斷地 Unwrap,一層層的獲取嵌套的 error
err = Unwrap(err)
}
return false
}
回傳 true 的條件是錯誤鏈里的 err 能被賦值到 target 所指向的變數;或者 err 實作的 As(interface{}) bool 方法回傳 true,
前者,會將 err 賦給 target 所指向的變數;后者,由 As 函式提供這個功能,
如果 target 不是一個指向“實作了 error 介面的型別或者其它介面型別”的非空的指標的時候,函式會 panic,
這一部分的內容,飛雪無情大佬的文章【飛雪無情 分析 1.13 錯誤】寫得比較好,推薦閱讀,
總結
Go 語言使用 error 和 panic 處理錯誤和例外是一個非常好的做法,比較清晰,至于是使用 error 還是 panic,看具體的業務場景,
當然,Go 中的 error 過于簡單,以至于無法記錄太多的背景關系資訊,對于錯誤包裹也沒有比較好的辦法,當然,這些可以通過第三方庫來解決,官方也在新發布的 go 1.13 中對這一塊作出了改進,相信在 Go 2 里會有更進一步的優化,
本文還列舉了一些處理 error 的示例,例如不要兩次處理一個錯誤,判斷錯誤的行為而不是型別等等,
參考資料里列舉了很多錯誤處理相關的示例,這篇文章作為一個引子,
參考資料
【Go 2 錯誤提案】https://go.googlesource.com/proposal/+/master/design/29934-error-values.md
【check & handle】https://go.googlesource.com/proposal/+/master/design/go2draft-error-handling-overview.md
【錯誤討論的 issue】https://github.com/golang/go/issues/29934
【error value 的 FAQ】https://github.com/golang/go/wiki/ErrorValueFAQ
【error 包】https://golang.org/pkg/errors/
【飛雪無情的博客 錯誤處理】https://www.flysnow.org/2019/01/01/golang-error-handle-suggestion.html
【飛雪無情 分析 1.13 錯誤】https://www.flysnow.org/2019/09/06/go1.13-error-wrapping.html
【Tony Bai Go語言錯誤處理】https://tonybai.com/2015/10/30/error-handling-in-go/
【Go 官方 error 使用教程】https://blog.golang.org/error-handling-and-go
【Go FAQ】https://golang.org/doc/faq#exceptions
【ethancai 錯誤處理】https://ethancai.github.io/2017/12/29/Error-Handling-in-Go/
【Dave cheney GoCon 2016 演講】https://dave.cheney.net/paste/gocon-spring-2016.pdf
【Morsing's Blog Effective error handling in Go】http://morsmachine.dk/error-handling
【如何優雅的在Golang中進行錯誤處理】https://www.ituring.com.cn/article/508191
【Go 2 錯誤處理提案:try 還是 check?】https://toutiao.io/posts/uh9qo7/preview
【try 提案】https://github.com/golang/go/issues/32437
【否決 try 提案】https://github.com/golang/go/issues/32437#issuecomment-512035919
【Go 語言的錯誤處理機制是一個優秀的設計嗎?】https://www.zhihu.com/question/27158146/answer/44676012
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/71575.html
標籤:Go
下一篇:Go微服務全鏈路跟蹤詳解
