Golang 中的错误处理建议

一、概述

Golang 的错误处理一直是一个比较讨论比较多的话。我刚接触 Golang 的时候也看过关于错误处理的一些文档,但是并没有放在心上。在我使用 Golang 一段时间之后,我觉得我可能无法忽略这个问题。因此,这篇文章主要是为了整理一些在 Golang 中常用的错误处理技巧和原则。

二、错误处理的技巧和原则

2.1 使用封装来避免重复的错误判断

在 Golang 的项目中,最多的一句代码肯定是 if err != nil。Golang 将错误作为返回值,因此你不得不处理这些错误。但是有的时候,错误处理的判断可能会占据你的代码的一半篇幅,这使得代码看起来乱糟糟的。在官方的博客中有一个这样的例子:

_, 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

是的,你没有看错,这里其实就是调用了 3 行 fd.Write,但是你不得不写上 9 错误判断。因此官方的博客中也给出了一个比较优雅的处理方案:将 io.Writer 再封装一层。

type errWriter struct {
    w   io.Writer
    err error
}

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

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
}

现在看上去就好多了,write(buf []byte) 方法在内部判断了错误值,来避免在外面多次的错误判断。当然,这种写法可能也有它的弊端,比如你没有办法知道出错在哪一行调用。大多数情况下,你只需要检查错误,然后进行处理而已。因此这种技巧还是很有用的。Golang 的标准库也有很多类似的技巧。比如

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()
}

其中 b.Write 是有错误值返回的,这只是为了符合 io.Writer 接口。你可以在调用 b.Flush() 的时候再进行错误值的判断。

2.2 Golang 1.13 前的错误处理

检验错误

大多数情况下,我们只需要对错误进行简单的判断即可。因为我们不需要对错误做其他的处理,只需要保证代码逻辑正确执行即可。

if err != nil {
    // something went wrong
}

但有的时候我们需要根据错误类型进行不同的处理,比如网络连接未连接/断开导致的错误,我们应该在判断是未连接/断开时,进行重连操作。 在涉及到错误类型的判断时,我们通常有两种方法

  1. 将错误和已知的值进行比对
var ErrNotFound = errors.New("not found")

if err == ErrNotFound {
    // something wasn't found
}
  1. 判断错误的具体类型
type NotFoundError struct {
    Name string
}

func (e *NotFoundError) Error() string { return e.Name + ": not found" }

if e, ok := err.(*NotFoundError); ok {
    // e.Name wasn't found
}

添加信息

当一个错误在经过多层的调用栈向上返回时,我们通常会在这个错误上添加一些额外的信息,以帮助开发人员判断错误出现时程序运行到了哪里,发生了什么。最简单的方式是,使用之前的错误信息构造新的错误:

if err != nil {
    return fmt.Errorf("decompress %v: %v", name, err)
}

使用 fmt.Errorf 只保留了上一个错误的文本,丢弃了其他所有的信息。如果我们想保留上一个错误的所有信息,我们可以使用下面的方式:

type QueryError struct {
    Query string
    Err   error
}

if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
    // query failed because of a permission problem
}

2.3 Golang 1.13 中的错误处理

Golang 1.13 中,如果一个错误包含了另一个错误,则可以通过实现 Unwrap() 方法来返回底层的错误。如果 e1.Unwrap() 返回了 e2,我们就可以说 e1 包含了 e2。

使用 Is 和 As 来检验错误

在 2.2 中提到了错误信息的常见处理方式,在 Golang 1.13 中,标准库中添加了几个方法来帮助我们更快速的完成以上的工作。当前前提是,你的自定义 Error 正确的实现了 Unwrap() 方法

errors.Is 用来将一个错误和一个值进行对比:

// Similar to:
//   if err == ErrNotFound { … }
if errors.Is(err, ErrNotFound) {
    // something wasn't found
}

errors.As 用来判断一个错误是否是一个特定的类型:

// Similar to:
//   if e, ok := err.(*QueryError); ok { … }
var e *QueryError
if errors.As(err, &e) {
    // err is a *QueryError, and e is set to the error's value
}

当操作一个包装了的错误时,IsAs 会考虑错误链上所有的错误。一个完整的例子如下:

type ErrorA struct {
    Msg string
}

func (e *ErrorA) Error() string {
    return e.Msg
}

type ErrorB struct {
    Msg string
    Err *ErrorA
}

func (e *ErrorB) Error() string {
    return e.Msg + e.Err.Msg
}

func (e *ErrorB) Unwrap() error {
    return e.Err
}

func main() {
    a := &ErrorA{"error a"}

    b := &ErrorB{"error b", a}

    if errors.Is(b, a) {
        log.Println("error b is a")
    }

    var tmpa *ErrorA
    if errors.As(b, &tmpa) {
        log.Println("error b as ErrorA")
    }
}

输出如下:

error b is a
error b as ErrorA

使用 %w 来包装错误

Go 1.13 中增加了 %w,当 %w 出现时,由 fmt.Errorf 返回的错误,将会有 Unwrap 方法,返回的是 %w 对应的值。下面是一个简单的例子:

type ErrorA struct {
    Msg string
}

func (e *ErrorA) Error() string {
    return e.Msg
}

func main() {
    a := &ErrorA{"error a"}

    b := fmt.Errorf("new error: %w", a)

    if errors.Is(b, a) {
        fmt.Println("error b is a")
    }

    var tmpa *ErrorA
    if errors.As(b, &tmpa) {
        fmt.Println("error b as ErrorA")
    }
}

输出如下:

error b is a
error b as ErrorA

是否需要对错误进行包装

当你向一个 error 中添加额外的上下文信息时,要么使用 fmt.Errorf,要么实现一个自定义的错误类型,这是你就要决定这个新的错误是否应该包装原始的错误信息。这是一个没有标准答案的问题,它取决于新错误创建的上下文。

包装一个错误是为了将它暴露给调用者。这样调用者就可以根据不同的原始错误作出不同的处理,比如 os.Open(file) 会返回文件不存在这种具体的错误, 这样调用者就可以通过创建文件来让代码可以正确往下执行。

当我们不想暴露实现细节时就不要包装错误。因为暴露一个具备细节的错误,就意味的调用者和我们的代码产生了耦合。这也违反了抽象的原则。

三、参考资料

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据