编程语言应该如何对待错误?例如,一个用给定名称打开文件并将其读取到缓冲区的函数可能由于多种原因而失败:文件可能不存在,打开程序可能缺乏打开文件的权限,或者文件太大,无法放入缓冲区。大多数语言都使用异常:抛出的异常会在调用栈中传播,直到被try-catch块处理为止。异常模型将错误视为特殊情况,与程序的常规返回流分开处理。
这种方法有几个缺点。首先,它可以向程序员隐藏错误处理路径,特别是在不强制捕获异常的情况下,例如在Python中。即使在必须处理java风格的已检异常的语言中,如果在不同于最初调用的级别上处理异常,也并不总是很明显错误是从哪里抛出的。我们都见过将很长的代码块封装在一个try - catch块中。在这种情况下,catch块实际上充当了goto语句,这通常被认为是有害的(奇怪的是,在C中被认为可以接受关键字的少数用例之一是错误后清理,因为该语言没有golang风格的defer语句)。
如果您确实在其源代码捕获异常,则会得到一个不那么优雅的Go错误模式版本。这可以解决混淆代码的问题,但会遇到另一个问题:性能。在Java等语言中,抛出异常可能比函数正常返回慢数百倍。根据本文,Java中最大的性能开销是打印异常的堆栈跟踪,这是昂贵的,因为运行的程序必须检查编译它的源代码。仅仅进入一个try块也不是空闲的,因为CPU内存寄存器之前的状态需要保存,因为在抛出异常时可能需要恢复它们。
如果您将异常视为通常不发生的异常情况,那么异常的缺点其实并不重要。这可能是传统的单体应用程序的情况,其中大部分代码库不必进行网络调用——一个操作格式良好的数据的函数不太可能出现错误(除非出现bug)。一旦你在代码中添加了I/O,代码无错误的梦想就破灭了:你可以忽略错误,但你不能假装它们不存在!
try {
doSomething()
} catch (IOException e) {
// ignore it
}
与大多数其他编程语言不同,Golang认为错误是不可避免的。如果在单体架构时代还没有出现这种情况,那么在今天的模块化后端服务中,服务通常是外部API调用、对数据库的读写以及与其他服务通信之间的薄包装器。上述所有操作都可能失败,解析或验证从它们接收到的数据也可能失败(通常是无模式的JSON)。Golang使这些调用可以返回的错误变得明确,与普通返回值的排名相等。这是通过函数调用返回多个值的能力来支持的,这在大多数语言中通常是不可能的。Golang的错误处理系统不仅仅是一种语言怪癖,它是一种将错误视为可选返回值的完全不同的方式!
重复 if err != nil
对Go错误处理的一个常见批评是被迫重复以下代码块:
res, err := doSomething()
if err != nil {
// Handle error
}
对于新用户来说,这可能是无用的,也是一种浪费:一个在其他语言中需要3行代码的函数可能会增长到12行:
// In Java
DBResult doSomething() throws IOException {
APIResult res = getFromAPI()
Model parsed = parse(res)
return writeToDB(parsed)
}// In Golang
func doSomething() error {
res, err := getFromAPI()
if err != nil {
// Handle API error
}
parsed, err := parse(res)
if err != nil {
// Handle parse error
}
res, err = writeToDB(parsed)
if err != nil {
// Handle database error
}
return res
}
这么多行代码!所以效率低下!
如果你认为上面的代码不优雅或者是在浪费代码,你可能忽略了我们检查代码中错误的全部原因:我们需要能够以不同的方式处理它们!对api或数据库的调用可能会重试。有时,事件的顺序很重要:调用外部API之前发生的错误可能不是什么大问题(因为数据从来没有发送过),而API调用和写入本地数据库之间的错误可能需要立即关注,因为它可能意味着系统最终处于不一致的状态。即使我们只想将错误传播给调用者,我们也可能希望用失败原因的解释包装它们,或者为每个错误返回一个自定义错误类型。并非所有的错误都是相同的,向调用者返回适当的错误是API设计的重要部分,无论是对于内部包还是REST API。
您不必担心在代码中重复if err != nil——这就是Go中的代码预期的样子。
自定义错误类型和错误包装
从导出的方法返回错误时,请考虑指定自定义错误类型,而不是单独使用错误字符串。字符串在意外的代码中是可以的,但在导出的函数中,它们会成为函数公共API的一部分。更改错误字符串将是一个重大的改变——没有明确的错误类型,需要检查返回错误类型的单元测试将不得不依赖原始字符串值!事实上,基于字符串的错误在私有方法中也会使测试不同的错误情况变得困难,所以你也应该考虑在包中使用它们。回到错误和异常的争论,返回错误也使代码比抛出异常更容易测试,因为错误只是要检查的返回值。不需要测试框架或在测试中捕获异常。
在database/sql包中可以找到一个简单自定义错误类型的很好的例子。它定义了一个导出的常量列表,表示包可以返回的错误类型,最著名的是sql.ErrNoRows。虽然从API设计的角度来看,这种特殊的错误类型略有问题(你可以认为API应该返回一个空的结构体而不是一个错误),但任何需要检查空行的应用程序都可以导入这个常量并在代码中使用它,而不必担心错误消息本身会改变并破坏代码。
对于更复杂的错误处理,可以通过实现error()方法来定义自定义错误类型,该方法返回错误字符串。自定义错误可以包含元数据,例如错误代码或原始请求参数。如果你想表示错误的类别,它们很有用。DigitalOcean的本教程展示了如何使用自定义错误类型来表示一类可以重试的临时错误。
通常情况下,通过将低级错误包装成高级解释,错误会在程序的调用栈中传播。例如,一个数据库错误可能以以下格式记录在API调用处理程序中:error calling CreateUser endpoint: error query database: pq: deadlock detected。这很有用,因为它可以帮助我们跟踪在整个系统中传播的错误,向我们展示错误的根本原因(数据库事务引擎中的死锁)以及它对更广泛的系统的影响(调用者无法创建新用户)。自Go 1.13错误包装以来,此模式具有特殊的语言支持。通过在创建字符串错误时使用%w动词,可以使用Unwrap()方法访问底层错误。除了比较errors的相等性的errors. is()和errors. as()函数外,程序还可以获取包装错误的原始类型或标识。这在某些情况下可能有用,尽管我认为在确定如何处理该错误时,最好使用顶级错误类型。
Panics
不要panic()!一般来说,这是一个很好的人生课程,但在编写Go代码时尤其重要。长期运行的应用程序应该优雅地处理错误,而不是惊慌失措。即使在无法恢复的情况下(例如在启动时验证配置),最好记录错误并优雅地退出。严重恐慌比错误消息更难诊断,可能会跳过延迟执行的重要关闭代码。
Logging
我还想简要介绍一下日志记录,因为它是处理错误的关键部分。通常情况下,你能做的最好的事情就是记录接收到的错误,然后继续处理下一个请求。
除非你正在构建简单的命令行工具或个人项目,否则你的应用程序应该使用一个结构化的日志库,它为日志添加时间戳,并提供对日志级别的控制。最后一部分尤其重要,因为它允许你突出显示应用程序记录的所有错误和警告。通过帮助将它们与信息级日志分离,这将为您节省无数小时。微服务架构还应该在日志行中包括服务的名称,以及机器实例的名称。默认情况下,当它们被记录时,程序代码不必担心包含它们。你也可以在日志的结构化部分记录其他字段,比如接收到的错误(如果你不想在日志消息本身中嵌入它)或违规的请求或响应。只要确保你的日志没有泄露任何敏感数据,例如密码、API密钥或用户的个人数据!
对于日志库,我过去使用过logrus和zerlog,但你也可以选择其他结构化日志库。如果你想了解更多,网上有很多关于如何使用这些的指南。如果您将应用程序部署到云端,您可能需要日志库上的适配器,以根据您的云平台的日志API格式化日志-如果没有它,一些功能,如日志级别可能无法被云平台检测到(我过去曾发生过这样的情况)。
如果您在应用程序中使用调试级别的日志(通常默认情况下不记录日志),请确保您的应用程序有一种简单的方法来更改日志级别,而无需更改代码。改变日志级别还可以暂时屏蔽信息——甚至警告级别的日志,以防它们突然变得太嘈杂,开始淹没错误。用户可以使用启动时检查的环境变量来设置日志级别。
希望你学到了一些新东西,你可以在下次使用Go甚至其他语言编程时应用!