Go语言中的错误处理(Error Handling in Go)

上图来自 go proverbs
概述
在实际工程项目中,我们希望通过程序的错误信息快速定位问题,但是又不喜欢错误处理代码写的冗余而又啰嗦。
Go语言没有提供像Java、C#语言中的try...catch
异常处理方式,而是通过函数返回值逐层往上抛。
这种设计,鼓励工程师在代码中显式的检查错误,而非忽略错误,好处就是避免漏掉本应处理的错误。但是带来一个弊端,让代码啰嗦。
Go标准包提供的错误处理功能
error
是个interface
:
1 | type error interface { |
如何创建error
:
1 | // example 1 |
如何自定义error
:
1 | // errorString is a trivial implementation of error. |
自定义error
类型可以拥有一些附加方法。比如net.Error
定义如下:
1 | package net |
网络客户端程序代码可以使用类型断言判断网络错误是瞬时错误还是永久错误。比如,一个网络爬虫可以在碰到瞬时错误的时候,等待一段时间然后重试。
1 | if nerr, ok := err.(net.Error); ok && nerr.Temporary() { |
错误处理策略
实际项目开发、运维过程中,会经常碰到如下问题:
- 函数该如何返回错误,是用值,还是用特殊的错误类型
- 如何检查被调用函数返回的错误,是判断错误值,还是用类型断言
- 程序中每层代码在碰到错误的时候,是每层都处理,还是只用在最上层处理,如何做到优雅
- 日志中的异常信息不够完整、缺少stack strace,不方便定位错误原因
上面的问题都涉及到该如何处理错误,下面来介绍了生产级Go语言代码中如何处理错误。
三种错误处理策略
Go语言中一般有三种错误处理策略:
- 返回和检查错误值:通过特定值表示成功和不同的错误,上层代码检查错误的值,来判断被调用
func
的执行状态 - 自定义错误类型:通过自定义的错误类型来表示特定的错误,上层代码通过类型断言判断错误的类型
- 隐藏内部细节的错误处理:上层代码不应该知道(依赖)被调用函数返回错误的任何细节
返回和检查错误值
这种方式在其它语言中,也很常见。比如,C Error Codes in Linux。
go标准库中提供一些例子:
这种策略是最不灵活的错误处理策略,上层代码需要判断返回错误值是否等于特定值。如果想修改返回的错误值,则会破坏上层调用代码的逻辑。
1 | buf := make([]byte, 100) |
另外一种场景也属于这类情况,上层代码通过检查错误的Error()
方法的返回值是否包含特定字符串,来判定如何进行错误处理。
1 | func readfile(path string) error { |
error
interface 的Error
方法的输出,是给人看的,不是给机器看的。我们通常会把Error
方法返回的字符串打印到日志中,或者显示在控制台上。永远不要通过判断Error
方法返回的字符串是否包含特定字符串,来决定错误处理的方式。
如果你是开发一个公共库,库的API返回了特定值的错误值。那么必须把这个特定值的错误定义为public
,写在文档中。
“高内聚、低耦合”是衡量公共库质量的一个重要方面,而返回特定错误值的方式,增加了公共库和调用代码的耦合性。让模块之间产生了依赖。
自定义错误类型
这种方式的典型用法如下:
1 | // 定义错误类型 |
这种方式相比于“返回和检查错误值”,很大一个优点在于可以将 底层错误 包起来一起返回给上层,这样可以提供更多的上下文信息。比如os.PathError
:
1 | // PathError records an error and the operation |
然而,这种方式依然会增加模块之间的依赖。
隐藏内部细节的错误处理
这种策略之所以叫“隐藏内部细节的错误处理”,是因为当上层代码碰到错误发生的时候,不知道错误的内部细节。
作为上层代码,你所需要知道的只是被调用函数是否正常工作。 如果你接受这个原则,将极大降低模块之间的耦合性。
1 | import “github.com/quux/bar” |
上面的例子中,Foo
这个方法不返回的错误的具体内容。这样,Foo
函数的开发者可以不断调整返回错误的内容来提供更多的错误信息,而不会破坏Foo
提供的协议。这就是“隐藏内部细节”的内涵。
最合适的错误处理策略
上面我们提到了三种错误处理策略,其中第三种策略耦合性最低。然而,第三种方式也存在一些问题:
- 如何获得更详细错误信息,比如
stack trace
,帮助定位错误原因 - 如何优雅的处理错误
- 有些场景需要了解错误细节,比如网络调用,需要知道是否是瞬时的中断
- 是否每层捕捉到错误的时候都需要处理
输出更详细的错误信息来定位问题
1 | func AuthenticateRequest(r *Request) error { |
上面这段代码,在我看来,在顶层打印错误的时候,只看到一个类似于”No such file or directory”的文字,从这段文字中,无法了解到错误是哪行代码产生的,也无法知道当时出错的调用堆栈。
我们调整一下代码,如下:
1 | func AuthenticateRequest(r *Request) error { |
通过fmt.Errorf
创建一个新的错误,添加更多的上下文信息到新的错误中,但这样仍不能解决上面提出的问题(错误发生的位置和调用堆栈)。
goErrorHandlingSample](https://github.com/EthanCai/goErrorHandlingSample)这个repo中的例子演示了,不同错误处理方式,输出的错误信息的区别。
1 | > go run sample1/main.go |
1 | > go run sample2/main.go |
1 | > go run sample3/main.go |
1 | > go run sample4/main.go |
sample4/main.go
中将出错的代码行数也打印了出来,这样的日志,可以帮助我们更方便的定位问题原因。
通过行为断言错误
在有些场景下,仅仅知道是否出错是不够的。比如,和进程外其它服务通信,需要了解错误的属性,以决定是否需要重试操作。
这种情况下,不要判断错误值或者错误的类型,可以判断错误是否实现某个行为。
1 | type temporary interface { |
这种实现方式的好处在于,不需要知道具体的错误类型,也就不需要引用定义了错误类型的三方package
。如果你是底层代码的开发者,哪天你想更换一个实现更好的error
,也不用担心影响上层代码逻辑。如果你是上层代码的开发者,你只需要关注error
是否实现了特定行为,不用担心引用的三方package
升级后,程序逻辑失败。
不要忽略错误,也不要重复处理错误
遇到错误,而不去处理,导致信息缺失,会增加后期的运维成本
1 | func Write(w io.Writer, buf []byte) { |
重复处理,添加了不必要的处理逻辑,导致信息冗余,也会增加后期的运维成本
1 | func Write(w io.Writer, buf []byte) error { |
补充的错误处理原则
从稳定性视角出发,为了便于发现和分析问题:
- 错误信息需要记录到日志
- 错误量需要体现在metrics中
Package github.com/pkg/errors
建议使用包 github.com/pkg/errors
来处理错误。这个包提供这样几个主要的API:
1 | // 以下代码在errors.go中 |
example_test.go
有一些使用案例。
各种场景下的错误处理案例
error相等判断
当不确定待处理的error是否被包装时,不能直接使用相等进行判断。
例子
Bad:
1 | if err == EOF { |
Good:
1 | if errors.Is(err, EOF) { // it will succeed if err wraps EOF |
error类型转换
当不确定待处理的error是否被包装时,不能直接使用类型断言进行。
例子
Bad:
1 | nerr, ok := err.(*CError) |
Good:
1 | var nerr *CError |
如何向外暴露error
例子
Bad:
1 | // package io |
Good:
1 | // package io |
程序启动时,如何处理依赖的三方资源无法连接 或者 不存在
根据三方资源的对于业务逻辑的重要性,采取是强依赖、还是弱依赖的处理策略,比如:
- 如果三方资源是 类似DB的强依赖 资源,启动时无法连接,记录错误并
panic
- 如果三方资源是 弱依赖的服务,启动时无法连接,记录错误但无需
panic
服务之间的调用(比如A调用B),被调用服务程序逻辑内部发生的错误,是否要把错误信息返回给调用方
在没有安全要求的情况下,B尽量返回详细的错误信息(message
、stack trace
等),方便看到错误信息时候,能够直接定位错误原因。
在存在安全限制的场景下,B返回在安全限制以内尽量详细的错误信息,比如:
- A是自有的C端的 客户端应用或者前端web应用,B不能返回
stack trace
- A是外部的应用,调用服务B,B不能返回 内部的错误
message
、stack trace
数据访问层,使用gorm等orm框架查不到结果不应该报error
数据访问层,使用gorm等orm框架查不到结果不应该报error,原因如下:
- 从业务逻辑的角度来看,查不到结果 是一种正常的现象,不应当视为错误。比如,注册的时候检查账户是否已经存在,去掉订单的时候检查订单是否存在 等情况下,不应该将查不到数据视为错误。
- 如果在业务逻辑里判断
err != gorm.errnotfound
,不符合开闭原则,如果后面要换一个数据库框架,需要改动业务逻辑代码
数据访问层建议的处理方式:
1 | // Dao层的查询函数 |
总结
错误处理策略满足以下要求
从代码结构的角度出发,不增加代码耦合度
从服务稳定性的角度出发,便于发现和分析问题
没有最好的策略,只有最合适的策略。在实际的项目开发过程中,不要拘泥于教条,灵活运用各种策略。让代码更健壮,让服务更稳定。
参考
- The Go Blog
- Dave Cheney
- Go Packages
- pkg: Artisanal, hand crafted, barrel aged, Go packages
- Go 2 Draft Designs