Golang 错误处理最佳实践

Golang有很多优点,这也是它如此流行的主要原因。但是Go 1 对错误处理的支持过于简单了,以至于日常开发中会有诸多不便利。
这些不足催生了一些开源解决方案。与此同时, Go 官方也在从语言和标准库层面作出改进。
本文将分析一些常见问题,对比各种解决方案,并展示了迄今为止(go 1.13)的最佳实践。
先说结论:建议使用 github.com/pkg/errors
进行错误处理。原因将在下面详细阐述
问题
Golang开发中经常需要检查返回的错误值并作相应处理。最简示例如下:
import (
"database/sql"
"fmt"
)
func foo() error {
return sql.ErrNoRows
}
func bar() error {
return foo()
}
func main() {
err := bar()
if err != nil {
fmt.Printf("got err, %+v\n", err)
}
}
//Outputs:
// got err, sql: no rows in result set
有时需要根据返回的error类型作不同处理,例如:
import (
"database/sql"
"fmt"
)
func foo() error {
return sql.ErrNoRows
}
func bar() error {
return foo()
}
func main() {
err := bar()
if err == sql.ErrNoRows {
fmt.Printf("data not found, %+v\n", err)
return
}
if err != nil {
// Unknown error
}
}
//Outputs:
// data not found, sql: no rows in result set
实践中经常需要为错误增加上下文信息后再返回,以方便调用者了解错误场景。例如 foo方法时常写成
func foo() error {
return fmt.Errorf("foo err, %v", sql.ErrNoRows)
}
但这时 err == sql.ErrNoRows
便不再成立。除此之外,上述写法都在返回错误时都丢掉了调用栈这个重要的诊断信息。我们需要更灵活、更通用的方式来应对此类问题。
解决方案
针对Go的不足,目前有几种解决方案。他们可以作上下文包装,不丢失原始错误信息, 还能尽量保留完整的调用栈
1. github.com/pkg/errors
来自 Dave Cheney , 有三个关键方法
Wrap
方法用来包装底层错误,增加上下文文本信息并附加调用栈。 一般用于包装对第三方代码(标准库或第三方库)的调用。WithMessage
方法仅增加上下文文本信息,不附加调用栈。 如果确定错误已被Wrap
过或不关心调用栈,可以使用此方法。 注意:不要反复Wrap
,会导致调用栈重复Cause
方法用来判断底层错误 。
用这个包重写上述程序:
import (
"database/sql"
"fmt"
"github.com/pkg/errors"
)
func foo() error {
return errors.Wrap(sql.ErrNoRows, "foo failed")
}
func bar() error {
return errors.WithMessage(foo(), "bar failed")
}
func main() {
err := bar()
if errors.Cause(err) == sql.ErrNoRows {
fmt.Printf("data not found, %v\n", err)
fmt.Printf("%+v\n", err)
return
}
if err != nil {
// unknown error
}
}/*Output:data not found, bar failed: foo failed: sql: no rows in result set
sql: no rows in result set
foo failed
main.foo
/usr/three/main.go:11
main.bar
/usr/three/main.go:15
main.main
/usr/three/main.go:19
runtime.main
...*/
从输出内容可以看到, 使用 %v
作为格式化参数,那么错误信息会保持一行, 其中依次包含调用栈的上下文文本。 使用 %+v
,则会输出完整的调用栈详情
如果不需要增加额外上下文信息,仅附加调用栈后返回,可以使用 WithStack
方法:
func foo() error {
return errors.WithStack(sql.ErrNoRows)
}
注意:无论是 Wrap
, WithMessage
还是 WithStack
,当传入的 err 参数为 nil 时, 都会返回nil, 这意味着我们在调用此方法之前无需作 nil 判断,保持了代码简洁
2. golang.org/x/xerrors
结合社区反馈,Go 团队完成了在 Go 2 中简化错误处理的提案。 Go核心团队成员 Russ Cox 在xerrors中部分实现了提案中的内容。它用与 github.com/pkg/errors
相似的思路解决同一问题, 引入了一个新的 fmt 格式化动词: %w
,使用 Is
进行判断。:
import (
"database/sql"
"fmt"
"golang.org/x/xerrors"
)
func bar() error {
if err := foo(); err != nil {
return xerrors.Errorf("bar failed: %w", foo())
}
return nil
}
func foo() error {
return xerrors.Errorf("foo failed: %w", sql.ErrNoRows)
}
func main() {
err := bar()
if xerrors.Is(err, sql.ErrNoRows) {
fmt.Printf("data not found, %v\n", err)
fmt.Printf("%+v\n", err)
return
}
if err != nil {
// unknown error
}
}/* Outputs:data not found, bar failed: foo failed: sql: no rows in result set
bar failed:
main.bar
/usr/four/main.go:12
- foo failed:
main.foo
/usr/four/main.go:18
- sql: no rows in result set
*/
与 github.com/pkg/errors
相比,它有几点不足:
- 使用
: %w
代替了 Wrap , 看似简化, 但失去了编译期检查。 如果没有冒号,或: %w
不位于于格式化字符串的结尾,或冒号与百分号之间没有空格,包装将失效且不报错 - 更严重的是, 调用
xerrors.Errorf
之前需要对参数进行nil判断。 这实际完全没有简化开发者的工作
3. Go 1.13 内置支持
到了 Go 1.13 ,xerrors 的部分功能(不是全部,下面会说明)被整合进了标准库。 它继承了 xerrors的全部缺点, 并额外贡献了一项。因此目前没有使用它的必要
import (
"database/sql"
"errors"
"fmt"
)
func bar() error {
if err := foo(); err != nil {
return fmt.Errorf("bar failed: %w", foo())
}
return nil
}
func foo() error {
return fmt.Errorf("foo failed: %w", sql.ErrNoRows)
}
func main() {
err := bar()
if errors.Is(err, sql.ErrNoRows) {
fmt.Printf("data not found, %+v\n", err)
return
}
if err != nil {
// unknown error
}
}/* Outputs:
data not found, bar failed: foo failed: sql: no rows in result set
*/
与 xerrors 版本非常接近。但是它不支持调用栈信息输出, 根据官方的说法, 此功能没有明确时间表。因此其实用性远低于 github.com/pkg/errors
总结
通过以上对比, 相信你已经有了选择。 再明确一下我的看法。 我的选择顺序是 1 > 2 > 3
- 如果你正在使用
github.com/pkg/errors
,保持现状。目前还没有比它更好的选择 - 如果你已经大量使用
golang.org/x/xerrors
, 别盲目换成内置方案,它目前还不值得
总的来说,Go 在诞生之初就在各个方面表现得相当成熟、稳健。 在演进路线上很少出现犹疑和摇摆。 而在错误处理方面却是个例外。 除了被广泛吐槽的 if err != nil
之外, 就连其改进路线也备受争议、分歧明显,以致于一个改进提案都会因为压倒性的反对意见而不得不作出调整。 好在Go 团队比以前更加乐于倾听社区意见,团队甚至专门就此问题建了个反馈收集页面。相信最终大家会找到更好的解决方案
写在最后
虽然我们讨论了如何高效地包装错误,但与其它技术一样, 它只应该应用于适合的地方。 它不应该成为通行准则。
为什么呢? 因为当开始使用 errors.Cause(err, sql.ErrNoRows)
或 xerrors.Is(err, sql.ErrNoRows)
时, 就意味着 sql.ErrNoRows
作为实现细节被暴露给外界了, 它成了API的一部分。
如果只是利用库代码进行业务开发, 包装后作判断的作法可以被理解和接受的。
而对于API的定义者来说, 这个问题就变得格外重要。 也许更好的办法是定义一个基础错误类型, 然后从其中派生出具体错误实例, 并附带错误编码