本文聊一下Go – errors 包的错误链的使用场景、实现以及应用,也弄清楚了一些容易混淆的概念。
在 Go 官方博客中有介绍 errors,尽管官方文章已经足够详细了。
本文主要是结合具体场景,有助于厘清容易混淆的概念。
errors 包的功能单纯,就是支持错误链——错误可以包裹(Wrap)另外的错误的能力。
以 Go 1.23.3 的源码为例,除去 test 相关有三个简单文件,只有 3 个文件,定义了 5 个关键函数,共 300 行左右的代码(含注释)。
func As(err error, target any) bool
func Is(err, target error) bool
func Join(errs ...error) error
func New(text string) error
func Unwrap(err error) error
文章导航
1. error 和 errors 的区分
error 大家都很熟悉,它是 Go 语言的一个内置的接口定义,只要实现了 Error() string
它就是个 error
。
// The error built-in interface type is the conventional interface for// representing an error condition, with the nil value representing no error.
type error interface {
Error() string
}
而 errors 是 Go 的标准库里的一个包,主要是实现了错误操作相关的几个函数。
Package errors implements functions to manipulate errors.
errors.New()
函数就可以创建一个 error。另外,fmt.Errorf
可以包装一个 error。
2. 错误链
2.1 什么是错误链
如果一个 error 实现了 Unwrap()
方法,返回一个底层错误,而这个底层错误可能也实现了 UnWrap()
方法,这样通过不断的 Unwrap()
就会形成一个错误序列——称作「错误链」。
在 Go 说明errors的文档中有这么一句:
The most significant of these is a convention rather than a change: an error which contains another may implement an
Unwrap
method returning the underlying error. Ife1.Unwrap()
returnse2
, then we say thate1
wrapse2
, and that you can unwrape1
to gete2
.
如何理解「a convention rather than a change」?
这里的 convention(约定) 指错误类型实现 Unwrap
,而这里的 change(改变) 应该是指对语言本身或者标准库没有改变。
虽然我们不断的提到 wrap,但是实际上只有 Unwrap()
方法,而没有 Wrap()
方法。我们来简单描述一下几个函数:
errors.New()
: 创建一个包含错误信息的 errorfmt.Errorf()
: 创建并包装一个或者多个 errorerrors.Is(err, fs.ErrExist)
: 检查err
的错误链中,是否有错误fs.ErrExist
errors.As(err, &perr)
: 检查err
是否能赋值给 &perr(必须是个指针)并实际赋值Unwrap(err)
: 调用err
的Unwrap()
方法,如果没实现则返回nil
,解包装返回[]error
的也会返回nil
Join(errs...)
: 合并多个err
2.2 为什么需要错误链
我们来看看什么场景下需要使用错误链,这有助于的我们理解它存在的意义,我们在碰到一样的场景也可以使用。
简单理解,错误链用在需要添加上下文信息 + 需要识别特定错误类型 的复杂错误处理逻辑中。
生活中的例子:在线购物
假设你正在开发一个电商平台,用户下单购买商品的过程中会经历多个步骤,比如商品选择、库存检查、支付、订单生成等。在每个步骤中都可能发生不同的错误,而这些错误的信息需要被逐层传递,最终帮助用户和开发人员明确问题所在。
场景 :在线购物中的错误链
假设有一个用户想购买一件商品,但在购买过程中发生了多个错误。每个错误的类型可能不一样,而且每个步骤都需要给出更多的上下文信息,以帮助开发者了解具体的问题。
- 商品库存不足: 用户选择了一个商品并放入购物车,但在库存检查时发现商品库存不足,系统会返回错误:“商品库存不足”。
- 支付失败: 用户选择商品并确认订单后,进入支付环节。如果支付过程中出现问题,比如信用卡信息错误,系统会返回错误:“支付失败:信用卡信息无效”。
- 订单生成失败: 如果支付成功,但在生成订单的时候由于系统故障,导致订单没有生成,系统返回错误:“订单生成失败:系统错误”。
错误链的实现:
在这个场景中,错误链的作用就是将每个环节发生的错误传递到上层,并且每个错误都可以附加不同的上下文信息,让用户和开发人员了解问题的具体原因。
我们考虑一个开发中会用到的场景
你封装了一个函数,查询Redis,
如果 Key 不存在(得到 redis.Nil 错误),
此时你需要将 Key 带出函数,并且外部仍然可以使用 redis.Nil 做判断。
- 如果直接使用
errors.New()
可以带出 key 信息,但是错误将不再是 redis.Nil - 如果直接范围
redis.Nil
则无法带出 key 信息
聪明的你,会写出这样的代码:
// 从 Redis 中获取值的函数
func getValueFromRedis(ctx context.Context, client *redis.Client, key string) (string, error) {
val, err := client.Get(ctx, key).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
// 使用 fmt.Errorf 包装 redis.Nil 错误并添加额外信息
return "", fmt.Errorf("key '%s' not found in Redis: %w", key, err)
}
return "", err
}
return val, nil
}
// 外部函数调用 getValueFromRedis
func main() {
// 创建 Redis 客户端
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
ctx := context.Background()
// 尝试获取一个不存在的键
key := "nonexistent_key"
value, err := getValueFromRedis(ctx, client, key)
if err != nil {
// 打印错误信息
fmt.Println("Error:", err)
// 检查错误是否是 redis.Nil
if errors.Is(err, redis.Nil) {
fmt.Println("The error is redis.Nil")
}
} else {
fmt.Println("Value:", value)
}
}
上边的代码会输出:
Error: key 'nonexistent_key' not found in Redis: redis: nil
The error is redis.Nil
我们这里用到了 fmt.Errorf
进行了错误包装,即带出了 key 信息,又可以通过 errors.Is()
判断包装的错误是否是特定错误。
2.3 自定义的包装
上边的例子中,fmt.Errorf
实际是创建了一个新的对象:
如果有更复杂的需求,我们可以自己实现这样的封装。比如除了 err 和 msg 之外,我们需要携带其他信息,或者有实现其他方法等情况。
2.4 更复杂的包装
上边的 fmt.Errorf()
如果参数中只有一个错误,会调用返回 wrapError
,他有一个 Unwrap()
方法会返回 error。
而 fmt.Errorf()
也是可以包装多个错误,返回的是 wrapErrors
,他的 Unwrap()
方法会返回 []error
。
我们前边也提到了函数,可以使用 errors.Join()
也有类似的作用。
如果上边的 newErr
是这样定义的,通过 errors.Is()
方法判断 newErr
是否为 err1
、err2
、err3
都会返回 true
。
2.5 不是所有的错误都需要错误链
上边提到 需要添加上下文信息 + 需要识别特定错误类型 的场景需要错误链,如果不都需要的话,则没必要使用错误链。
开发中通常会使用一个简单的代码如下。
(聪明的你,请先假设FetchValue()
和 DefaultValue()
假设已经实现。)
value, err := c.FetchValue(args...)
if err != nil {
value, err = c.DefaultValue(args...)
}
当在自己实现的一个简单的 Redis key-value 的 FetchValue()
的时候,他想:
- 返回
redis.Nil
错误的时候,使用DefaultValue()
- 返回其他 Redis错误时,跳过使用
DefaultValue()
显然,上边仅仅通过 err != nil
是不能满足这种需求的。我们定义一个特殊的 error 就可以解决问题。因为这个场景中,GetValue()
中只需判断他是否需要使用默认值,而不需要带出FetchValue
方法内部的其他错误信息:
var errUseDefaultValue = errors.New("use default value")
...
func (c *CachableConfig[T]) GetValue(args ...any) (T, error) {
...
value, err := c.FetchValue(args...)
// 这里直接使用 ==
比较也没问题
if err == errUseDefaultValue {
value, err = c.DefaultValue(args...)
}
性能敏感的场景:在极端性能敏感的场景中,错误链的遍历可能会带来一些开销。因为 errors.Is()
和 errors.As()
中会用到反射。
3. 源码学习
3.1 errors.Is()
Is()
封装了会先判断一下空,然后调用内部函数 is()
进行判断:
内部函数 is()
会先直接比较一下是否跟 target 相等,然后看 err 本身是否有实现 Is()
方法,若都不相等,则不断的拆包装并比较。
我们也看到,这里有判断 Unwrap()
是返回的单个 error 还是 []error。如果前者,会覆盖 err 继续循环检查;如果是后者,会遍历每个 err 调用 is()
进行判断。
【编程技巧】这里外边 一个大的 for{}
可以针对返回单个 error 的场景,使用循环替代递归调用 is()
,在性能上可以得到优化。
3.2 errors.As()
As()
函数和 Is()
有些类似,封装了一个 as()
的内部函数,但是它会对 target
参数有更多的校验。
因为内部函数 as()
跟 is()
很相似,这里就不再做详细解析,其中反射相关的在下一小节介绍。
3.3 反射的一些用法
在 wrap.go 文件中,Is()
/ As()
等函数中,有使用到 internal/reflectlite
这个包,它是 Go 语言标准库中的一个内部包。
【编程技巧】Go语言中,internal
包有一些特殊的规则,他其中的子包,上级目录可以导入这些子包中的内容,但上级目录之外的无法导入。这种设计主要是为了让开发者能够更好地封装内部实现细节,避免这些细节被项目之外的代码所依赖,从而提高代码的可维护性。
而 internal/reflectlite
包的主要用途是提供一个轻量级的反射(reflection)实现。其设计目的是在某些情况下替代标准库中的 reflect
包,以减少依赖和提高性能。其中,一些基本的反射功能包括:
- 获取变量类型信息:
reflectlite.TypeOf(err)
、targetType.Kind()
- 读取和设置变量的值操作: 读取值
reflectlite.ValueOf(target)
、设置值targetVal.Elem().Set(reflectlite.ValueOf(err)
- 类型检查: 检查一个类型是否实现了某个接口
targetType.Implements(errorType)
、是否可以赋值reflectlite.TypeOf(err).AssignableTo(targetType)
- 方法调用: 获取方法
Method()
、调用方法Call()
,errors 包中没有使用到
4. 总结
4.1 几个概念不再混淆
看完上边的内容,之前可能混淆的概念,我们就清清楚楚了。简单列一下,就当巩固一下啦。
A. errors.Unwrap()
和自定义错误的 Unwrap()
errors.Unwrap()
需要一个error 输入参数,调用其 Unwrap 返回结果- 自定义错误的
Unwrap()
用于包装错误,在 errors 的 Is/As/Unwrap 函数中被调用
B. errors.New
和 fmt.Errorf
的区别
erros.New()
函数会创建一个error
fmt.Errorf()
会包装一个或多个
C. Is()
和 As()
的区别
Is()
使用来判断一个错误是否包含在错误链中As()
将一个错误转换为特定的错误类型
D. 变量和类型的命名区别
- 错误类型通常以
Error
结尾,比如encoding/json
中的UnmarshalTypeError
- 通常以
Err
开头,比如redis.ErrNil