五分鐘搞定 Golang 錯誤處理
本文介紹了 Go 語言處理和返回報錯的最佳實踐。恰當的錯誤處理可以幫助開發人員更好的理解并調試程序中的問題,報錯信息應該描述性的表達出錯的原因,并且應該使用錯誤哨兵和 errors.Is 來更好的實現錯誤處理和調試。
級別 1: if err != nil
這是最簡單的錯誤返回方法,大多數人都熟悉這種模式。如果代碼調用了一個可能返回錯誤的函數,那么檢查錯誤是否為 nil,如果不是,則返回報錯。
import (
"errors"
"fmt"
)
func doSomething() (float64, error) {
result, err := mayReturnError();
if err != nil {
return 0, err
}
return result, nil
}
這種方法的問題:
雖然這可能是最簡單也是最常用的方法,但存在一個主要問題:缺乏上下文。如果代碼的調用棧比較深,就沒法知道是哪個函數報錯。
想象一下,在某個調用棧中,函數 A() 調用 B(),B() 調用 C(),C() 返回一個類似下面這樣的錯誤:
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)
}
}
如果運行該程序,將輸出以下內容:
Error: negative value not allowed
我們無法通過報錯信息得知調用棧的哪個位置出錯,而不得不在代碼編輯器中打開程序,搜索特定錯誤字符串,才能找到報錯的源頭。
級別 2:封裝報錯
為了給錯誤添加上下文,我們用 fmt.Errorf 對錯誤進行包裝。
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)
}
}
運行這個程序,會得到以下輸出結果:
Error: A: B: C: negative value not allowed
這樣就能知道調用棧。
但仍然存在問題。
這種方法的問題:
我們現在知道哪里報錯,但仍然不知道出了什么問題。
級別 3:描述性錯誤
這個錯誤描述得不夠清楚。為了說明這一點,需要稍微復雜一點的例子。
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
}
在本例中,沒法通過報錯知道是哪個操作失敗了,不管是 StepOne 還是 StepTwo,都會收到同樣的錯誤提示:Error:DoSomething: DoSomethingElseWithTwoSteps:UnderlyingError。
要解決這個問題,需要補充上下文,說明具體出了什么問題。
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 失敗,就會收到錯誤信息:DoSomething: DoSomethingElseWithTwoSteps:StepOne failed: UnderlyingError。
這種方法的問題:
- 這些報錯通過函數名來輸出調用棧,但并不能表達錯誤的性質,錯誤應該是描述性的。
- HTTP 狀態代碼就是個很好的例子。如果收到 404,就說明試圖獲取的資源不存在。
級別 4:錯誤哨兵(Error Sentinels)
錯誤哨兵是可以重復使用的預定義錯誤常量。
函數失敗的原因有很多,但我喜歡將其大致分為 4 類。未找到錯誤(Not Found Error)、已存在錯誤(Already Exists Error)、先決條件失敗錯誤(Failed Precondition Error)和內部錯誤(Internal Error),靈感來自 gRPC 狀態碼[2]。下面用一句話來解釋每種類型。
- Not Found Error(未找到錯誤):調用者想要的資源不存在。例如:已刪除的文章。
- Already Exists Error(已存在錯誤):調用者創建的資源已存在。例如:同名組織。
- Failed Precondition Error(前提條件失敗錯誤):調用者要執行的操作不符合執行條件或處于不良狀態。例如:嘗試從余額為 0 的賬戶中扣款。
- Internal Error(內部錯誤):不屬于上述類別的任何其他錯誤都屬于內部錯誤。
僅有這些錯誤類型還不夠,必須讓調用者知道這是哪種錯誤,可以通過錯誤哨兵和 errors.Is 來實現。
假設有一個人們可以獲取和更新錢包余額的 REST API,我們看看如何在從數據庫獲取錢包時使用錯誤哨兵。
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 處理程序,可以看到錯誤哨兵是怎么用的。
func getWalletBalance() {
wallet, err := getWalletFromDB(id)
if errors.Is(err, WalletDoesNotExistErr) {
// return 404
} else if errors.Is(err, CouldNotGetWalletErr) {
// return 500
}
}
再看另一個用戶更新余額的例子。
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
}
利用哨兵編寫更好的錯誤信息:
我喜歡用以下兩種方式來格式化錯誤信息。
- fmt.Errorf("%w: description: %w", Sentinel, err)
- fmt.Errorf("%w: description", Sentinel)
這樣可以確保錯誤能說明問題,解釋出錯的現象和根本原因。
這一點很重要,因為從上面的例子中可以看出,同一類型的錯誤可能是由兩個不同的潛在問題造成的。因此,描述可以幫助我們準確找出出錯原因。
補充內容:如何記錄錯誤
不需要記錄所有錯誤,為什么?
Error: C: negative value not allowed
Error: B: C: negative value not allowed
Error: A: B: C: negative value not allowed
相反,應該只記錄 "被處理" 的錯誤。所謂的 "被處理" 的錯誤,是指調用者在收到報錯后,可以對錯誤進行處理并繼續執行,而不是僅僅返回錯誤。
最好的例子還是 REST 處理程序。如果 REST 處理程序收到錯誤,可以查看錯誤類型,然后發送帶有狀態碼的響應,并停止傳播錯誤。
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