Golang 中更好的错误处理:理论和实践技巧

Python011

Golang 中更好的错误处理:理论和实践技巧,第1张

云和安全管理服务专家新钛云服 张春翻译

这种方法有几个缺点。首先,它可以对程序员隐藏错误处理路径,特别是在捕获异常不是强制性的情况下,例如在 Python 中。即使在具有必须处理的 Java 风格的检查异常的语言中,如果在与原始调用不同的级别上处理错误,也并不总是很明显错误是从哪里引发的。

我们都见过长长的代码块包装在一个 try-catch 块中。在这种情况下,catch 块实际上充当 goto 语句,这通常被认为是有害的(奇怪的是,C 中的关键字被认为可以接受的少数用例之一是错误后清理,因为该语言没有 Golang- 样式延迟语句)。

如果你确实从源头捕获异常,你会得到一个不太优雅的 Go 错误模式版本。这可能会解决混淆代码的问题,但会遇到另一个问题:性能。在诸如 Java 之类的语言中,抛出异常可能比函数的常规返回慢数百倍。

Java 中最大的性能成本是由打印异常的堆栈跟踪造成的,这是昂贵的,因为运行的程序必须检查编译它的源代码 。仅仅进入一个 try 块也不是空闲的,因为需要保存 CPU 内存寄存器的先前状态,因为它们可能需要在抛出异常的情况下恢复。

如果您将异常视为通常不会发生的异常情况,那么异常的缺点并不重要。这可能是传统的单体应用程序的情况,其中大部分代码库不必进行网络调用——一个操作格式良好的数据的函数不太可能遇到错误(除了错误的情况)。一旦您在代码中添加 I/O,无错误代码的梦想就会破灭:您可以忽略错误,但不能假装它们不存在!

try {

doSometing()

} catch (IOException e) {

// ignore it

}

与大多数其他编程语言不同,Golang 接受错误是不可避免的。 如果在单体架构时代还不是这样,那么在今天的模块化后端服务中,服务通常和外部 API 调用、数据库读取和写入以及与其他服务通信

以上所有方法都可能失败,解析或验证从它们接收到的数据(通常在无模式 JSON 中)也可能失败。Golang 使可以从这些调用返回的错误显式化,与普通返回值的等级相同。从函数调用返回多个值的能力支持这一点,这在大多数语言中通常是不可能的。Golang 的错误处理系统不仅仅是一种语言怪癖,它是一种将错误视为替代返回值的完全不同的方式!

重复 if err != nil

对 Go 错误处理的一个常见批评是被迫重复以下代码块:

res, err := doSomething()

if err != nil {

// Handle error

}

对于新用户来说,这可能会觉得没用而且浪费行数:在其他语言中需要 3 行的函数很可能会增长到 12 行

这么多行代码!这么低效!如果您认为上述内容不优雅或浪费代码,您可能忽略了我们检查代码中的错误的全部原因:我们需要能够以不同的方式处理它们!对 API 或数据库的调用可能会被重试。

有时事件的顺序很重要:调用外部 API 之前发生的错误可能不是什么大问题(因为数据从未通过发送),而 API 调用和写入本地数据库之间的错误可能需要立即注意,因为 这可能意味着系统最终处于不一致的状态。即使我们只想将错误传播给调用者,我们也可能希望用失败的解释来包装它们,或者为每个错误返回一个自定义错误类型。

并非所有错误都是相同的,并且向调用者返回适当的错误是 API 设计的重要部分,无论是对于内部包还是 REST API

不必担心在你的代码中重复 if err != nil ——这就是 Go 中的代码应该看起来的样子。

自定义错误类型和错误包装

从导出的方法返回错误时,请考虑指定自定义错误类型,而不是单独使用错误字符串。字符串在意外代码中是可以的,但在导出的函数中,它们成为函数公共 API 的一部分。更改错误字符串将是一项重大更改——如果没有明确的错误类型,需要检查返回错误类型的单元测试将不得不依赖原始字符串值!事实上,基于字符串的错误也使得在私有方法中测试不同的错误案例变得困难,因此您也应该考虑在包中使用它们。回到错误与异常的争论,返回错误也使代码比抛出异常更容易测试,因为错误只是要检查的返回值。不需要测试框架或在测试中捕获异常

可以在 database/sql 包中找到简单自定义错误类型的一个很好的示例。它定义了一个导出常量列表,表示包可以返回的错误类型,最著名的是 sql.ErrNoRows。虽然从 API 设计的角度来看,这种特定的错误类型有点问题(您可能会争辩说 API 应该返回一个空结构而不是错误),但任何需要检查空行的应用程序都可以导入该常量并在代码中使用它不必担心错误消息本身会改变和破坏代码。

对于更复杂的错误处理,您可以通过实现返回错误字符串的 Error() 方法来定义自定义错误类型。自定义错误可以包括元数据,例如错误代码或原始请求参数。如果您想表示错误类别,它们很有用。DigitalOcean 的本教程展示了如何使用自定义错误类型来表示可以重试的一类临时错误。

通常,错误会通过将低级错误与更高级别的解释包装起来,从而在程序的调用堆栈中传播。例如,数据库错误可能会以下列格式记录在 API 调用处理程序中:调用 CreateUser 端点时出错:查询数据库时出错:pq:检测到死锁。这很有用,因为它可以帮助我们跟踪错误在系统中传播的过程,向我们展示根本原因(数据库事务引擎中的死锁)以及它对更广泛系统的影响(调用者无法创建新用户)。

自 Go 1.13 以来,此模式具有特殊的语言支持,并带有错误包装。通过在创建字符串错误时使用 %w 动词,可以使用 Unwrap() 方法访问底层错误。除了比较错误相等性的函数 errors.Is() 和 errors.As() 外,程序还可以获取包装错误的原始类型或标识。这在某些情况下可能很有用,尽管我认为在确定如何处理所述错误时最好使用顶级错误的类型。

Panics

不要 panic()!长时间运行的应用程序应该优雅地处理错误而不是panic。即使在无法恢复的情况下(例如在启动时验证配置),最好记录一个错误并优雅地退出。panic比错误消息更难诊断,并且可能会跳过被推迟的重要关闭代码。

Logging

我还想简要介绍一下日志记录,因为它是处理错误的关键部分。通常你能做的最好的事情就是记录收到的错误并继续下一个请求。

除非您正在构建简单的命令行工具或个人项目,否则您的应用程序应该使用结构化的日志库,该库可以为日志添加时间戳,并提供对日志级别的控制。最后一部分特别重要,因为它将允许您突出显示应用程序记录的所有错误和警告。通过帮助将它们与信息级日志分开,这将为您节省无数时间。

微服务架构还应该在日志行中包含服务的名称以及机器实例的名称。默认情况下记录这些时,程序代码不必担心包含它们。您也可以在日志的结构化部分中记录其他字段,例如收到的错误(如果您不想将其嵌入日志消息本身)或有问题的请求或响应。只需确保您的日志没有泄露任何敏感数据,例如密码、API 密钥或用户的个人数据!

对于日志库,我过去使用过 logrus 和 zerolog,但您也可以选择其他结构化日志库。如果您想了解更多信息,互联网上有许多关于如何使用这些的指南。如果您将应用程序部署到云中,您可能需要日志库上的适配器来根据您的云平台的日志 API 格式化日志 - 没有它,云平台可能无法检测到日志级别等某些功能。

如果您在应用程序中使用调试级别日志(默认情况下通常不记录),请确保您的应用程序可以轻松更改日志级别,而无需更改代码。更改日志级别还可以暂时使信息级别甚至警告级别的日志静音,以防它们突然变得过于嘈杂并开始淹没错误。您可以使用在启动时检查以设置日志级别的环境变量来实现这一点。

原文:https://levelup.gitconnected.com/better-error-handling-in-golang-theory-and-practical-tips-758b90d3f6b4

作者:andruzhang,腾讯 IEG 后台开发工程师

在后台开发中,针对错误处理,有三个维度的问题需要解决:

一个面向过程的函数,在不同的处理过程中需要 handle 不同的错误信息;一个面向对象的函数,针对一个操作所返回的不同类型的错误,有可能需要进行不同的处理。此外,在遇到错误时,也可以使用断言的方式,快速中止函数流程,大大提高代码的可读性。

在许多高级语言中都提供了 try ... catch 的语法,函数内部可以通过这种方案,实现一个统一的错误处理逻辑。而即便是 C 这种 “中级语言” 虽然没有,但是程序员也可以使用宏定义的方式,来实现某种程度上的错误断言。

但是,对于 Go 的情况就比较尴尬了。

我们先来看断言,我们的目的是,仅使用一行代码就能够检查错误并终止当前函数。由于没有 throw,没有宏,如果要实现一行断言,有两种方法。

第一种是把 if 的错误判断写在一行内,比如:

第二种方法是借用 panic 函数,结合 recover 来实现:

这两种方法都值得商榷。

首先,将 if 写在同一行内的问题有:

至于第二种方法,我们要分情况看;

不过使用 panic 来断言的方案,虽然在业务逻辑中基本上不用,但在测试场景下则是非常常见的。测试嘛,用牛刀有何不可?稍微大一点的系统开销也没啥问题。对于 Go 来说,非常热门的单元测试框架 goconvey 就是使用 panic 机制来实现单元测试中的断言,用的人都说好。

综上,在 Go 中,对于业务代码,笔者不建议采用断言,遇到错误的时候建议还是老老实实采用这种格式:

而在单测代码中,则完全可以大大方方地采用类似于 goconvey 之类基于 panic 机制的断言。

众所周知 Go 是没有 try ... catch 的,而且从官方的态度来看,短时间内也没有考虑的计划。但程序员有这个需求呀。笔者采用的方法,是将需要返回的 err 变量在函数内部全局化,然后结合 defer 统一处理:

这种方案要特别注意变量作用域问题.比如前面的 if err = DoSomething()err != nil { 行,如果我们将 err = ... 改为 err := ...,那么这一行中的 err 变量和函数最前面定义的 (err error) 不是同一个变量,因此即便在此处发生了错误,但是在 defer 函数中无法捕获到 err 变量了。

在 try ... catch 方面,笔者其实没有特别好的方法来模拟,即便是上面的方法也有一个很让人头疼的问题:defer 写法导致错误处理前置,而正常逻辑后置了,从可读性的角度来说非常不友好。因此也希望读者能够指教。同时还是希望 Go 官方能够继续迭代,支持这种语法。

这一点在 Go 里面,一开始看起来还是比较统一的,这就是 Go 最开始就定义的 error 类型,以系统标准的方式,统一了进程内函数级的错误返回模式。调用方使用 if err != nil 的统一模式,来判断一个调用是不是成功了。

但是随着 Go 的逐步推广,由于 error 接口的高自由度,程序员们对于 “如何判断该错误是什么错误” 的时候,出现了分歧。

在 Go 1.13 之前,对于 error 类型的传递,有三种常见的模式:

这个流派很简单,就是将各种错误信息直接定义为一个类枚举值的模式,比如:

当遇到相应的错误信息时,直接返回对应的 error 类枚举值就行了。对于调用方也非常方便,可以采用 switch - case 来判断错误类型:

个人觉得这种设计模式本质上还是 C error code 模式。

这种流派则是充分使用了 “error 是一个 interface” 的特性,重新自定义一个 error 类型。一方面是用不同的类型来表示不同的错误分类,另一方面则能够实现对于同一错误类型,能够给调用方提供更佳详尽的信息。举个例子,我们可以定义多个不同的错误类型如下:

对于调用方,则通过以下代码来判断不同的错误:

这种模式,一方面可以透传底层错误,另一方面又可以添加自定义的信息。但对于调用方而言,灾难在于如果要判断某一个错误的具体类型,只能用 strings.Contains() 来实现,而错误的具体描述文字是不可靠的,同一类型的信息可能会有不同的表达;而在 fmt.Errorf 的过程中,各个业务添加的额外信息也可能会有不同的文字,这带来了极大的不可靠性,提高了模块之间的耦合度。

在 go 1.13 版本发布之后,针对 fmt.Errorf 增加了 wraping 功能,并在 errors 包中添加了 Is() 和 As() 函数。关于这个模式的原理和使用已经有很多文章了,本文就不再赘述。

这个功能,合并并改造了前文的所谓 “== 流派” 和 “fmt.Errorf” 流派,统一使用 errors.Is() 函数;此外,也算是官方对类型断言流派的认可(专门用 As() 函数来支持)。

在实际应用中,函数/模块透传错误时,应该采用 Go 的 error wrapping 模式,也就是 fmt.Errorf() 配合 %w 使用,业务方可以放心地添加自己的错误信息,只要调用方统一采用 errors.Is() 和 errors.As() 即可。

服务/系统层面的错误信息返回,大部分协议都可以看成是 code - message 模式或者是其变体:

这种模式的特点是:code 是给程序代码使用的,代码判断这是一个什么类型的错误,进入相应的分支处理;而 message 是给人看的,程序可以以某种形式抛出或者记录这个错误信息,供用户查看。

在这一层面有什么问题呢?code for computer,message for user,好像挺好的。

但有时候,我们可能会收到用户/客户反馈一个问题:“XXX 报错了,帮忙看看什么问题?”。用户看不懂我们的错误提示吗?

在笔者的经验中,我们在使用 code - message 机制的时候,特别是业务初期,难以避免的是前后端的设计文案没能完整地覆盖所有的错误用例,或者是错误极其罕见。因此当出现错误时,提示暧昧不清(甚至是直接提示错误信息),导致用户从错误信息中找到解决方案

在这种情况下,尽量覆盖所有错误路径肯定是最完美的方法。不过在做到这一点之前,码农们往往有下面的解决方案:

既要隐藏信息,又要暴露信息,我可以摔盘子吗……

这里,笔者从日益普及的短信验证码有了个灵感——人的短期记忆对 4 个字符还是比较强的,因此我们可以考虑把错误代码缩短到 4 个字符——不区分大小写,因为如果人在记忆时还要记录大小写的话,难度会增加不少。

怎么用 4 个字符表示尽量多的数据呢?数字+字母总共有 36 个字符,理论上使用 4 位 36 进制可以表示 36x36x36x36 = 1679616 个值。因此我们只要找到一个针对错误信息字符串的哈希算法,把输出值限制在 1679616 范围内就行了。

这里我采用的是 MD5 作为例子。MD5 的输出是 128 位,理论上我可以取 MD5 的输出,模 1679616 就可以得到一个简易的结果。实际上为了减少除法运算,我采用的是取高 20 位(0xFFFFF)的简易方式(20 位二进制的最大值为 1048575),然后将这个数字转成 36 进制的字符串输出。

当出现异常错误时,我们可以将 message 的提示信息如下展示:“未知错误,错误代码 30EV,如需协助,请联系 XXX”。顺带一提,30EV 是 "Access denied for user 'db_user'@'127.0.0.1'" 的计算结果,这样一来,我就对调用方隐藏了敏感信息。

至于后台侧,还是需要实实在在地将这个哈希值和具体的错误信息记录在日志或者其他支持搜索的渠道里。当用户提供该代码时,可以快速定位。

这种方案的优点很明显:

简易的错误码生成代码如下:

当然这种方案也有局限性,笔者能想到的是需要注意以下两点:

此外,笔者需要再强调的是:在开发中,针对各种不同的、正式的错误用例依然需要完整覆盖,尽可能通过已有的 code - message 机制将足够清晰的信息告知主调方。这种 hashcode 的错误代码生成方法,仅适用于错误用例遗漏、或者是快速迭代过程中,用于发现和调试遗漏的错误用例的临时方案。