Golang 中更好的错误处理:理论和实践技巧
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Golang 中更好的错误处理:理论和实践技巧相关的知识,希望对你有一定的参考价值。
参考技术A云和安全管理服务专家新钛云服 张春翻译
这种方法有几个缺点。首先,它可以对程序员隐藏错误处理路径,特别是在捕获异常不是强制性的情况下,例如在 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
更优雅的 Golang 错误处理
转摘自:
Golang 中的错误处理是一个被大家经常拿出来讨论的(另外一个是)。其中泛型这个问题,rsc 在最近的计划中也了纳入他今年的考虑计划中,同时,在2016年也进行了一些更新,相信未来会有一些更好的方案提出。这个文章我们讨论一下如何在现行的 Golang 框架下提供更友好和优雅的错误处理。
从现状谈起
Golang 中的错误处理原则,开发者曾经之前专门发布了几篇文章( 和 、 )介绍。分别介绍了 Golang 中处理一般预知到的错误与遇到崩溃时的错误处理机制。
一般情况下,我们还是以官方博客中的错误处理例子为例:
func main() {
f, err := os.Open("filename.ext") if err != nil {
log.Fatal(err) // 或者更简单的:
// return err
}
...
}
当然对于简化代码行数,还有另外一种写法:
func main() {
... if f, err = os.Open("filename.ext"); err != nil{
log.Fatal(err)
}
...
}
正常情况下,Golang 现有的哲学中,要求你尽量手工处理所有的错误返回,这稍微增加了开发人员的心智负担。关于这部分设计的讨论,请参考本文最开始提供的参考链接,此处不做太多探讨。
本质上,Golang 中的错误类型 error
是一个接口类型:
type error interface {
Error() string
}
只要满足这一接口定义的所有数值都可以传入 error
类型的位置。在 中也提到了关于错误的描述: Errors are values
。这一句如何理解呢?
Errors are values
事实上,在实际使用过程中,你可能也发现了对 Golang 而言,所有的信息是非常不足的。比如下面这个例子:
buf := make([]byte, 100)
n, err := r.Read(buf)
buf = buf[:n]if err == io.EOF {
log.Fatal("read failed:", err)
}
事实上这只会打印信息 2017/02/08 13:53:54 read failed:EOF
,这对我们真实环境下的错误调试与分析其实是并没有任何意义的,我们在查看日志获取错误信息的时候能够获取到的信息十分有限。
于是乎,一些提供了上下文方式的一些错误处理形式便在很多类库中非常常见:
err := os.Remove("/tmp/nonexist")
log.Println(err)
输出了:
2017/02/08 14:09:22 remove /tmp/nonexist: no such file or directory
这种方式提供了一种更加直观的上下文信息,比如具体出错的内容,也可以是出现错误的文件等等。通过查看Remove的实现,我们可以看到:
// PathError records an error and the operation and file path that caused it.type PathError struct {
Op string
Path string
Err error
}func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }// file_unix.go 针对 *nix 系统的实现// Remove removes the named file or directory.// If there is an error, it will be of type *PathError.func Remove(name string) error { // System call interface forces us to know
// whether name is a file or directory.
// Try both: it is cheaper on average than
// doing a Stat plus the right one.
e := syscall.Unlink(name) if e == nil { return nil
}
e1 := syscall.Rmdir(name) if e1 == nil { return nil
} // Both failed: figure out which error to return.
// OS X and Linux differ on whether unlink(dir)
// returns EISDIR, so can't use that. However,
// both agree that rmdir(file) returns ENOTDIR,
// so we can use that to decide which error is real.
// Rmdir might also return ENOTDIR if given a bad
// file path, like /etc/passwd/foo, but in that case,
// both errors will be ENOTDIR, so it's okay to
// use the error from unlink.
if e1 != syscall.ENOTDIR {
e = e1
} return &PathError{"remove", name, e}
}
实际上这里 Golang 标准库中返回了一个名为 PathError
的结构体,这个结构体定义了操作类型、路径和原始的错误信息,然后通过 Error
方法对所有信息进行了整合。
但是这样也会存在问题,比如需要进行单独类型复杂的分类处理,比如上面例子中,需要单独处理 PathError
这种问题,你可能需要一个单独的类型推导:
err := xxxx()if err != nil {
swtich err := err.(type) { case *os.PathError:
... default:
...
}
}
这样反倒会增加错误处理的复杂度。同时,这些错误必须变为导出类型,也会增加整个系统的复杂度。
另外一个问题是,我们在出现错误时,我们通常也希望获取更多的堆栈信息,方便我们进行后续的故障追踪。在现有的错误体系中,这相对比较复杂:你很难通过一个接口类型获取完整的调用堆栈。这时,我们可能就需要一个第三方库区去解决遇到的这些错误处理问题。
还有一种情况是,我们希望在错误处理过程中同样可以附加一些信息,这些也会相对比较麻烦。
更优雅的错误处理
之前提到了多种实际应用场景中出现的错误处理方法和遇到的一些问题,这里推荐使用第三方库去解决部分问题:github.com/pkg/errors
。
比如当我们出现问题时,我们可以简单的使用 errors.New
或者 errors.Errorf
生成一个错误变量:
err := errors.New("whoops")// orerr := errors.Errorf("whoops: %s", "foo")
当我们需要附加信息时,则可以使用:
cause := errors.New("whoops")
err := errors.Wrap(cause, "oh noes")
当需要获取调用堆栈时,则可以使用:
err := errors.New("whoops")
fmt.Printf("%+v", err)
其他建议
在上面做类型推导时,我们发现在处理一类错误时可能需要多个错误类型,这可能在某些情况下相对来说比较复杂,很多时候我们可以使用接口形式去方便处理:
type temporary interface {
Temporary() bool}// IsTemporary returns true if err is temporary.func IsTemporary(err error) bool {
te, ok := errors.Cause(err).(temporary) return ok && te.Temporary()
}
这样就可以提供更加方便的错误解析和处理。
广告时间
我们正在招收新人 Gopher,应届毕业生 or 实习生欢迎投递简历。我们正在努力实现开发流程标准化,如果你想获得提高,相信也是一个非常不错的机会。简历投递 kevin [at] yeeuu [dot] com。
以上是关于Golang 中更好的错误处理:理论和实践技巧的主要内容,如果未能解决你的问题,请参考以下文章