Golang的错误处理

Golang中的错误处理一直都是很多人吐槽的问题,还有人开玩笑说Golang代码中50%都是if err != nil{}。我刚开始写Golang的时候也很不习惯,几乎所有函数都得处理error。之前写习惯了Python,都是在可能会出现问题的地方try ... except ..
基于Golang的这种机制,要想把error用好还是需要一些技巧。这篇文章就总结下Golang中error优雅的使用方法

Error vs Except

Golang为什么没有采用Except这种捕获错误的方式,可以看下这篇原文。文章中介绍了几种成熟语言(CC++Java)采用捕获异常方式都存的一些问题,比如在C语言中,函数只有一个返回值,如果想向调用者表示异常,一种是接受指针,将数据写入指针指向的地址,然后返回状态码,另外一种就是返回结构体,其中包含异常。而C++中用了try ... catch...,每次调用函数时都要写,而且多个函数时还不知道是具体是哪个函数报出的异常。

基于上面的这些问题,所以在Golang中才支持函数返回多个值,可以将error显示的返回给调用者,交给调用者处理。在程序流程中可以显示的控制每个阶段error,就算你没有处理error,也会显示的在代码里展示出来

在Golang中,通常会看到类似这样的代码

1
2
3
4
5
6
7
func test() (string, error) {
// ...
}
result, err := test()
if err != nil {
// ...
}

Golang函数可以返回多个值,一般error都是在最后一个参数。每次调用函数后都要先判断error,不能直接使用返回值

最好不要写出下面的代码

1
2
3
4
5
func test() (string, error) {
// ...
}
result, _ := test()
// ...

这样忽略error和前面说的不判断error都是不可取的,因为这样可能会panic,导致你的程序退出

errors are values

Error源码

error type的源码

1
2
3
type error interface {
Error() string
}

要想自己构造一个error只要实现Error方法就可以,比如package/errors实现就很简单

1
2
3
4
5
6
7
8
9
10
11
12
13
package errors

func New(text string) error {
return &errorString{text}
}

type errorString struct {
s string
}

func (e *errorString) Error() string {
return e.s
}

erros包只有使用New方法才能构造一个自定义的error,因为errorString是不可被外部包导入的。而且New方法返回的是一个指针,是防止出现下面的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func New(s string) error {
return error{s}
}

type error struct {
s string
}

func (e error) Error() string {
return e.s
}

func main() {
e1 := New("test")
e2 := New("test")
fmt.Printf("e1 == e2, %t\n", e1 == e2)
// e1 == e2, true

e3 := errors.New("test")
e4 := errors.New("test")
fmt.Printf("e3 == e4, %t\n", e3 == e4)
// e3 == e4, false
}

如果不返回指针,e.s相等的两个error就会相等。程序的error就会很混乱了。还有可能调用者就会声明和第三包相同的error,并且会拿它和第三包的error进行判断,如果某天第三方包的e.s进行了更新,这种判断就会失效。

还有个问题就是这里为什么要使用结构体指针呢?其实只要是指针就可以,并不一定需要结构体

只返回结构体也不行,结构体比较的是两个结构体的每个字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func New(s string) error {
e := errorString(s)
return &e
}

type errorString string

func (e errorString) Error() string {
return string(e)
}

func main() {
e1 := New("test")
e2 := New("test")
fmt.Printf("e1 == e2, %t\n", e1 == e2)

e3 := errors.New("test")
e4 := errors.New("test")
fmt.Printf("e3 == e4, %t\n", e3 == e4)
}

之所以要单独声明一个变量e := errorString(s),是因为Golang中函数调用是不可寻址的,所以要将函数返回结果赋值给一个变量。关于哪些可寻址可以参考这篇文章

处理Error的几种方式

Sentinel Error

这种方式就是声明包内可导出的全局Error,让包的使用者可以判断Error,比如在io包中

1
2
3
4
5
6
7
8
9
10
11
12
13
// ErrShortBuffer means that a read required a longer buffer than was provided.
var ErrShortBuffer = errors.New("short buffer")

var EOF = errors.New("EOF")

// ErrUnexpectedEOF means that EOF was encountered in the
// middle of reading a fixed-size block or data structure.
var ErrUnexpectedEOF = errors.New("unexpected EOF")

// ErrNoProgress is returned by some clients of an io.Reader when
// many calls to Read have failed to return any data or error,
// usually the sign of a broken io.Reader implementation.
var ErrNoProgress = errors.New("multiple Read calls return no data or error")

经常会用到io.EOF来判断文件是否读取完毕

1
2
3
4
5
6
for {
target, _, c := br.ReadLine()
if c == io.EOF {
break
}
}

这种方式是不推荐的,因为程序增加了对外部包的依赖,要依赖特定的错误。第三方包如果想更新某个错误也非常难。而且这种方式也携带不了过多的上下文信息,有些Error为了携带上下文信息,会使用fmt.Errorf方法,将上下文信息带到Error()的输出中,在检查错误时会检查error是否包含特定的字符串,这种方式是非常不推荐的,一旦第三方包的错误信息变化,那么所有的Error检查都会失败。

Error types

当调用函数拿到error,需要判断error是否是某一类错误时,就会用到类型断言,而类型断言又分为对具体类型进行断言和对interface进行断言

对具体类型进行断言
1
2
3
4
5
6
7
8
9
10
11
12
13
type TimeoutError struct {
Message string
Line string
}

if v, ok := e.(pkg.TimeoutError); ok{
// error就是pkg.TimeoutError类型,可以调用该类型的方法
}

switch e.(type){
case pkg.TimeoutError:
// error就是pkg.TimeoutError类型,可以调用该类型的方法
}

这种方式还是不太推荐,虽然它比Sentinel error能够方便的携带更多上下文信息,但是还有几个问题

  • 要引入第三方包具体的error类型,增加了对第三方库的依赖

  • 要对引入第三方的error的方法要非常熟悉

  • 在处理Wrap()处理过的error非常麻烦,因为有可能最底层的error是你不知道的哪个包的error

    1
    2
    3
    4
    5
    6
    7
    8
    if v, ok := errors.Cause(e).(pkg.TimeoutError); ok{
    // error就是pkg.TimeoutError类型,可以调用该类型的方法
    }

    switch errors.Cause(e).(type){
    case pkg.TimeoutError:
    // error就是pkg.TimeoutError类型,可以调用该类型的方法
    }
  • 不知道error返回的是具体的结构体还是指针,都要考虑到

    1
    2
    3
    4
    switch errors.Cause(e).(type){
    case pkg.TimeoutError, *pkg.TimeoutError:
    // error就是pkg.TimeoutError类型,可以调用该类型的方法
    }
对interface进行断言
1
2
3
4
5
6
7
type Error interface{
Timeout() bool
}

if v, ok := e.(Error); ok {
v.Timeout() // 判断是否是timeout
}

通过这种方式,只检测给定的error是否有Timeout方法,就不用引入特定的error,减少了依赖,也不怕error被Wrap()多次。

github.com/pkg/erros中的Cause()方法就是用的这种思路

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func Cause(err error) error {
type causer interface {
Cause() error
}

for err != nil {
cause, ok := err.(causer)
if !ok {
break
}
err = cause.Cause()
}
return err
}

这种方式是推荐使用的,因为他不依赖于具体的error,第三方包更新代码时也不需要考虑error信息变化导致的问题

Opaque Error

这最后一种方式是最推荐使用的,思路就是遇到error我就不处理并不关心具体的error,没有遇到error我就正常执行

1
2
3
4
5
v, err := test()
if err != nil {
return
}
// 处理v

大部分情况上面都可以满足,但是有些情况我们需要知道error具体的一些信息,比如error是不是超时,如果是超时的话就需要重试机制。这种方式和对interface进行断言比较类似

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
type temporary interface {
Temporary() bool
}

func IsTemporary(e error) bool {
v, ok := e.(temporary)
return ok && v.Temporary()
}

type timeoutError struct {
s string
}

func (e *timeoutError) Error() string {
return fmt.Sprintf("timeout:%s", e.s)
}

func (e *timeoutError) Temporary() bool { return true }

func Conn() error {
// conn
return &timeoutError{"mongo client"}
}

func main() {
// 在第三包内被调用
err := Conn()
if IsTemporary(err) {
// retry
}
}

使用这种方式,能够满足我们的要求并且对第三包的依赖最少

Golang 1.13 error

在1.13版本中error机制有了部分改进,其实也是借鉴了github.com/pkg/errors的部分设计思想

增加了UnwrapIsAs方法

在1.13版本之前,通常是这么使用error

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type testErr struct {
Message string
Err error
}

func (e *testErr) Error() string {
return e.Message
}

_, err := testA(nil, "s")

if e, ok := err.(*testErr); ok && e.Message == io.EOF.Error() {
fmt.Println(e.Message)
}

Is、As方法

在1.13中,只要你实现了Unwrap方法,就可以用Is方法判断是否是同一个类型的error

As方法可以用来转换error为特定的类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
type testErr struct {
Message string
Err error
}

func (e *testErr) Error() string {
return e.Message
}

func (e *testErr) Unwrap() error {
return e.Err
}

func testA() error {
return &testErr{
Message: "test",
Err: io.ErrUnexpectedEOF,
}
}

func main() {
err1 := testA()

// if err1.Err == io.ErrUnexpectedEOF {}
if errors.Is(err1, io.ErrUnexpectedEOF) {
fmt.Println("err1 is testErr")
// err1 is testErr
}

// if e, ok := err1.(*testErr); ok {
// fmt.Println(e.Message)
// }
var e *testErr
if errors.As(err1, &e) {
fmt.Println(e.Message)
// test
}
}

需要注意Is方法会自动调用Unwrap方法,所以需要在Unwrap方法中返回最原始的error

%w

在1.13中可以用%wwrap一个error

1
2
3
4
5
6
7
8
9
10
11
err1 := fmt.Errorf("test %w", io.ErrUnexpectedEOF)

if errors.Is(err1, io.ErrUnexpectedEOF) {
fmt.Println("Is: err1 is io.ErrUnexpectedEOF")
// Is: err1 is io.ErrUnexpectedEOF
}

if err1 == io.ErrUnexpectedEOF {
fmt.Println("==: err1 is io.ErrUnexpectedEOF")
// no output
}

注意不要对外暴露内部的error,比如查询数据库时的错误sql.ErrNoRows,当函数内部暴露了error时,调用者可能会这样处理

1
2
err := db.search()
if errors.Is(err, sql.ErrNoRows){...}

这样调用者就依赖函数内部的实现,如果某天更换了数据库,这种代码就没有意义了,像是这种最好是用fmt.Errof("%s")

自定义Is方法

看看Is源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func Is(err, target error) bool {
if target == nil {
return err == target
}

isComparable := reflectlite.TypeOf(target).Comparable()
for {
if isComparable && err == target {
return true
}
if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
return true
}
// TODO: consider supporing target.Is(err). This would allow
// user-definable predicates, but also may allow for coping with sloppy
// APIs, thereby making it easier to get away with them.
if err = Unwrap(err); err == nil {
return false
}
}
}

主要target error链中有一个error是相等的,就会返回true

代码中会先调用err的Is方法,然后调用Unwrap方法,所以我们可以不实现Unwrap只实现Is方法就可以自定义我们error的判断方法。如果不自己实现Is方法,是没有办法判断两个被wrap的error,因为总是会调用Unwrap方法获取到最底层的error。而通过自己实现Is方法,就可以判断两个被wrap的error是否相等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

type testErr struct {
Message string
Err error
}

func (e *testErr) Error() string {
return e.Message
}


func (e *testErr) Is(target error) bool {
t, ok := target.(*testErr)
if !ok {
return false
}

return e.Err == t.Err
}

func testA() error {
return &testErr{
Message: "test",
Err: io.ErrUnexpectedEOF,
}
}

func main() {
e := testA()
if errors.Is(e, &testErr{"test123", io.ErrUnexpectedEOF}) {
fmt.Println("io.ErrUnexpectedEOF == io.ErrUnexpectedEOF")
}
}

上面自定义了Is方法,只要error.err相同就认为两个error相同,实现了被wrap error的判断

github.com/pkg/erros

这个库应该是目前对error处理最好的库了,error标准库都借鉴了它的一些实现。该库最重要的就是提供了堆栈信息,而且还支持对error添加自定义的信息、获取最底层的error信息等功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import (
"fmt"

"github.com/pkg/errors"
)

func main() {
e := errors.New("base")
e1 := errors.WithMessage(e, "lay1")
e2 := errors.WithMessage(e1, "lay2")

// %+v,可打印出堆栈信息
fmt.Printf("%+v\n", e2)
// base
// main.main
// /Users/xx/studies/golang-test/main.go:10
// runtime.main
// /usr/local/Cellar/go/1.13.8/libexec/src/runtime/proc.go:203
// runtime.goexit
// /usr/local/Cellar/go/1.13.8/libexec/src/runtime/asm_amd64.s:1357
// lay1
// lay2

// Cause 可以获取最底层的error
fmt.Printf("%+v\n", errors.Cause(e2))
// base
// main.main
// /Users/xx/studies/golang-test/main.go:10
// runtime.main
// /usr/local/Cellar/go/1.13.8/libexec/src/runtime/proc.go:203
// runtime.goexit
// /usr/local/Cellar/go/1.13.8/libexec/src/runtime/asm_amd64.s:1357

// Is 可以判断两个err是否在同一个链中
fmt.Printf("e2 is e1, %t\n", errors.Is(e2, e1))
// e2 is e1, true

// Unwrap 可以获取下一层级中的error
fmt.Printf("e1:\n%+v\n", errors.Unwrap(e2))
// base
// main.main
// /Users/xx/studies/golang-test/main.go:10
// runtime.main
// /usr/local/Cellar/go/1.13.8/libexec/src/runtime/proc.go:203
// runtime.goexit
// /usr/local/Cellar/go/1.13.8/libexec/src/runtime/asm_amd64.s:1357
// lay1
}

使用该库时也需要注意

  • 在处理第三方库的err时,最好不要使用该包进行wrap,因为这会暴露内部的实现给调用者,一般都要在这里转换成系统内部的错误。而且如果你有需求要暴露内部实现error时,也不要使用该包,因为不清楚第三方库是否已经使用了它,及有可能因为原始error被wrap了两次,在日志中也会打印两次堆栈信息
  • 自己的程序返回的错误,只在在最底层出现错误的时候添加堆栈信息,其他步骤只需要添加文本信息即可(WithMessage方法)

    Panic

panic在Golang中意味着程序退出,不能理解成其他语言中的throw,有些错误处理会写成这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func test(s string) {
if s != "world" {
panic("invalid field")
}
}

func main() {
defer func() {
if err := recover(); err != nil {
fmt.Printf("err:%s\n", err)
}
}()
test("hello")
}

这种代码强烈不推荐的,panic只能是意味着程序退出,不能用作error

但是当我们在检测一些强依赖时是可以panic的,比如数据库连接不上就panic,不让程序启动

还有一些情况我们是通过recover捕获不了error的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func test(s string) {
if s != "world" {
panic("invalid field")
}
}

func main() {
defer func() {
if err := recover(); err != nil {
fmt.Printf("err:%s\n", err)
}
}()

go test("hello")

time.Sleep(1 * time.Second)
}

这种也算的goroutine的panic,在主函数里是捕获不到的,优雅的处理方式是在goroutine里使用recover

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func test(s string) {
if s != "world" {
panic("invalid field")
}
}

func Go(s string, fn func(s string)) {
go func() {
defer func() {
if err := recover(); err != nil {
fmt.Printf("err:%s\n", err)
}
}()
fn(s)
}()
}

func main() {
Go("hello", test)

time.Sleep(1 * time.Second)
}

Error处理技巧

将主逻辑放在外面

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
v, err := test()
if err != nil{
// handler error
}
// main


v,err := test()
if err == nil{
// main
}
}

上面第一段代码是优于第二段代码的,因为主要的逻辑都是在if外面,if只是用来处理特殊情况

return error

1
2
3
4
5
6
7
func test() error {
err := checkData()
if err != nil {
return err
}
return nil
}

上面这种代码是经常出现的,我查了之前我以前写的代码,也有不少这样的例子,其实完全可以用一行代码

1
2
3
func test() error {
return checkData()
}

减少重复处理error

将重复处理error的逻辑整合到同一个函数内

1
2
3
4
5
6
7
8
9
10
11
12
_, err = fd.Write(p0[a:b])
if err != nil {
return err
}
_, err = fd.Write(p1[c:d])
if err != nil {
return err
}
_, err = fd.Write(p2[e:f])
if err != nil {
return err
}

经常会出现这样的代码,同样的函数调用多次,每次都要处理error,可以把这种相同的错误处理放到同一个函数内

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type errWriter struct {
w io.Writer
err error
}

func (ew *errWriter) write(buf []byte) {
if ew.err != nil {
return
}
_, ew.err = ew.w.Write(buf)
}

func main() {
ew := &errWriter{w: fd}
ew.write(p0[a:b])
ew.write(p1[c:d])
ew.write(p2[e:f])
if ew.err != nil {
return ew.err
}
}

统一处理error

在写代码处理http请求时,通常写出下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func init() {
http.HandleFunc("/view", viewRecord)
}

func viewRecord(w http.ResponseWriter, r *http.Request) {
c := appengine.NewContext(r)
key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
record := new(Record)
if err := datastore.Get(c, key, record); err != nil {
http.Error(w, err.Error(), 500)
return
}
if err := viewTemplate.Execute(w, record); err != nil {
http.Error(w, err.Error(), 500)
}
}

这种代码单个看起来还好,但是当handler越来越多的时候就不太好管理了,不容易统一返回接口的格式,通常我们会遇到转换状态码的情况,有些状态码只在项目内部使用,到外部时需要转换一下,像是这种情况就需要统一的处理了

优化后的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
type appError struct {
Error error
Message string
Code int
}

type appHandler func(http.ResponseWriter, *http.Request) *appError

func init() {
http.Handle("/view", appHandler(viewRecord))
}

func (fn appHandler) ServerHTTP(w http.ResponseWriter, r *http.Request) {
if e := fn(w, r); e != nil { // e is *appError, not os.Error.
c := appengine.NewContext(r)
c.Errorf("%v", e.Error)
http.Error(w, e.Message, e.Code)
}
}

func viewRecord(w http.ResponseWriter, r *http.Request) *appError {
c := appengine.NewContext(r)
key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
record := new(Record)
if err := datastore.Get(c, key, record); err != nil {
return &appError{err, "Record not found", 404}
}
if err := viewTemplate.Execute(w, record); err != nil {
return &appError{err, "Can't display record", 500}
}
return nil
}

首先定义了一个统一的接口返回结构体appError,所有的接口都返回该结构体,然后在ServerHttp中对每个接口返回的errro做统一的处理,像之前提到过的转换状态码就可以在这个函数内完成。

只处理一次error

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func test() error {
v, err := db.search()
if err != nil {
logger.Error(err)
return err
}
}

func main() {
err := test()
if err != nil {
logger.Error(err)
}

}

上面的代码中对error处理了两次,应该只对error处理一次,即使是打印日志也算是处理了error

总结

前面介绍了很多种方法,总结一下,优先使用github.com/pkg/errors包,因为会携带堆栈信息。尽量使用opaque error的方式。如果你代码中的包要给第三方使用,不要暴露具体的error给对方。在自己的包中要将error向上传递,在最上层进行打印

参考链接

https://medium.com/gett-engineering/error-handling-in-go-53b8a7112d04

https://medium.com/gett-engineering/error-handling-in-go-1-13-5ee6d1e0a55c

https://dave.cheney.net/2012/01/18/why-go-gets-exceptions-right

https://blog.golang.org/error-handling-and-go

https://medium.com/hackernoon/golang-handling-errors-gracefully-8e27f1db729f

https://morsmachine.dk/error-handling

https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully

https://blog.golang.org/errors-are-values