在使用 Go 開發的後臺服務中,對於錯誤處理,一直以來都有多種不同的方案,本文探討並提出一種從服務內到服務外的錯誤傳遞、返回和回溯的完整方案,還請讀者們一起討論。
問題提出
在後臺開發中,針對錯誤處理,有三個維度的問題需要解決:
- 函式內部的錯誤處理: 這指的是一個函式在執行過程中遇到各種錯誤時的錯誤處理。這是一個語言級的問題
- 函式/模組的錯誤資訊返回: 一個函式在操作錯誤之後,要怎麼將這個錯誤資訊優雅地返回,方便呼叫方(也要優雅地)處理。這也是一個語言級的問題
- 服務/系統的錯誤資訊返回: 微服務/系統在處理失敗時,如何返回一個友好的錯誤資訊,依然是需要讓呼叫方優雅地理解和處理。這是一個服務級的問題,適用於任何語言
針對這三個維度的問題,筆者準備寫三篇文章一一說明。首先本文就是第一篇:函式內部的錯誤處理
高階語言的錯誤處理機制
一個程式導向的函式,在不同的處理過程中需要 handle 不同的錯誤資訊;一個物件導向的函式,針對一個操作所返回的不同型別的錯誤,有可能需要進行不同的處理。此外,在遇到錯誤時,也可以使用斷言的方式,快速中止函式流程,大大提高程式碼的可讀性。
在許多高階語言中都提供了 try ... catch
的語法,函式內部可以通過這種方案,實現一個統一的錯誤處理邏輯。而即便是 C
這種 “中級語言”,雖然沒有 try catch
,但是程式設計師也可以使用巨集定義配合 goto LABEL
的方式,來實現某種程度上的錯誤斷言和處理。
Go 的錯誤斷言
在 Go 的情況就比較尷尬了。我們先來看斷言,我們的目的是,僅使用一行程式碼就能夠檢查錯誤並終止當前函式。由於沒有 throw
、沒有巨集,如果要實現一行斷言,有兩種方法。
方法一:單行 if + return
第一種是把 if
的錯誤判斷寫在一行內,比如:
if err != nil { return err }
這種方法有值得商榷的點:
- 雖然符合 Go 的程式碼規範,但是在實操中,if 語句中的花括號不換行這一點還是非常有爭議的,並且筆者在實際程式碼中也很少見到過
- 程式碼不夠直觀,大致瀏覽程式碼的時候,斷言程式碼不顯眼,而且在花括號中除了
return
之外也沒法別的了,原因是 Go 的規範中強烈不建議使用;
來分隔多條語句(if
條件判斷除外)
因此,筆者強烈不建議這麼做。
方法二:panic + recover
第二種方法是借用 panic
函式,結合 recover
來實現,如以下程式碼所示:
func SomeProcess() (err error)
defer func() {
if e := recover(); e != nil {
err = e.(error)
}
}()
assert := func(cond bool, e error) {
if !cond {
panic(e)
}
}
// ...
err = DoSomething()
assert(err == nil, fmt.Errorf("DoSomething() error: %w", err))
// ...
}
這種方法好不好呢?我們要分情況看
首先,panic
的設計原意,是在當程式或協程遇到嚴重錯誤,完全無法繼續執行下去的時候,才會呼叫(_比如段錯誤、共享資源競爭錯誤_)。這相當於 Linux 中 FATAL
級別的錯誤日誌,用這種機制,僅僅用來進行普通的錯誤處理(ERROR
級別),殺雞用牛刀了。
其次,panic
呼叫本身,相比於普通的業務邏輯的系統開銷是比較大的。而錯誤處理這種事情,可能是常態化邏輯,頻繁的 panic - recover
操作,也會大大降低系統的吞吐。
但是話雖這麼說,使用 panic 來斷言的方案,雖然在業務邏輯中基本上不用,但在測試場景下則是非常常見的。測試嘛,用牛刀有何不可?稍微大一點的系統開銷也沒啥問題。對於 Go 來說,非常熱門的單元測試框架 goconvey 就是使用 panic
機制來實現單元測試中的斷言,用的人都說好。
結論建議
綜上,在 Go 中,對於業務程式碼,筆者是不建議採用斷言的,遇到錯誤的時候建議還是老老實實採用這種格式:
if err := DoSomething(); err != nil {
// ...
}
而在單測程式碼中,則完全可以大大方方地採用類似於 goconvey
之類基於 panic 機制的斷言。
Go 的 try ... catch
眾所周知,Go(當前版本 1.17)是沒有 try ... catch
的,而且從官方的態度而言,短時間內也沒有明確的計劃。但是程式設計師有這個需求呀。這裡也催生出了集中解決方案
defer 函式
筆者採用的方法,是將需要返回的 err
變數在函式內部全域性化,然後結合 defer
統一處理:
func SomeProcess() (err error) { // <-- 注意,err 變數必須在這裡有定義
defer func() {
if err == nil {
return
}
// 這下面的邏輯,就當作 catch 作用了
if errors.Is(err, somepkg.ErrRecordNotExist) {
err = nil // 這裡是舉一個例子,有可能捕獲到某些錯誤,對於該函式而言不算錯誤,因此 err = nil
} else if errors.Like(err, somepkg.ErrConnectionClosed) {
// ... // 或者是說遇到連線斷開的操作時,可能需要做一些重連操作之類的;甚至乎還可以在這裡重連成功之後,重新拉起一次請求
} else {
// ...
}
}()
// ...
if err = DoSomething(); err != nil {
return
}
// ...
}
這種方案要特別注意變數作用域問題。
比如前面的 if err = DoSomething(); err != nil {
行,如果我們將 err = ...
改為 err := ...
,那麼這一行中的 err
變數和函式最前面定義的 (err error)
不是同一個變數,因此即便在此處發生了錯誤,但是在 defer 函式中無法捕獲到 err 變數了。
在 try ... catch
方面,筆者其實沒有特別好的方法來模擬,即便是上面的方法也有一個很讓人頭疼的問題:defer 寫法導致錯誤處理前置,而正常邏輯後置了。
命名的錯誤處理函式
要解決前文提及的 defer 寫法導致錯誤處理前置的問題,有第一種解決方法是比較常規的,那就是將 defer 後面的匿名函式改成一個命名函式,抽象出一個專門的錯誤處理函式。這個時候我們可以將上一段函式進行這樣的改造:
func SomeProcess() error {
// ...
if err = DoSomething(); err != nil {
return unifiedError(err)
}
// ...
}
func unifiedError(err error) error {
if errors.Is(err, somepkg.ErrRecordNotExist) {
return nil // 有可能捕獲到某些錯誤,對於該函式而言不算錯誤,因此 err = nil
} else if errors.Like(err, somepkg.ErrConnectionClosed) {
return fmt.Errorf("handle XXX error: %w", err)
// ...
} else {
return err
}
}
這樣就舒服一些了,至少邏輯前置,錯誤處理後置。不過讀者肯定會發現——這不是什麼語言都可以這麼搞嘛?誠然,這怎麼看都不像是對 try ... catch
的模擬,但這種方法依然很推薦,特別是錯誤處理程式碼很長的時候。
goto LABEL
理論上,我們可以通過 goto
語句,將錯誤處理後置,比如:
func SomeProcess() error {
// ...
if err = DoSomething(); err != nil {
goto ERR
}
// ...
return nil
ERR:
// ...
}
對 C
語言比較熟悉的同學可能會覺得很親切,因為在 Linux 核心中就有大量這種寫法。這種寫法呢,筆者其實說不出具體不好的地方,但是這個看起來很像 C 的寫法,其實限制很多,反而比起 C 而言,需要注意的地方也更多:
- 僅限於 ANSI-C 的話,要求所有的區域性變數都需要前置宣告,這就避免了因為變數作用域而帶來的同名變數覆蓋;但 Go 需要注意這個問題。
- C 支援巨集定義,配合前文可以實現斷言,使得錯誤處理語句可以做得比較優雅;而 Go 不支援
- Go 經常有很多匿名函式,匿名函式無法
goto
到外層函式的標籤,這也限制了goto
的使用
不過筆者倒也不是不支援使用 goto
,只是覺得在現有機制下,還是使用前兩種模式比較符合 Go 的習慣。
下一篇文章是《如何在 Go 中優雅的處理和返回錯誤(2)——函式/模組的錯誤資訊返回》,筆者詳細整理了 Go 1.13 之後的 error wrapping
功能,敬請期待~~
本文章採用 知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議 進行許可。
原作者: amc,歡迎轉載,但請註明出處。
原文標題:《如何在 Go 中優雅的處理和返回錯誤(1)——函式內部的錯誤處理》
釋出日期:2021-09-30
原文連結:https://segmentfault.com/a/1190000040762538。