转转运维Go工程化实践之Error使用及处理

2022年2月22日 358点热度 0人点赞 0条评论

转转运维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 处理与使用没有银弹, 每种错误设计的背后都有一些基于业务场景的思考, 需要基于实际业务需求, 权衡利弊, 灵活运用.


关于作者

吕瑞, 转转运维工程师, 负责云计算/云原生方向运维及开发.

28120转转运维Go工程化实践之Error使用及处理

这个人很懒,什么都没留下

文章评论