一文搞懂 Go 錯誤鏈

0. Go 錯誤處理簡要回顧

Go 是一種非常強調錯誤處理的編程語言。在 Go 中,錯誤被表示爲實現了 error 接口的類型的值,error 接口只有一個方法:

type error interface {
 Error() string
}

這個接口的引入使得 Go 程序可以以一致和符合慣用法的方式進行錯誤處理。

在所有編程語言中,錯誤處理的挑戰之一都是能提供足夠的錯誤上下文信息,以幫助開發人員診斷問題,同時又可以避免開發人員淹沒在不必要的細節中。在 Go 中,這一挑戰目前是通過使用 錯誤鏈 (error chain) 來解決的。

注:Go 官方用戶調查結果 [1] 表明,Go 社區對 Go 錯誤處理機制改進的期望還是很高的。這對 Go 核心團隊而言,依然是一個不小的挑戰。好在 Go 1.18 泛型落地 [2],隨着 Go 泛型的逐漸成熟,更優雅的錯誤處理方案有可能會在不遠的將來浮出水面。

錯誤鏈是一種將一個錯誤包裹在另一個錯誤中的技術,以提供關於錯誤的額外的上下文。當錯誤通過多層代碼傳播時,這種技術特別有用,每層代碼都會爲錯誤信息添加自己的上下文。

不過,最初 Go 的錯誤處理機制是不支持錯誤鏈的,Go 對錯誤鏈的支持和完善是在 Go 1.13 版本 [3] 中才開始的事情。

衆所周知,在 Go 中,錯誤處理通常使用 if err != nil 的慣用法來完成。當一個函數返回一個錯誤時,調用代碼會檢查該錯誤是否爲 nil。如果錯誤不是 nil,通常會被打印到日誌中或返回給調用者。

例如,看下面這個讀取文件的函數:

func readFile(filename string) ([]byte, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        return nil, err
    }
    return data, nil
}

在這段代碼中,os.ReadFile() 如果讀取文件失敗,會返回一個錯誤。如果發生這種情況,readFile 函數會將錯誤返回給它的調用者。Go 的這種基本的錯誤處理機制簡單有效好理解,但它也有自己的侷限性。其中一個主要的限制是錯誤信息可能是模糊的。當一個錯誤在多層代碼中傳播時,開發人員可能很難確定錯誤的真實來源和原因。 我們看一下下面這段代碼:

func processFile(filename string) error {
    data, err := readFile(filename)
    if err != nil {
        return err
    }
    // process the file data...
    return nil
}

在這個例子中,如果 readFile() 返回一個錯誤,錯誤信息將只表明該文件無法被讀取,它不會提供任何關於造成錯誤的原因或錯誤發生地點的準確信息

Go 基本錯誤處理的另一個約束是在處理錯誤時,錯誤的上下文可能會丟失。尤其是當一個錯誤通過多層代碼時,某一層可能會忽略收到的錯誤信息,而是構造自己的錯誤信息並返回給調用者,這樣最初的錯誤上下文就會在錯誤的傳遞過程中丟失了,這不利於問題的快速診斷。

那麼,我們如何解決這些限制呢?下面我們就來探討一下錯誤鏈是如何如何幫助 Go 開發人員解決這些限制問題的。

1. 錯誤包裝 (error wrapping) 與錯誤鏈

爲了解決基本錯誤處理的侷限性,Go 在 1.13 版本中提供了 Unwrap 接口和 fmt.Errorf 的 %w 的格式化動詞 [4],用於構建可以包裹 (wrap) 其他錯誤的錯誤以及從一個包裹了其他錯誤的錯誤中判斷是否有某個指定錯誤,並從中提取錯誤信息。

fmt.Errorf 是最常用的用於包裹錯誤的函數,它接收一個現有的錯誤,並將其包裝在一個新的錯誤中,並可以附着更多的錯誤上下文信息。

例如,改造一下上面的示例代碼:

func processFile(filename string) error {
    data, err := readFile(filename)
    if err != nil {
        return fmt.Errorf("failed to read file: %w", err)
    }
    // process the file data...
    return nil
}

在這段代碼中,fmt.Errorf 通過 %w 創建了一個新的錯誤,新錯誤包裹 (wrap) 了原來的錯誤,並附加了一些錯誤上下文信息(failed to read file)。這個新的錯誤可以在調用堆棧中傳播並提供更多關於這個錯誤的上下文。

爲了從錯誤鏈中檢索原始錯誤,Go 在 errors 包中提供了 Is、As 和 Unwrap() 函數。Is 和 As 函數用於判定某個 error 是否存在於錯誤鏈中,Unwrap 這個函數返回錯誤鏈中的下一個直接錯誤。

下面是一個完整的例子:

func readFile(filename string) ([]byte, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        return nil, err
    }
    return data, nil
}

func processFile(filename string) error {
    data, err := readFile(filename)
    if err != nil {
        return fmt.Errorf("failed to read file: %w", err)
    }
    fmt.Println(string(data))
    return nil
}

func main() {
    err := processFile("1.txt")
    if err != nil {
        fmt.Println(err)
        fmt.Println(errors.Is(err, os.ErrNotExist))
        err = errors.Unwrap(err)
        fmt.Println(err)
        err = errors.Unwrap(err)
        fmt.Println(err)
        return
    }
}

運行這個程序 (前提:1.txt 文件並不存在),結果如下:

$go run demo1.go
failed to read file: open 1.txt: no such file or directory
true
open 1.txt: no such file or directory
no such file or directory

該示例中錯誤的 wrap 和 unwrap 關係如下圖:

像這種由錯誤逐個包裹而形成的鏈式結構 (如下圖),我們稱之爲錯誤鏈

接下來,我們再來詳細說一下 Go 錯誤鏈的使用。

2. Go 中錯誤鏈的使用

2.1 如何創建錯誤鏈

就像前面提到的,我們通過包裹錯誤來創建錯誤鏈

目前 Go 標準庫中提供的用於 wrap error 的 API 有 fmt.Errorf 和 errors.Join。fmt.Errorf 最常用,在上面的示例中我們演示過了。errors.Join 用於將一組 errors wrap 爲一個 error。

fmt.Errorf 也支持通過多個 %w 一次打包多個 error,下面是一個完整的例子:

func main() {
    err1 := errors.New("error1")
    err2 := errors.New("error2")
    err3 := errors.New("error3")

    err := fmt.Errorf("wrap multiple error: %w, %w, %w", err1, err2, err3)
    fmt.Println(err)
    e, ok := err.(interface{ Unwrap() []error })
    if !ok {
        fmt.Println("not imple Unwrap []error")
        return
    }
    fmt.Println(e.Unwrap())
}

示例運行輸出如下:

wrap multiple error: error1, error2, error3
[error1 error2 error3]

我們看到,通過 fmt.Errorf 一次 wrap 的多個 error 在 String 化後,是在一行輸出的。這點與 errors.Join 的有所不同。下面是用 errors.Join 一次打包多個 error 的示例:

func main() {
    err1 := errors.New("error1")
    err2 := errors.New("error2")
    err3 := errors.New("error3")

    err := errors.Join(err1, err2, err3)
    fmt.Println(err)
    errs, ok := err.(interface{ Unwrap() []error })
    if !ok {
        fmt.Println("not imple Unwrap []error")
        return
    }
    fmt.Println(errs.Unwrap())
}

這個示例輸出如下:

$go run demo2.go
error1
error2
error3
[error1 error2 error3]

我們看到,通過 errors.Join 一次 wrap 的多個 error 在 String 化後,每個錯誤單獨佔一行。

如果對上面的輸出格式都不滿意,那麼你還可以自定義 Error 類型,只要至少實現了 String() string 和 Unwrap() error 或 Unwrap() []error 即可。

2.2 判定某個錯誤是否在錯誤鏈中

前面提到過 errors 包提供了 Is 和 As 函數來判斷某個錯誤是否在錯誤鏈中,對於一次 wrap 多個 error 值的情況,errors.Is 和 As 也都按預期可用。

2.3 獲取錯誤鏈中特定錯誤的上下文信息

有些時候,我們需要從錯誤鏈上獲取某個特定錯誤的上下文信息,通過 Go 標準庫可以至少有兩種實現方式:

第一種:通過 errors.Unwrap 函數來逐一 unwrap 錯誤鏈中的錯誤。

由於不確定錯誤鏈上的 error 個數以及每個 error 的特徵,這種方式十分適合用來獲取 root cause error,即錯誤鏈中最後面的一個 error。下面是一個示例:

func rootCause(err error) error {
    for {
        e, ok := err.(interface{ Unwrap() error })
        if !ok {
            return err
        }
        err = e.Unwrap()
        if err == nil {
            return nil
        }
    }
}

func main() {
    err1 := errors.New("error1")

    err2 := fmt.Errorf("2nd err: %w", err1)
    err3 := fmt.Errorf("3rd err: %w", err2)

    fmt.Println(err3) // 3rd err: 2nd err: error1

    fmt.Println(rootCause(err1)) // error1
    fmt.Println(rootCause(err2)) // error1
    fmt.Println(rootCause(err3)) // error1
}

第二種:通過 errors.As 函數將 error chain 中特定類型的 error 提取出來

error.As 函數用於判斷某個 error 是否是特定類型的 error,如果是則將那個 error 提取出來,比如:

type MyError struct {
    err string
}

func (e *MyError) Error() string {
    return e.err
}

func main() {
    err1 := &MyError{"temp error"}
    err2 := fmt.Errorf("2nd err: %w", err1)
    err3 := fmt.Errorf("3rd err: %w", err2)

    fmt.Println(err3)

    var e *MyError
    ok := errors.As(err3, &e)
    if ok {
        fmt.Println(e)
        return
    }
}

在這個示例中,我們通過 errors.As 將錯誤鏈 err3 中的 err1 提取到 e 中,後續就可以使用 err1 這個特定錯誤的信息了。

3. 小結

錯誤鏈是 Go 中提供信息豐富的錯誤信息的一項重要技術。通過用額外的上下文包裝錯誤,你可以提供關於錯誤的更具體的信息,並幫助開發人員更快地診斷出問題。

不過錯誤鏈在使用中有一些事項還是要注意的,比如:避免嵌套錯誤鏈。嵌套的錯誤鏈會使你的代碼難以調試,也難以理解錯誤的根本原因。

結合錯誤鏈,通過給錯誤添加上下文,創建自定義錯誤類型,並在適當的抽象層次上處理錯誤,你可以寫出簡潔、可讀和信息豐富的錯誤處理代碼。


Gopher Daily(Gopher 每日新聞) 歸檔倉庫 - https://github.com/bigwhite/gopherdaily

我的聯繫方式:

商務合作方式:撰稿、出書、培訓、在線課程、合夥創業、諮詢、廣告合作。

參考資料

[1]  Go 官方用戶調查結果: https://go.dev/blog/survey2022-q2-results

[2]  Go 1.18 泛型落地: https://tonybai.com/2022/04/20/some-changes-in-go-1-18

[3]  Go 1.13 版本: https://tonybai.com/2019/10/27/some-changes-in-go-1-13/

[4]  Go 在 1.13 版本中提供了 Unwrap 接口和 fmt.Errorf 的 %w 的格式化動詞: https://tonybai.com/2019/10/18/errors-handling-in-go-1-13/

[5]  “Gopher 部落” 知識星球: https://wx.zsxq.com/dweb2/index/group/51284458844544

[6]  鏈接地址: https://m.do.co/c/bff6eed92687

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/D_CVrPzjP3O81EpFqLoynQ