Working with Errors in Go 1.13
Damien Neil and Jonathan Amsterdam
17 October 2019
介绍
Go 将 error 作为 values 已经很好的为我们服务了数十年。尽管标准库对 errors 的支持很少——只有errors.New和fmt.Errorf函数,会产生只包含消息的 errors——内置的 error interface 允许 Go 程序猿添加他们想要的任何信息。它所需要的只是实现一个Error方法:
1 | type QueryError struct { |
像这样的 Error 类型无处不在,而且他们存储的信息千差万别,从时间戳到文件名到服务器地址。通常,该信息包括另一个较低级的 error 以提供额外的上下文。
这种一个错误包含另一个的模式在 Go 代码中如此普遍,经过广泛讨论,Go 1.13 添加了对他的明确支持。本文描述了标准库中提供支持的新增内容:errors包中的三个新函数和新增的格式化动词fmt.Errorf。
在描述这些变化的细节之前,让我们再看一下在以前的版本如何检查 errors 和构造 error 。
Go 1.13 之前的错误
检查错误
Go 错误是值。程序以集中方式根据这些值做出决策。最常用的是比较error和nil去查看操作是否失败了。
1 | if err != nil { |
有时我们将error和已知的预定值比较,去查看是否发生了特定的error。
1 | var ErrNotFound = errors.New("not found") |
error值也许是满足语言定义的error接口的任何类型。程序可以用类型断言或者类型切换将error视为更具体的类型。
1 | type NotFoundError struct { |
添加信息
通常,函数在向其添加信息时将error传递到调用堆栈上,就像当error发生时发生了什么的简短描述。一种简单的方式实现是构造一个新的error包括了前一个的文字。
1 | if err != nil { |
使用fmt.Errorf创建一个新的error丢弃了所有来自原始error的东西除了文字。就像我们在上面看到的QueryError,也许我们有时想要定义一个新的error类型包含底层错误,保留它以供代码检查。再看一下QueryError:
1 | type QueryError struct { |
程序可以查看*QueryError内部的值去根据底层错误做决策。你有时会看到这作为unwrapping(解包)错误:
1 | if e, ok := err.(*QueryError); ok && e.Err == ErrPermission { |
标准库中的os.PathError类型是另一个一个error包含另一个的例子。
在 Go 1.13 中的错误
展开方法(The Unwrap method)
Go 1.13 介绍了简化处理错误包含其他错误的errors和fmt标准库包的新特性。其中最重要的是约定而不是变更:一个错误包含其他的要实现Unwrap方法返回底层错误。如果e1.Unwrap()返回了e2,那么我们说e1 wraps e2,然后你可以unwrap e1 去获得 e2。
按照这个预定,我们可以给出上面的QueryError类型一个Unwrap方法返回他包含的错误:
1 | func (e *QueryError) Unwrap() error { return e.Err } |
解包error的结果也许他自身也有一个Unwrap方法,我们按顺序调用由重复展开错误链产生的错误。( we call the sequence of errors produced by repeated unwrapping the error chain.)
使用 Is 和 As 检查错误
Go 1.13 errors包包含了两个检查错误的新函数:Is和As。
errors.Is函数将error和值比较:
1 | // Similar to: |
As函数测试error是否是特定类型:
1 | // Similar to: |
在这个最简单的栗子中,errors.Is函数表现的像是和 sentinel error 比较,errors.As函数表现的像是类型断言。当在处理包装错误(wrapped errors)时,然而,这些函数会考虑链中的所有错误。让我们再看一下上面解包一个QueryError去检查底层错误的代码:
1 | if e, ok := err.(*QueryError); ok && e.Err == ErrPermission { |
使用errors.Is函数,我们可以像下面这样写:
1 | if errors.Is(err, ErrPermission) { |
errors包也包括了一个新的Unwrap函数,返回调用error的Unwrap方法的结果,或者nil当error没有Unwrap方法时。通常使用errors.Is或者errors.As方法更好,但是,这些函数会在单次调用中检查整个链。
注意:尽管将指针指向指针可能会感觉怪怪的,在这种情况下是对的。把他想象成一个指向错误类型的值,在这种情况下事情就是这样,他返回的error是一个指针类型
使用 %w 包装 errors
像以前提到的,用fmt.Errorf函数向error中添加信息是很普遍的。
1 | if err != nil { |
在 Go 1.13 中,fmt.Errorf函数支持新的%w。当这个动词出现时,由fmt.Errorf返回的error将有一个Unwrap方法返回%w参数,这肯定是一个error。在其他方面,%w和%v相同。
1 | if err != nil { |
使用%w包装error使其可用于errors.Is和errors.As:
1 | err := fmt.Errorf("access denied: %w", ErrPermission) |
是否包装
当向error添加上下文时,无论是使用fmt.Errorf还是通过自定义实现,你都需要决定新error是否应该包装原始的。这个问题没有唯一的答案,他取决于被创建的error的上下文。包装error将其暴露给调用者。当这么做会暴露实现细节时不要包装error。
作为一个栗子,想象一下一个Parse函数从io.Reader读取复杂的数据结构。如果error发生了,我们希望报告在哪一行哪一列发生了。如果从io.Reader读取时发生错误,我们想要包装error以便允许检查底层问题。既然调用者为函数提供了io.Reader,那么将由他产生的error暴露是有意义的。
相比之下,一个对数据库发起多次调用的函数也许不应该返回error,因为解包的结果是这些调用中的一个。如果函数使用的数据库是实现细节,那么暴露这些错误违反了抽象(原则,从具体问题中,提取出具有共性的模式,再使用通用的解决方法加以处理)。例如,pkg包中的LookupUser函数使用Go的database/sql包,那么他也许会碰到sql.ErrNoRows错误。如果你使用fmt.Errorf("accessing DB: %v", err)返回一个error,那么调用者无法查看内部发现sql.ErrNoRows。但是如果函数使用fmt.Errorf("accessing DB: %w", err),那么调用者可以合理的这么写:
1 | err := pkg.LookupUser(...) |
此时,如果你不想中断客户端,该函数必须始终返回sql.ErrNoRows,即使你切换到一个不同的数据库包。换句话说,包装error使得那个error变成你的 API 的一部分。如果你不想承诺在未来将那个error作为你 API 的一部分,那么你不应该包装error。
很重要的一点是无论你是否包装,error文字要是一样的。试图理解error的人无论用哪种方式都将获得相同的信息。包装的选择是关于给程序额外的信息让他们可以做出更明智的决定,或者保留该信息以保留抽象层。
使用 Is 和 As 方法自定义 error
errors.Is方法为了匹配目标值检查链中的每一个error。在默认情况下,如果两者相等那么error匹配目标。此外,链中的error也许会通过实现Is声明他匹配目标。
举个例子,这个error灵感来自Upspin error package,他将error与模板比较,仅考虑模板中的非零字段:
1 | type Error struct { |
errors.As函数在出现时类似的参考As方法。
错误和包 APIs
一个返回错误的包(大多数都应该这么做)应该描述程序员可能依赖的那些错误属性。一个设计良好的包也会避免返回不应该依赖的错误的属性。
最简单的规范操作是说操作成功还是失败,个别的返回 nil 或 非 nil 错误值。在大多数情况下,不需要进一步的信息。
如果我们希望函数返回可识别的错误条件,类似“未发现项目,” 我们可能会返回包装预定值的error:
1 | var ErrNotFound = errors.New("not found") |
还有一些其他存在的模式,提供可供调用者从语义上检查的错误,类似直接返回一个预定值,一个指定的类型,或者可以被判断函数(predicate function)检查的值。
在所有情况下,都要注意不要将内部细节暴露给用户。正如我们在上面“是否要包装”中提到的那样,当你返回一个来自其他包的error,你应该将error转化为一个不会暴露底层error的格式,除非你愿意承诺在未来返回这个特定错误。
1 | f, err := os.Open(filename) |
如果函数被定义成返回一个包装了一些预定值或类型的error,不要直接返回底层的error:
1 | var ErrPermission = errors.New("permission denied") |
结论
尽管我们讨论的更改仅包含三个函数和一个格式化动词,我们希望他们对改进 Go 程序中错误的处理方式有很大的帮助。我们期望提供额外上下文的包装将变得普遍,帮助程序做出更好的决策和帮助程序员更快的找到 bug。
正如 Russ Cox 在他的 GopherCon 2019 主题演讲中所说,在 Go 2 的道路上,我们进行实验、简化和发布。现在我们已经发布了这些更改,我们期待接下来的实验�