『Gopher 』
首发于『Gopher 』
Go与Error的前世今生

Go与Error的前世今生

前世篇

先讨论目前已有的标准库中是如何处理错误的。

标准库的错误处理

本文所有标准库举例代码为go 1.11.4版本标准库代码

一、 简单判断为nil的错误处理

大致定义

通过判断err是否为nil,而简单的将该err作为返回值返回给上层调用者。

举例

net/http库中Client类型的Get方法就很好的示范了这一用法。

func (c *Client) Get(url string) (resp *Response, err error) {
    req, err := NewRequest("GET", url, nil)
    if err != nil {
        return nil, err
    }
    return c.Do(req)
}

源码位置

在代码中我们可以清晰的看出,在调用NewRequest方法时可能产生一个错误,而这段代码简单的判断了错误是否为nil,则立即将该err返回给上层的调用者,由上层调用者来决断如何处理。

讨论

大部分代码中的错误处理,基本上都是这种,简单易行。 毕竟大部分中间过程的函数并不知道该如何解决这些错误,只是简单的返回。

二、 类型断言的错误处理

大致定义

通过对返回err值的类型断言,来判断err属于哪一种类型,从而根据类型进行错误处理。

举例

net/http库中Server类型的Serve方法判断当前Listener的Accept失败原因就使用了这一个方法。

这里,先判断是否当前错误为网络错误导致。

调用Temporary方法判断这个错误是否为临时的错误,然后决断是退出服务还是过一段时间后重新尝试Accept。

rw, e := l.Accept()
if e != nil {
    select {
    case <-srv.getDoneChan():
        return ErrServerClosed
    default:
    }
    if ne, ok := e.(net.Error); ok && ne.Temporary() {
       // ignore code here
        continue
    }
    return e
}

源码位置

讨论

通过类型断言来处理错误,往往适用于可以按照类型来处理的错误。 在这样的方式,错误信息中即包含了错误的种类,又可以包含错误发生时上下文的信息。 然而缺点是,设计比较复杂,代码量往往剧增。 一般适用于: 1. 上层需要对错误的类型进行甄别从而处理 2. 包含更多错误信息,而不仅仅是调用Error()方法返回的文本。

三、值的比较

大致定义

根据错误值的比较,得到当前错误的类型。

举例

最常见的就是对io库的EOF的值的判断,EOF是一种信号,表示该输入的流是否已经到达了末尾。

比如json库中Decoder类型在读取reader中的数据时,发生了错误。

在Decoder的readValue方法中,通过比对err与io.EOF的值是否相等,来判断流是否结束。

// Did the last read have an error?
// Delayed until now to allow buffer scan.
if err != nil {
    if err == io.EOF {
        if dec.scan.step(&dec.scan, ' ') == scanEnd {
            break Input
        }
        if nonSpace(dec.buf) {
            err = io.ErrUnexpectedEOF
        }
    }
    dec.err = err
    return 0, err
}

源码位置

讨论

使用这种方法,通过值的比对,传递了错误的信息。譬如io.EOF传递了一种流末尾的信号。 一般适用于:

  1. 传递某种信号
  2. 错误简单,且发生错误时,不需要填充上下文的信息。

四、 错误文本内容的比较

大致定义

调用error类型的Error方法得到错误信息文本,通过判断文本的信息,从而得到错误的类型、原始信息等等。

举例

这里举两个例子,一个是os包的isNotExist,一个是net/http包中测试代码TestClientErrorWithRequestURI。

os包中isNotExist方法在plan9系统的实现,是通过判断错误信息的文本是否包含相关msg来确定是不是NotExist错误(文件不存在错误)。

func isNotExist(err error) bool {
      return checkErrMessageContent(err, "does not exist", "not found",
            "has been removed", "no parent")
} 

源码位置

TestClientErrorWithRequestURI方法中,通过判断返回error文本中是否包含RequestURI来判断是否由RequestURI错误导致的错误。

func TestClientErrorWithRequestURI(t *testing.T) {
        defer afterTest(t)
        req, _ := NewRequest("GET", "http://localhost:1234/", nil)
        req.RequestURI = "/this/field/is/illegal/and/should/error/"
        _, err := DefaultClient.Do(req)
        if err == nil {
            t.Fatalf("expected an error")
        }
        if !strings.Contains(err.Error(), "RequestURI") {
            t.Errorf("wanted error mentioning RequestURI; got error: %v", err)
        }
}

讨论

上面两种案例体现了,这种方法的两种适用场景。

  1. 调用包中没有对错误信息有很好的判断的方法,无法判断错误类型,错误的信息也是动态的、包含上下文信息的而不是一个固定的值,既不类似net.Error这种有明确定义的类型,又不类似sql.ErrNoRows、io.EOF这种固定的值的错误。 上层包为了分情况去处理错误,只能通过文本匹配的方式进行错误处理。
  2. 一些在设计角度上来说不需要上层清晰处理的错误,在函数内简单的用fmt.Errorf()或者errors.New()产生的error。 然而,在单元测试阶段又需要对错误的类型进行判断。实际应用中,从标准库的代码来看,大量诸如此类的错误判断都是存在于单元测试中。

今生篇

社区

无论是标准库,还是go的文档中的错误处理的示例,由于业务场景简单。往往缺乏许多在错误处理中实际需求的内容。 譬如,错误发生时的调用栈、一层层错误向上返回的路径、引起上层错误的原始底层错误信息、保留错误的类别与原始信息,这些都有很严重的缺失。 因此社区中有许多优秀的库来试图在不改变语法兼容的情况下引入新的概念,去解决诸如此类的这些问题。

errorx库

github.com/joomcode/errorx 项目地址

简介

errorx,对错误处理的提出以下概念:

  • 类型 type
    • type是一种值
    • 每个错误具备不同的类型即type
    • 类型之间可以有继承关系
    • 类型检查的isType方法可以检查任何父类型
    • type有命名空间的概念,可以通过namespace进行隔离
  • 特性 trait
    • trait是通用的类型特性,比如Timeout超时错误、Temporary临时性错误
    • 可以检测错误类型的特性
  • 装饰 decorate
    • 装饰用于给错误添加更多额外的信息
    • 装饰后的错误,错误的类型不变
  • 包裹 wrap
    • 包裹用于根据一个错误生成一个新的错误,会变成新的错误类型。
    • 被包裹的错误,是发生错误的下层原因。

示例代码

来自godoc

return errorx.IllegalState.New("unfortunate")

if err != nil {
    return errorx.Decorate(err, "this could be so much better")
}

log.Errorf("Error: %+v", err)

pkg/errors库

github.com/pkg/errors 项目地址

简介

errors包解决了保留原始错误值的能力,引入了causer的概念。

errors包认为错误包含了错误的信息与造成错误的原因。

type causer interface {
        Cause() error
}

一个实现了causer接口的错误,可以通过Cause方法给出底层的原始错误信息。

而errors包提供的Wrap方法,则简明扼要的帮助我们生成这个causer接口的错误。

func Wrap(err error, message string) error

通过传入导致错误发生的原错误,和附加的上下文消息,得到了一个新的、实现了causer的错误。

上层调用者使用errors.Cause(err)方法就能拿到这次错误造成的罪魁祸首。

示例代码

func queryDB(id string)([]Item,error){
    return nil,sql.ErrNoRows
}

var ErrNoSuchItems = errors.New("no such items")

func query(id string)([]Item,error){
    items,err := queryFromDatabase(id)
    if err!=nil{
        underlyErr =errors.Cause(err)
        switch underlyErr {
            case sql.ErrNoRows:
              return nil,errors.Wrapf(ErrNoSuchItems,"id=%s",id)
            default:
              return nil,errors.Wrapf(err,"when query by id=%s",id)
        }
    }
    return items,nil
}

errors包还提供可以查看在生成error值时的调用栈详情的方法WithStack。

这样生成的错误在fmt.Printf等方法使用时候,若使用%+v占位符则会携带错误发生时的调用栈。

exp/errors 即将合入标准库的errors包

即将合并到标准库的新errors包golang.org/x/exp/errors 位于实验性质exp包下的errors包。

实现了草案 Error Values(草案链接)描述的内容。

接下来,我们先简单的描述一下草案的内容:

期望实现的目标

  1. 代码中的错误检查更健全,更不容易出错。
  2. 希望用标准化的格式打印出错误额外的详细信息。
  3. 任何方案都必须与以往的代码保持兼容。
  4. 在包内新建错误类型必须与以前一样保持简单。
  5. 创建错误必须高效,必须是一个额定的开销。

草案中的设计

错误检查

首先我们谈一个概念,错误链。

在我们日常的错误处理时, 底层的错误在向上传递的过程中,我们希望在某些环节添加上一些额外的信息,则根据底层的错误产生一个新的错误,并继续向上传递。

type Wrapper interface {
    Unwrap() error
}

这时,传递的错误,可以实现Wrapper这样的接口,以便返回造成当前错误的原因即底层的错误。

一层一层的错误通过Wrapper接口,实现了类似一个链表的结构。

我们举个具体的例子:

假设某段代码需要调用一个查询用户的功能,名为 QueryUsers,产生了请求一次远程调用,若此时发生了一次网络的EOF的错误。

则我们可能会看到类似这样的链:

QueryUsersError(user id) -> rpc.Error(server address) -> io.EOF

-> 表示指向错误发生原因

可以称这样的链为错误链

上层调用在处理错误时可能会有期望有不同的处理。

譬如: 上层可能会对RPC失败进行降级处理、EOF时进行重试等等。

这个时候我们就可能需要遍历链上的每个错误做:

  • 进行类型断言,查看该错误链是否存在rpc.Error,类似以上所讨论的类型断言的错误处理
  • 值的比较查看该错误链是否存在io.EOF或是其他错误,类似以上所讨论的值的比较


像这样的需求,草案中定义了以下内容,方便实现上述两个所需的功能。

type Wrapper interface {
    Unwrap() error
}
// Is 指出当前的错误链是否存在目标错误。
func Is(err, target error) bool

// As 检查当前错误链上是否存在目标类型。若存在则ok为true,e为类型转换后的结果。若不存在则ok为false,e为空值
func As(type E)(err error) (e E, ok bool)

这里,我们注意到其中As方法为范型的写法。 而在exp/errors库中,As方法的签名如下:

func As(err error, target interface{}) bool

类似于json的Unmarshal方法。

错误格式化

草案定义了一个错误类型可选的接口Formatter。

package errors

type Formatter interface {
    Format(p Printer) (next error)
}

当错误实现formatter接口时,格式化包(一般是fmt包,也有可能是类似的包比如本地化包golang.org/x/text/messa)在格式化error时将会调用该error的Format方法,并传入当前的Printer对象。

type Printer interface {
    // Print appends args to the message output.
    Print(args ...interface{})

    // Printf writes a formatted string.
    Printf(format string, args ...interface{})

    // Detail reports whether error detail is requested.
    // After the first call to Detail, all text written to the Printer
    // is formatted as additional detail, or ignored when
    // detail has not been requested.
    // If Detail returns false, the caller can avoid printing the detail at all.
    Detail() bool
}

Printer接口如上定义。 草案给了一个示例的实现代码

func (e *WriteError) Format(p errors.Printer) (next error) {
    p.Printf("write %s database", e.Database)
    if p.Detail() {
        p.Printf("more detail here")
    }
    return e.Err
}

func (e *WriteError) Error() string { return fmt.Sprint(e) }

其中Detail方法指出当前格式化的时候是否应该打印详细内容。

在使用%+v时候,Detail会返回true,这样就会打印出详细的内容。

若使用如%v时,Detail会返回false。

注意,这里草案中没有提到Format函数的返回值next,这个返回值实际是草案希望打印错误时打印整个错误链,next实际应当是当前错误指向的下一层error的值。

即,若实现了Wrapper接口,next理论上应为Unwrap()函数调用返回的值。

在草案中,给出的错误链的格式示例如下所示:

write users database:
    more detail here
    /path/to/database.go:111
--- call myserver.Method:
    /path/to/grpc.go:222
--- dial myserver:3333:
    /path/to/net/dial.go:333
--- open /etc/resolv.conf:
    /path/to/os/open.go:444
--- permission denied

errors包在草案中没有提到的部分

Opaque 隐藏错误链

// Opaque returns an error with the same error formatting as err
// but that does not match err and cannot be unwrapped.
func Opaque(err error) error

Opaque函数隐藏了错误链,意味者调用Unwrap方法不会得到下层的错误,然而却保留了的错误格式化详情。

Caller 获取调用者信息

// Caller returns a Frame that describes a frame on the caller's stack.
// The argument skip is the number of frames to skip over.
// Caller(0) returns the frame for the caller of Caller.
func Caller(skip int) Frame

// A Frame contains part of a call stack.
type Frame struct

// Format prints the stack as error detail.
// It should be called from an error's Format implementation,
// before printing any other error detail.
func (f Frame) Format(p Printer)

Caller 是帮助用户在实现Formatter接口时可以方便的获取调用栈信息的方法。

缺失的wrapper实现

草案和新的实验库都没有谈及通用的wrapper实现。

这里我们给出一个参考实现:

import (
    "golang.org/x/exp/errors"
)

type Wrapper struct {
    next   error
    msg    string
    frames *errors.Frame
}

// Prepare 准备创建wrapper
func Prepare() *builder {
    return &builder{}
}

type builder struct {
    w Wrapper
}

// WithCaller 保存调用者信息
func (e *builder) WithCaller() *builder {
    var frames = errors.Caller(2)
    e.w.frames = &frames
    return e
}

// WithCause 设置下层错误
func (e *builder) WithCause(err error) *builder {
    e.w.next = err
    return e
}
// WithMsg 设置错误消息
func (e *builder) WithMsg(msg string) *builder {
    e.w.msg = msg
    return e
}

// Build 创建Wrapper
func (e *builder) Build() *Wrapper {
    return &e.w
}

// Error 返回错误消息
func (e *Wrapper) Error() string {
    return e.msg
}

// FormatError 实现了Formatter接口
func (e *Wrapper) FormatError(p errors.Printer) (next error) {
    p.Print(e.msg)
    if p.Detail() && e.frames != nil {
        e.frames.Format(p)
    }
    return e.next
}

// Unwrap 返回错误链的下一层
func (e *Wrapper) Unwrap() error {
    return e.next
}

// New 根据msg 和cause 创建Wrapper
func New(msg string, cause error) *Wrapper {
    return Prepare().WithCaller().WithCause(cause).WithMsg(msg).Build()
}

来世篇

Go2草案

错误处理 Error Handling

错误处理的问题描述

错误处理的具体方案描述

错误处理的相关草案提出了handle check的新语法。

直接看草案中给的例子:

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

使用check handle语法之后:

func CopyFile(src, dst string) error {
	handle err {
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}

	r := check os.Open(src)
	defer r.Close()

	w := check os.Create(dst)
	handle err {
		w.Close()
		os.Remove(dst) // (only if a check fails)
	}

	check io.Copy(w, r)
	check w.Close()
	return nil
}

使用了check handle的代码清晰、简洁、明了不少。

根据草案,它的使用方法为:

1. 使用handle err,表示接下来的err将如何处理。

2. 使用check关键字作用在一个函数调用上,拦截掉err的返回值。 这个返回的错误值将使用上面的handle块去处理。

错误值的语义化 Error Values

草案链接

这里就不再做展开,参考我们上述讨论过的exp/errors包


结束语

以上我们讨论了,早些时候go的错误问题、现在主流在使用的error处理方式,以及即将成为标准库的error处理方式,和未来在go2的草案中描述的问题。

希望上述的总结能对读者有所帮助。

编辑于 2019-01-29

文章被以下专栏收录