Golang error 的突圍

寫過 C 的同窗知道,C 語言中經常返回整數錯誤碼(errno)來表示函數處理出錯,一般用 -1 來表示錯誤,用 0 表示正確。git

而在 Go 中,咱們使用 error 類型來表示錯誤,不過它再也不是一個整數類型,是一個接口類型:程序員

type error interface {
    Error() string
}

它表示那些能用一個字符串就能說清的錯誤。github

咱們最經常使用的就是 errors.New() 函數,很是簡單:golang

// 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() stringspring

一般這就夠了,它能反映當時「出錯了」,可是有些時候咱們須要更加具體的信息,例如:小程序

func Sqrt(f float64) (float64, error) {
    if f < 0 {
        return 0, errors.New("math: square root of negative number")
    }
    // implementation
}

當調用者發現出錯的時候,只知道傳入了一個負數進來,並不清楚到底傳的是什麼值。在 Go 裏:bash

It is the error implementation's responsibility to summarize the context.網絡

它要求返回這個錯誤的函數要給出具體的「上下文」信息,也就是說,在 Sqrt 函數裏,要給出這個負數究竟是什麼。app

因此,若是發現 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.

文中還列舉了不少,都頗有意思:

go proverbs

下面咱們講三條關於 error 的「箴言」。

Errors are just values

Errors are just values 的實際意思是隻要實現了 Error 接口的類型均可以認爲是 Error,重要的是要理解這些「箴言」背後的道理。

做者把處理 error 的方式分爲三種:

  1. Sentinel errors
  2. Error Types
  3. 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 typesSentinel 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 包,使得錯誤處理變得更加優雅。

做者最後給出了一些結論:

  1. errors 就像對外提供的 API 同樣,須要認真對待。
  2. 將 errors 當作黑盒,判斷它的行爲,而不是類型。
  3. 儘可能不要使用 sentinel errors。
  4. 使用第三方的錯誤包來包裹 error(errors.Wrap),使得它更好用。
  5. 使用 errors.Cause 來獲取底層的錯誤。

胎死腹中的 try 提案

以前已經出現用 「check & handle」 關鍵字和 「try 內置函數」改進錯誤處理流程的提案,目前 try 內置函數的提案已經被官方提早拒絕,緣由是社區裏一邊倒地反對聲音。

關於這兩個提案的具體內容見參考資料【check & handle】和【try 提案】。

go 1.13 的改進

有一些 Go 語言失敗的嘗試,好比 Go 1.5 引入的 vendor 和 internal 來管理包,最後被濫用而引起了不少問題。所以 Go 1.13 直接拋棄了 GOPATHvendor 特性,改用 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.Unwraperrors.Iserrors.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

相關文章
相關標籤/搜索