五分鐘搞定 Golang 錯(cuò)誤處理
本文介紹了 Go 語言處理和返回報(bào)錯(cuò)的最佳實(shí)踐。恰當(dāng)?shù)腻e(cuò)誤處理可以幫助開發(fā)人員更好的理解并調(diào)試程序中的問題,報(bào)錯(cuò)信息應(yīng)該描述性的表達(dá)出錯(cuò)的原因,并且應(yīng)該使用錯(cuò)誤哨兵和 errors.Is 來更好的實(shí)現(xiàn)錯(cuò)誤處理和調(diào)試。
級別 1: if err != nil
這是最簡單的錯(cuò)誤返回方法,大多數(shù)人都熟悉這種模式。如果代碼調(diào)用了一個(gè)可能返回錯(cuò)誤的函數(shù),那么檢查錯(cuò)誤是否為 nil,如果不是,則返回報(bào)錯(cuò)。
import (
"errors"
"fmt"
)
func doSomething() (float64, error) {
result, err := mayReturnError();
if err != nil {
return 0, err
}
return result, nil
}
這種方法的問題:
雖然這可能是最簡單也是最常用的方法,但存在一個(gè)主要問題:缺乏上下文。如果代碼的調(diào)用棧比較深,就沒法知道是哪個(gè)函數(shù)報(bào)錯(cuò)。
想象一下,在某個(gè)調(diào)用棧中,函數(shù) A() 調(diào)用 B(),B() 調(diào)用 C(),C() 返回一個(gè)類似下面這樣的錯(cuò)誤:
package main
import (
"errors"
"fmt"
)
func A(x int) (int, error) {
result, err := B(x)
if err != nil {
return 0, err
}
return result * 3, nil
}
func B(x int) (int, error) {
result, err := C(x)
if err != nil {
return 0, err
}
return result + 2, nil
}
func C(x int) (int, error) {
if x < 0 {
return 0, errors.New("negative value not allowed")
}
return x * x, nil
}
func main() {
// Call function A with invalid input
result, err := A(-2)
if err == nil {
fmt.Println("Result:", result)
} else {
fmt.Println("Error:", err)
}
}
如果運(yùn)行該程序,將輸出以下內(nèi)容:
Error: negative value not allowed
我們無法通過報(bào)錯(cuò)信息得知調(diào)用棧的哪個(gè)位置出錯(cuò),而不得不在代碼編輯器中打開程序,搜索特定錯(cuò)誤字符串,才能找到報(bào)錯(cuò)的源頭。
級別 2:封裝報(bào)錯(cuò)
為了給錯(cuò)誤添加上下文,我們用 fmt.Errorf 對錯(cuò)誤進(jìn)行包裝。
package main
import (
"errors"
"fmt"
)
func A(x int) (int, error) {
result, err := B(x)
if err != nil {
return 0, fmt.Errorf("A: %w", err)
}
return result * 3, nil
}
func B(x int) (int, error) {
result, err := C(x)
if err != nil {
return 0, fmt.Errorf("B: %w", err)
}
return result + 2, nil
}
func C(x int) (int, error) {
if x < 0 {
return 0, fmt.Errorf("C: %w", errors.New("negative value not allowed"))
}
return x * x, nil
}
func main() {
// Call function A with invalid input
result, err := A(-2)
if err == nil {
fmt.Println("Result:", result)
} else {
fmt.Println("Error:", err)
}
}
運(yùn)行這個(gè)程序,會得到以下輸出結(jié)果:
Error: A: B: C: negative value not allowed
這樣就能知道調(diào)用棧。
但仍然存在問題。
這種方法的問題:
我們現(xiàn)在知道哪里報(bào)錯(cuò),但仍然不知道出了什么問題。
級別 3:描述性錯(cuò)誤
這個(gè)錯(cuò)誤描述得不夠清楚。為了說明這一點(diǎn),需要稍微復(fù)雜一點(diǎn)的例子。
import (
"errors"
"fmt"
)
func DoSomething() (int, error) {
result, err := DoSomethingElseWithTwoSteps()
if err != nil {
return 0, fmt.Errorf("DoSomething: %w", err)
}
return result * 3, nil
}
func DoSomethingElseWithTwoSteps() (int, error) {
stepOne, err := StepOne()
if err != nil {
return 0, fmt.Errorf("DoSomethingElseWithTwoSteps:%w", err)
}
stepTwo, err := StepTwo()
if err != nil {
return 0, fmt.Errorf("DoSomethingElseWithTwoSteps: %w", err)
}
return stepOne + StepTwo, nil
}
在本例中,沒法通過報(bào)錯(cuò)知道是哪個(gè)操作失敗了,不管是 StepOne 還是 StepTwo,都會收到同樣的錯(cuò)誤提示:Error:DoSomething: DoSomethingElseWithTwoSteps:UnderlyingError。
要解決這個(gè)問題,需要補(bǔ)充上下文,說明具體出了什么問題。
import (
"errors"
"fmt"
)
func DoSomething() (int, error) {
result, err := DoSomethingElseWithTwoSteps()
if err != nil {
return 0, fmt.Errorf("DoSomething: %w", err)
}
return result * 3, nil
}
func DoSomethingElseWithTwoSteps() (int, error) {
stepOne, err := StepOne()
if err != nil {
return 0, fmt.Errorf("DoSomethingElseWithTwoSteps: StepOne: %w", err)
}
stepTwo, err := StepTwo()
if err != nil {
return 0, fmt.Errorf("DoSomethingElseWithTwoSteps: StepTwo: %w", err)
}
return stepOne + StepTwo, nil
}
因此,如果 StepOne 失敗,就會收到錯(cuò)誤信息:DoSomething: DoSomethingElseWithTwoSteps:StepOne failed: UnderlyingError。
這種方法的問題:
- 這些報(bào)錯(cuò)通過函數(shù)名來輸出調(diào)用棧,但并不能表達(dá)錯(cuò)誤的性質(zhì),錯(cuò)誤應(yīng)該是描述性的。
- HTTP 狀態(tài)代碼就是個(gè)很好的例子。如果收到 404,就說明試圖獲取的資源不存在。
級別 4:錯(cuò)誤哨兵(Error Sentinels)
錯(cuò)誤哨兵是可以重復(fù)使用的預(yù)定義錯(cuò)誤常量。
函數(shù)失敗的原因有很多,但我喜歡將其大致分為 4 類。未找到錯(cuò)誤(Not Found Error)、已存在錯(cuò)誤(Already Exists Error)、先決條件失敗錯(cuò)誤(Failed Precondition Error)和內(nèi)部錯(cuò)誤(Internal Error),靈感來自 gRPC 狀態(tài)碼[2]。下面用一句話來解釋每種類型。
- Not Found Error(未找到錯(cuò)誤):調(diào)用者想要的資源不存在。例如:已刪除的文章。
- Already Exists Error(已存在錯(cuò)誤):調(diào)用者創(chuàng)建的資源已存在。例如:同名組織。
- Failed Precondition Error(前提條件失敗錯(cuò)誤):調(diào)用者要執(zhí)行的操作不符合執(zhí)行條件或處于不良狀態(tài)。例如:嘗試從余額為 0 的賬戶中扣款。
- Internal Error(內(nèi)部錯(cuò)誤):不屬于上述類別的任何其他錯(cuò)誤都屬于內(nèi)部錯(cuò)誤。
僅有這些錯(cuò)誤類型還不夠,必須讓調(diào)用者知道這是哪種錯(cuò)誤,可以通過錯(cuò)誤哨兵和 errors.Is 來實(shí)現(xiàn)。
假設(shè)有一個(gè)人們可以獲取和更新錢包余額的 REST API,我們看看如何在從數(shù)據(jù)庫獲取錢包時(shí)使用錯(cuò)誤哨兵。
import (
"fmt"
"net/http"
"errors"
)
// These are error sentinels
var (
WalletDoesNotExistErr = errors.New("Wallet does not exist") //Type of Not Found Error
CouldNotGetWalletErr = errors.New("Could not get Wallet") //Type of Internal Error
)
func getWalletFromDB(id int) (int, error) {
// Dummy implementation: simulate retrieving a wallet from a database
balance, err := db.get(id)
if err != nil {
if balance == nil {
return 0, fmt.Errorf("%w: Wallet(id:%s) does not exist: %w", WalletDoesNotExistErr, id, err)
} else {
return 0, return fmt.Errorf("%w: could not get Wallet(id:%s) from db: %w", CouldNotGetWalletErr, id, err)
}
}
return *balance, nil
}
通過下面的 REST 處理程序,可以看到錯(cuò)誤哨兵是怎么用的。
func getWalletBalance() {
wallet, err := getWalletFromDB(id)
if errors.Is(err, WalletDoesNotExistErr) {
// return 404
} else if errors.Is(err, CouldNotGetWalletErr) {
// return 500
}
}
再看另一個(gè)用戶更新余額的例子。
import (
"fmt"
"net/http"
"errors"
)
var (
WalletDoesNotExistErr = errors.New("Wallet does not exist") //Type of Not Found Error
CouldNotDebitWalletErr = errors.New("Could not debit Wallet") //Type of Internal Error
InsiffucientWalletBalanceErr = errors.New("Insufficient balance in Wallet") //Type of Failed Precondition Error
)
func debitWalletInDB(id int, amount int) error {
// Dummy implementation: simulate retrieving a wallet from a database
balance, err := db.get(id)
if err != nil {
if balance == nil {
return fmt.Errorf("%w: Wallet(id:%s) does not exist: %w", WalletDoesNotExistErr, id, err)
} else {
return fmt.Errorf("%w: could not get Wallet(id:%s) from db: %w", CouldNotDebitWalletErr, id, err)
}
}
if *balance <= 0 {
return 0, fmt.Errorf("%w: Wallet(id:%s) balance is 0", InsiffucientWalletBalanceErr, id)
}
updatedBalance := *balance - amount
// Dummy implementation: simulate updating a wallet into a database
err := db.update(id, updatedBalance)
if err != nil {
return fmt.Errorf("%w: could not update Wallet(id:%s) from db: %w", CouldNotDebitWalletErr, id, err)
}
return nil
}
利用哨兵編寫更好的錯(cuò)誤信息:
我喜歡用以下兩種方式來格式化錯(cuò)誤信息。
- fmt.Errorf("%w: description: %w", Sentinel, err)
- fmt.Errorf("%w: description", Sentinel)
這樣可以確保錯(cuò)誤能說明問題,解釋出錯(cuò)的現(xiàn)象和根本原因。
這一點(diǎn)很重要,因?yàn)閺纳厦娴睦又锌梢钥闯?,同一類型的錯(cuò)誤可能是由兩個(gè)不同的潛在問題造成的。因此,描述可以幫助我們準(zhǔn)確找出出錯(cuò)原因。
補(bǔ)充內(nèi)容:如何記錄錯(cuò)誤
不需要記錄所有錯(cuò)誤,為什么?
Error: C: negative value not allowed
Error: B: C: negative value not allowed
Error: A: B: C: negative value not allowed
相反,應(yīng)該只記錄 "被處理" 的錯(cuò)誤。所謂的 "被處理" 的錯(cuò)誤,是指調(diào)用者在收到報(bào)錯(cuò)后,可以對錯(cuò)誤進(jìn)行處理并繼續(xù)執(zhí)行,而不是僅僅返回錯(cuò)誤。
最好的例子還是 REST 處理程序。如果 REST 處理程序收到錯(cuò)誤,可以查看錯(cuò)誤類型,然后發(fā)送帶有狀態(tài)碼的響應(yīng),并停止傳播錯(cuò)誤。
func getWalletBalance() {
wallet, err := getWalletFromDB(id)
if err != nil {
fmt.Printf("%w", err)
}
if errors.Is(err, WalletDoesNotExistErr) {
// return 404
} else if errors.Is(err, CouldNotGetWalletErr) {
// return 500
}
}
參考資料:
- [1] Conquering Errors in Go: A Guide to Returning and Handling errors: https://blog.rideapp.in/conquering-errors-in-go-a-guide-to-returns-and-handling-a13885905433
- [2] gRPC Status Codes: https://grpc.github.io/grpc/core/md_doc_statuscodes.html