這篇文章摘取至我在日本東京舉辦的 GoCon spring conference 上的演講稿。
錯誤只是一些值
我花了很多時間來思考如何在 Go 中處理錯誤是最好的。我真希望能有一種簡單直接的方式來處理錯誤,一些我們只要讓 Go 程序員記住就能使用的規則,就像教數學或字母表一樣。
然而,我得到的結論是:處理錯誤不止有一種方式。我認為 Go 處理錯誤的方式可以劃分為 3 種主要的策略。
標記錯誤策略
第一種錯誤處理策略,我稱之為標記錯誤
這個名字來源于在實際編程中,使用一個指定的值來表示程序已經無法繼續執行。所以在 Go 中我們使用一個指定的值來表示錯誤。
例如:系統包里面的 io.EOF 或是在 syscall 包中更底層些的常量錯誤例如
syscall.ENOENT。
甚至還有標記表示沒有錯誤發生例如:path/filepath.Walk 中的 go/build.NoGoError 和 path/filepath.SkipDir。
使用標記值是靈活性最差的一種錯誤處理策略,調用者必須使用相等操作符來比較返回值和預先定義的值。當你想要提供更多的相關信息時,返回不同的錯誤值會破壞等式檢查操作。
即使通過 fmt.Errorf 來提供更多的信息也會干擾調用者的等式測試,調用者必須去看 Error 方法輸出的結果是否匹配某個指定的字符串。
永遠不要檢查 error.Error 的輸出
順便說一下,我相信你永遠都不需要檢查 error.Error 方法的返回值。 error 接口中的 Error 方法是提供給使用者查看的信息,而不是用來給代碼做判斷的。
這些信息應該在日志文件中或者是顯示屏上出現,你不需要通過檢查這些信息來改變程序行為。
我知道有時候這樣很難,就像有些人在 twitter 上提到的那樣,這條建議在寫測試的時候不適用。盡管如此,在我看來,作為一種編碼風格,你應該避免比較字符型的錯誤信息。
標記錯誤成為公開 API 的一部分
如果你的公開函數或方法返回了一些指定的錯誤值,那么這些值必須是公開的,當然也需要在文檔中有所描述。這些加入到你的 API 中了。
如果你的 API 定義了一個返回指定錯誤的接口,那么所有該接口的實現都必須只返回這個錯誤,就算能提供更多的其他信息也不應該返回除了指定錯誤之外的信息。
我們可以在 io.Reader 中看到這樣的處理方式。 io.Copy 要求 reader 實現返回 io.EOF 通知調用者沒有更多的數據了,但是這并不是一個錯誤。
標記錯誤在兩個包之間制造了依賴關系
最大的問題是標記錯誤在兩個包之間制造了源碼層面的依賴關系。例如:檢查一個錯誤是否是 io.EOF 你的代碼必須引入 io 包。
這個例子看起來沒那么糟糕,因為這是很普通的操作。但是想象一下,項目中很多包導出錯誤值,而其他包必須導入對應的包才能檢查錯誤條件,這樣就違背了低耦合的設計原則。
我參與過的一個大型項目,使用的就是這種錯誤處理模式,我可以告訴你不好的設計所帶來的循環引入問題近在咫尺。
結論:避免使用標記錯誤策略
所以,我的建議是避免在代碼中使用標記錯誤處理策略。在標準庫中有些情況使用了這種處理方式,但是這并不是你應該效仿的一種處理模式。
如果有人要求你從你的包里面暴露一個錯誤值,你應該禮貌的拒絕他,并提供一個替代方案,也就是下面將要提到的方法。
錯誤類型
錯誤類型是我想討論的第二種 Go 錯誤處理模式。
錯誤類型是你創建的實現 error 接口的類型。在下面的例子中, MyError 類型記錄了文件,行號,相關的錯誤信息。
因為 MyError 是一個類型,所以調用者可以使用 type assertion 從 error 中獲取相關信息。
錯誤類型比標記錯誤最大的改進就是通過封裝底層的錯誤來提供更多的相關信息。
一個絕佳的例子就是 os.PathError 除了底層錯誤外還提供了使用哪個文件,執行哪個操作等相關信息。
錯誤類型存在的問題
調用者可以使用 type assertion 或者 type switch,error 類型必須是公開的。
如果你的代碼實現了一個約定指定錯誤類型的接口,那么所有這個接口的實現者,都要依賴于定義這個錯誤類型的包。
對包的錯誤類型的過度暴露,使調用者和包之間產生了很強的耦合性,導致了 API 的脆弱性。
結論:避免錯誤類型
雖然錯誤類型在發生錯誤時能夠捕捉到更多的環境信息,比標記錯誤要好一些,但是錯誤類型也存在很多和標記錯誤一樣的問題。
所以,在這里我的建議是避免使用錯誤類型,至少避免使他們成為你 API 接口的一部分。
封裝錯誤
現在我們到了第三個錯誤處理分類。在我看來這個是靈活性最好的處理策略,在調用者和你的代碼之間產生的耦合度最低。
我管這種處理方式叫做封裝錯誤 (Opaque errors),因為當你發現有錯誤發生時,你無法知道內部的錯誤情況。作為調用者,你只知道調用的結果成功或者失敗。
封裝錯誤處理方式只返回錯誤不去猜測他的內容。如果你采用了這種處理方式,那么錯誤處理在調試方面會變得非常有價值。
例如:Foo 的調用約定沒有指定在發生錯誤時會返回哪些相關信息,這樣 Foo 函數的開發者就可以自由的提供相關錯誤信息,并且不會影響到和調用者之間的約定。
斷言行為,而不是類型
在少數情況下,這種二元錯誤處理方案是不夠的。
例如:在和進程外交互的時候,比如網絡活動,需要調用者評估錯誤情況來決定是否需要重試操作。
在這種情況下我們斷言錯誤實現指定的行為要比斷言指定類型或值好些。看看下面的例子:
我們可以傳任何錯誤給 IsTemporary 來判斷錯誤是否需要重試。
如果錯誤沒有實現 temporary 接口;那么就沒有 Temporary 方法,那么錯誤就不是 temporary。
如果錯誤實現了 Temporary,如果 Temporary 返回 true 那么調用者就可以考慮重試該操作。
這里的關鍵點是這個實現邏輯不需要導入定義錯誤的包或者了解任何關于錯誤的底層類型,我們只要簡單的關注它的行為即可。
優雅的處理錯誤,而不僅僅只是檢查錯誤
這引出了我想談的第二個 Go 語言的格言:優雅的處理錯誤,而不僅僅只是檢查錯誤。你能在下面的代碼中找出錯誤么?
一個很明顯的建議是上面的代碼可以簡化為
但是這只是個簡單的問題,任何人在代碼審查的時候都應該看到。更根本的問題是這段代碼看不出來原始錯誤是在哪里發生的。
如果 authenticate 返回錯誤, 那么 AuthenticateRequest 將會返回錯誤給調用者,調用者也一樣返回。 在程序的最上一層主函數塊內打印錯誤信息到屏幕或者日志文件,然而所有信息就是 No such file or directory
沒有錯誤發生的文件,行號等信息,也沒有調用棧信息。代碼的編寫者必須在一堆函數中查找哪個調用路徑會返回 file not found 錯誤。
Donovan 和 Kernighan 寫的 The Go Programming Language 建議你使用 fmt.Errorf 在錯誤路徑中增加相關信息
就像我們在前面提到的,這個模式不兼容標記錯誤或者類型斷言,因為轉換錯誤值到字符串,再和其他的字符串合并,再使用 fmt.Errorf 轉換為error 打破了對等關系,破壞了原始錯誤的相關信息。
注解錯誤
我在這里建議一個給錯誤添加相關信息的方法,要用到一個簡單的包。代碼在 github.com/pkg/errors。這個包有兩個主要的函數:
第一個函數是封裝函數 Wrap ,輸入一個錯誤和一個信息,生成一個新的錯誤返回。
第二個函數是 Cause ,輸入一個封裝過的錯誤,解包之后得到原始的錯誤信息。
使用這兩個函數,我們現在可以給任何錯誤添加相關信息,并且在我們需要查看底層錯誤類型的時候可以解包查看。下面的例子是讀取文件內容到內存的函數。
我們使用這個函數寫一個讀取配置文件的函數,然后在 main 中調用。
如果 ReadConfig 發生錯誤,由于使用了 errors.Wrap,我們可以得到一個 K&D 風格的包含相關信息的錯誤
could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory
因為 errors.Wrap 生成了發生錯誤時的調用棧信息,所以我們可以查看額外的調用棧調試信息。這又是一個同樣的例子,但是這次我們用 errors.Print 替換 fmt.Println
我們會得到如下的信息:
第一行來至 ReadConfig, 第二行來至 os.Open 的 ReadFile, 剩下的來至 os 包,沒有攜帶位置信息。
現在我們介紹了關于打包錯誤生成棧的概念,我們需要談談如何解包。下面是 errors.Cause 函數的作用。
操作中,當你需要檢查一個錯誤是否匹配一個指定值或類型時,你需要先使用 errors.Cause 獲取原始錯誤信息
只處理一次錯誤
最后我想要說的是,你只需要處理一次錯誤。處理錯誤意味著檢查錯誤值,然后作出決定。
如果你不需要做出決定,你可以忽略這個錯誤。在上面的例子可以看到我們忽略了 w.Write 返回的錯誤。
但是在返回一個錯誤時做出多個決定也是有問題的。
在這個例子中,如果 Write 發生錯誤, 一行信息會寫入日志,記錄發送錯誤的文件和行號,同時把錯誤返回給調用者,同樣的調用者也可能會寫入日志,然后返回,直到程序的最頂層。
日志文件里就會出現一堆重復的信息,但是在程序最頂層獲得的原始錯誤卻沒有任何相關信息。
使用 errors 包可以讓你在 error 里面加入相關信息,并且內容是可以被人和機器所識別的。
結論
最后,錯誤是你提供的包中公開 API 的一部分,要像其他公開 API 一樣小心對待。
為了獲得最大的靈活性,我建議你嘗試把所有的錯誤當做封裝錯誤來處理,在那些無法做到的情況下,斷言行為錯誤,而不是類型或值。
盡可能少的在你程序中使用標記錯誤,在錯誤發生時盡早的使用 errors.Wrap 打包封裝為封裝錯誤。
via: https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully
作者:Dave Cheney 譯者:tyler2018 校對:polaris1119
本文由 GCTT 原創編譯,Go語言中文網 榮譽推出