【Go】理解和使用错误链
潘忠显 / 2024-12-02
前边写过两篇小文,透彻的介绍过 Go 语言中的 context 包和 time 包。
本文介绍一下 errors 包。它比之前介绍 context 和 time 都简单。
errors 包的功能单纯,就是支持错误链——错误可以包裹(Wrap)另外的错误的能力。
以 Go 1.22.3 的源码为例,除去 test 相关有三个简单文件,只有 3 个文件,定义了 5 个函数,共 300 行左右的代码(含注释)。
其实,在 Go 官方博客中有介绍 errors,那篇文章已经足够详细了。本文主要是结合具体场景,有助于厘清容易混淆的概念。
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 1.13 说明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...)
: 合并多个
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 不是所有的错误都需要错误链
上边提到 需要添加上下文信息 + 需要识别特定错误类型 的场景需要错误链,如果不都需要的话,则没必要使用错误链。
我自己写了简单的 cache 包,其中有几行代码是这样的。其中,FetchValue()
和 DefaultValue()
需要用户自己实现。(聪明的你,请先忽略考虑 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. 几个概念不再混淆
看完上边的内容,之前可能混淆的概念,我们就清清楚楚了。我这里简单列一下,就当巩固一下啦。
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
总结
本文介绍了Go语言中的错误链的使用场景、实现以及应用,我们也弄清楚了一些容易混淆的概念。