Go 部落格

處理 Go 1.13 中的錯誤

Damien Neil 和 Jonathan Amsterdam
2019 年 10 月 17 日

簡介

我們在過去 10 年來秉承 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 函式就像與 sentinel 錯誤的比較,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 錯誤套件啟發的錯誤,它會將一個錯誤與一個範本進行比較,只考慮在範本中非零的欄位

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

一個傳回錯誤的套件(以及絕大多數套件都會這樣做)應該說明程式設計人員可以依賴哪些錯誤屬性。設計良好的套件也會避免傳回不應該依賴其屬性的錯誤。

最簡單的規範是聲明操作會成功或失敗,分別傳回零值或非零值錯誤值。在許多情況下,不需要進一步的資訊。

如果我們希望函式傳回明確的錯誤狀態,例如「找不到項目」,我們可以用錯誤封裝哨兵物件。

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 程式處理錯誤的方式。我們預期提供額外內容的包裝會變成普遍做法,有助於程式做出更好的決策,並幫助程式設計師更快找出錯誤。

正如 Russ Cox 在他的 GopherCon 2019 主旨演講 中所說,在 Go 2 的道路上,我們會進行實驗、簡化和交付。我們已交付這些變更,現在我們期待後續實驗。

下一篇:Go Modules:v2 及更後續的版本
上一篇:發佈 Go 模組
網誌索引