Go

Go开发关键技术指南:Errors

2020-01-10  本文已影响0人  winlinvip

Errors

错误处理是现实中经常碰到的、难以处理好的问题,下面会从下面几个方面探讨错误处理:

错误和异常

我们总会遇到非预期的非正常情况,有一种是符合预期的,比如函数返回error并处理,这种叫做可以预见到的错误,还有一种是预见不到的比如除零、空指针、数组越界等叫做panic,panic的处理主要参考Defer, Panic, and Recover

错误处理的模型一般有两种,一般是错误码模型比如C/C++和Go,还有异常模型比如Java和C#。Go没有选择异常模型,因为错误码比异常更有优势,参考文章Cleaner, more elegant, and wrong 以及Cleaner, more elegant, and harder to recognize。看下面的代码:

try {
  AccessDatabase accessDb = new AccessDatabase();
  accessDb.GenerateDatabase();
} catch (Exception e) {
  // Inspect caught exception
}

public void GenerateDatabase()
{
  CreatePhysicalDatabase();
  CreateTables();
  CreateIndexes();
}

这段代码的错误处理有很多问题,比如如果CreateIndexes抛出异常,会导致数据库和表不会删除,造成脏数据。从代码编写者和维护者的角度看这两个模型,会比较清楚:

Really Easy Hard Really Hard
Writing bad error-code-based code
Writing bad exception-based code
Writing good
error-code-based code
Writing good
exception-based code

错误处理不容易做好,要说容易那说明做错了;要把错误处理写对了,基于错误码模型虽然很难,但比异常模型简单。

Really Easy Hard Really Hard
Recognizing that error-code-based
code is badly-written
Recognizing the difference
between bad error-code-based
code and not-bad
error-code-based code.
Recognizing that
error-code-base code
is not badly-written
Recognizing that
exception-based code
is badly-written
Recognizing that
exception-based code
is not badly-written
Recognizing
the difference between
bad exception-based code
and
not-bad exception-based code

如果使用错误码模型,非常容易就能看出错误处理没有写对,也能很容易知道做得好不好;要知道是否做得非常好,错误码模型也不太容易。
如果使用异常模型,无论做的好不好都很难知道,而且也很难知道怎么做好。

Errors in Go

Go官方的error介绍,简单一句话就是返回错误对象的方式,参考Error handling and Go,解释了error是什么,如何判断具体的错误,显式返回错误的好处。文中举的例子就是打开文件错误:

func Open(name string) (file *File, err error)

Go可以返回多个值,最后一个一般是error,我们需要检查和处理这个错误,这就是Go的错误处理的官方介绍:

if err := Open("src.txt"); err != nil {
    // Handle err
}

看起来非常简单的错误处理,有什么难的呢?骚等,在Go2的草案中,提到的三个点Error HandlingError ValuesGenerics泛型,两个点都是错误处理的,这说明了Go1中对于错误是有改进的地方。

再详细看下Go2的草案,错误处理:Error Handling中,主要描述了发生错误时的重复代码,以及不能便捷处理错误的情况。比如草案中举的这个例子No Error Handling: CopyFile,没有做任何错误处理:

package main

import (
  "fmt"
  "io"
  "os"
)

func CopyFile(src, dst string) error {
  r, _ := os.Open(src)
  defer r.Close()

  w, _ := os.Create(dst)
  io.Copy(w, r)
  w.Close()

  return nil
}

func main() {
  fmt.Println(CopyFile("src.txt", "dst.txt"))
}

还有草案中这个例子Not Nice and still Wrong: CopyFile,错误处理是特别啰嗦,而且比较明显有问题:

package main

import (
  "fmt"
  "io"
  "os"
)

func CopyFile(src, dst string) error {
  r, err := os.Open(src)
  if err != nil {
    return err
  }
  defer r.Close()

  w, err := os.Create(dst)
  if err != nil {
    return err
  }
  defer w.Close()

  if _, err := io.Copy(w, r); err != nil {
    return err
  }
  if err := w.Close(); err != nil {
    return err
  }
  return nil
}

func main() {
  fmt.Println(CopyFile("src.txt", "dst.txt"))
}

io.Copyw.Close出现错误时,目标文件实际上是有问题,那应该需要删除dst文件的。而且需要给出错误时的信息,比如是哪个文件,不能直接返回err。所以Go中正确的错误处理,应该是这个例子Good: CopyFile,虽然啰嗦繁琐不简洁:

package main

import (
  "fmt"
  "io"
  "os"
)

func CopyFile(src, dst string) error {
  r, err := os.Open(src)
  if err != nil {
    return fmt.Errorf("copy %s %s: %v", src, dst, err)
  }
  defer r.Close()

  w, err := os.Create(dst)
  if err != nil {
    return fmt.Errorf("copy %s %s: %v", src, dst, err)
  }

  if _, err := io.Copy(w, r); err != nil {
    w.Close()
    os.Remove(dst)
    return fmt.Errorf("copy %s %s: %v", src, dst, err)
  }

  if err := w.Close(); err != nil {
    os.Remove(dst)
    return fmt.Errorf("copy %s %s: %v", src, dst, err)
  }
  return nil
}

func main() {
  fmt.Println(CopyFile("src.txt", "dst.txt"))
}

具体应该如何简洁的处理错误,可以读Error Handling,大致是引入关键字handle和check,由于本文重点侧重Go1如何错误处理,就不展开分享了。

明显上面每次都返回的fmt.Errorf信息也是不够的,所以Go2还对于错误的值有提案,参考Error Values。大规模程序应该面向错误编程和测试,同时错误应该包含足够的信息。Go1中判断error具体是什么错误有几种办法:

在复杂程序中,有用的错误需要包含调用链的信息。例如,考虑一次数据库写,可能调用了RPC,RPC调用了域名解析,最终是没有权限读/etc/resolve.conf文件,那么给出下面的调用链会非常有用:

write users database: call myserver.Method: \
    dial myserver:3333: open /etc/resolv.conf: permission denied

Errors Solutions

由于Go1的错误值没有完整的解决这个问题,才导致出现非常多的错误处理的库,比如:

Go1.13改进了errors,参考如下实例代码:

package main

import (
    "errors"
    "fmt"
    "io"
)

func foo() error {
    return fmt.Errorf("read err: %w", io.EOF)
}

func bar() error {
    if err := foo(); err != nil {
        return fmt.Errorf("foo err: %w", err)
    }
    return nil
}

func main() {
    if err := bar(); err != nil {
        fmt.Printf("err: %+v\n", err)
        fmt.Printf("unwrap: %+v\n", errors.Unwrap(err))
        fmt.Printf("unwrap of unwrap: %+v\n", errors.Unwrap(errors.Unwrap(err)))
        fmt.Printf("err is io.EOF? %v\n", errors.Is(err, io.EOF))
    }
}

运行结果如下:

err: foo err: read err: EOF
unwrap: read err: EOF
unwrap of unwrap: EOF
err is io.EOF? true

从上面的例子可以看出:

另外,错误处理往往和log是容易混为一谈的,因为遇到错误一般会打日志,特别是在C/C++中返回错误码一般都会打日志记录下,有时候还会记录一个全局的错误码比如linux的errno,而这种习惯,造成了error和log混淆造成比较大的困扰。考虑以前写了一个C++的服务器,出现错误时会在每一层打印日志,所以就会形成堆栈式的错误日志,便于排查问题,如果只有一个错误,不知道调用上下文,排查会很困难:

avc decode avc_packet_type failed. ret=3001
Codec parse video failed, ret=3001
origin hub error, ret=3001

这种比只打印一条日志origin hub error, ret=3001要好,但是还不够好:

  1. 和Go的错误一样,比较啰嗦,有重复的信息。如果能提供堆栈信息,可以省去很多需要手动写的信息。
  2. 对于应用程序可以打日志,但是对于库,信息都应该包含在error中,不应该直接打印日志。如果底层的库都要打印日志,那会导致底层库都要依赖日志库,这是很多库都有日志打印函数供调用者重写。
  3. 对于多线程,看不到线程信息,或者看不到业务层ID的信息。对于服务器来说,有时候需要知道这个错误是哪个连接的,从而查询这个连接之前的上下文信息。

改进后的错误日志变成了在底层返回,而不在底层打印在调用层打印,有调用链和堆栈,有线程切换的ID信息,也有文件的行数:

Error processing video, code=3001 : origin hub : codec parser : avc decoder
[100] video_avc_demux() at [srs_kernel_codec.cpp:676]
[100] on_video() at [srs_app_source.cpp:1076]
[101] on_video_imp() at [srs_app_source:2357]

从Go2的描述来说,实际上这个错误处理也还没有考虑完备。从实际开发来说,已经比较实用了。

总结下Go的error,错误处理应该注意的点:

  1. 凡是有返回错误码的函数,必须显式的处理错误,如果要忽略错误,也应该显式的忽略和写注释。
  2. 错误必须带丰富的错误信息,比如堆栈,发生错误时的参数,调用链给的描述等等。特别要强调变量,我看过太多日志描述了一对常量,比如"Verify the nonce, timestamp and token of specified appid failed",而这个消息一般会提到工单中,然后就是再问用户,哪个session或request甚至时间点?这么一大堆常量有啥用呢,关键是变量,关键是变量呐。
  3. 尽量避免重复的信息,提高错误处理的开发体验,糟糕的体验会导致无效的错误处理代码比如拷贝和漏掉关键信息。
  4. 分离错误和日志,发生错误时返回带完整信息的错误,在调用的顶层决定是将错误用日志打印,还是发送到监控系统,还是转换错误,或者忽略。

Best Practice

推荐用github.com/pkg/errors这个错误处理的库,基本上是够用的,参考Refine: CopyFile,可以看到CopyFile中低级重复的代码已经比较少了:

package main

import (
  "fmt"
  "github.com/pkg/errors"
  "io"
  "os"
)

func CopyFile(src, dst string) error {
  r, err := os.Open(src)
  if err != nil {
    return errors.Wrap(err, "open source")
  }
  defer r.Close()

  w, err := os.Create(dst)
  if err != nil {
    return errors.Wrap(err, "create dest")
  }

  nn, err := io.Copy(w, r)
  if err != nil {
    w.Close()
    os.Remove(dst)
    return errors.Wrap(err, "copy body")
  }

  if err := w.Close(); err != nil {
    os.Remove(dst)
    return errors.Wrapf(err, "close dest, nn=%v", nn)
  }

  return nil
}

func LoadSystem() error {
  src, dst := "src.txt", "dst.txt"
  if err := CopyFile(src, dst); err != nil {
    return errors.WithMessage(err, fmt.Sprintf("load src=%v, dst=%v", src, dst))
  }

  // Do other jobs.

  return nil
}

func main() {
  if err := LoadSystem(); err != nil {
    fmt.Printf("err %+v\n", err)
  }
}

改写的函数中,用errors.Wraperrors.Wrapf代替了fmt.Errorf,我们不记录src和dst的值,因为在上层会记录这个值(参考下面的代码),而只记录我们这个函数产生的数据,比如nn

import "github.com/pkg/errors"

func LoadSystem() error {
    src, dst := "src.txt", "dst.txt"
    if err := CopyFile(src, dst); err != nil {
        return errors.WithMessage(err, fmt.Sprintf("load src=%v, dst=%v", src, dst))
    }

    // Do other jobs.

    return nil
}

在这个上层函数中,我们用的是errors.WithMessage添加了这一层的错误信息,包括srcdst,所以CopyFile里面就不用重复记录这两个数据了。同时我们也没有打印日志,只是返回了带完整信息的错误。

func main() {
    if err := LoadSystem(); err != nil {
        fmt.Printf("err %+v\n", err)
    }
}

在顶层调用时,我们拿到错误,可以决定是打印还是忽略还是送监控系统。

比如我们在调用层打印错误,使用%+v打印详细的错误,有完整的信息:

err open src.txt: no such file or directory
open source
main.CopyFile
    /Users/winlin/t.go:13
main.LoadSystem
    /Users/winlin/t.go:39
main.main
    /Users/winlin/t.go:49
runtime.main
    /usr/local/Cellar/go/1.8.3/libexec/src/runtime/proc.go:185
runtime.goexit
    /usr/local/Cellar/go/1.8.3/libexec/src/runtime/asm_amd64.s:2197
load src=src.txt, dst=dst.txt

但是这个库也有些小毛病:

  1. CopyFile中还是有可能会有重复的信息,还是Go2的handlecheck方案是最终解决。
  2. 有时候需要用户调用Wrap,有时候是调用WithMessage(不需要加堆栈时),这个真是非常不好用的地方(这个我们已经修改了库,可以全部使用Wrap不用WithMessage,会去掉重复的堆栈)。

Links

由于简书限制了文章字数,只好分成不同章节:

上一篇下一篇

猜你喜欢

热点阅读