小马别过河
2022/10/25阅读:17主题:默认主题
Go[译]:Working with Errors in Go 1.13
介绍
过去, Go 把错误作为值处理的方式,给了我们很大的帮助。虽然标准库对错误的支持很有限:只有 errors.New
和 fmt.Errorf
方法,用于生成一个包含信息的错误。内置的error
接口允许开发者加任何自定义的信息。它只要求对应的类型实现Error
方法:
type QueryError struct {
Query string
Err error
}
func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }
像这样的错误类型无处不在,他们存储的信息千差万别,如时间戳、文件名、服务地址。通常,该消息会包含另一个低级的错误来提供额外的上下文。
一个 error 包含另一个 error 的模式普遍存在,经过广泛的讨论,Go 1.13 增加了对它的支持。本文描述标准库对这个新支持的支持:3 个 errors 包的新方法,和fmt.Errorf
的一个新的格式动词(formatting verb)。
Go 1.13 之前的错误
错误检查
Go 的 error 是值。程序基于这些值做出判断。最常见的就是判断 error 是否为 nil
以判断操作是否失败。
if err != nil {
// something went wrong
}
有时候我们拿 error 和已知的"哨兵错误"(sentinel value)对比,以判断发生的错误是否是某个具体的错误。
// 定义一个哨兵错误
var ErrNotFound = errors.New("not found")
if err == ErrNotFound {
// something wasn't found
}
一个错误值可能是实现了error
接口的任何类型。程序可以使用类型断言和类型转换来查看 error 是否是某个特定的类型。
type NotFoundError struct {
Name string
}
func (e *NotFoundError) Error() string { return e.Name + ": not found" }
if e, ok := err.(*NotFoundError); ok {
// e.Name wasn't found
}
附加信息
通常,函数在调用栈中传递 error 的过程中,会给 error 添加一些信息,如触发错误时的简要描述。一个简单的方法就是,构造一个新的 error,新 error 包含着旧 error。
if err != nil {
return fmt.Errorf("decompress %v: %v", name, err)
}
用fmt.Errorf
生成的新 error,保留着原 error 的错误信息,但是会丢失原 error 其他的数据。如上面的 QueryError
,我们有时想要定义一个新的错误,包含着底层 error,以便检查。再看下QueryError
:
type QueryError struct {
Query string
Err error
}
程序可以查看 QueryError 底层的 error。你有可能看过这种代码:
if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
// query failed because of a permission problem
}
标准库中的os.PathError
也是包含着另一个错误的例子。
Go 1.13 的错误
Unwrap
Go 1.13 errors
和fmt
标准包引入了新的功能,以简化一个error 包含另一个 error 的场景。最重要的是约定,而不是更改:一个 error 如果包含另一个 error,它低层可能实现一个 Unwrap
方法,返回底层的 error。如果 e1.Unwrap()
返回 e2
,我们称 e1
包裹(wrap)e2
,你可以解包(unwrap)e1
获得 e2
。
接着上面的讨论,我们可以给 QueryError
新增一个 Unwrap
方法,它会返回包裹着的错误:
func (e *QueryError) Unwrap() error { return e.Err }
解包出来的 error 可能也有Unwrap
方法;可以重复解包的 error 序列,我们称之为错误链。
使用 Is
和 As
检查 error
Go 1.13 errors
包新增两个检查错误的函数:Is
和 As
。
errors.Is
函数比较 error 的值:
// 等价于:
// if err == ErrNotFound { … }
if errors.Is(err, ErrNotFound) {
// something wasn't found
}
As
函数检测 error 是否特定的类型:
// 等价于:
// if e, ok := err.(*QueryError); ok { … }
var e *QueryError
// Note: *QueryError is the type of the error.
if errors.As(err, &e) {
// err is a *QueryError, and e is set to the error's value
}
最简单的一个场景,errors.Is
函数用于比较哨兵错误(sentinel error),errors.As
函数的用于类型断言。如果传入的错误包含另一个错误,这两个函数会检查整个错误链的 error。我们再看下解包QueryError
,以检查底层层的错误:
if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
// query failed because of a permission problem
}
使用errors.Is
函数,等价于:
if errors.Is(err, ErrPermission) {
// err, or some error that it wraps, is a permission problem
}
errors
包还提供了一个新的Unwrap
函数,如果传入的 error 参数实现了Unwrap
方法,就调用它的Unwrap
方法,否则返回 nil。一般来说,更推荐使用errors.Is
或erros.As
,毕竟它们会检查整个错误链。
使用%w
包裹error
上文提过,fmt.Errorf
函数可以增加新的信息到已有的 error。
if err != nil {
return fmt.Errorf("decompress %v: %v", name, err)
}
在 Go1.13,fmt.Errorf
函数增加了%w
动词,它表示fmt.Errorf
返回一个包含Unwrap
方法的 error,其他方面%w
等价于%v
。
if err != nil {
// 返回一个包含 err 的错误
return fmt.Errorf("decompress %v: %w", name, err)
}
%w
包裹的一个 error 可用于errors.Is
和errors.As
:
err := fmt.Errorf("access denied: %w", ErrPermission)
...
if errors.Is(err, ErrPermission) ...
是否包裹 error
当增加额外的上下文到 error,使用fmt.Errorf
或者自定义,你需要决定新的 error 是否需要包裹原 error。答案不是唯一的,它取决于创建的新 error。如果要暴露原 error 给调用者,就包裹起来。如果不想暴露实现细节,就不要包裹。
比如,想象一个从io.Reader
读取复杂数据结构的Parse
函数。如果发生错误,我们希望报道出错的行数和列数。如果io.Reader
读取时出错,我们降期望包裹底层的 error,以便检查。由于调用者提供了io.Reader
给函数,暴露底层的错误是有意义的。
相比之下,一个调用数据库的函数,可能不应该返回一个包裹底层错误的错误。如果函数用的数据库包含了实现细节,暴露这些错误是对抽象的一种破坏。比如,如果你的pkg
包的LookupUser
函数,引用了 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
,即使你切换到别的数据库包。换句话说,包裹一个 error 会使这个 error 成为你的 API 的一部分。如果你不想保证永远支持 error 作为你的 API 的一部分,就不应该包裹这个错误。
有一个重点,无论你是否包裹 error,错误的内容都是一样的。是否包裹 error,是给程序附加信息以便调用者做出更多的选择,或者保留该信息以保留抽象。
自定义错误使用Is
和As
方法
errors.Is
函数把目标 error 和错误链中的 error 依次匹配。默认情况下,如果两者相等,则匹配成功。此外,错误链中的错误通过实现 Is
方法,来声明它跟目标错误是否匹配。
比如,考虑到这个错误是 Upspin
error 包启发的,它将对比错误和模板,只考虑非零的字段:
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 User field is "someuser".
}
errors.As
函数也类似。
error和包API
一个返回 error 的包应该描述程序员所依赖的 error 的属性。一个设计良好的包,不能返回不应该被依赖的 error。
最简单的一个规范是,操作成功失败或成功,分别返回 nil 和非 nil 的错误。很多情况下,不需要进一步的信息。
如果我们希望返回一个可识别的错误条件,如“找不到item”,我们可以返回一个包裹哨兵错误的 error。
var ErrNotFound = errors.New("not found")
// FetchItem returns the named item.
//
// If no item with the name exists, FetchItem returns an error
// wrapping ErrNotFound.
func FetchItem(name string) (*Item, error) {
if itemNotFound(name) {
return nil, fmt.Errorf("%q: %w", name, ErrNotFound)
}
// ...
}
还有其他的模式可以提供给调用者检查的错误,比如直接返回哨兵错误,特定类型,或者使用特定的函数检测。
所有情况下,都应该注意不要将内部细节暴露给用户。正如我们上面聊到的是否包裹错误,当你返回一个其他包的 error ,你应该把它转换成另一个不暴露底层的错误,除非你愿意承诺将来还维护这个特定错误。
f, err := os.Open(filename)
if err != nil {
// os.Open 返回的 *os.PathError 是内部细节
// 为了避免暴露给调用者,重新打包成新的 error,error 内容一样。
// 我们使用 %v 而不是 %w,%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 { … }
// 如果我们增加额外的信息到 error,会出问题。为了避免这种情况,应该返回一个抱着哨兵错误的 error。调用者必须这样判断:
// if err := pkg.DoSomething(); errors.Is(err, pkg.ErrPermission) { ... }
return fmt.Errorf("%w", ErrPermission)
}
// ...
}
结论
虽然我们只提及了3个函数和1个格式动词,我们希望他们大大改善 Go 程序中的错误处理方程。我们希望把上下文包裹进 error 变得通用,帮助程序更好的决策和帮助程序员更快地发现 bug。
引用
[1]. 本文链接:https://go.dev/blog/go1.13-errors
作者介绍