Go语言错误处理的姿势

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

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

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

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

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

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

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

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

    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()调用者收到的错误:

     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
    
    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.

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

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

    参考资料

    更新日志

    • (2021/10)感谢V2EX网友Mitt对文中示例伪代码的勘误;
    • (2021/10)感谢V2EX网友ZSeptember对文章标题和主旨的建议。
    分享

    nanmu42
    作者
    nanmu42
    用心构建美好事物。

    目录