原文:https://go.dev/blog/error-handling-and-go
错误处理与 Go
Andrew Gerrand
2011 年 7 月 12 日
介绍
如果你写过任何 Go 代码,你可能会遇到内置的 error 类型。Go 代码使用 error 值来表明不正常的状态。例如:os.Open 函数在他打开文件失败时会返回非空的 error。
1 | func Open(name string) (file *File, err error) |
以下代码使用 os.Open 去打开一个文件。如果发生了错误他会调用log.Fatal
去打印错误消息和停止。(log.Fatal 会调用 os.Exit())
1 | f, err := os.Open("filename.ext") |
你可以在 Go 中用知道的关于这个 error 类型来完成很多工作,但是本文我们会仔细看看 error 和讨论在 Go 中错误处理的一些良好实践。
错误类型
error
类型是一个接口类型。error 变量可以将自身描述为代表任何值的字符串。这是接口的声明:
1 | type error interface { |
error 类型,和所有内置类型一样,在 universe block 中提前定义。(is predeclared in the universe block.)
最常用的 error 实现是 errors 包的未导出 errorString 类型
1 | // errorString is a trivial implementation of error. |
你可以使用 errors.New 函数构造这些值中的一个,他接收一个字符串并将其转换为 errors.errorString 然后作为 error 值返回。
1 | // New returns an error that formats as the given text. |
以下是你可以如何使用 errors.New:
1 | func Sqrt(f float64) (float64, error) { |
调用者将负数传递给 Sqrt 会收到非空的 error 值(其具体表示为一个 errors.errorString 值)。调用者通过调用 error 的 Error 方法或者直接打印来访问 error zifuchuan(“math:square root…”)。
1 | f, err := Sqrt(-1) |
fmt 包通过调用他的 Error() 字符串方法格式化一个 error 值。
包含上下文是 error 实现的责任。os.Open 返回的错误格式是:open /etc/passwd: permission denied,而不仅仅是:permission denied。我们的 Sqrt 返回的错误缺少有关无效参数的信息。
为了添加该信息,一个有用的函数是 fmt 包的 Errorf。他通过 Printf 的规则格式化字符串并且通过 errors.New 创建一个 error 返回。
1 | if f < 0 { |
在许多情况下,fmt.Errorf 已经足够好了,但是既然 error 是一个 interface,那么你可以随意的使用 data structure作为 error 值,允许调用者去检查 error 的细节。
例如,我们假设调用者也许想要恢复传递给 Sqrt 的无效参数。我们可以通过定义新的 error 实现而不是使用 errors.errorString 来实现。
1 | type NegativeSqrtError float64 |
然后一个复杂的调用者可以使用类型断言(type assertion)去检查 NegativeSqrtError 并专门处理他,当调用者仅仅只是将 error 传递给 fmt.Println 或者 log.Fatal 不会有行为变化。
从另一个栗子来说,json 包制定了 json.Decode 函数在解析 JSON blob (binary large object)时遇到语法错误时返回 SyntaxError 类型。
1 | type SyntaxError struct { |
Offset 字段甚至没有在 error 的默认格式化中展示,但是调用者可以使用它将文件和行信息添加到他们的错误消息中:
1 | if err := dec.Decode(&val); err != nil { |
(这是来自 Camlistore 项目的一些实际代码的略微简化版本。)
error 接口只需要一个 Error 方法。额特定的 error 实现可能有额外的方法。例如,按照通常的约定,net 包返回 error 类型的错误,但是一些通过 net.Error 接口定义的 error 实现有一些额外方法:
1 | package net |
客户端代码可以使用 type assertion 测试 net.Error,然后区分暂时性的网络问题和永久的。例如,网络爬虫在碰到暂时错误时也许会休眠然后重试,否则(发生永久错误)放弃。
1 | if nerr, ok := err.(net.Error); ok && nerr.Temporary() { |
简化重复的错误处理
在 Go 中,错误处理是很重要的。语言的设计和约定鼓励你在发生错误的地方检查(与其他语言中抛出异常有时捕获的约定不同)。在某些情况下这使得 Go 代码冗长,但幸运的是,你可以用一些技术来最小化重复的错误处理。
考虑一个带有 HTTP handler 的 App Engine 程序,从数据存储中检所记录并使用模板对他进行格式化。
1 | func init() { |
该函数处理由 datastore.Get 函数和 viewTemplate 的执行方法返回的 error 。在这两种情况下,他向用户展示一条简单的带有 HTTP 状态码 500 的消息(内部服务错误,Internal Server Error)。这看起来是可管理的代码量,但是添加更多的 HTTP handlers 很快你会得到完全相同的错误处理代码结果。
为了减少重复,我们可以定义自己的 HTTP appHandler 类型,包含一个 error 返回值:
1 | type appHandler func(http.ResponseWriter, *http.Request) error |
然后我们可以改变 viewRecord 函数去返回错误:
1 | func viewRecord(w http.ResponseWriter, r *http.Request) error { |
这比原始版本要简化(了一些),但是 http 包不理解返回错误的函数。我们可以在 appHandler 中实现 http.Handler 接口的 ServeHTTP 方法来修复:
1 | func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
ServerHTTP 方法调用 appHandler 函数并且将返回的错误(如果有)展示给用户。注意方法的 receiver,fn,是一个函数。(Go 可以干这事!)方法通过调用接收者在表达式中的 fn(w, r) 调用函数。
现在,使用 http 包注册 viewRecord时,我们使用 Handler 函数(而不是 HandlerFunc),因为 appHandler 是一个 http.Handler(不是 http.HandlerFunc)。
1 | func init() { |
有了这个基本的错误处理设施,我们可以使他更加方便使用。而不仅仅是展示错误字符串,给用户一个简单的错误消息和一个合适的 HTTP 状态码会更好,同时将完整的 error 记录到 App Engine 的开发者控制台以进行调试。
为此,我们创建了一个 appError 结构体,包含了一个错误和一些其他字段:
1 | type appError struct { |
下一步我们更新 appHandler 返回类型,返回 *appError 值:
1 | type appHandler func(http.ResponseWriter, *http.Request) *appError |
(通常返回错误的具体类型而不是错误是错的,在 Go FAQ 中讨论了理由,但是这里这么做是对的因为 ServeHTTP 是唯一能看到值和使用它内容的地方。)
然后使 appHandler 的 ServeHTTP 方法向用户展示 appError 的消息和 HTTP 状态码并且将完整 Error 记录到开发者控制台:
1 | func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
最后,我们更新 viewRecord 到新的函数签名,当他遭遇错误是返回更多上下文。
1 | func viewRecord(w http.ResponseWriter, r *http.Request) *appError { |
这个版本的 viewRecord 和原始的一样长,但是现在这些行都有特别的意义然后我们可以提供友好的用户体验。
这还没有结束,我们可以在我们的应用里更进一步的提升错误处理。一些想法:
- 给错误处理一个漂亮的 HTML 模板
- 当用户是管理员时通过将堆栈信息写入 HTTP response 使得调试更加容易
- 为 appError 写一个为更容易调试记录堆栈信息的构造函数
- 在 appHandler 中从 painc 恢复,将之作为危急(Critical)记录到控制台,同时告诉用户严重的错误发生了。这是一个妙招去避免将由程序引发的高深莫测的错误暴露给用户。有关更多详细信息,请阅读 Defer, Panic, and Recover
结论
正确的错误处理是优秀软件的基本要求。通过使用本文中标书的技术,你应该可以写出更加可靠和简洁的 Go 代码。
后记——读后感
在 Go 中不应该忽视每一处可能发生错误的代码(其他的语言其实也一样),在生产环境中,凡是可能会发生 error 的地方,那么早晚都会产生 error,我们应该坚信这一点,面向 error 编程。
在确定了面向 error 编程之后,该如何编写出好的错误处理代码?通读本文(错误处理与Go),可以知道如下两点:
- 将重复的错误处理代码抽出,作为统一的错误处理
- 将错误抛到更高层,直到最高层才开始统一处理错误
第二点会让最上层的代码充满错误处理,该如何编写好最上层的处理代码呢?我认为好的处理代码应该是这样的:
1 | err := doSomething1() |
这样写保证了阅读代码的连贯性,虽然有大量的错误处理,在梳理业务逻辑时直接忽略跳过就可以了,而且大量的错误也无法避免,每一个微服务都是不可靠的,会挂在每一个你意想不到的时候,此时需要我们正确的处理错误给用户正确的响应。
个人不是很喜欢 Java 中 try catch 会导致正常的处理逻辑有缩进。而可能因为一些历史遗留原因(早期 try catch 会有很高的性能损失),大家对异常的处理都是 try catch 而不是 throw 在上层统一处理,同时也难以在上层统一处理可能来自不同运行时的不同 exception,需要经过精心的设计才可以。同时因为 Java 中大量的异常,异常不再是异常,变得有些司空见惯了,从良性到灾难性都有,异常的严重性由调用者来区分。我觉得这也跟巨石后端时代程序不能挂掉有关,无论是怎样的 exception,都一定要捕获,即使没有正确的处理。
看到过一个说法:抛异常本身没有错,Java 抛异常也没有错,但是对于程序猿来说很多时候这些异常是难以处理的。(可以参考这篇文章)
关于如何编写良好的错误处理代码先告一段落,接下来讨论如何编写良好的自定义错误。
TODO…
待补�