如何優雅地在 Go 程序中做錯誤處理?

【導讀】本文是一篇演講記錄,其中介紹了 go 項目中做錯誤處理的最佳實踐。

Errors are just values

我花了很多時間考慮 Go 程序中錯誤處理的最佳方法。我真希望存在單一的錯誤處理方式,可以通過死記硬背教給所有 Go 程序員,就像教數學或英文字母表一樣。

但是,我得出結論,不存在單一的錯誤處理方式。相反,我認爲 Go 的錯誤處理可以分爲三個核心策略。

Sentinel errors

第一類錯誤處理就是我所說的_sentinel errors_。

if err == ErrSomething { … }

該名稱源於計算機編程中使用特定值的實踐,表示不可能進一步處理。因此,對於 Go,我們使用特定值來表示錯誤。

例子包括 io.EOF 類的值,或低層級的錯誤,如 syscall 包中的常 syscall.ENOENT

甚至還有 sentinel errors 表示_沒有_發生錯誤,比如 go/build.NoGoError , 和 path/filepath.Walkpath/filepath.SkipDir

使用 sentinel 值是靈活性最小的錯誤處理策略,因爲調用者必須使用等於運算符,將結果與預先聲明的值進行比較。當您想要提供更多上下文時就會出現問題,因爲返回一個不同的錯誤會破壞相等檢查。

即使是用心良苦的使用 fmt.Errorf 爲錯誤添加一些上下文,將使調用者的相等測試失敗。調用者轉而被迫查看 errorError 方法的輸出,以查看它是否與特定字符串匹配。

Never inspect the output of error.Error

另外,我認爲永遠不應該檢查 error.Error 方法的輸出。error 接口上的 Error 方法是爲人類,而不是代碼。

該字符串的內容屬於日誌文件,或顯示在屏幕上。您不應該嘗試通過檢查它以更改程序的行爲。

我知道有時候這是不可能的,正如有人在推特上指出的那樣,此建議並不適用於編寫測試。更重要的是,在我看來,比較錯誤的字符串形式是一種代碼氣味,你應該儘量避免它。

Sentinel errors become part of your public API

如果您的 public 函數或方法返回特定值的錯誤,那麼該值必須是 public 的,當然還要有文檔記錄。這會增加 API 的面積。

如果您的 API 定義了一個返回特定錯誤的接口,則該接口的所有實現都將被限制爲僅返回該錯誤,即使它們可能提供更具描述性的錯誤。

通過 io.Reader 看到這一點 。像 io.Copy 這樣的函數,需要一個 reader 實現來_精確_地返回 io.EOF,以便向調用者發出不再有數據的信號,但這不是錯誤 。

Sentinel errors create a dependency between two packages

到目前爲止,sentinel error values 的最大問題是它們在兩個包之間創建源代碼依賴性。例如,要檢查錯誤是否等於 io.EOF,您的代碼必 import io 包。

這個具體示例聽起來並不那麼糟糕,因爲它很常見,但想象一下,當項目中的許多包導出 error values,項目中的其他包必須 import 以檢查特定的錯誤條件時存在的耦合。

在一個玩弄這種模式的大型項目中工作過,我可以告訴你,以 import 循環的形式出現的糟糕設計的幽靈從未遠離我們的腦海。

Conclusion: avoid sentinel errors

所以,我的建議是在你編寫的代碼中避免使用 sentinel error values。在某些情況下,它們會在標準庫中使用,但你不應該模仿這種模式。

如果有人要求您從包中導出錯誤值,您應該禮貌地拒絕,而是建議一種替代方法,例如我將在下面討論的方法。

Error types

Error types 是我想討論的 Go 錯誤處理的第二種形式。

if err, ok := err.(SomeType); ok { … }

錯誤類型是您創建的實現錯誤接口的類型。在此示例中,MyError 類型跟蹤文件和行,以及解釋所發生情況的消息。

type MyError struct {
 Msg string
 File string
 Line int
}

func (e *MyError) Error() string {
 return fmt.Sprintf("%s:%d: %s”, e.File, e.Line, e.Msg)
}

return &MyError{"Something happened", “server.go", 42}

由於 MyError error 是一種類型,因此調用者可以使用類型斷言從錯誤中提取額外的上下文。

err := something()
switch err := err.(type) {
case nil:
// call succeeded, nothing to do
case *MyError:
fmt.Println(“error occurred on line:”, err.Line)
default:
// unknown error
}

error types 相對於 error values 的重大改進是,它們能夠包裝底層錯誤以提供更多上下文。

一個很好的例子是 os.PathError 類型,它通過它試圖執行的操作和它試圖使用的文件來註釋底層錯誤。

// PathError records an error and the operation
// and file path that caused it.
type PathError struct {
 Op string
 Path string
 Err error // the cause
}

func (e *PathError) Error() string
Problems with error types

調用者可以使用類型斷言或類型 switch,error types 必須是 public。

如果您的代碼實現了一個接口,其契約需要特定的錯誤類型,則該接口的所有實現者都需要依賴於定義錯誤類型的包。

對包類型的深入瞭解,會建立與調用者很強耦合,從而形成一個脆弱的 API。

Conclusion: avoid error types

雖然 error typessentinel error values 更好,因爲它們可以捕獲更多關於錯誤的上下文,錯誤類型同樣擁有許多 error values 的問題。

所以我的建議是避免 error types,或者至少避免使它們成爲公共 API 的一部分。

Opaque errors

現在我們來看第三類錯誤處理。在我看來,這是最靈活的錯誤處理策略,因爲它需要的代碼和調用者之間的耦合最小。

我將這種方式稱爲不透明的錯誤處理,因爲雖然您知道發生了錯誤,但您無法查看錯誤內部。作爲調用者,您對操作結果的所有了解都是有效的,或者沒有。

這就是不透明的錯誤處理 - 只返回錯誤而不假設其內容。如果採用此方式,則錯誤處理可以作爲調試輔助工具,變得非常有用。

import “github.com/quux/bar”

func fn() error {
 x, err := bar.Foo()
 if err != nil {
  return err
 }
 // use x
}

例如,Foo 的契約不保證它將在錯誤的上下文中返回什麼。通過傳遞錯誤附帶額外的上下文,Foo 的作者現在可以自由地註釋錯誤,而不會違反與調用者的契約。

Assert errors for behaviour, not type

在少數情況下,使用二分法(是否有錯誤)來進行錯誤處理是不夠的。

例如,與進程外部的服務(例如網絡活動)的交互,要求調用者查看錯誤的性質,以確定重試操作是否合理。

在這種情況下,我們可以斷言錯誤實現了特定的行爲,而不是斷言錯誤是特定的類型或值。考慮這個例子:

type temporary interface {
 Temporary() bool
}

// IsTemporary returns true if err is temporary.
func IsTemporary(err error) bool {
 te, ok := err.(temporary)
 return ok && te.Temporary()
}

可以將任何錯誤傳遞給 IsTemporary 以確定錯誤是否可以重試。

如果錯誤沒有實現 temporary 接口;也就是說,它沒有 Temporary 方法,那麼錯誤不是臨時的。

如果錯誤確實實現了 Temporary,那麼如果 true 返回 true ,調用者可以重試該操作。

這裏的關鍵是,此邏輯可以在不導入定義錯誤的包,或者直接知道任何關於 err的基礎類型的情況下實現 - 我們只是對它的行爲感興趣。

Don’t just check errors, handle them gracefully

讓我想到了第二句 Go 諺語,我想談談;不要僅僅檢查錯誤,優雅地處理它們。你能用以下代碼提出一些問題嗎?

func AuthenticateRequest(r *Request) error {
 err := authenticate(r.User)
 if err != nil {
  return err
 }
 return nil
}

一個明顯的建議是,函數的五行可以替換爲:

return authenticate(r.User)

但這是每個人都應該在代碼審查中發現的簡單問題。這段代碼更根本的問題是無法分辨原始錯誤來自哪裏。

如果 authenticate 返回錯誤,那麼 AuthenticateRequest 會將錯誤返回給調用者,調用者也可能會這樣做,依此類推。在程序的頂部,程序的主體將錯誤打印到屏幕或日誌文件,所有打印的都會是:No such file or directory

沒有生成錯誤的文件和行的信息。沒有導致錯誤的調用堆棧的 stack trace。該代碼的作者將被迫進行一個長的會話,將他們的代碼二等分,以發現哪個代碼路徑觸發了文件未找到錯誤。

Donovan 和 Kernighan 的_The Go Programming Language_建議您使用 fmt.Errorf 向錯誤路徑添加上下文

func AuthenticateRequest(r *Request) error {
 err := authenticate(r.User)
 if err != nil {
  return **fmt.Errorf("authenticate failed: %v", err)**
 }
 return nil
}

但是正如我們之前看到的,這種模式與使用 sentinel error values 或類型斷言不兼容,因爲將錯誤值轉換爲字符串,將其與另一個字符串合併,然後使用 fmt.Errorf 將其轉換回錯誤,破壞了相等性,同時完全破壞了原始錯誤中的上下文。

Annotating errors

我想建議一種方法來爲錯誤添加上下文,爲此,我將介紹一個簡單的包。該代碼在 github.com/pkg/errors 提供。錯誤包有兩個主要函數:

// Wrap annotates cause with a message.
func Wrap(cause error, message string) error

第一個函數是 Wrap,它接收一個錯誤和一段消息,併產生一個新的錯誤。

// Cause unwraps an annotated error.
func Cause(err error) error

第二個函數是 Cause,它接收可能已被包裝的錯誤,並將其解包以恢復原始錯誤。

使用這兩個函數,我們現在可以註釋任何錯誤,並在需要檢查時恢復底層錯誤。考慮一個將文件內容讀入內存的函數的例子。

func ReadFile(path string) ([]byte, error) {
 f, err := os.Open(path)
 if err != nil {
  return nil, **errors.Wrap(err, "open failed")**
 }
 defer f.Close()

 buf, err := ioutil.ReadAll(f)
 if err != nil {
  return nil, **errors.Wrap(err, "read failed")**
 }
 return buf, nil
}

我們將使用此函數編寫一個函數來讀取配置文件,然後從 main 調用它。

func ReadConfig() ([]byte, error) {
 home := os.Getenv("HOME")
 config, err := ReadFile(filepath.Join(home, ".settings.xml"))
 return config, **errors.Wrap(err, "could not read config")**
}

func main() {
 _, err := ReadConfig()
 if err != nil {
  fmt.Println(err)
  os.Exit(1)
 }
}

如果 ReadConfig 代碼路徑失敗,因爲我們使用了 errors.Wrap,我們在 K&D 樣式中得到一個很好的註釋錯誤。

could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory

因爲 errors.Wrap 會產生堆棧錯誤,所以我們可以檢查該堆棧以獲取其他調試信息。這又是一個相同的例子,但這次我們用 fmt.Println 替換 errors.Print

func main() {
 _, err := ReadConfig()
 if err != nil {
  errors.Print(err)
  os.Exit(1)
 }
}

我們會得到如下信息:

readfile.go:27: could not read config
readfile.go:14: open failed
open /Users/dfc/.settings.xml: no such file or directory

第一行來自 ReadConfig,第二行來自 ReadFileos.Open 部分,其餘部分來自 os 包本身,它不攜帶位置信息。

現在我們已經介紹了包裝錯誤生成堆棧的概念,我們需要討論反向操作,展開它們。這是 errors.Cause 函數的域。

// IsTemporary returns true if err is temporary.
func IsTemporary(err error) bool {
 te, ok := **errors.Cause(err)**.(temporary)
 return ok && te.Temporary()
}

在操作中,每當您需要檢查錯誤是否與特定值或類型匹配時,您應首先使用 errors.Cause 函數恢復原始錯誤。

Only handle errors once

最後,我想提一下:你應該只處理一次錯誤。處理錯誤意味着檢查錯誤值並做出決定。

func Write(w io.Writer, buf []byte) {
 w.Write(buf)
}

如果不做決定,則忽略該錯誤。正如我們在這裏看到的那樣,w.Write 的錯誤被丟棄了。

但是,針對單個錯誤做出多個決策也存在問題。

func Write(w io.Writer, buf []byte) error {
 _, err := w.Write(buf)
 if err != nil {
  // annotated error goes to log file
  log.Println("unable to write:", err)

  // unannotated error returned to caller
  return err
 }
 return nil
}

在此示例中,如果在 Write 期間發生錯誤,則會將一行寫入日誌文件,注意錯誤發生的文件和行,並且錯誤也會返回給調用者,調用者可能會將其記錄並返回,一路回到程序的頂部。

因此,您在日誌文件中獲得了重複的行的堆棧,但是在程序的頂部,您將獲得沒有原始錯誤的任何上下文。有人使用 Java 嗎?

func Write(w io.Write, buf []byte) error {
 _, err := w.Write(buf)
 return **errors.Wrap(err, "write failed")**
}

使用 errors 包,您可以以人和機器都可檢查的方式向錯誤值添加上下文。

Conclusion

總之,錯誤是包 public API 的一部分,對待它們就像對待 public API 的其他部分一樣小心。

爲了獲得最大的靈活性,我建議您嘗試將所有錯誤都視爲不透明的。在不能這樣做的情況下,斷言行爲錯誤,而不是類型或值錯誤。

最小化程序中的 sentinel error values,並在錯誤發生時立即用 errors.Wrap 將其包裝,從而將錯誤轉換爲不透明錯誤。

最後,如果需要檢查,請使用 errors.Cause 恢復底層錯誤。

轉自:

cyningsun.com/09-09-2019/dont-just-check-errors-handle-them-gracefully-cn.html

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