1. 程式人生 > 程式設計 >譯|Don’t just check errors, handle them gracefully

譯|Don’t just check errors, handle them gracefully

來源:cyningsun.github.io/09-09-2019/…

本文摘自我最近在日本東京舉行的GoCon春季會議上的演講。

Don't just check errors,handle them gracefully

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 為錯誤新增一些上下文,將使呼叫者的相等測試失敗。 呼叫者轉而被迫檢視 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 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

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,"read failed")**
	}
	return buf,nil
}
複製程式碼

我們將使用此函式編寫一個函式來讀取配置檔案,然後從 main 呼叫它。

func ReadConfig() ([]byte,error) {
	home := os.Getenv("HOME")
	config,err := ReadFile(filepath.Join(home,".settings.xml"))
	return config,"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
}
複製程式碼

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,err := w.Write(buf)
	return **errors.Wrap(err,"write failed")**
}
複製程式碼

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

Conclusion

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

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

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

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