來源:cyningsun.github.io/09-09-2019/…html
本文摘自我最近在日本東京舉行的GoCon春季會議上的演講。git
我花了不少時間考慮Go程序中錯誤處理的最佳方法。我真但願存在單一的錯誤處理方式,能夠經過死記硬背教給全部Go程序員,就像教數學或英文字母表同樣。程序員
可是,我得出結論,不存在單一的錯誤處理方式。 相反,我認爲Go的錯誤處理能夠分爲三個核心策略。github
第一類錯誤處理就是我所說的_sentinel errors_。編程
if err == ErrSomething { … }
複製代碼
該名稱源於計算機編程中使用特定值的實踐,表示不可能進一步處理。 所以,對於Go,咱們使用特定值來表示錯誤。網絡
例子包括 io.EOF
類的值,或低層級的錯誤,如 syscall
包中的常 syscall.ENOENT
。app
甚至還有 sentinel errors
表示_沒有_發生錯誤,好比 go/build.NoGoError
, 和 path/filepath.Walk
的 path/filepath.SkipDir
。函數
使用 sentinel
值是靈活性最小的錯誤處理策略,由於調用者必須使用等於運算符,將結果與預先聲明的值進行比較。 當您想要提供更多上下文時就會出現問題,由於返回一個不一樣的錯誤會破壞相等檢查。工具
即便是用心良苦的使用 fmt.Errorf
爲錯誤添加一些上下文,將使調用者的相等測試失敗。 調用者轉而被迫查看 error
的 Error
方法的輸出,以查看它是否與特定字符串匹配。測試
另外,我認爲永遠不該該檢查 error.Error
方法的輸出。error
接口上的 Error
方法是爲人類,而不是代碼。
該字符串的內容屬於日誌文件,或顯示在屏幕上。 您不該該嘗試經過檢查它以更改程序的行爲。
我知道有時候這是不可能的,正若有人在推特上指出的那樣,此建議並不適用於編寫測試。 更重要的是,在我看來,比較錯誤的字符串形式是一種代碼氣味,你應該儘可能避免它。
若是您的 public 函數或方法返回特定值的錯誤,那麼該值必須是 public 的,固然還要有文檔記錄。 這會增長API的面積。
若是您的API定義了一個返回特定錯誤的接口,則該接口的全部實現都將被限制爲僅返回該錯誤,即便它們可能提供更具描述性的錯誤。
經過 io.Reader
看到這一點 。 像 io.Copy
這樣的函數,須要一個 reader 實現來_精確_地返回 io.EOF
,以便向調用者發出再也不有數據的信號,但這不是錯誤 。
到目前爲止,sentinel error values
的最大問題是它們在兩個包之間建立源代碼依賴性。 例如,要檢查錯誤是否等於 io.EOF
,您的代碼必 import io
包。
這個具體示例聽起來並不那麼糟糕,由於它很常見,但想象一下,當項目中的許多包導出 error values
,項目中的其餘包必須 import 以檢查特定的錯誤條件時存在的耦合。
在一個玩弄這種模式的大型項目中工做過,我能夠告訴你,以 import 循環的形式出現的糟糕設計的幽靈從未遠離咱們的腦海。
因此,個人建議是在你編寫的代碼中避免使用 sentinel error values
。 在某些狀況下,它們會在標準庫中使用,但你不該該模仿這種模式。
若是有人要求您從包中導出錯誤值,您應該禮貌地拒絕,而是建議一種替代方法,例如我將在下面討論的方法。
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 複製代碼
調用者可使用類型斷言或類型 switch,error types
必須是 public。
若是您的代碼實現了一個接口,其契約須要特定的錯誤類型,則該接口的全部實現者都須要依賴於定義錯誤類型的包。
對包類型的深刻了解,會創建與調用者很強耦合,從而造成一個脆弱的API。
雖然 error types
比 sentinel error values
更好,由於它們能夠捕獲更多關於錯誤的上下文,錯誤類型一樣擁有許多 error values
的問題。
因此個人建議是避免 error types
,或者至少避免使它們成爲公共API的一部分。
如今咱們來看第三類錯誤處理。 在我看來,這是最靈活的錯誤處理策略,由於它須要的代碼和調用者之間的耦合最小。
我將這種方式稱爲不透明的錯誤處理,由於雖然您知道發生了錯誤,但您沒法查看錯誤內部。 做爲調用者,您對操做結果的全部瞭解都是有效的,或者沒有。
這就是不透明的錯誤處理 - 只返回錯誤而不假設其內容。 若是採用此方式,則錯誤處理能夠做爲調試輔助工具,變得很是有用。
import 「github.com/quux/bar」
func fn() error {
x, err := bar.Foo()
if err != nil {
return err
}
// use x
}
複製代碼
例如,Foo
的契約不保證它將在錯誤的上下文中返回什麼。經過傳遞錯誤附帶額外的上下文,Foo
的做者如今能夠自由地註釋錯誤,而不會違反與調用者的契約。
在少數狀況下,使用二分法(是否有錯誤)來進行錯誤處理是不夠的。
例如,與進程外部的服務(例如網絡活動)的交互,要求調用者查看錯誤的性質,以肯定重試操做是否合理。
在這種狀況下,咱們能夠斷言錯誤實現了特定的行爲,而不是斷言錯誤是特定的類型或值。 考慮這個例子:
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
的基礎類型的狀況下實現 - 咱們只是對它的行爲感興趣。
讓我想到了第二句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
將其轉換回錯誤,破壞了相等性,同時徹底破壞了原始錯誤中的上下文。
我想建議一種方法來爲錯誤添加上下文,爲此,我將介紹一個簡單的包。 該代碼在 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
,第二行來自 ReadFile
的 os.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
函數恢復原始錯誤。
最後,我想提一下:你應該只處理一次錯誤。 處理錯誤意味着檢查錯誤值並作出決定。
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
}
複製代碼
In this example if an error occurs during Write
, a line will be written to a log file, noting the file and line that the error occurred, and the error is also returned to the caller, who possibly will log it, and return it, all the way back up to the top of the program.
So you get a stack of duplicate lines in your log file, but at the top of the program you get the original error without any context. Java anyone?
在此示例中,若是在 Write
期間發生錯誤,則會將一行寫入日誌文件,注意錯誤發生的文件和行,而且錯誤也會返回給調用者,調用者可能會將其記錄並返回,一路回到程序的頂部。
所以,您在日誌文件中得到了重複的行的堆棧,可是在程序的頂部,您將得到沒有原始錯誤的任何上下文。 有人使用Java嗎?
func Write(w io.Write, buf []byte) error {
_, err := w.Write(buf)
return **errors.Wrap(err, "write failed")**
}
複製代碼
使用 errors
包,您能夠以人和機器均可檢查的方式向錯誤值添加上下文。
總之,錯誤是包 public API 的一部分,對待它們就像對待 public API 的其餘部分同樣當心。
爲了得到最大的靈活性,我建議您嘗試將全部錯誤都視爲不透明的。在不能這樣作的狀況下,斷言行爲錯誤,而不是類型或值錯誤。
最小化程序中的 sentinel error values
,並在錯誤發生時當即用 errors.Wrap
將其包裝,從而將錯誤轉換爲不透明錯誤。
最後,若是須要檢查,請使用 errors.Cause
恢復底層錯誤。