當前 Go 版本 1.17.5
Go 的錯誤處理設計#
關於 Go 的錯誤處理,一直爭議不斷。其實 Go 本身的爭議也不少 😂。這裡不進行評價,只是簡單地和主流語言對比,總結一下 Go 的特徵。
作為一個歷史悠久的語言,C 的錯誤處理非常混亂。典型情況下,C 通過返回值表示執行是否成功,至於失敗的具體原因,通過額外的全局變量傳遞。這種設計的致命缺陷是錯誤與返回結果混淆在一起,例如函數 int atoi(const char *str)
,如果轉換出錯應該返回什麼呢?0?-1?其實都不合理,因為任何數字都有可能代表一個正常的結果。
為此,部分開發者選擇利用指針傳遞結果,而返回值僅表示是否出錯。這確實解決了上述問題,但又導致了「參數」語義的混淆。沒有經驗的同學看到什麼入參出參搞的一臉懵比。
而近現代語言通常採用 try-catch
的思想。例如 java 與 python。這種模型既分離的錯誤與返回值和參數,也提供了結構化處理的可能。通過面相對象的思想,開發者可以自定義錯誤類、子類,它們又可以包裝其他錯誤,確保錯誤上下文不會丟失。
Go 則與眾不同。它利用多返回值的特性做到了錯誤的分離,因此,很多 Go 的函數最後一個返回值都是一個 error
來標識是否出錯。相比於 C 這是一個進步,但沒有提供靈活的捕獲機制,這就仁者見仁智者見智了。
我個人的觀點是:不應該試圖在 A 語言中復刻 B 語言的功能,而是根據語言自身的特點,在宏觀理念的指導下,實現針對性的錯誤處理方案。 後文將以這個觀點為繼續進行探索。
基本理念#
雖然語法不同,但是一些語言無關的思想還是很重要的,我們應該盡量遵循。
首先,一個錯誤信息,至少要具有兩個作用:
- 給程序看。可以根據錯誤類型進入處理分支。
- 給人看。告訴我們到底發生了什麼。
不要重複處理錯誤#
私以為這是很容易踩的一個坑,考慮下面的代碼:
func foo() (Result, error) {
result, err := repository.Find(id)
if err != nil {
log.Errof(err)
return Result{}, err
}
return result, nil
}
這裡打印了錯誤(相當於處理),然後返回它。可以想象,foo()
的調用方會再次打印,再返回。最終一個錯誤會打印出一大堆東西。
錯誤要包含調用棧#
調用棧是 debug 的基本需要。如果僅僅是一層層返回 error,那麼最頂層的函數將收到最底層的錯誤。試想你用 Go 寫了一個 web 後端,在處理一個支付請求時出現了 io 錯誤。這對 debug 毫無幫助 —— 無法定位哪個環節出錯。
另一個不好的方案是,每一層只返回自己的錯誤,例如:
func pay(){
if err := checkOrder(); err!=nil {
return errors.New("支付異常")
}
return nil
}
func checkOrder() error {
if err := calcMoney(); err!=nil {
return errors.New("計算金額異常")
}
return nil
}
func calcMoney() error {
if err := querySql(); err!=nil {
return errors.New("查詢數據庫異常")
}
return nil
}
這種情況和上一種相反:頂層函數只能得到最近的錯誤,對引發的原因一無所知,顯然這不是我們希望的。
錯誤要是結構化的#
有聰明的同學改造了一下代碼來解決丟失調用棧的問題:
func checkOrder() error {
if err := calcMoney(); err!=nil {
return fmt.Errorf("計算金額異常:%s", err)
}
return nil
}
確實,修改後頂層函數得到的異常如下:
支付異常:計算金額異常:查詢數據庫異常
現在基本上可以看出調用棧了,但僅限於人類。這個錯誤是否屬於數據庫異常?計算機無從得知。我們可以通過自然語言處理來解決,除非你瘋了。
所以需要結構化錯誤 —— 錯誤有包含關係,子錯誤是父錯誤的一種,頂層函數可以輕鬆判斷異常類型。
錯誤要有上下文#
這一點很好理解了,我們希望錯誤日誌包含一些相關的數據,比如用戶 id,訂單 id 等。
值得注意的是,這個原則要和「不重複處理」結合,否則將會得到這樣的天書 log:
支付異常 uid=123, orderId=456, reqId=328952104:計算金額異常 uid=123, orderId=456, reqId=328952104:查詢數據庫異常 uid=123, orderId=456, reqId=328952104
實踐#
錯誤鏈#
曾經,在 Go 中做到保留調用棧和結構化是很麻煩的,為此 errors 開源庫被廣泛使用。不過 Go 1.13 一定程度上增強了錯誤處理,Go 2 也計劃進一步改善。因此這個庫已進入維護狀態。
現在,調用棧不再需要我們自己構造,通過 fmt.Errorf("... %w", ..., err)
就可以包裝一個錯誤,層層套娃形成錯誤鏈。相應的,通過 errors.Is()
或 errors.As()
可以判斷一個錯誤(鏈)是否是(包含)另一個錯誤,前者要求嚴格相等,後者只需類型一致。不知不覺中,「結構化」也基本上實現了。
等等!為什麼說「基本」?雖然標準庫提供了判斷錯誤類型的方法,但是錯誤類型是啥?相比 java 中拋出一個具體異常,Go 中基本上只會返回底層錯誤接口 error
,而沒有具體結構體,那怎麼判斷呀?難道又要回到 err.msg
的時代?當然不是,包裝一個錯誤,除了使用 fmt
之外,還可以自己定義一個結構體。實際上,fmt.Errorf()
返回的是 wrapError
,相當於是便捷函數,用於無需明確錯誤類型的場景。
自定義結構體#
自定義錯誤結構體不僅幫助識別錯誤類型,還順便解決了上下文問題。通過簡單的 string 自然是 OK 的,不過為了讓上下文本身也可以被程序識別,更好的辦法是作為結構體的一個字段:
type orderError struct {
orderId int
msg string
err error
}
func (e *orderError) Error() string {
return e.msg
}
func (e *orderError) Unwrap() error {
return e.err
}
這個結構體不僅實現了 error 接口,還額外擁有 Unwrap()
方法,這樣就可以包裝其他異常,確保不丟失調用棧。
那麼就可以這麼來返回:
func checkOrder() error {
if err := calcMoney(); err!=nil {
return orderError{123, "計算金額異常", err}
}
return nil
}
公开 OR 私有#
有了自己的結構體,隨之而來的就是它需要公開嗎?這個問題標準庫已經給出了答案:一般不需要。所以我們見到的大多數函數,只返回 error
而不是 xxxError
。根據網上的資料,這麼做旨在隱藏實現細節,提高 lib 的靈活性,減少升級時需要考慮的兼容問題。
不公開結構體,也就是意味著外部無法通過 errors.As()
判斷了,為此,需要公開一個函數幫助外部確認這是否是屬於本 lib 的錯誤。
func IsOrderError(err error) bool {
return errors.As(err, orderError)
}
Error Check Hell#
上面已經總結了返回錯誤的結構,符合一開始提出的基本理念。在實際代碼中,錯誤檢查可能會充斥著項目,甚至每一個調用都裹著一個 if
來及時打斷並返回 —— 因為 Go 沒有 throw 或 raise 機制。看下面這個惡心的例子:
func checkPersion(*p Persion) error {
if err := checkAttr(p.name); err != nil{
return err
}
if err := checkAttr(p.age); err != nil{
return err
}
if err := checkAttr(p.country); err != nil{
return err
}
if err := checkAttr(p.work); err != nil{
return err
}
return nil
}
抽取匿名函數#
此方法適用於連續調用同一個函數。
把錯誤檢查抽取到匿名函數中,若已經存在錯誤,那麼不真正執行,直接返回。
func checkPersion(*p Persion) error {
var err error
check := func(attr interface{}){
if err != nil{
return
}
err = checkAttr(attr)
}
check(p.name)
check(p.age)
check(p.country)
check(p.work)
// more check
return err
}
利用 panic#
⚠️ 使用 panic 來代替 error 是錯誤的習慣。不要濫用此技巧。
// checkAttr() 不再返回 error 而是直接 panic
func checkAttr(attr interface{}) {
if attr == nil{
panic(checkErr{...})
}
}
func checkPersion(*p Persion) (err error) {
defer func() {
if r := recover(); r != nil {
// 恢復 checkAttr() 的 panic 轉為 error
err = r.(checkErr)
}
}()
// do any thing
}
用 panic 簡化 check 的關鍵在於 recover 時只處理已知的錯誤,對於未知情況應該繼續傳遞 panic。因為 panic 原則上僅用於不可恢復的嚴重錯誤(例如數組越界),如果不分情況一律 recover 則可能會掩蓋 bug 引發未知的後果。
有的網站給出下面這種寫法,非常不推薦,除非你知道自己在幹嘛:
func checkPersion(*p Persion) (err error) {
defer func() {
if r := recover(); r != nil {
var ok bool
// 這裡對於未知錯誤也一並捕獲了
err, ok = r.(error)
if !ok {
err = fmt.Errorf("failed to check persion: %v", r)
}
}
}()
// do something thing
}
儘管很多網站都宣傳不要濫用 panic,但我認為,如果像第一個例子那樣,確保自己只捕獲已知的異常來簡化 error check,應該不算做濫用 —— 此時 panic 不會對包外部的調用者造成任何影響 —— 原來會 panic 的現在依然會 panic,原來會返回 error 的現在依然只返回 error。Go 的官方文檔 effective go 也承認了這種用法:
With our recovery pattern in place, the
do
function (and anything it calls) can get out of any bad situation cleanly by callingpanic
. We can use that idea to simplify error handling in complex software.
有了 recovery 模式,我們就可以隨時通過調用 panic 簡單地擺脫異常情況,可以使用該思想來簡化複雜軟件中的錯誤處理。