本文介绍了 Go 语言处理和返回报错的最佳实践。恰当的错误处理可以帮助开发人员更好的理解并调试程序中的问题,报错信息应该描述性的表达出错的原因,并且应该使用错误哨兵和
errors.Is
来更好的实现错误处理和调试。原文:Conquering Errors in Go: A Guide to Returning and Handling errors
级别 1: if err != nil
这是最简单的错误返回方法,大多数人都熟悉这种模式。如果代码调用了一个可能返回错误的函数,那么检查错误是否为 nil
,如果不是,则返回报错。
golang
import (
"errors"
"fmt"
)
func doSomething() (float64, error) {
result, err := mayReturnError();
if err != nil {
return 0, err
}
return result, nil
}
这种方法的问题
虽然这可能是最简单也是最常用的方法,但存在一个主要问题:缺乏上下文。如果代码的调用栈比较深,就没法知道是哪个函数报错。
想象一下,在某个调用栈中,函数 A()
调用 B()
,B()
调用 C()
,C()
返回一个类似下面这样的错误:
golang
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)
}
}
如果运行该程序,将输出以下内容:
javascript
Error: negative value not allowed
我们无法通过报错信息得知调用栈的哪个位置出错,而不得不在代码编辑器中打开程序,搜索特定错误字符串,才能找到报错的源头。
级别 2:封装报错
为了给错误添加上下文,我们用 fmt.Errorf
对错误进行包装。
golang
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)
}
}
运行这个程序,会得到以下输出结果:
javascript
Error: A: B: C: negative value not allowed
这样就能知道调用栈。
但仍然存在问题。
这种方法的问题
我们现在知道哪里报错,但仍然不知道出了什么问题。
级别 3:描述性错误
这个错误描述得不够清楚。为了说明这一点,需要稍微复杂一点的例子。
golang
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
。
要解决这个问题,需要补充上下文,说明具体出了什么问题。
golang
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 状态码。下面用一句话来解释每种类型。
Not Found Error(未找到错误):调用者想要的资源不存在。例如:已删除的文章。
Already Exists Error(已存在错误):调用者创建的资源已存在。例如:同名组织。
Failed Precondition Error(前提条件失败错误):调用者要执行的操作不符合执行条件或处于不良状态。例如:尝试从余额为 0 的账户中扣款。
Internal Error(内部错误):不属于上述类别的任何其他错误都属于内部错误。
仅有这些错误类型还不够,必须让调用者知道这是哪种错误,可以通过错误哨兵和 errors.Is
来实现。
假设有一个人们可以获取和更新钱包余额的 REST API,我们看看如何在从数据库获取钱包时使用错误哨兵。
golang
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 处理程序,可以看到错误哨兵是怎么用的。
golang
func getWalletBalance() {
wallet, err := getWalletFromDB(id)
if errors.Is(err, WalletDoesNotExistErr) {
// return 404
} else if errors.Is(err, CouldNotGetWalletErr) {
// return 500
}
}
再看另一个用户更新余额的例子。
golang
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)
这样可以确保错误能说明问题,解释出错的现象和根本原因。
这一点很重要,因为从上面的例子中可以看出,同一类型的错误可能是由两个不同的潜在问题造成的。因此,描述可以帮助我们准确找出出错原因。
补充内容:如何记录错误
不需要记录所有错误,为什么?
javascript
Error: C: negative value not allowed
Error: B: C: negative value not allowed
Error: A: B: C: negative value not allowed
相反,应该只记录 "被处理" 的错误。所谓的 "被处理" 的错误,是指调用者在收到报错后,可以对错误进行处理并继续执行,而不是仅仅返回错误。
最好的例子还是 REST 处理程序。如果 REST 处理程序收到错误,可以查看错误类型,然后发送带有状态码的响应,并停止传播错误。
golang
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
}
}
你好,我是俞凡,在Motorola做过研发,现在在Mavenir做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。为了方便大家以后能第一时间看到文章,请朋友们关注公众号"DeepNoMind",并设个星标吧,如果能一键三连(转发、点赞、在看),则能给我带来更多的支持和动力,激励我持续写下去,和大家共同成长进步!