Golang 错误处理实践和源码解析

730 阅读8分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第21天,点击查看活动详情

error 是 Golang 体系中的一个核心概念。在 Golang 中,函数可以返回多个值,我们经常会看到一个函数同时返回了 value 和 error。此时调用方应当根据场景处理 error。

今天我们来根据源码看看底层是怎么支持 error 机制的。

error 接口

在底层 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
}

非常直接,只需要提供一个能返回【错误信息】字符串的函数,就算实现了 error 接口。

这里还是用了【声明接口】+【提供默认实现】的经典模式,在整体 Golang 的协作流程中,我们依赖的都是接口。开发者也可以非常低成本地自己实现一个自定义的 error。

自定义 error 实现

type ErrorWithCode struct {
    msg string
    code int
}

func (e *ErrorWithCode) Error() string {
    return fmt.Sprintf("%s: code %d", e.msg, e.code)
}

func NewErrorWithCode(msg string, code int) error {
    return &ErrorWithCode{
        code: code,
        msg: msg,
    }
}

这是一个经典的 msg + code 的实现,msg 用于明文帮助RD判断问题,调用方看 code 即可。

errors 实现

如果你没有自定义的诉求,对于简单场景,可以使用 Golang 提供的 errors 包的能力,我们来看看。

package errors

// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
func New(text string) error {
	return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
	s string
}

func (e *errorString) Error() string {
	return e.s
}

errors 包借用 errorString 这个结构体提供了 error 接口的实现。通过 errors.New(),传入错误信息,就可以很简洁的创建一个 errorString。

fmt 实现

有时候我们也会用到 fmt.Errorf 来创建一个 error 接口的实现。区别在于,errors.New 只能接受一个已经组装好的字符串,而 fmt.Errorf 接受的是一个 format + 参数。对比一下:

myError := errors.New("only text")

param := "some string"
myFmtError := fmt.Errorf("text with param=%v", param)

使用 fmt.Errorf 创建出来的 error 接口实现我们来看一下:

package fmt

import "errors"

func Errorf(format string, a ...any) error {
	p := newPrinter()
	p.wrapErrs = true
	p.doPrintf(format, a)
	s := string(p.buf)
	var err error
	if p.wrappedErr == nil {
		err = errors.New(s)
	} else {
		err = &wrapError{s, p.wrappedErr}
	}
	p.free()
	return err
}

type wrapError struct {
	msg string
	err error
}

func (e *wrapError) Error() string {
	return e.msg
}

func (e *wrapError) Unwrap() error {
	return e.err
}

其实底层的实现,被拆分出了两个:

  • errors.New() : 此时底层还是 errors 包里的 errorString;
  • wrapError

逻辑比较直接,问题的关键在于通过 fmt.Errorf 创建 error 时是否存在 %w 标记。

包装 error 的方式

很多时候我们并不是直接处理一个底层原始的 error。比如函数 A 调用了函数 B,函数 B 又调用了 函数 C。 从 函数 B 的角度看,当 C 抛 error 了,B 的流程无法继续,也需要继续抛 error 给 A。

但问题出来了,如果只是用常规的 fmt.Errorf("biz failed, call C err=%v", err)处理(此处 err 为 C 返回的错误),意味着你抛弃了原始的错误,你把一个 error interface 变成了【错误信息字符串】。

再往上层抛的时候,如果函数 A 需要甄别某一个 C 的错误,进行处理,要怎么办呢?

只能匹配字符串,或要求 B 也相应的定义一个同样语义的错误。这样其实很不方便。

有没有办法能直接把底层函数的 error 跨级别透传上去呢?我不希望这是个字符串,我希望给到 A 原始的错误,这样 A 还可以针对这些错误进行判断,断言。

方案其实在上面已经给出来了,这就是 fmt.Errorf 现在提供了 wrappError 的意义。

以前我们还需要依赖外部包

import "github.com/pkg/errors"

if err != nil {
	return errors.Wrap(err, "some biz context") 
} 

现在使用最新的 Golang 版本,可以直接用

import "fmt"  

if err != nil {
	return fmt.Errorf("biz context: %w", err) 
} 

在 fmt 包的底层,会把 %w 识别出来,对 p.arg 断言为 error 接口,赋值给 p.wrappedErr

image.png

回到 Errorf 这里,如果上一步 p.wrappedErr 不为空,此时会返回新定义的 wrapError 结构体实现

if p.wrappedErr == nil {
        err = errors.New(s)
} else {
        err = &wrapError{s, p.wrappedErr}
}

这个 wrapError 的形式其实也是大家自定义 error 中常用的形式,错误信息 msg + 原始错误 error

type wrapError struct {
	msg string
	err error
}

func (e *wrapError) Error() string {
	return e.msg
}

func (e *wrapError) Unwrap() error {
	return e.err
}

errors 提供的工具函数

不管是 errors 包中的 errorString 结构,还是 fmt 包中的 wrapError 结构,两种实现都能满足大部分诉求。但我们还有一些常见的诉求:

判断两个 error 是否相等

以前我们经常用 err == someDefinedError 来进行比较。比较常见的是 GORM 第一版,针对没有找到数据的场景,我们会用 GORM 定义的一个 RecordNotFound error 来匹配当前的返回,如果是确实没有数据,可能我们就直接给上层返回 nil 而不是透传 error 了(语义上应该是空,而不是报错)

那么问题来了,我们前面有提到,error 是可能被包装的。事实上我们也不鼓励处理 error 的地方,无脑直接 return 给上层。比如下面这样:

data, err := executeBusinessLogic()
if err != nil {
    return err
}

因为下层的错误,语义上来说并不一定要导致中间层也报错,而且上层的函数可能也不需要直接感知底层错误。

ok,既然有 wrap,那直接 == 的时候,肯定就没法匹配了,我们需要有办法,对 someDefinedError 能进行链式处理,如果自身不匹配,不要直接说不相等,而是看看自己 wrap 的 error 是否匹配。如果内层还有 wrap,也能持续追到底层。如果都不匹配,才要返回【不相等】的业务语义。

errors 包针对这种场景,提供的函数为 func Is(err, target error) bool,对于 err 的整条错误链都进行校验,同时也支持业务自定义 Is 函数。

func Is(err, target error) bool {
	if target == nil {
		return err == target
	}

	isComparable := reflectlite.TypeOf(target).Comparable()
	for {
		if isComparable && err == target {
			return true
		}
		if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
			return true
		}
		if err = Unwrap(err); err == nil {
			return false
		}
	}
}

这里也能看到,如果 err wrap 了个内层的错误,就在 for 循环里继续 Unwrap(解包),拿到内层的 error,继续判断。

解包 error

解包的逻辑相对是清晰的,注意 error 是个接口,你实现 Error() 方法就够了,也就意味着,从这个角度划分,Golang 中的 error 分为两类:

  • 单层 error,只有一级,典型的案例就是 errors 包提供的 errorString 实现;
  • 多层 error,支持 wrap,典型的案例是 fmt 包提供的 wrapError 实现。

对于单层 error,进行解包是没有意义的,它自己就是原始的错误。 对于多层 error,由于 error 接口并没有一个解包方法定义,需要去适配一个统一的规范,支持解包。

这就是 errors 包提供的 Unwrap 函数的意义:

func Unwrap(err error) error {
	u, ok := err.(interface {
		Unwrap() error
	})
	if !ok {
		return nil
	}
	return u.Unwrap()
}

逻辑非常简单,如果你是个支持多层 error 的实现,那么请提供对应的 Unwrap() error 方法,这样 errors.Unwrap 就能识别到,进而进行解包。

断言 error 接口为指定错误类型

一个常见的问题在于,error 毕竟是个接口,假设我们实现了业务自己的 error 结构,需要类型转化。应该怎么做?

在 Go 1.13 之前我们会这样(借用开篇的自定义 ErrorWithCode):

if e, ok := err.(*ErrorWithCode); ok {     
	fmt.Println(e.code) 
}

现在,更加鼓励的方案是 errors 包提供的 func As(err error, target any) bool 函数

import "errors"  

var e ErrorWithCode 
if errors.As(err, e) {     
	fmt.Println(e.code) 
}

经过 As 处理后,会将 err 的值转化到 e 中,可以直接使用。根据返回的 bool 判断是否断言成功。

我们来看一下源码实现:

func As(err error, target any) bool {
	if target == nil {
		panic("errors: target cannot be nil")
	}
	val := reflectlite.ValueOf(target)
	typ := val.Type()
	if typ.Kind() != reflectlite.Ptr || val.IsNil() {
		panic("errors: target must be a non-nil pointer")
	}
	targetType := typ.Elem()
	if targetType.Kind() != reflectlite.Interface && !targetType.Implements(errorType) {
		panic("errors: *target must be interface or implement error")
	}
	for err != nil {
		if reflectlite.TypeOf(err).AssignableTo(targetType) {
			val.Elem().Set(reflectlite.ValueOf(err))
			return true
		}
		if x, ok := err.(interface{ As(any) bool }); ok && x.As(target) {
			return true
		}
		err = Unwrap(err)
	}
	return false
}

可以看到,As 底层依赖反射来判断 target 是否是 Assignable 的,如果是,就通过 Set 方法写入。此外也同样支持了自定义的实现来提供自己的 As(any) bool 实现。

精华在于这里,基础的反射操作:

if reflectlite.TypeOf(err).AssignableTo(targetType) {
        val.Elem().Set(reflectlite.ValueOf(err))
        return true
}

err == nil 的经典误区

这里简单重提一下,感兴趣的同学建议还是好好看看上一篇 Golang 中的 nil 用法解析

error 是个接口,接口的底层是 type + value,而 == nil 这类操作,只有在 type 和 value 都是空的,才会返回 true。

value 为空是开发者能感知到的,我们通常不会在这里犯错。但 type 是否为空,这一点是很 trick 的。

因为当你声明了一个变量类型,再包上 interface 时,这个动态类型已经确定,那么无论什么时候跟 nil 去 ==,都为 false。

所以,具体到 error 这种场景,我们的建议是:函数返回值是 error 接口时,如果你要返回一个 nil 的 error,请直接 return nil,而不是声明一个变量,然后直接返回。

return nil // 完美,不会有问题


var empty *ErrorWithCode
return empty // 表面上是 nil,因为你的返回值是 error 接口,会导致后续接口 == nil 时出问题