[3分钟]GO:关于代码中的错误处理

95 阅读10分钟

六、错误处理

我已经给出了几个关于错误处理的 演示文稿 ,并在我的博客上写了很多关于错误处理的文章。我在昨天的会议上也讲了很多关于错误处理的内容,所以在这里不再赘述。

相反,我想介绍与错误处理相关的两个其他方面。

6.1 通过消除错误来消除错误处理

如果你昨天在我的演讲中,我谈到了改进错误处理的提案。但是你知道有什么比改进错误处理的语法更好吗?那就是根本不需要处理错误。

注意: 我不是说“删除你的错误处理”。我的建议是,修改你的代码,这样就不用处理错误了。

本节从 John Ousterhout 最近的著作 “软件设计哲学” 中汲取灵感。该书的其中一章是“定义不存在的错误”。我们将尝试将此建议应用于 Go 语言。

6.1.1 计算行数

让我们编写一个函数来计算文件中的行数。

func CountLines(r io.Reader) (int, error) { var (  br    = bufio.NewReader(r)  lines int  err   error ) for {  _, err = br.ReadString('\n')  lines++  if err != nil {   break  } } if err != io.EOF {  return 0, err } return lines, nil}

由于我们遵循前面部分的建议【指 API设计 章节】,CountLines 需要一个 io.Reader,而不是一个 *File;它的任务是调用者为我们想要计算的内容提供 io.Reader

我们构造一个 bufio.Reader,然后在一个循环中调用 ReadString 方法,递增计数器直到我们到达文件的末尾,然后我们返回读取的行数。

至少这是我们想要编写的代码,但是这个函数由于需要错误处理而变得更加复杂。 例如,有这样一个奇怪的结构:

_, err = br.ReadString('\n')lines++if err != nil {    break}

我们在检查错误之前增加了行数,这样做看起来很奇怪。

我们必须以这种方式编写它的原因是,如果在遇到换行符之前就读到文件结束,则 ReadString 将返回错误。如果文件中没有换行符,同样会出现这种情况。

为了解决这个问题,我们重新排列逻辑增来加行数,然后查看是否需要退出循环。

注意: 这个逻辑仍然不完美,你能发现错误吗?

但是我们还没有完成检查错误。当 ReadString 到达文件末尾时,预期它会返回 io.EOFReadString 需要某种方式在没有什么可读时来停止。因此,在我们将错误返回给 CountLine 的调用者之前,我们需要检查错误是否是 io.EOF,如果不是将其错误返回,否则我们返回 nil 说一切正常。

我认为这是 Russ Cox(Go团队负责人) 观察到错误处理可能会模糊函数操作的一个很好的例子。我们来看一个改进的版本。

func CountLines(r io.Reader) (int, error) { sc := bufio.NewScanner(r) lines := 0 for sc.Scan() {  lines++ } return lines, sc.Err()}

这个改进的版本从 bufio.Reader 切换到 bufio.Scanner

bufio.Scanner 内部使用 bufio.Reader,但它添加了一个很好的抽象层,它有助于通过隐藏 CountLines 的操作来消除错误处理

注意: bufio.Scanner 可以扫描任何模式,但默认情况下它会查找换行符。

如果扫描程序匹配了一行文本并且没有遇到错误,则 sc.Scan() 方法返回 true因此,只有当扫描仪的缓冲区中有一行文本时,才会调用 for 循环的主体。这意味着我们修改后的 CountLines 正确处理没有换行符的情况,并且还处理文件为空的情况。

其次,当 sc.Scan 在遇到错误时返回 false,我们的 for 循环将在到达文件结尾或遇到错误时退出。bufio.Scanner 类型会记住遇到的第一个错误,一旦我们使用 sc.Err() 方法退出循环,我们就可以获取该错误。

最后sc.Err() 负责处理 io.EOF 并在达到文件末尾时将其转换为 nil,而不会遇到其他错误。

贴士: 当遇到难以忍受的错误处理时,请尝试将某些操作提取到辅助程序类型中

6.1.2. WriteResponse

我的第二个例子受到了 Errors are values 博客文章 的启发。

在本章前面我们已经看过处理打开、写入和关闭文件的示例。错误处理是存在的,但是是在可接受范围内的,因为操作可以封装在诸如 ioutil.ReadFileioutil.WriteFile 之类的辅助程序中。但是,在处理底层网络协议时,有必要使用 I/O 原始的错误处理来直接构建响应,这样就可能会变得重复。看一下构建 HTTP 响应的 HTTP 服务器的这个片段。

type Header struct { Key, Value string}type Status struct { Code   int Reason string}func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error { _, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason) if err != nil {  return err } for _, h := range headers {  _, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value)  if err != nil {   return err  } } if _, err := fmt.Fprint(w, "\r\n"); err != nil {  return err } _, err = io.Copy(w, body) return err}

首先,我们使用 fmt.Fprintf 构造状态码并检查错误。 然后对于每个标题,我们写入键值对,每次都检查错误。 最后,我们使用额外的 \r\n 终止标题部分,检查错误之后将响应主体复制到客户端。 最后,虽然我们不需要检查 io.Copy 中的错误,但我们需要将 io.Copy 返回的两个返回值形式转换为 WriteResponse 的单个返回值。

这里很多重复性的工作。 我们可以通过引入一个包装器类型 errWriter 来使其更容易。

errWriter 实现 io.Writer 接口,因此可用于包装现有的 io.WritererrWriter 将数据传递给其底层 writer,直到检测到错误。 从此时起,它会丢弃任何写入并返回先前的错误。

type errWriter struct { io.Writer err error}func (e *errWriter) Write(buf []byte) (int, error) { // 先判断之前有没有错误 if e.err != nil {  return 0, e.err } var n int n, e.err = e.Writer.Write(buf) return n, nil}func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error { ew := &errWriter{Writer: w} fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason) for _, h := range headers {  fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value) } fmt.Fprint(ew, "\r\n") io.Copy(ew, body) return ew.err}

errWriter 应用于 WriteResponse 可以显着提高代码的清晰度每个操作不再需要自己做错误检查。 通过检查 ew.err 字段,将错误报告移动到函数末尾,从而避免转换从 io.Copy 的两个返回值。

总结:1)检查逻辑,减少不必要的错误 2)使用更合适的库及函数 3)将相似的error抽离封装,将对error的处理封装进函数

6.2. 错误只处理一次

最后,我想提一下你应该只处理错误一次处理错误意味着检查错误值并做出单一决定

// WriteAll writes the contents of buf to the supplied writer.func WriteAll(w io.Writer, buf []byte) {        w.Write(buf)}

如果你做出的决定少于一个,则忽略该错误。 正如我们在这里看到的那样, w.WriteAll 的错误被丢弃。【这里的决定是发现错误之后的决定吗?这里没有很理解】

但是,针对单个错误做出多个决策也是有问题的。 以下是我经常遇到的代码。

func WriteAll(w io.Writer, buf []byte) error { _, err := w.Write(buf) if err != nil {  log.Println("unable to write:", err) // annotated error goes to log file        注释的 error 放到 log 文件  return err                           // unannotated error returned to caller    未注释的 error 返回给调用者 } return nil}

在此示例中,如果在 w.Write 期间发生错误,则会写入日志文件,注明错误发生的文件与行数,并且错误也会返回给调用者,调用者可能会记录该错误并将其返回到上一级一直回到程序的顶部

调用者可能正在做同样的事情

func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil {  log.Printf("could not marshal config: %v", err)  return err } if err := WriteAll(w, buf); err != nil {  log.Println("could not write config: %v", err)  return err } return nil}

因此你在日志文件中得到一堆重复的内容

unable to write: io.EOF
could not write config: io.EOF

但在程序的顶部,虽然得到了原始错误,但没有相关内容【没有提示信息】

err := WriteConfig(f, &conf)fmt.Println(err) // io.EOF

我想深入研究这一点,因为我认为日志记录和返回的问题不只是个人喜好问题。

func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil {  log.Printf("could not marshal config: %v", err)  // oops, forgot to return } if err := WriteAll(w, buf); err != nil {  log.Println("could not write config: %v", err)  return err } return nil}

很多问题是程序员忘记从错误中返回。正如我们之前谈到的那样,Go 语言风格是使用 **guard clauses** ,随着函数的进行检查前提条件并提前返回

在这个例子中,作者检查了错误,记录了它,但忘了返回。这就引起了一个微妙的错误。

Go 语言中的错误处理规定,如果出现错误,你不能对其他返回值的内容做出任何假设。 由于 JSON 解析失败,buf 的内容未知,可能它什么都没有,但更糟的是它可能包含解析的 JSON 片段部分。

由于程序员在检查并记录错误后忘记返回,因此损坏的缓冲区将传递给 WriteAll,这可能会成功,因此配置文件将被错误地写入。但是,该函数会正常返回,并且发生问题的唯一日志行是有关 JSON 解析错误,而与写入配置失败有关。

6.2.1. 为错误添加相关内容

发生错误的原因是作者试图在错误消息中添加 context【上下文,即提示信息】 。 他们试图给自己留下一些线索,指出错误的根源。

让我们看看使用 fmt.Errorf另一种方式

func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil {  return fmt.Errorf("could not marshal config: %v", err) } if err := WriteAll(w, buf); err != nil {  return fmt.Errorf("could not write config: %v", err) } return nil}func WriteAll(w io.Writer, buf []byte) error { _, err := w.Write(buf) if err != nil {  return fmt.Errorf("write failed: %v", err) } return nil}

通过将注释与返回的错误组合起来,就更难以忘记错误的返回来避免意外继续。

如果写入文件时发生 I/O 错误,则 errorError() 方法会报告以下类似的内容;

could not write config: write failed: input/output error

6.2.2. 使用 github.com/pkg/errors 包装 errors

fmt.Errorf 模式适用于注释错误 message,但这样做的代价模糊了原始错误的类型。 我认为将错误视为不透明值对于松散耦合的软件非常重要,因此如果你使用错误值,需要做的唯一事情是原始错误的类型应该无关紧要的

  1. 检查它是否为 nil。

  2. 输出或记录它。

但是在某些不常见的情况下,比如您需要恢复原始错误。 在这种情况下,使用类似我的 errors来注释这样的错误, 如下

func ReadFile(path string) ([]byte, error) { f, err := os.Open(path) if err != nil {  return nil, errors.Wrap(err, "open failed") } defer f.Close() buf, err := ioutil.ReadAll(f) if err != nil {  return nil, errors.Wrap(err, "read failed") } return buf, nil}func ReadConfig() ([]byte, error) { home := os.Getenv("HOME") config, err := ReadFile(filepath.Join(home, ".settings.xml")) return config, errors.WithMessage(err, "could not read config")}func main() { _, err := ReadConfig() if err != nil {  fmt.Println(err)  os.Exit(1) }}

现在报告的错误就是 K&D 样式错误,

could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory

并且错误值保留对原始原因的引用

func main() { _, err := ReadConfig() if err != nil {  fmt.Printf("original error: %T %v\n", errors.Cause(err), errors.Cause(err))  fmt.Printf("stack trace:\n%+v\n", err)  os.Exit(1) }}

因此,你可以恢复原始错误打印堆栈跟踪;

original error: *os.PathError open /Users/dfc/.settings.xml: no such file or directory
stack trace:
open /Users/dfc/.settings.xml: no such file or directory
open failed
main.ReadFile
        /Users/dfc/devel/practical-go/src/errors/readfile2.go:16
main.ReadConfig
        /Users/dfc/devel/practical-go/src/errors/readfile2.go:29
main.main
        /Users/dfc/devel/practical-go/src/errors/readfile2.go:35
runtime.main
        /Users/dfc/go/src/runtime/proc.go:201
runtime.goexit
        /Users/dfc/go/src/runtime/asm_amd64.s:1333
could not read config

使用 errors 包,你可以以机器可检查的方式向错误值添加上下文。 如果昨天你来听我的演讲,你会知道这个库在被移植到即将发布的 Go 语言版本的标准库中。

总结:1)对错误只进行一次处理 2)如果错误的类型不重要,使用fmt.Errorf添加上下文【提示信息】来避免忘记返回错误 3)如果原始错误很重要,使用errors处理错误

内容学习于该博客:英文博客[1]

同时借鉴于该翻译:中文翻译[2]

参考资料

[1]

英文博客:

dave.cheney.net/practical-g…

[2]

中文翻译:

github.com/llitfkitfk/…