Go 部落格

Go 1.13 中的錯誤處理

Damien Neil 和 Jonathan Amsterdam
2019 年 10 月 17 日

引言

Go 將錯誤視為值的處理方式在過去十年中為我們提供了很好的服務。儘管標準庫對錯誤的支援很少——只有 errors.Newfmt.Errorf 函式,它們產生的錯誤只包含一條訊息——但內建的 error 介面允許 Go 程式設計師新增他們想要的任何資訊。它只需要一個實現了 Error 方法的型別。

type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }

這樣的錯誤型別隨處可見,它們儲存的資訊也千差萬別,從時間戳到檔名再到伺服器地址。通常,這些資訊包含另一個較低級別的錯誤,以提供額外的上下文。

一個錯誤包含另一個錯誤的模式在 Go 程式碼中非常普遍,經過廣泛討論後,Go 1.13 添加了對此的顯式支援。本文將描述標準庫中提供此支援的新增內容:errors 包中的三個新函式,以及 fmt.Errorf 的一個新的格式化動詞。

在詳細描述這些變更之前,讓我們回顧一下在之前的語言版本中如何檢查和構造錯誤。

Go 1.13 之前的錯誤

檢查錯誤

Go 錯誤是值。程式以幾種方式根據這些值做出決定。最常見的方式是將錯誤與 nil 進行比較,以檢視操作是否失敗。

if err != nil {
    // something went wrong
}

有時我們將錯誤與一個已知的哨兵值進行比較,以檢視是否發生了特定錯誤。

var ErrNotFound = errors.New("not found")

if err == ErrNotFound {
    // something wasn't found
}

錯誤值可以是滿足語言定義的 error 介面的任何型別。程式可以使用型別斷言或型別 switch 將錯誤值視為更具體的型別。

type NotFoundError struct {
    Name string
}

func (e *NotFoundError) Error() string { return e.Name + ": not found" }

if e, ok := err.(*NotFoundError); ok {
    // e.Name wasn't found
}

新增資訊

函式經常在向上層呼叫堆疊傳遞錯誤時向其中新增資訊,例如對錯誤發生時的簡要描述。一種簡單的方法是構造一個包含先前錯誤文字的新錯誤。

if err != nil {
    return fmt.Errorf("decompress %v: %v", name, err)
}

使用 fmt.Errorf 建立新錯誤會丟棄原始錯誤中的所有內容,只保留文字。正如我們在上面的 QueryError 中所見,有時我們可能希望定義一個包含底層錯誤的新錯誤型別,以便程式碼可以檢查它。以下是 QueryError 的再次出現:

type QueryError struct {
    Query string
    Err   error
}

程式可以檢視 *QueryError 值內部,以根據底層錯誤做出決策。有時你會看到這被稱為“解包”錯誤。

if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
    // query failed because of a permission problem
}

標準庫中的 os.PathError 型別是另一個包含其他錯誤的示例。

Go 1.13 中的錯誤

Unwrap 方法

Go 1.13 在 errorsfmt 標準庫包中引入了新功能,以簡化處理包含其他錯誤的錯誤。其中最重要的是一個約定而非改動:一個包含其他錯誤的錯誤可以實現一個 Unwrap 方法,返回底層錯誤。如果 e1.Unwrap() 返回 e2,那麼我們稱 e1 包裝e2,並且你可以解包 e1 來獲得 e2

遵循此約定,我們可以為上面提到的 QueryError 型別提供一個 Unwrap 方法,返回其包含的錯誤。

func (e *QueryError) Unwrap() error { return e.Err }

解包錯誤的結果本身可能也有一個 Unwrap 方法;我們將重複解包產生的錯誤序列稱為錯誤鏈

使用 Is 和 As 檢查錯誤

Go 1.13 的 errors 包包含兩個用於檢查錯誤的新函式:IsAs

errors.Is 函式將錯誤與一個值進行比較。

// Similar to:
//   if err == ErrNotFound { … }
if errors.Is(err, ErrNotFound) {
    // something wasn't found
}

As 函式測試一個錯誤是否屬於特定型別。

// Similar to:
//   if e, ok := err.(*QueryError); ok { … }
var e *QueryError
// Note: *QueryError is the type of the error.
if errors.As(err, &e) {
    // err is a *QueryError, and e is set to the error's value
}

在最簡單的情況下,errors.Is 函式的行為類似於與哨兵錯誤進行比較,而 errors.As 函式的行為類似於型別斷言。然而,當處理包裝錯誤時,這些函式會考慮鏈中的所有錯誤。讓我們再次檢視上面解包 QueryError 以檢查底層錯誤的示例:

if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
    // query failed because of a permission problem
}

使用 errors.Is 函式,我們可以這樣寫:

if errors.Is(err, ErrPermission) {
    // err, or some error that it wraps, is a permission problem
}

errors 包還包含一個新的 Unwrap 函式,它返回呼叫錯誤的 Unwrap 方法的結果,如果錯誤沒有 Unwrap 方法則返回 nil。然而,通常最好使用 errors.Iserrors.As,因為這些函式會在一次呼叫中檢查整個錯誤鏈。

注意:儘管獲取指向指標的指標可能感覺奇怪,但在本例中是正確的。可以將其理解為獲取錯誤型別值的指標;在本例中碰巧返回的錯誤是一個指標型別。

使用 %w 包裝錯誤

如前所述,通常使用 fmt.Errorf 函式向錯誤新增額外資訊。

if err != nil {
    return fmt.Errorf("decompress %v: %v", name, err)
}

在 Go 1.13 中,fmt.Errorf 函式支援一個新的 %w 動詞。當存在此動詞時,fmt.Errorf 返回的錯誤將具有一個 Unwrap 方法,返回 %w 的引數,該引數必須是一個錯誤。在所有其他方面,%w%v 相同。

if err != nil {
    // Return an error which unwraps to err.
    return fmt.Errorf("decompress %v: %w", name, err)
}

使用 %w 包裝錯誤使其可供 errors.Iserrors.As 使用。

err := fmt.Errorf("access denied: %w", ErrPermission)
...
if errors.Is(err, ErrPermission) ...

是否包裝

在向錯誤新增額外上下文時,無論是使用 fmt.Errorf 還是透過實現自定義型別,都需要決定新錯誤是否應該包裝原始錯誤。這個問題沒有單一答案;它取決於建立新錯誤的上下文。包裝錯誤是為了向呼叫者暴露它。如果包裝錯誤會暴露實現細節,則不要包裝。

舉個例子,想象一個 Parse 函式,它從 io.Reader 中讀取複雜的資料結構。如果發生錯誤,我們希望報告錯誤發生的行號和列號。如果在從 io.Reader 讀取時發生錯誤,我們將希望包裝該錯誤,以允許檢查底層問題。由於呼叫者向函式提供了 io.Reader,暴露它產生的錯誤是有意義的。

相比之下,一個對資料庫進行多次呼叫的函式可能不應該返回一個解包後是其中一次呼叫結果的錯誤。如果函式使用的資料庫是實現細節,那麼暴露這些錯誤就是違反抽象原則。例如,如果你的包 pkgLookupUser 函式使用了 Go 的 database/sql 包,那麼它可能會遇到 sql.ErrNoRows 錯誤。如果你使用 fmt.Errorf("accessing DB: %v", err) 返回該錯誤,那麼呼叫者就無法檢視內部以找到 sql.ErrNoRows。但如果函式返回的是 fmt.Errorf("accessing DB: %w", err),那麼呼叫者就可以合理地寫:

err := pkg.LookupUser(...)
if errors.Is(err, sql.ErrNoRows) …

在這一點上,如果你不想破壞你的客戶端,即使切換到不同的資料庫包,該函式也必須始終返回 sql.ErrNoRows。換句話說,包裝錯誤會使該錯誤成為你的 API 的一部分。如果你不想在將來承諾支援該錯誤作為 API 的一部分,就不應該包裝該錯誤。

重要的是要記住,無論你是否包裝,錯誤文字都是相同的。試圖理解錯誤的無論如何都會獲得相同的資訊;包裝的選擇在於是否向程式提供額外資訊,以便它們能夠做出更明智的決策,或者保留這些資訊以維護抽象層。

使用 Is 和 As 方法自定義錯誤測試

errors.Is 函式檢查鏈中的每個錯誤是否與目標值匹配。預設情況下,如果兩者相等,則錯誤與目標匹配。此外,鏈中的錯誤可以透過實現 Is 方法來宣告它與目標匹配。

例如,考慮這個受 Upspin error package 啟發的錯誤,它將錯誤與模板進行比較,只考慮模板中非零的欄位:

type Error struct {
    Path string
    User string
}

func (e *Error) Is(target error) bool {
    t, ok := target.(*Error)
    if !ok {
        return false
    }
    return (e.Path == t.Path || t.Path == "") &&
           (e.User == t.User || t.User == "")
}

if errors.Is(err, &Error{User: "someuser"}) {
    // err's User field is "someuser".
}

errors.As 函式在存在時也類似地參考 As 方法。

錯誤與包 API

返回錯誤的包(大多數都是如此)應該描述程式設計師可以依賴的那些錯誤的哪些屬性。一個精心設計的包也應該避免返回不應該依賴的屬性的錯誤。

最簡單的規範是說明操作要麼成功要麼失敗,分別返回 nil 或非 nil 錯誤值。在許多情況下,不需要更多資訊。

如果我們希望一個函式返回一個可識別的錯誤條件,例如“未找到專案”,我們可以返回一個包裝了哨兵錯誤的錯誤。

var ErrNotFound = errors.New("not found")

// FetchItem returns the named item.
//
// If no item with the name exists, FetchItem returns an error
// wrapping ErrNotFound.
func FetchItem(name string) (*Item, error) {
    if itemNotFound(name) {
        return nil, fmt.Errorf("%q: %w", name, ErrNotFound)
    }
    // ...
}

還有其他現有的模式可以提供可由呼叫者語義上檢查的錯誤,例如直接返回哨兵值、特定型別或可以使用謂詞函式檢查的值。

在所有情況下,都應該注意不要向用戶暴露內部細節。正如我們在上面的“是否包裝”中提到的,當你從另一個包返回錯誤時,你應該將錯誤轉換為不暴露底層錯誤的形式,除非你願意承諾將來返回該特定錯誤。

f, err := os.Open(filename)
if err != nil {
    // The *os.PathError returned by os.Open is an internal detail.
    // To avoid exposing it to the caller, repackage it as a new
    // error with the same text. We use the %v formatting verb, since
    // %w would permit the caller to unwrap the original *os.PathError.
    return fmt.Errorf("%v", err)
}

如果一個函式被定義為返回一個包裝了某個哨兵或型別的錯誤,請不要直接返回底層錯誤。

var ErrPermission = errors.New("permission denied")

// DoSomething returns an error wrapping ErrPermission if the user
// does not have permission to do something.
func DoSomething() error {
    if !userHasPermission() {
        // If we return ErrPermission directly, callers might come
        // to depend on the exact error value, writing code like this:
        //
        //     if err := pkg.DoSomething(); err == pkg.ErrPermission { … }
        //
        // This will cause problems if we want to add additional
        // context to the error in the future. To avoid this, we
        // return an error wrapping the sentinel so that users must
        // always unwrap it:
        //
        //     if err := pkg.DoSomething(); errors.Is(err, pkg.ErrPermission) { ... }
        return fmt.Errorf("%w", ErrPermission)
    }
    // ...
}

結論

儘管我們討論的改動只有三個函式和一個格式化動詞,但我們希望它們能極大地改善 Go 程式中錯誤的處理方式。我們期望透過包裝來提供額外上下文將變得普遍,幫助程式做出更好的決策,並幫助程式設計師更快地找到 bug。

正如 Russ Cox 在他的 GopherCon 2019 主題演講中所說,在邁向 Go 2 的道路上,我們進行實驗、簡化併發布。現在我們已經發布了這些改動,我們期待接下來的實驗。

下一篇文章:Go Modules: v2 及更高版本
上一篇文章:釋出 Go Modules
部落格索引