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 介面的型別。程式可以使用型別斷言或型別開關將錯誤值視為更具體的型別。

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 傳遞給了函式,因此暴露它產生的錯誤是有意義的。

相比之下,一個對資料庫進行多次呼叫的函式可能不應該返回一個可以解包到其中一次呼叫的結果的錯誤。如果函式使用的資料庫是實現細節,那麼暴露這些錯誤就是違反了抽象。例如,如果您的 pkg 包的 LookupUser 函式使用了 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 錯誤包啟發的錯誤,該錯誤將一個錯誤與一個模板進行比較,僅考慮模板中非零的欄位。

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 在他 2019 年 GopherCon 主題演講中的演講中所說,在 Go 2 的道路上,我們會進行實驗、簡化和釋出。既然我們已經發布了這些更改,我們期待著隨之而來的實驗。

下一篇文章:Go 模組:v2 及以後
上一篇文章:釋出 Go 模組
部落格索引