5 分钟搞定 Golang 错误处理

本文介绍了 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",并设个星标吧,如果能一键三连(转发、点赞、在看),则能给我带来更多的支持和动力,激励我持续写下去,和大家共同成长进步!

相关推荐
大鹏dapeng3 小时前
Gone v2 goner/gin——试试用依赖注入的方式打开gin-gonic/gin
后端·go
程序员爱钓鱼11 小时前
Go语言实现企业级定时任务管理器:一文掌握 Cron 任务调度系统的设计与实践
后端·go·排序算法
梦兮林夕12 小时前
深入浅出 Gin 路由管理:从基础到最佳实践
后端·go·gin
zhuyasen1 天前
Go语言配置解析:基于viper的conf库优雅解析配置文件
后端·go
木昜先生1 天前
用Go Fiber快速开发Web应用
go
Warson_L1 天前
zsh: command not found: goctl
后端·go
Serverless社区1 天前
一键部署QwQ-32B推理模型,2种方式简单、快速体验
go
大鹏dapeng1 天前
Gone V2 Provider 机制介绍
后端·go·github
Nathan__271 天前
go-文件缓存与锁
缓存·go·