Golang 错误处理最佳实践

Che Dan
Che Dan
Dec 25, 2019 · 9 min read

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 , 有三个关键方法

  1. Wrap 方法用来包装底层错误,增加上下文文本信息并附加调用栈。 一般用于包装对第三方代码(标准库或第三方库)的调用。
  2. WithMessage 方法仅增加上下文文本信息,不附加调用栈。 如果确定错误已被 Wrap 过或不关心调用栈,可以使用此方法。 注意:不要反复 Wrap ,会导致调用栈重复
  3. 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 相比,它有几点不足:

  1. 使用 : %w 代替了 Wrap , 看似简化, 但失去了编译期检查。 如果没有冒号,或 : %w 不位于于格式化字符串的结尾,或冒号与百分号之间没有空格,包装将失效且不报错
  2. 更严重的是, 调用 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的定义者来说, 这个问题就变得格外重要。 也许更好的办法是定义一个基础错误类型, 然后从其中派生出具体错误实例, 并附带错误编码


全文完,欢迎留言与我讨论 ^-^

猜你喜欢:

Che Dan

Written by

Che Dan

programmer / creator

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade