LJ的Blog

学海无涯苦做舟

0%

错误处理与 Go(翻译)

原文: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
2
3
4
5
f, err := os.Open("filename.ext")
if err != nil {
log.Fatal(err)
}
// do something with the open *File f

你可以在 Go 中用知道的关于这个 error 类型来完成很多工作,但是本文我们会仔细看看 error 和讨论在 Go 中错误处理的一些良好实践。

错误类型

error类型是一个接口类型。error 变量可以将自身描述为代表任何值的字符串。这是接口的声明:

1
2
3
type error interface {
Error() string
}

error 类型,和所有内置类型一样,在 universe block 中提前定义。(is predeclared in the universe block.)

最常用的 error 实现是 errors 包的未导出 errorString 类型

1
2
3
4
5
6
7
8
9
// errorString is a trivial implementation of error.
// errorString 是 error 的一个简单实现
type errorString struct {
s string
}

func (e *errorString) Error() string {
return e.s
}

你可以使用 errors.New 函数构造这些值中的一个,他接收一个字符串并将其转换为 errors.errorString 然后作为 error 值返回。

1
2
3
4
// New returns an error that formats as the given text.
func New(text string) error {
return &errorString{text}
}

以下是你可以如何使用 errors.New:

1
2
3
4
5
6
func Sqrt(f float64) (float64, error) {
if f < 0 {
return 0, errors.New("math: square root of negative number")
}
// implementation
}

调用者将负数传递给 Sqrt 会收到非空的 error 值(其具体表示为一个 errors.errorString 值)。调用者通过调用 error 的 Error 方法或者直接打印来访问 error zifuchuan(“math:square root…”)。

1
2
3
4
f, err := Sqrt(-1)
if err != nil {
fmt.Println(err)
}

fmt 包通过调用他的 Error() 字符串方法格式化一个 error 值。

包含上下文是 error 实现的责任。os.Open 返回的错误格式是:open /etc/passwd: permission denied,而不仅仅是:permission denied。我们的 Sqrt 返回的错误缺少有关无效参数的信息。

为了添加该信息,一个有用的函数是 fmt 包的 Errorf。他通过 Printf 的规则格式化字符串并且通过 errors.New 创建一个 error 返回。

1
2
3
if f < 0 {
return 0, fmt.Errorf("math: square root of negative number %g", f)
}

在许多情况下,fmt.Errorf 已经足够好了,但是既然 error 是一个 interface,那么你可以随意的使用 data structure作为 error 值,允许调用者去检查 error 的细节。

例如,我们假设调用者也许想要恢复传递给 Sqrt 的无效参数。我们可以通过定义新的 error 实现而不是使用 errors.errorString 来实现。

1
2
3
4
5
type NegativeSqrtError float64

func (f NegativeSqrtError) Error() string {
return fmt.Sprintf("math: square root of negative number %g", float64(f))
}

然后一个复杂的调用者可以使用类型断言(type assertion)去检查 NegativeSqrtError 并专门处理他,当调用者仅仅只是将 error 传递给 fmt.Println 或者 log.Fatal 不会有行为变化。

从另一个栗子来说,json 包制定了 json.Decode 函数在解析 JSON blob (binary large object)时遇到语法错误时返回 SyntaxError 类型。

1
2
3
4
5
6
type SyntaxError struct {
msg string // description of error
Offset int64 // error occurred after reading Offset bytes
}

func (e *SyntaxError) Error() string { return e.msg }

Offset 字段甚至没有在 error 的默认格式化中展示,但是调用者可以使用它将文件和行信息添加到他们的错误消息中:

1
2
3
4
5
6
7
if err := dec.Decode(&val); err != nil {
if serr, ok := err.(*json.SyntaxError); ok {
line, col := findLine(f, serr.Offset)
return fmt.Errorf("%s:%d:%d: %v", f.Name(), line, col, err)
}
return err
}

(这是来自 Camlistore 项目的一些实际代码的略微简化版本。)

error 接口只需要一个 Error 方法。额特定的 error 实现可能有额外的方法。例如,按照通常的约定,net 包返回 error 类型的错误,但是一些通过 net.Error 接口定义的 error 实现有一些额外方法:

1
2
3
4
5
6
7
package net

type Error interface {
error
Timeout() bool // Is the error a timeout?
Temporary() bool // Is the error temporary?
}

客户端代码可以使用 type assertion 测试 net.Error,然后区分暂时性的网络问题和永久的。例如,网络爬虫在碰到暂时错误时也许会休眠然后重试,否则(发生永久错误)放弃。

1
2
3
4
5
6
7
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
time.Sleep(1e9)
continue
}
if err != nil {
log.Fatal(err)
}

简化重复的错误处理

在 Go 中,错误处理是很重要的。语言的设计和约定鼓励你在发生错误的地方检查(与其他语言中抛出异常有时捕获的约定不同)。在某些情况下这使得 Go 代码冗长,但幸运的是,你可以用一些技术来最小化重复的错误处理。

考虑一个带有 HTTP handler 的 App Engine 程序,从数据存储中检所记录并使用模板对他进行格式化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func init() {
http.HandleFunc("/view", viewRecord)
}

func viewRecord(w http.ResponseWriter, r *http.Request) {
c := appengine.NewContext(r)
key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
record := new(Record)
if err := datastore.Get(c, key, record); err != nil {
http.Error(w, err.Error(), 500)
return
}
if err := viewTemplate.Execute(w, record); err != nil {
http.Error(w, err.Error(), 500)
}
}

该函数处理由 datastore.Get 函数和 viewTemplate 的执行方法返回的 error 。在这两种情况下,他向用户展示一条简单的带有 HTTP 状态码 500 的消息(内部服务错误,Internal Server Error)。这看起来是可管理的代码量,但是添加更多的 HTTP handlers 很快你会得到完全相同的错误处理代码结果。

为了减少重复,我们可以定义自己的 HTTP appHandler 类型,包含一个 error 返回值:

1
2
type appHandler func(http.ResponseWriter, *http.Request) error

然后我们可以改变 viewRecord 函数去返回错误:

1
2
3
4
5
6
7
8
9
func viewRecord(w http.ResponseWriter, r *http.Request) error {
c := appengine.NewContext(r)
key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
record := new(Record)
if err := datastore.Get(c, key, record); err != nil {
return err
}
return viewTemplate.Execute(w, record)
}

这比原始版本要简化(了一些),但是 http 包不理解返回错误的函数。我们可以在 appHandler 中实现 http.Handler 接口的 ServeHTTP 方法来修复:

1
2
3
4
5
func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := fn(w, r); err != nil {
http.Error(w, err.Error(), 500)
}
}

ServerHTTP 方法调用 appHandler 函数并且将返回的错误(如果有)展示给用户。注意方法的 receiver,fn,是一个函数。(Go 可以干这事!)方法通过调用接收者在表达式中的 fn(w, r) 调用函数。

现在,使用 http 包注册 viewRecord时,我们使用 Handler 函数(而不是 HandlerFunc),因为 appHandler 是一个 http.Handler(不是 http.HandlerFunc)。

1
2
3
func init() {
http.Handle("/view", appHandler(viewRecord))
}

有了这个基本的错误处理设施,我们可以使他更加方便使用。而不仅仅是展示错误字符串,给用户一个简单的错误消息和一个合适的 HTTP 状态码会更好,同时将完整的 error 记录到 App Engine 的开发者控制台以进行调试。

为此,我们创建了一个 appError 结构体,包含了一个错误和一些其他字段:

1
2
3
4
5
type appError struct {
Error error
Message string
Code int
}

下一步我们更新 appHandler 返回类型,返回 *appError 值:

1
2
type appHandler func(http.ResponseWriter, *http.Request) *appError

(通常返回错误的具体类型而不是错误是错的,在 Go FAQ 中讨论了理由,但是这里这么做是对的因为 ServeHTTP 是唯一能看到值和使用它内容的地方。)

然后使 appHandler 的 ServeHTTP 方法向用户展示 appError 的消息和 HTTP 状态码并且将完整 Error 记录到开发者控制台:

1
2
3
4
5
6
7
func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if e := fn(w, r); e != nil { // e is *appError, not os.Error.
c := appengine.NewContext(r)
c.Errorf("%v", e.Error)
http.Error(w, e.Message, e.Code)
}
}

最后,我们更新 viewRecord 到新的函数签名,当他遭遇错误是返回更多上下文。

1
2
3
4
5
6
7
8
9
10
11
12
func viewRecord(w http.ResponseWriter, r *http.Request) *appError {
c := appengine.NewContext(r)
key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
record := new(Record)
if err := datastore.Get(c, key, record); err != nil {
return &appError{err, "Record not found", 404}
}
if err := viewTemplate.Execute(w, record); err != nil {
return &appError{err, "Can't display record", 500}
}
return nil
}

这个版本的 viewRecord 和原始的一样长,但是现在这些行都有特别的意义然后我们可以提供友好的用户体验。

这还没有结束,我们可以在我们的应用里更进一步的提升错误处理。一些想法:

  • 给错误处理一个漂亮的 HTML 模板
  • 当用户是管理员时通过将堆栈信息写入 HTTP response 使得调试更加容易
  • 为 appError 写一个为更容易调试记录堆栈信息的构造函数
  • 在 appHandler 中从 painc 恢复,将之作为危急(Critical)记录到控制台,同时告诉用户严重的错误发生了。这是一个妙招去避免将由程序引发的高深莫测的错误暴露给用户。有关更多详细信息,请阅读 Defer, Panic, and Recover

结论

正确的错误处理是优秀软件的基本要求。通过使用本文中标书的技术,你应该可以写出更加可靠和简洁的 Go 代码。

后记——读后感

在 Go 中不应该忽视每一处可能发生错误的代码(其他的语言其实也一样),在生产环境中,凡是可能会发生 error 的地方,那么早晚都会产生 error,我们应该坚信这一点,面向 error 编程。

在确定了面向 error 编程之后,该如何编写出好的错误处理代码?通读本文(错误处理与Go),可以知道如下两点:

  • 将重复的错误处理代码抽出,作为统一的错误处理
  • 将错误抛到更高层,直到最高层才开始统一处理错误

第二点会让最上层的代码充满错误处理,该如何编写好最上层的处理代码呢?我认为好的处理代码应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
err := doSomething1()
if err != nil {
// error handle
return
}

err = doSomething2()
if err != nil {
// error handler
return
}

这样写保证了阅读代码的连贯性,虽然有大量的错误处理,在梳理业务逻辑时直接忽略跳过就可以了,而且大量的错误也无法避免,每一个微服务都是不可靠的,会挂在每一个你意想不到的时候,此时需要我们正确的处理错误给用户正确的响应。

个人不是很喜欢 Java 中 try catch 会导致正常的处理逻辑有缩进。而可能因为一些历史遗留原因(早期 try catch 会有很高的性能损失),大家对异常的处理都是 try catch 而不是 throw 在上层统一处理,同时也难以在上层统一处理可能来自不同运行时的不同 exception,需要经过精心的设计才可以。同时因为 Java 中大量的异常,异常不再是异常,变得有些司空见惯了,从良性到灾难性都有,异常的严重性由调用者来区分。我觉得这也跟巨石后端时代程序不能挂掉有关,无论是怎样的 exception,都一定要捕获,即使没有正确的处理。

看到过一个说法:抛异常本身没有错,Java 抛异常也没有错,但是对于程序猿来说很多时候这些异常是难以处理的。(可以参考这篇文章)

关于如何编写良好的错误处理代码先告一段落,接下来讨论如何编写良好的自定义错误。

TODO…
待补�