https://gocn.vip/topics/9858
在过去的十年中,Go 将 错误作为数值的处理对我们很有帮助。尽管标准库对错误的支持非常少 —— 只有 errors.New 和 fmt.Errorf 函数,它们产生的错误仅包含消息 —— 内置的 error 接口允许 Go 程序员添加所需的任何信息。它所需要的只是一种实现 Error 方法的类型:
type QueryError struct {
Query string
Err error
}
func (e *QueryError) Error() string { return e.Query + “: “ + e.Err.Error() }
像这样的错误类型无处不在,它们存储的信息变化很大,时间戳,文件名,服务器地址等等,什么都可以装。通常,该信息包括另一个较低级别的错误以提供额外的上下文信息。
在 Go 代码中,一个错误包含另一个错误的模式非常普遍,以至于在广泛讨论之后,Go 1.13 添加了对此错误的明确支持。这篇文章描述了标准库中提供支持的附加功能:errors 包中的三个新功能,以及 fmt.Errorf 的新格式动词。
在我们详细描述更改之前,让我们回顾一下在语言的早期版本中如何检查和构造错误。
Go 1.13 版本之前的错误是什么样的
检查错误
Go 的错误是值。程序以几种方式基于这些值做出决策。最常见的是将错误与 “零” 进行比较,看看操作是否失败。
if err != nil { // 如果 err != nil
// 有啥出错了
}
有时我们将它和一个 哨兵 值比较,来看看是否出现了某个特定的错误。
var ErrNotFound = errors.New(“找不到”)
if err == ErrNotFound {
// 有什么东西找不到
}
错误值可以是满足语言定义的 “错误” 接口的任何类型。程序可以使用类型断言或类型转换将错误值视为更具体的类型。
type NotFoundError struct {
Name string
}
func (e *NotFoundError) Error() string { return e.Name + “: 未找到” }
if e, ok := err.(*NotFoundError); ok {
// 找不到 e.Name
}
添加信息
通常,函数在向调用堆栈中添加信息时会将错误向上传递,例如错误发生时发生的情况的简要描述。一个简单的方法是构造一个新的错误,其中包含前一个错误的文本:
if err != nil {
return fmt.Errorf(“decompress %v: %v”, name, err)
}
使用 fmt.Errorf 创建新错误会丢弃除文本之外的原始错误中的所有内容。正如我们在上面的 QueryError 中所看到的,我们有时可能想要定义一个包含错误的新错误类型,并保存它以供代码检查。这里又是 QueryError :
type QueryError struct {
Query string
Err error // 这个错误类型中有一个属性是 Err
}
程序可以查看一个 *ErrQuery 值,根据内层的错误做出决策。你有时会看到这被称为将错误展开(unwrapping)。
if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
// 由于权限错误导致的查询错误
}
标准库中的 os.PathError 类型是一个错误包含另一个错误的另一个例子。
Go 1.13 中的错误处理
Unwrap 方法(错误的展开方法)
Go 1.13 为 error 和 fmt 标准库包引入了新功能,以简化处理包含其他错误的错误。其中最重要的是约定而不是变更:包含另一个错误的错误可能会实现一个返回底层错误的 unwrap (展开)方法。如果 e1.Unwrap() 返回 e2,那么我们说 e1包裹 e2,你可以 unwrap e1 得到 e2。
按照这个约定,我们可以在返回包含错误的 unwrap 方法之上给出 QueryError 类型:
func (e *QueryError) Unwrap() error { return e.Err }
将错误展开后,得到的结果可能也有一个展开方法,我们将这种情况称之为重复展开错误链 而产生了一个错误序列。
使用 Is 和 As 函数检查错误
Go 1.13 的 errors 包中包含了两个新的用来检查错误的函数:Is 和 As。
errors.Is 函数可以将一个错误和一个值进行比较。
// 类似于这种情况:
// if err == ErrNotFound { … }
if errors.Is(err, ErrNotFound) {
// 有什么东西没找到
}
As 函数测试一个错误是否是一个特定的类型。
// Similar to:
// if e, ok := err.(*QueryError); ok { … }
var e *QueryError
if errors.As(err, &e) {
// err 是 *QueryError,同时 e 被设定为 err 的值
}
在最简单的情况下,errors.Is 函数的行为类似于与哨兵错误的比较,而 errors.As 函数的行为更接近于类型声明。当对包裹过的错误进行操作时,这些函数会考虑链中的所有错误。
让我们再一次看一下上面的示例,从上面的例子中,我们通过展开 QueryError 来检查底层错误:
if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
// 由于权限问题,查询失败
}
使用 errors.Is 函数,我们可以采用这种写法:
if errors.Is(err, ErrPermission) {
// err 本身或者 err 包裹的错误,是一个权限问题
}
errors 包中有一个新的 Unwrap 函数。它的返回值是调用错误的 Unwrap 方法返回的结果。如果错误没有 Unwrap 方法,Unwrap 函数会返回 nil。一般是最好使用 errors.Is 函数或者 errors.As 函数。但是这两个函数都会在单次调用中检查整个错误链。
用 % w 包裹错误
如前所述,一般我们会用 fmt.Errorf 函数向错误添加额外的信息。
if err != nil {
return fmt.Errorf(“decompress %v: %v”, name, err)
}
在 Go 1.13 中,fmt.Errorf 函数支持新的 %w 动词。当存在该动词时,fmt.Errorf 返回的错误将具有 Unwrap 方法,该方法返回 %w 的参数,该参数必须是错误。在其他方面,%w 与 %v 相同。
if err != nil {
// 将err包裹起来,然后返回
return fmt.Errorf(“decompress %v: %w”, name, err)
}
用 %w 包裹错误使得它可以用于 errors.Is 函数 errors.As 函数。
err := fmt.Errorf(“访问被拒绝: %w”, ErrPermission)
…
if errors.Is(err, ErrPermission) …
你是否真的需要包裹错误
在使用 fmt.Errorf 或通过实现自定义类型向错误添加其他上下文时,您需要确定新错误是否应该包裹原始错误。这个问题没有一个答案。它取决于创建新错误的上下文。包裹错误可以将原始错误暴露给调用者。如果包裹错误会导致你的包暴露实现的细节,不要包裹错误。
例如,假设一个 Parse 函数从 io.Reader 读取复杂的数据结构。如果发生错误,我们希望报告发生错误的行号和列号。如果从 io.Reader 读取时发生错误,我们将希望包裹该错误以允许检查基本问题。由于调用者向函数提供了 io.Reader,因此暴露由它产生的错误是有意义的。
相反,对数据库进行多次调用的函数可能不应返回将这些调用之一的结果展开的错误。如果该函数使用的数据库是实现细节,那么暴露这些错误就是对抽象的违反。例如,如果包的 LookupUser 函数 pkg 使用 Go 的 database / sql 包,则它可能会遇到 sql.ErrNoRows 错误。如果使用 fmt.Errorf(“ accessing DB:%v”,err) 返回该错误,则调用者无法在内部查找 sql.ErrNoRows。但是如果函数反而返回 fmt.Errorf(“ accessing DB:%w”,err),则调用者可以合理地编写
err := pkg.LookupUser(…)
if errors.Is(err, sql.ErrNoRows) …
此时,即使您不希望中断客户端,即使切换到其他数据库程序包,该函数也必须始终返回 sql.ErrNoRows。换句话说,包装错误会使该错误成为您 API 的一部分。如果您不想将来将错误作为 API 的一部分来支持,则不应包装该错误。
请务必记住,无论是否换行,错误文本都将相同。试图理解该错误的人将以相同的方式获得相同的信息;包装的选择是关于是否给程序附加信息,以便他们可以做出更明智的决定,或者保留该信息以保留抽象层。
使用 Is 和 As 方法来定制错误测试
errors.Is 会逐个检查错误链之中的错误是否和给出的目标值匹配。默认情况下如果错误和目标值相等会被认定为匹配成功。除了这种方法之外,错误链上的一个错误也可以通过实现 Is 方法来声明它和某一个目标是匹配的。
比如说下面这个受 Upspin 错误包 启发的错误 —— 将一个错误和一个模版进行比较,只对模版之中非零字段进行考察。
type Error struct {
Path string
User string
}
func (e Error) Is(target error) bool {
t, ok := target.(Error)
if !ok {
return false
}
return (e.Path == t.Path || t.Path == “”) &&
(e.User == t.User || t.User == “”)
}
if errors.Is(err, &Error{User: “someuser”}) {
// err’s 的用户定义字段是 “someuser”.
}
errors.As 也类似地会调用 As 方法来做判断。
错误和包 API
一个会返回错误的包(大多数都会)应该描述清楚编程人员将会需要这些错误的那些属性。一个设计良好的包应该要同时注意到避免返回的错误之中包含不被需要的属性。
最简单的要求就是用于说明操作成功与否 —— 成功和失败时分别返回 nil 或者非 nil 值。大多数时候是不太需要更加精细的信息的。
如果希望函数可以返回一个可识别的错误,比如 “无法找到项目”,可能就要在错误内部包裹一个哨兵了。
var ErrNotFound = errors.New(“not found”)
// FetchItem 返回指定名称的项目
//
// 如果给定名称的项目不存在,则返回包裹了
// ErrNotFound 的错误。
func FetchItem(name string) (*Item, error) {
if itemNotFound(name) {
return nil, fmt.Errorf(“%q: %w”, name, ErrNotFound)
}
// …
}
要说其他提供可语义上由调用方检查的错误的方法,也是有的。比如直接返回一个哨兵值、返回特定的类型、或者一个可以被条件检查函数判断的数值。
不论是哪一种情况,都需要注意避免将内部细节暴漏给使用者。就如我们在 “你是否真的需要包裹错误” 之中简单讨论到的。当你返回一个来自其他包的错误类型时,就应该将其转化成一种不会暴露底层细节的形式。除非你愿意承诺在包的将来版本仍然会返回那种类型的错误。
f, err := os.Open(filename)
if err != nil {
// 由 os.Open 返回的 *os.PathError 属于内部细节
// 为了避免将其暴漏给调用方,将其重新包裹为
// 一个具有相同文字信息的错误。这里使用 %v 格式化谓词,
// 因为 %w 有内部 *os.PathError 被展开的可能。
return fmt.Errorf(“%v”, err)
}
如果一个函数定义成会返回一个包裹哨兵或类型的错误,那就不要直接返回底层的错误。
var ErrPermission = errors.New(“permission denied”)
// DoSomething 会在用户没有做某些事群的许可时
// 返回一个包裹 ErrPermission 的错误
func DoSomething() error {
if !userHasPermission() {
// 如果直接返回 ErrPermission,那调用方就可能
// 依赖确切的返回值,然后写出这样的代码
//
// if err := pkg.DoSomething(); err == pkg.ErrPermission { … }
//
// 这样的话如果我们以后想要给错误加入其他上下文,
// 是会出问题的。为了避免这种事情,我们使用错误包裹
// 哨兵,这样用户每次都必须将其展开:
//
// if err := pkg.DoSomething(); errors.Is(err, pkg.ErrPermission) { … }
return fmt.Errorf(“%w”, ErrPermission)
}
// …
}
结束语
尽管我们只讨论了三个函数和一个格式化谓词,但是我们希望他们可以很好地提高 Go 语言中错误处理的体验。我们希望使用包裹来提供额外的信息会成为普遍的手段,用以帮助程序作出更好的决定,用于帮助程序员更快地找到 bug。
如 Russ Cox 在他 GopherCon 2019 上的 Keynote 之中写到的,在通往 Go 2 的路上,我们遵循尝试、简化、发布的流程。现在我们已经把这些改变发布到了大家手上,我们很期待后续更多的实验。