如何優雅地在 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.Walk
的 path/filepath.SkipDir
。
使用 sentinel
值是靈活性最小的錯誤處理策略,因爲調用者必須使用等於運算符,將結果與預先聲明的值進行比較。當您想要提供更多上下文時就會出現問題,因爲返回一個不同的錯誤會破壞相等檢查。
即使是用心良苦的使用 fmt.Errorf
爲錯誤添加一些上下文,將使調用者的相等測試失敗。調用者轉而被迫查看 error
的 Error
方法的輸出,以查看它是否與特定字符串匹配。
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 types
比 sentinel 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
,第二行來自 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
函數恢復原始錯誤。
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