Golang异常处理

260 阅读6分钟

概述

在日常的工作中,服务端提供的服务通常由Golang进行编码构建,代码的编写中有非常多的形如此类的代码:

resp, err := httpClient.Do(req)
if err != nil {
    log.Printf("get media info err=%v\n", err)
    return err
}

发生异常通常会日志打一遍,之后再把错误抛给上层调用者,代码会大量加入错误的判断处理,同时在问题抛出后还需要通过打印出的日志信息去代码中检索定位问题,可能不是一种好的异常处理方式。我们希望在一方面能够减少频繁的 if err == nil 代码量,同时能够和其他语言一样获得堆栈信息,在观察日志时提高效率,减少代码分割。

1. 通过消除错误来消除错误处理

在程序设计时,可以通过结构体的包装,将error信息内嵌至结构体中保存下来,以达到减少异常处理的频次。

func CountLines(r io.Reader) (int, error) {
    var (
        br    = bufio.NewReader(r)
        lines int
        err   error
    )

    for {
        _, err = br.ReadString('\n')
        lines ++
        if err != nil {
            break
        }
    }

    if err != io.EOF {
        return 0, err
    }

    return lines, nil
}

这是一个简单的行数读取函数,每一行的读取都需要进行错误的判断,最后还需要对异常进行EOF判断,我们可以通过对err封装来消除繁琐的异常处理代码:

func CountLines2(r io.Reader) (int, error) {
    sc := bufio.NewScanner(r)
    lines := 0

    for sc.Scan() {
        lines ++
    }

    return lines, sc.Err()
}

通过使用Scanner可以明显减少异常处理的步骤,通过查看Scanner的结构体可以发现,err内嵌至结构体中:

type Scanner struct {
    r            io.Reader // The reader provided by the client.
    ...
    err error  // Sticky error.
    ...
}
...
func (s *Scanner) Err() error { 
    if s.err == io.EOF {
        return nil 
    } 
    return s.err 
}

通过这种方式,异常处理的逻辑被复用,主干逻辑代码非常简洁,不会有较强的割裂感。

其实有一些常见的使用场景,例如MySQL中的Rows扫描记录异常,在使用形如扫描器的结构时,一定要处理Err(),例如Mysql扫描查询结果时,如果其中某一行出现了异常且没有及时对Err进行处理,则可能会对程序的最终结果造成影响。


上面的这种方式能够在一定程度上减少异常处理的分支代码,但是错误依然没有包含堆栈跟踪信息,并没有解决异常处理中我们需要的更详细的上下文及更优雅的处理方式,使用者依然需要进行长时间的代码分割,来查找错误的引发方式。

2. Wrap errors

Dave Cheney:

You should only handle errors once. Handling an error means inspecting the error value, and making a single decision.

(您应该只处理一次错误。处理错误意味着检查错误值,并做出单一决定)

形如最开始对异常的处理,在代码中先打印错误,再把错误抛给调用者,实际上对错误进行了两次处理,调用者收到错误信息后又会重复打印日志,再向上层调用者抛出,最终的日志会非常散乱,集中式采集到后会是非常割裂的,所以要进行异常的包装,日志记录与错误无关且与调试没有帮助的信息应该被视为噪音,日志记录的原因应该是因为某些东西失败了,而日志包含了答案。

  • 错误要被日志记录
  • 程序处理错误保证100%完整性
  • 之后不再报告当前错误

因此我们可以引入 "github.com/pkg/errors" ,errors包允许以不破坏错误原始值的方式将上下文添加到代码中的失败路径。

go get github.com/pkg/errors

errors用法

为错误添加上下文

errors.Wrap() 函数能够返回一个新错误,包含了堆栈跟踪以及提供的消息,我们可以将上下文添加到原始错误中进行包装:

_, err := ioutil.ReadAll(r)
if err != nil {
        return errors.Wrap(err, "read failed")
}

检索错误原因

使用 errors.Wrap() 函数包装错误后,我们可以通过 errors.Cause()进行检查,将递归检索未实现 causer 的最顶层错误,并视为原始错误

type causer interface {
        Cause() error
}

switch err := errors.Cause(err).(type) {
case *MyError:
        // handle specifically
default:
        // unknown error
}

格式化打印错误

errors包所有的错误类型都实现了 fmt.Formatter 并且都可以被格式化打印

%s  打印错误。如果错误有上下文,它将被递归打印。
%v  查看 %s 
%+v 扩展格式。错误的 StackTrace,每一栈帧都会被详细打印出来

使用用例

func ReadFile(path string)([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, errors.Wrap(err, "open failed" )
    }
    defer f.Close()
    
    // ...

    return []byte{}, err
}

func ReadConfig() ([]byte, error) {
    root := os.Getenv("ROOT")
    conf, err := ReadFile(filepath.Join(root, ".config.yml"))
    return conf, errors.WithMessage(err, "could not read config" )
}

func main() {
    _, err := ReadFile("xxxxxxx")
    if err != nil {
        fmt.Printf("original error: %T | %v\n", errors.Cause(err), errors.Cause(err))
        fmt.Printf("stack trace: \n%+v\n", err) 
        os.Exit(1)
    }
}

执行结果:

image.png

errors.Wrap() 与 errors.WithMessage() 没有本质区别,都会保留错误的原始信息,并且可以添加自己的附带信息。区别在于前者会保存最底层的堆栈信息,后者不会。

通过errors的错误包装,我们可以拿到原始的错误信息,同时可以通过 "==" 来与哨兵error进行比较判断错误类型,也可以通过断言来判断错误的触发行为,最重要的是,我们可以获得完整的堆栈信息

添加上下文后的改进,不用单独记录日志,一层层向上抛出即可:

resp, err := httpClient.Do(req)
if err != nil {
    return errors.Wrap(err, "get media info failed")
}

使用技巧

  • 在代码中使用 errors.New 或者 errors.Errorf 返回错误,会同时包含堆栈信息
  • 如果调用其他包内的函数,通常简单的直接返回即可
  • 如果和其他的库进行协作,考虑使用 errors.Wrap 或 errors.Wrapf 保存堆栈信息,同时加入自定义信息
  • 直接返回错误,而不是每个错误产生时到处打印日志
  • 在程序顶部或是goroutinue顶部,使用%+v记录堆栈详情信息
  • 使用 errors.Cause 获取 root error,再与哨兵异常进行判定
  • Wrap的选用最好最好仅用于 application,即应用于业务中,防止堆栈信息的重复打印
  • 如果不打算处理错误,那么需要用足够的上下文 wrap errors 并抛给上层调用者

Go 1.13之后

Go官方在版本1.13后加入了关于error的一些特性,用于简化处理包含其他错误的错误。

  • 包含另一个错误的 error 可以实现返回底层错误的 Unwrap() 方法。如果 e1.Unwrap() 返回 e2,那么我们说 e1 包装 e2,可以展开 e1 以获得 e2。
  • 包含两个用于检查错误的新函数 Is() 和 As(),用于类似的错误检查

内部实现:

type wrapError struct{
    msg string
    err error
}

func (e *wrapError) Error() string {
    return e.msg
}

func (e *wrapError) Unwrap() error {
    return e.err
}

参考文献

blog.golang.org/errors-are-…
blog.golang.org/error-handl…
blog.golang.org/go1.13-erro…
commandcenter.blogspot.com/2017/12/err…