最權威的講解Go 1.13中的錯誤處理

介紹

在過去的十年中, Go的errors are values的理念在編碼實踐中運行得也很良好。儘管標準庫對錯誤處理的的支持很少(只有errors.New和fmt.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語言的早期版本中如何檢查和構造錯誤。

Go 1.13版本之前的錯誤處理

檢查錯誤

錯誤是值(errors are values)。程序通過幾種方式基於這些值來做出決策。最常見的是通過與nil的比較來確定操作是否失敗。

if err != nil {
// 出錯了!
}

有時我們將錯誤與已知的前哨值(sentinel value)進行比較來查看是否發生了特定錯誤。比如:

var ErrNotFound = errors.New("not found")
if err == ErrNotFound {
// something wasn't found
}

錯誤值可以是滿足語言定義的error 接口的任何類型。程序可以使用類型斷言(type assertion)或類型開關(type 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在errors和fmt標準庫包中引入了新功能以簡化處理包含其他錯誤的錯誤。其中最重要的不是改變,而是一個約定:包含另一個錯誤的錯誤可以實現Unwrap方法來返回所包含的底層錯誤。如果e1.Unwrap()返回了e2,那麼我們說e1包裝了e2,您可以Unwrap e1來得到e2

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

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

Unwrap錯誤的結果本身(底層錯誤)可能也具有Unwrap方法。我們將這種通過重複unwrap而得到的錯誤序列為錯誤鏈。

使用Is和As檢查錯誤

Go 1.13的errors包中包括了兩個用於檢查錯誤的新函數:Is和As。

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
if errors.As(err, &e) {
// err is a *QueryError, and e is set to the error's value
}

在最簡單的情況下,errors.Is函數的行為類似於上面對哨兵錯誤(sentinel error))的比較,而errors.As函數的行為類似於類型斷言(type assertion)。但是,在處理包裝錯誤(包含其他錯誤的錯誤)時,這些函數會考慮錯誤鏈中的所有錯誤。讓我們再次看一下通過展開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.Is或errors.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對應的參數必須是錯誤(類型)。在所有其他方面,%w與%v等同。

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

使用%w創建的包裝錯誤可用於errors.Is和errors.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 error包的啟發,它將錯誤與模板進行了比較,並且僅考慮模板中非零的字段:

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方法,如果該錯誤實現了As方法。

錯誤和包API

返回錯誤的程序包(大多數都會返回錯誤)應描述程序員可能依賴的那些錯誤的屬性。一個經過精心設計的程序包也將避免返回帶有不應依賴的屬性的錯誤。

最簡單的規約是用於說明操作成功或失敗的屬性,分別返回nil或non-nil錯誤值。在許多情況下,不需要進一步的信息了。

如果我們希望函數返回可識別的錯誤條件,例如“item not found”,則可能會返回包裝哨兵的錯誤。

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() {
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)
}
// ...
}

結論

儘管我們討論的更改僅包含三個函數和一個格式化動詞(%w),但我們希望它們能大幅改善Go程序中錯誤處理的方式。我們希望通過包裝來提供其他上下文的方式得到Gopher們地普遍使用,從而幫助程序做出更好的決策,並幫助程序員更快地發現錯誤。

正如Russ Cox在GopherCon 2019主題演講中所說的那樣,在Go2的道路上,我們進行了實驗,簡化和發佈。現在,我們已經發布了這些更改,我們期待接下來的實驗。

本文翻譯自Go官方博客:《Working with Errors in Go 1.13》

原文鏈接:https://tonybai.com/2019/10/18/errors-handling-in-go-1-13/

"


分享到:


相關文章: