golang如何优雅的处理错误

1 / 289

在程序员中,尤其是go新手,经常听到的一个讨论话题是:如何处理错误。当下面这段代码出现次数过多时,这个话题大多数时候都会变成对go的悲叹。 if err != nil { return err }

我们最近扫描了所有我们能找到的开源项目代码,但是确发现这段代码平均一两页出现一次,远没有我们原本设想的出现次数那么多。如果有人任然坚信一定要输入if err !=nil,那么肯定是那里出错了,显然,是go本身的问题。

这是不幸,有误,并且容易纠正的一个问题。可能当go新手在询问”如何处理错误“的时候,他们学会了这个模式(指if err !=nil),并且对错误的认知到此为止了。在其他语言,一个程序员使用try-catch代码块或者而其他类似的机制去处理错误。因此,这些程序员们认为,在我之前的语言中我用try-catch去处理错误,那么我在go中我也要用相应的if err != nil处理错误就行了。随着时间过去,在go代码中出现许多这样的小片段,导致看起来十分愚蠢。

无论这个解释再怎么好,很明显的一点就是go程序员忽略了一个关于错误的概念:错误是值(Errors are value)。

我们可以对值进行编程,并且因为错误是值,所以我们也可以对错误进行编程。

原文:Values can be programmed, and since errors are values, errors can be programmed.

一个常见的涉及到错误的语句是测试错误值是不是空值,但是我们还可以对错误值进行无数种处理,使用其中一些处理,可以让我们的程序变得更好,消灭那些每一错误都要用样板if处理的语句。

这里有一个简单例子,来自bufio包的Scanner类,它的Scan方法执行基础的I/O,这其中明显可能产生错误,然而,scan方法并没有一个错误,而是返回一个布尔值,并且在scan方法执行结束后,运行一个单独的方法,报告是否单独发生错误。客户端代码如下

scanner := bufio.NewScanner(input) for scanner.Scan() { token := scanner.Text() // process token } if err := scanner.Err(); err != nil { // process the error }

当然,可以对每个error进行一次空值检查,但是它出现并执行一次,Scan方法也可以被定义如下

func (s *Scanner) Scan() (token []byte, error)

用户的代码样例如下:

scanner := bufio.NewScanner(input) for { token, err := scanner.Scan() if err != nil { return err // or maybe break } // process token }

这两个没有多大的不同,但是有一个很重要的区别是,在后者的客户端代码,用户必须在每次循环中都检查一次错误,但是在真正的ScannerAPI中,只会对token进行迭代,错误处理从关键API代码中抽象出来了。所以在真实的API中,用户端的代码因此更加自然,一直循环直到结束,然后再考虑错误,错误处理并不会影响到控制流。

在这背后发生了什么呢?当scan遇到了I/O错误,它会记录并且返回false,另一个单独的方法Err,报告这个错误当客户端调用它的时候,虽然这有些琐碎,但是不同于到处输入if err != nil,或者告诉客户必须时刻检查每个token是否有错误。这个方法对错误进行了编程。简单的编程处理,仍然是编程。

值得强调的是,无论使用哪种设计,最至关重要的是,无论错误如何暴露出来,都要去检查处理它。我们在这的讨论,不是讨论如何避免检查错误,而是如何使用go更优雅地处理错误。

我在东京参加2014年秋季GoCon大会时,反复出现了错误检查代码的话题。一个热情的 gopher,@jxck,反映了他对错误处理的抱怨,他有一些像这样的代码:

_, err = fd.Write(p0[a:b]) if err != nil { return err } _, err = fd.Write(p1[c:d]) if err != nil { return err } _, err = fd.Write(p2[e:f]) if err != nil { return err } // and so on

这出现了过多重复的代码。在真实的编码过程中,它更长些,并且有更多情况要处理,因此用一个辅助函数去重构它并不容易,但是在理想环境中,定义一个函数来处理错误变量或许能帮助我们

var err error write := func(buf []byte) { if err != nil { return } _, err = w.Write(buf) } write(p0[a:b]) write(p1[c:d]) write(p2[e:f]) // and so on if err != nil { return err }

这个方式看起来不错,但是在执行写操作的时候,每个函数都需要闭包操作,每次单独的辅助函数调用的时候都需要维护一个共同变量。

我们可以做得更干脆点,更适用点,并且复用性更高,通过借鉴上文的Scan方法,我向@jxck提到了这个技巧,但是他不知道如何去使用,在一段时间的交流后,由于语言障碍,我借用他的电脑,写下了这样一段代码,试着去提醒他。

我定义了一个对象叫做errWriter,如下:

type errWriter struct { w io.Writer err error }

并且给他赋予一个方法write,他并不需要标准的Write方法签名,并且他的小写突出了区别。写方法调用了Write方法,并且记录了第一个遇到的错误用于后续使用。

func (ew *errWriter) write(buf []byte) { if ew.err != nil { return } _, ew.err = ew.w.Write(buf) }

当错误发生时,write将会变成一个无操作函数,但是会保存错误值。

定义errWriter类型,并且他的write方法,上述代码可以被重构为

ew := &errWriter{w: fd} ew.write(p0[a:b]) ew.write(p1[c:d]) ew.write(p2[e:f]) // and so on if ew.err != nil { return ew.err }

相比比起闭包的方法,这种写法更为清晰且透明。再也不杂乱了,使用错误值编程,可以使得代码更漂亮。

在一些标准包中,其他一些代码也可以基于这个思想去构建,甚至直接使用errWriter

而且,errWriter的存在,还可以做更多的事情来提供帮助,特别是在不那么人工的例子中,他可以积累字节数,可以将写操作合并到一个缓冲区中,以原子的方式操作。

事实上,这个方式经常出现在标准包汇总。archive/zipnet/http 都有使用到这个方法。本文的讨论更为突出的表现了 bufio package's Writer 就是基于errWriter想法实现的,虽然 bufio.Writer.Write 返回一个错误,这主要是基于io.Writer接口。Writer方法的实现就像我们上文写的errWriter.write方法,Flush报告错误,使得我们的例子可以如下:

b := bufio.NewWriter(fd) b.Write(p0[a:b]) b.Write(p1[c:d]) b.Write(p2[e:f]) // and so on if b.Flush() != nil { return b.Flush() }

至少对于某些应用程序,此方法存在一个明显的缺点:无法知道发生错误之前已完成多少处理。如果该信息很重要,则必须采用更细粒度的方法。但是,通常,最后进行全有或全无的检查就足够了。

我们只研究了一种避免重复错误处理代码的技术。请记住,使用 errWriter 或 bufio.Writer 并不是简化错误处理的唯一方法,并且这种方法并不适合所有情况。但是,关键的教训是,错误是值,而 Go 编程语言的全部功能可用于处理它们。

使用该语言简化错误处理。

但是请记住:无论做什么,都要检查错误!

文章原文地址