转转运维Go工程化实践之Error使用及处理
在生产项目中, 通过程序抛出的错误信息, 可以帮助开发者快速定位问题所在. 但是 Go 语言中没有提供像 Java 中的
try...catch
和 Python 中的try...except
异常处理方式, 而是通过 Go语言多返回值的特性, 通过函数的返回值将error
逐层向上抛出. 本文将介绍转转运维团队在 Go语言中对错误的使用及处理实践
Error vs Exception
使用错误, 而不是异常的优势
-
简单可控, 处理每个函数可能抛出的错误, 不会有异常中变量的语义迷惑
-
考虑失败, 而不是成功, 更加关注错误的处理
-
保持程序流程的线性执行, 没有隐藏的控制流
使用错误的弊端
-
对每个抛出
error
的函数或方式都接收处理其错误, 导致代码冗长啰嗦 -
error
逐层向上抛出, 容易出现对错误的重复处理, 导致排查问题时, 干扰信息太多 -
滥用
painc
或忽略recover
导致的程序健壮性大幅降低
panic and recover
Go 语言中的"异常"称之为 panic
恐慌. panic
在 Go 语言中可以被认为是最严重的错误. 当运行中的函数抛出 panic
时, 程序将被终止运行. 在实际生产代码中, 大致可以分为两种恐慌的抛出情况. 一种为使用的库函数或数据结构在运行中被意外抛出, 一种为开发人员显式主动的在代码中抛出.
painc 使用场景
一般在常规的项目中, 不应该出现大量主动抛出的 panic
, 一般情况下, 主动抛出的 panic
仅仅在程序进行初始化的时候抛出. 比如程序运行需要初始化日志配置, 依赖一些中间件, 或是程序需要连接后端数据库时, 这些强依赖的组件如果初始化失败, 程序就无法完成后续的功能, 所以当程序启动时, 这些组件出现 error
, 一般情况下我们会用 panic
抛出
另一种使用 panic
的情况为配置文件读取. 当配置文件中某变量的值在检查时, 明显出现异常时, 将抛出 panic
. 避免由于配置文件的错误, 导致各种逻辑上的问题, 难以排查. 例如, 在为运行环境为测试环境的配置中, 出现了依赖组件连接线上IP网段的情况; 配置文件中定义的数据库连接端口为 330600000
, 连接池数量 1048576
等等.
总结: 只有在真正出现以外的情况, 且不可恢复的程序错误时, 才会用到 panic
recover 使用场景
recover
关键字用来接住程序中抛出的 panic
, 是程序引发 panic
后的最后屏障. recover
一般有以下两种使用场景.
一种是在"主进程"中使用, 用来接住由于意外情况, 在主进程中被抛出的 panic
. 例如一个 http server
服务, 当用户的一个不合规 request
或由于服务端逻辑设计缺陷, 导致触发到服务端的 panic
时, 如果没有 recover
住 http server
的 panic
, 那么进程将完全退出, 服务彻底无法使用, 需要靠外部机制感知到进程退出, 并进行重启操作以恢复服务的运行, 比如在裸机上使用 systemd 守护运行或使用容器运行. 如果 http server
进程内部主动 recover
了 panic
, 当 recover
捕获到 panic
后, 可以重新启动 http server
以对外提供服务.
另外一个实际案例是一个纯后台服务, 该后台由多个 worker goroutine
组成, 主 Goroutine 收到请求后会通过 channel
发送到 woker goroutine
进行处理. 此时如果某一个 worker goroutine
出现 panic
, 那么将导致整个程序崩溃掉, 此时可以给每个 woker goroutine
都配置有 recover
函数, 当 worker goroutine
意外 panic
后, 由 goroutine function
内部的 recover
进行"重启"
总结: recover 一般用在 main goroutine
和 other goroutine
(包含 野生goroutine
和 worker goroutine
) 中, 用于快速恢复崩溃的goroutine
panic&recover使用总结, 一般情况下, 除了程序初始化时的强依赖引发的 panic
之外, 其他所有的情况的 panic
都应该尽量被 recover
处理, 哪怕 recover
的处理结果是 exit
自定义 Error
Sentinel Error
sentinel error
又被称之为预定义错误, 在某些基础库或第三方库中, 会有大量的 sentinel error
被定义.
// ErrShortWrite means that a write accepted fewer bytes than requested
// but failed to return an explicit error.
var ErrShortWrite = errors.New("short write")
// errInvalidWrite means that a write returned an impossible count.
var errInvalidWrite = errors.New("invalid write result")
// ErrShortBuffer means that a read required a longer buffer than was provided.
var ErrShortBuffer = errors.New("short buffer")
// EOF is the error returned by Read when no more input is available.
// (Read must return EOF itself, not an error wrapping EOF,
// because callers will test for EOF using ==.)
// Functions should return EOF only to signal a graceful end of input.
// If the EOF occurs unexpectedly in a structured data stream,
// the appropriate error is either ErrUnexpectedEOF or some other error
// giving more detail.
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")
sentinel error
是错误定义和使用中, 最不灵活的一种. 调用方需要使用 ==
与预定义的错误进行比较判断, 而当你的错误需要包含更多的信息时, 将破坏调用方的 ==
检查. 迫使调用方不得不使用字符串包含匹配进行 ==
等值检查.
如果一个接口的方法返回了一个 sentinel error
的错误, 那么这个接口的所有实现者都必须返回这个错误. 比如 io.Reader
这类函数需要实现者返回 io.EOF
错误来告诉调用者读取数据正常结束, 但是这又不是个错误.
sentinel error
还在包与包之间创建了依赖关系, 当调用者需要对错误进行等值检查时, 调用者必须 import
对应的包, 当项目中出现许多这样的错误定义与等值判断时, 一方面容易出现耦合, 一方面很容易出现循环依赖的问题
总结: 在项目开发中, 尽量避免出现使用 sentinel error
隐藏内部细节的错误处理
标准库中的提供的错误接口定义如下
type error interface {
Error() string
}
自定义 error
// 实现自定义error
type myError struct {
s string
}
func (m *myError) Error() string {
return m.s
}
自定义 error 可以有扩展的方法, 比如 net.Error
定义如下
package net
type Error interface {
error
Timeout() bool // 错误的原因是否是超时?
Temporary() bool // 错误是否是瞬时的, 是否是重试可恢复的?
}
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
time.Sleep(1e9)
continue
}
if err != nil {
log.Fatal(err)
}
当程序访问外部数据时, 有很多原因可能导致瞬时错误, 可以使用断言错误实现精准捕获瞬时错误, 以提供重试的机会.
在实际开发中, 作为服务的提供者, 包的开发者, 在对外提供服务时, 在包内定义隐藏瞬时错误接口
type temporary interface {
Temporary() bool
}
并暴露检查瞬时错误的公共函数
func IsTemporary(err error) bool {
temp, ok := err.(temporary)
return ok && temp.Temporary()
}
调用者使用软件包时, 可以使用包内提供的 IsTemporary
函数去检查收到的错误是否是瞬时错误, 并做出是否重试的决定.
错误处理
原则一: 无错误的正常代码流, 成为一条直线, 而不是缩进代码
一般情况下我们在处理错误时, 是优先判断错误是否 !=nil
, 在判断中处理错误, 而不是在判断的缩进代码中处理正常的流程
// 推荐写法
f, err := os.Open(path)
if err != nil {
// 处理错误
}
// 正常工作流
// 不推荐写法
f, err := os.Open(path)
if err == nil {
// 处理正常工作流
}
// 处理错误
还有一种布尔值的情况, 在 if
代码块中是处理 true
还是处理 false
取决于业务逻辑中, 哪种情况是异常情况, 尽量在 if
代码块里处理异常情况.
// 检查运行环境, 如果为 false 时, 运行环境异常
if ok := CheckEnvironment(); !ok {
// 处理异常情况
}
// 处理正常工作流
// 检查锁, 如果锁为 true, 运行环境异常
if ok := CheckLock(); ok {
// 处理异常情况
}
// 处理正常工作流
原则二: 不要忽略错误, 也不要重复处理错误
只要是可能会影响到服务正常运行的错误, 理论上都应该接收并处理. 但是错误仅应该被处理一次, 打印日志也是对错误的处理
原则三: wrap errors 输出更详细的错误信息
底层代码向上抛 error
, 最后在最上层的代码中打印出来的信息极少, 且没有上下文信息, 没有文件信息, 没有行数信息, 没有调用堆栈信息, 看了之后一头雾水, 无从查起.
为了解决这个问题, 有些做法是在底层函数向上抛出错误之前, 打印日志, 上层调用函数接收到错误之后, 再打印日志, 再向上抛出...... 导致的结果是错误信息被重复打印记录, 大量输出日志, 违反了第二条错误处理原则
更推荐的做法基于 github.com/pkg/errors
错误包对原始错误进行 errors.Wrap
的封装处理, 并在上层使用 errors.Cause
找出原始根因, 并打印堆栈, 记录日志, 错误必须被日志记录, 且只能记录一次
原则四: 处理后的错误, 不再是错误
当一个错误被函数或方法处理后, 该错误就不再是错误, 返回值不能是错误. 并且错误一旦处理过后, 不应再有任何地方报告当前错误
总结
Go 语言的 Error 设计哲学主要有两点
-
Errors Are Value: Go error 是一个接口类型, 只要实现了 Error() 方法, 即可自定义一个错误类型.
-
处理所有潜在的错误: Go 希望避免其他语言里异常这种隐晦的错误处理方法, 清晰的处理潜在的错误, 增加程序的健壮性.
Go 语言的 Error 设计初衷很简单, 基于 Errors Are Value
的设计理念, 使得 Go 语言的错误定义及使用非常灵活, 也正是这种"放飞自我"式的灵活使用方式, 造成了类似于错误处理重复出现, 代码冗长累赘, 错误重复处理等等诸多使用上的小问题, 本文基于转转运维生产项目中的摸索与实践, 总结出了一些覆盖大多数错误场景的处理方法, 但是 GO 语言的 Error 处理与使用没有银弹, 每种错误设计的背后都有一些基于业务场景的思考, 需要基于实际业务需求, 权衡利弊, 灵活运用.
关于作者
吕瑞, 转转运维工程师, 负责云计算/云原生方向运维及开发.
文章评论