Jason Pan

【Go】理解和使用错误链

潘忠显 / 2024-12-02


前边写过两篇小文,透彻的介绍过 Go 语言中的 context 包和 time 包。

本文介绍一下 errors 包。它比之前介绍 context 和 time 都简单。

errors 包的功能单纯,就是支持错误链——错误可以包裹(Wrap)另外的错误的能力

以 Go 1.22.3 的源码为例,除去 test 相关有三个简单文件,只有 3 个文件,定义了 5 个函数,共 300 行左右的代码(含注释)。

errors-pkg-index

其实,在 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. If e1.Unwrap() returns e2, then we say that e1 wraps e2, and that you can unwrap e1 to get e2.

如何理解「a convention rather than a change」呢?这里的 convention(约定) 应该是指错误类型实现 Unwrap,而这里的 change(改变) 应该是指对语言本身或者标准库没有改变。

虽然我们不断的提到 wrap,但是实际上只有 Unwrap() 方法,而没有 Wrap() 方法。我们来简单描述一下几个函数:

2.2 为什么需要错误链

我们来看看什么场景下需要使用错误链,这有助于的我们理解它存在的意义,我们在碰到一样的场景也可以使用。

简单理解,错误链用在需要添加上下文信息 + 需要识别特定错误类型 的复杂错误处理逻辑中。

我们考虑一个场景你封装了一个函数,查询Redis,如果 Key 不存在(得到 redis.Nil 错误),你需要将 Key 带出函数,并且外部仍然可以使用 redis.Nil 做判断

聪明的你,会写出这样的代码:

// 从 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 实际是创建了一个新的对象:

src-code-errorf

如果有更复杂的需求,我们可以自己实现这样的封装。比如除了 err 和 msg 之外,我们需要携带其他信息,或者有实现其他方法等情况。

2.4 更复杂的包装

上边的 fmt.Errorf() 如果参数中只有一个错误,会调用返回 wrapError,他有一个 Unwrap() 方法会返回 error。

fmt.Errorf() 也是可以包装多个错误,返回的是 wrapErrors,他的 Unwrap() 方法会返回 []error

我们前边也提到了函数,可以使用 errors.Join() 也有类似的作用。

errors-join

如果上边的 newErr 是这样定义的,通过 errors.Is() 方法判断 newErr 是否为 err1err2err3 都会返回 true

2.5 不是所有的错误都需要错误链

上边提到 需要添加上下文信息 + 需要识别特定错误类型 的场景需要错误链,如果不都需要的话,则没必要使用错误链。

我自己写了简单的 cache 包,其中有几行代码是这样的。其中,FetchValue()DefaultValue() 需要用户自己实现。(聪明的你,请先忽略考虑 DefaultValue 的使用问题)

	value, err := c.FetchValue(args...)
	if err != nil {
		value, err = c.DefaultValue(args...)
	}

当用户在自己实现的一个简单的 Redis key-value 的 FetchValue() 的时候,他想:

显然,上边仅仅通过 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() 进行判断:

errors-is

内部函数 is() 会先直接比较一下是否跟 target 相等,然后看 err 本身是否有实现 Is() 方法,若都不相等,则不断的拆包装并比较。

我们也看到,这里有判断 Unwrap() 是返回的单个 error 还是 []error。如果前者,会覆盖 err 继续循环检查;如果是后者,会遍历每个 err 调用 is() 进行判断。

【编程技巧】这里外边 一个大的 for{} 可以针对返回单个 error 的场景,使用循环替代递归调用 is(),在性能上可以得到优化。

errors-inner-is

3.2 errors.As()

As() 函数和 Is() 有些类似,封装了一个 as() 的内部函数,但是它会对 target 参数有更多的校验。

errors-as

因为内部函数 as()is() 很相似,这里就不再做详细解析,其中反射相关的在下一小节介绍。

3.3 反射的一些用法

在 wrap.go 文件中,Is() / As() 等函数中,有使用到 internal/reflectlite这个包,它是 Go 语言标准库中的一个内部包。

【编程技巧】Go语言中,internal包有一些特殊的规则,他其中的子包,上级目录可以导入这些子包中的内容,但上级目录之外的无法导入。这种设计主要是为了让开发者能够更好地封装内部实现细节,避免这些细节被项目之外的代码所依赖,从而提高代码的可维护性。

internal/reflectlite 包的主要用途是提供一个轻量级的反射(reflection)实现。其设计目的是在某些情况下替代标准库中的 reflect 包,以减少依赖和提高性能。其中,一些基本的反射功能包括:

4. 几个概念不再混淆

看完上边的内容,之前可能混淆的概念,我们就清清楚楚了。我这里简单列一下,就当巩固一下啦。

A. errors.Unwrap() 和自定义错误的 Unwrap()

B. errors.Newfmt.Errorf 的区别

C. Is()As() 的区别

D. 变量和类型的命名区别

总结

本文介绍了Go语言中的错误链的使用场景、实现以及应用,我们也弄清楚了一些容易混淆的概念。