首发于nanmu42
Go语言错误处理的姿势

Go语言错误处理的姿势

本文首发于nanmu42,谢绝转载,感谢你对原创的支持。(鞠躬)

Go语言的错误处理常常给新手和有其他语言背景的使用者带来疑惑,在这篇文章中,我们将区分错误(error)和异常(panic),讨论什么样的错误是“好”的(容易检查和排错),介绍一种让错误变“好”的常用方式(fmt.Errorf())。

对了,如果你有其他编程语言背景,并且才接触Go不久,推荐阅读我先前的文章《Golang第二语言指南》

本文中的“用户”意为编写代码的人(也就是你啦)。

区分异常(panic)和错误(error)

Go把表示程序遇到意外情况的方式分成了两种:panic(异常)和error(错误)。

就像Java/JS里的throw,Python里的raisepanic会中止程序执行进入异常处理逻辑,异常可以在当前函数或者调用链向上的任何一层<a href="https://go.dev/blog/defer-panic-and-recover">被defer recover捕获处理,没被捕获的panic会造成程序打印堆栈后异常退出。panic很少会在用户代码中出现,一般用来表示除数为0、内存不足、强制类型转换失败等语言层级的异常,它们一般少见,如果出现往往意味着用户本身的实现问题,用户一般不会着重考虑和防御它们。

error就显得平凡地多了,所有满足error这个interface的值都可以当做一个合法的错误:

type error interface {
    // 输出对该错误的文本描述
    Error() string
}

ef="https://go.dev/blog/errors-are-values">error是一个平凡的值,运行时不会用特殊的逻辑对待它,不处理它不会让程序打印堆栈异常退出。

error会在用户代码中大量出现,用来表示比如连接超时、JSON解析失败、文件不存在这些用户层级的错误,它们常见,往往和业务直接相关,用户一般着重考虑防御它们。

总结下:

  • panic:不着重考虑防御的异常,没有明确的抛出位置;
  • error:用户着重考虑防御的错误,抛出位置固定而明显,只要函数返回值元组中最后一个是error类型,用户就需要考虑防御和处理。

换句话说,Go语言中error是值这个特征在鼓励用户考虑和处理每个错误,写出鲁棒健壮的程序。

我们喜欢“好”错误

错误也分好坏?当然。

举个例子,你名下有个天气API服务,它在收到请求时会查询Redis中有没有对应缓存,在没有缓存时再请求外部服务,最后返回结果给调用方。有一天,调用方找到你,说接口不工作了,你查了日志,错误信息是context deadline exceeded,这个错误意味着代码里有地方发生了超时,可是在哪里呢?是缓存还是外部API?

好的错误是有根本原因和调用链上下文的错误,这样的错误容易排查,不需要去猜,试想如果你看到的错误信息是这样的:

reading cache: redis GET: context deadline exceeded

或者带有调用堆栈的:

context deadline exceeded
goroutine 1 [running]:
main.Example(0x19010001)
           /Users/hello/main.go
           temp/main.go:8 +0x64
main.main()
           /Users/bill/main.go
           temp/main.go:4 +0x32

那么你的排错工作都会容易得多。

实际应用中,我更喜欢第一种方式,比起堆栈,人工添加的文本错误上下文更易于阅读,有着更高的信息密度,而且看到的人就算没有接触相关代码也有可能理解错误原因。

为错误提供文本上下文

Go 1.13(2019年9月)中新增了fmt.Errorf()用于为错误提供上下文,errors标准库新增Is(), As(), Unwrap()用于便利化错误的鉴别和比较。

这个方案是讨论了很久才决定下来的,Go维护者们对于新特性的引入一直以来都比较谨慎。

在Go 1.13之前,社区内有各式各样自己的尝试,比较有名的是第三方库pkg/errors,它同时探索了添加上下文和添加堆栈两个方向。

fmt.Errorf()的用法大概是这样的,回忆刚刚我们想要的那种“好错误”:

reading cache: redis GET: context deadline exceeded

这个错误就像在说故事一样,从左到右层层递进,每层fmt.Errorf()叙述自己想要做的事情,然后用:分隔下一层,下一层用%w指代。下面这段伪代码中,FindWeather()调用ReadCache(),请试想ReadCache()报错时,FindWeather()调用者收到的错误:

func FindWeather(city string) (weather string, err error) {
    weather, err := ReadCache(city)
    if err != nil {
        err = fmt.Errorf("reading cache: %w", err)
        return
    }

    if weather != "" {
        // cache hit
        return 
    }

    // cache missed, query for data source and update cache
    // ...
}

func ReadCache(city string) (weather string, err error) {
    cacheKey := "city-" + city
    weather, err = cache.Get(cacheKey)
    if err == redis.Nil {
        // cache missed
        err = nil
        return 
    } else if err != nil {
        err = fmt.Errorf("redis Get: %w", err)
        return
    }

    return 
}

诚然if err != nilfmt.Errorf()这样的重复要打不少字(有得有失嘛),你可以看看如何配置自己喜欢的IDE的自动完成功能为你省下一些键盘敲击。

Go语言的静态分析工具可以为你检查出代码中忘记处理的错误(有时还挺关键的),不规范的错误上下文格式等(%d,%f, %v, %w, %x傻傻分不清),我一般使用它们的合集版本golangci-lint.

其他为错误添加上下文的方式

虽说官方把错误文本上下文加入标准库算是投了这个方案一票,但是如果有必要,你也可以使用其他为错误添加上下文的方案,这里列了一些例子:

参考资料

封面图 / Photo by Rhaúl V. Alva on Unsplash

编辑于 2021-10-03 17:13