细说Go Error

641 阅读10分钟

error包

golang中error类型是一个接口,只有一个Error方法,返回指定的错误内容。

error包内置errorStringstructerrorString实现了Error方法,然后有一个string类型的变量,存储error的内容,最后提供一个New方法返回error

type error interface {
   Error() string
}
func New(text string) error {
   return &errorString{text}
}

type errorString struct {
   s string
}

func (e *errorString) Error() string {
   return e.s
}

注意New方法返回的是一个指针,为什么这里要返回一个errorString类型的指针呢?下面我写了一个例子,执行之后会输出err string1。通过这个例子可以看出,如果error包返回的不是指针的话,假设使用者也定义了一个内容为EOFerror,那么如果和io.EOF做等值判断的话,就会返回true,主要是为了避免调用者使用2个相同内容的error,导致逻辑产生混乱。

package main

import (
   "errors"
   "fmt"
)

type errorString struct {
   text string
}

func (e errorString) Error() string {
   return e.text
}

// New 创建一个自定义错误
func New(s string) error {
   return errorString{text: s}
}

var errorString1 = New("error string1")
var errorString2 = errors.New("error string2")

func main() {
   if errorString1 == New("error string1") {
      fmt.Println("err string1") // 会输出
   }

   if errorString2 == errors.New("error string2") {
      fmt.Println("err string2") // 不会输出
   }
}

使用Error的几种常见类型

Sentinel Error

io包下的EOF错误,就属于Sentinel Error,然后我们在处理io操作的时候,需要和指定的io.EOFerror进行判断,如果是,就代表io流读完了。

var EOF = errors.New("EOF")
if err == io.EOF {
   // 读完了
}

使用哨兵error的缺点:

  • 由于错误信息较少,如果调用者使用fmt.Errorf方法进行包装这个error,增加部分错误内容,那么上层就不能使用==判断是否是这个哨兵error了。
  • 因为定义了哨兵error,此error就需要是公有的,然后需要有文档说明
  • 增加包依赖,因为需要进行==判断,包依赖的增加会增加循环引用的风险

Error Types

Error Type是实现了error接口的自定义类型,例如下面的例子的MyError就记录了出现error的文件名和行号。然后上层需要断言这个error,来获取自定义error中的其他数据。例如标准库中io.PathError

使用Error Type相比哨兵Sentinel Error,主要是可以包装一些特殊的错误信息,然后返回给上层,但是使用的时候还是需要断言,所以还是有包的依赖,耦合还是在的。

type MyError struct {
   s    string
   name string
   line int
}

func (this *MyError) Error() string {
   return fmt.Sprintf("line:%d,name:%s,error msg:%s", this.line, this.name, this.s)
}

func test() error {
   return &MyError{"xxx error", "a.go", 10}
}

func main() {
   err := test()
   switch e := err.(type) {
   case *MyError:
      fmt.Printf("error occur line:%d\n", e.line)
   case nil:
   default:
   }
}

Opaque errors

不透明的错误处理,这种方式最大的特点就是只返回错误,因为我们看不到error的内部。

但是如果想获取到error中的具体信息的话,可以使用下面这种形式。例如标准库net.Error,想知道是否是超时的error,只需要调用Timeout方法就可以了。timeout是一个内置的接口。

//net包的error接口
type Error interface {
   error
   Timeout() bool   // Is the error a timeout?
   Temporary() bool // Is the error temporary?
}

// 实现了net.Error接口
func (e *TimeoutError) Error() string   { return "i/o timeout" }
func (e *TimeoutError) Timeout() bool   { return true }
func (e *TimeoutError) Temporary() bool { return true }

// 内置的timeout接口
type timeout interface {
   Timeout() bool
}

看一下使用,想知道是否是超时的Timeout,就可以调用Timeout方法就可以了。当然这里乍一看也是需要类型断言,这样包也会耦合,但是假设标准库提供了IsTimeout函数,这样在使用的时候就不需要断言error的类型了。

if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
    // 超时逻辑
}
// 假设标准库提供IsTimeout函数
IsTimeout(e error) bool {
    t, ok := e.Err.(timeout)
    return ok && t.Timeout()
}

错误处理的姿势

  • error处理流程问题

看一下下面的案例,二种方式都可以处理error,但是更推荐第一种方式来进行处理,一般正确流程的代码都比较多,这样很多代码都会缩进,毕竟难看,还有一点我建议使用防御式编程,先把错误都全部处理了,然后再处理正常流程。

// 案例一
if err != nil {
   // 错误处理
}
// 业务逻辑...

// 案例二
if err == nil {
   // 业务逻辑...
}
// 错误处理
  • 减少err != nil出现的次数 看一下这个例子,其实可以将b方法改写成c方法,这样就可以减少err!=nil的判断次数了
func a() error {
   return nil
}

func b() error {
   if err := a(); err != nil {
      return err
   }
   return nil
}

func c() error {
  return a()
}
  • bufio.scan使用

看一下这个统计行数的逻辑,count2  使用 sc.Scan  之后一个 if err  的判断都没有,极大的简化了代码,这是因为在 sc.Scan  做了很多处理。后续我们写这种循环读取的逻辑也可以考虑像这样包装之后进行处理,这样外部包调用的时候就会十分简洁

func count(r io.Reader) (int, error) {
    var (
        br    = bufio.NewReader(r)
        lines int
        err   error
    )

    for {
        // 读取到换行符就说明是一行
        _, err = br.ReadString('\n')
        lines++
        if err != nil {
                break
        }
    }

    // 当错误是 EOF 的时候说明文件读取完毕了
    if err != io.EOF {
        return 0, err
    }

    return lines, err
}

func count2(r io.Reader) (int, error) {
    var (
        sc    = bufio.NewScanner(r)
        lines int
    )

    for sc.Scan() {
        lines++
    }

    return lines, sc.Err()
}

error writer

看一下下面这段逻辑比较丑吧,每一步写都需要对err进行处理

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

可以定义一个errWriter得结构体,里面包含errorio.Writer类型的变量,然后有了一个write方法,然后多次调用的时候其实调用的是write方法,然后将err写入到errWritererr中,每次调用都会判断自身的err是否不为nil,不为nil就不需要进行write操作了。这样也将多次判断err!=nil,变成了一处判断。当我们在多次处理同一逻辑的时候也可以进行借鉴。

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
}

Wrap errors(错误包装)

error如果不包装的话,最终到达最上层,也只能获得系统的error信息,例如no such file or dirctory。问题不好定位,需要一层一层的去捋代码,比较浪费时间。但是如果使用fmt.Errof包装error的话会产生一个新的error,这样如果对error进行等值判断的话,逻辑又走不通了,这样就比较尴尬了。

pkg/errors

我们看下Davepkg/errors包,这个包提供了几个方法,通过Wrap方法可以保存error生成的堆栈信息,WithMessage方法就是单纯的包装error,然后我们就可以使用%+v的方式来打印完整的堆栈信息。

看下这个例子,readConfig方法调用readFile方法,readFile方法打开文件失败会Wrap一个error,这个error包含堆栈信息,然后返回。readConfig方法调用WithMessage方法包装ReadFile方法返回的error,在输出的时候,调用的是errors.Cause方法,这个方法可以返回最底层的error类型,也就是根因。然后使用"%+v"输出堆栈信息。

这样就可以通过Wrap来解决包装内容的问题,也可以用Cause来解决哨兵error的问题,可以继续做等值判断。

package main

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

func readFile(name string) ([]byte, error) {
   buf, err := ioutil.ReadFile(name)
   if err != nil {
      return nil, errors.Wrap(err, "readFile open fail")
   }
   return buf, nil
}

func readConfig() ([]byte, error) {
   name := "/home/root/1.txt"
   config, err := readFile(name)
   return config, errors.WithMessage(err, "readConfig could not read config")
}

func main() {
   _, err := readConfig()
   if err != nil {
      fmt.Printf("%T | %v\n", errors.Cause(err), errors.Cause(err))
      fmt.Printf("stack:\n%+v", err)
   }
}

输出结果:

*os.PathError | open /home/root/1.txt: The system cannot find the path specified.
stack:                                                           
open /home/root/1.txt: The system cannot find the path specified.
readFile open fail                                               
main.readFile                                                    
        C:/project/pratice/error/error3.go:12                    
main.readConfig                                                  
        C:/project/pratice/error/error3.go:19                    
main.main                                                        
        C:/project/pratice/error/error3.go:24                    
runtime.main
        C:/Go/src/runtime/proc.go:200
runtime.goexit
        C:/Go/src/runtime/asm_amd64.s:1337
readConfig could not read config

使用pkg/errors的几个技巧

  • 可以直接使用NewErrorf方法直接生成带堆栈的error
  • 调用其他包(自己的应用中)内的函数,能处理就处理,不能处理可以带上上下文,直接向上抛
  • 调用第三方库,可以使用errors.Warperrors.Wrapf来保存堆栈信息
  • 作为基础库,不要生成带堆栈的error,不然业务层也在家堆栈的error,堆栈就会打多遍
  • 在程序的顶部或者工作goruntine顶部,使用%+v的方式把堆栈信息打印出来
  • 错误处理后,不要继续在return error

Go1.13 error新特性

go1.13为error和fmt标准库包引入了新特性。

Unwrap方法

在使用error types自定义错误时,如果实现了Unwrap方法,Unwrap方法返回的是根因,也就是被包装的error,然后我们就可以使用errors.Iserrors.As方法了。

var ErrNotFound = errors.New("NOT FOUND")

type FileNotFoundError struct {
   name string
   err  error
}

func (this *FileNotFoundError) Error() string {
   return fmt.Sprintf("name:%s,err:%v", this.name, this.err)
}

func (this *FileNotFoundError) Unwrap() error {
   return this.err
}

func main() {

   err := &FileNotFoundError{"a.txt", ErrNotFound}
   if errors.Is(err, ErrNotFound) {
      fmt.Println("err not found")
   }

   var fErr *FileNotFoundError
   if errors.As(err, &fErr) {
      fmt.Println("file name:" + fErr.name)
   }

}

输出结果:

err not found
file name:a.txt

Is方法

errors.Is方法就相当于上面提到的if err == ErrNotFound这种等值判断,但是不仅支持==的处理,也支持从target派生上来的error的判断。前面说过如果使用fmt.Errorf包装error后,就不能使用包装后的error和被包装哨兵error==判断了。所以go1.13只要error实现了Unwrap方法,就可以使用Is方法来进行判断,就可以判断从target派生上来的error,可以参考一下我上面的例子。

Is方法的原理就是循环找根因,也就是不停的调用Unwrap方法,直到找到根因,如果找到相同的err,就返回true,没找到就返回false。看源码可以发现error也可以自定义自己的Is方法,可以根据自己的业务进行Is方法的实现,没有定义的话就会走Unwrap逻辑

func Is(err, target error) bool {
   if target == nil {
      return err == target
   }

   isComparable := reflectlite.TypeOf(target).Comparable()
   for {
      if isComparable && err == target {
         return true
      }
      if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
         return true
      }
      // TODO: consider supporing target.Is(err). This would allow
      // user-definable predicates, but also may allow for coping with sloppy
      // APIs, thereby making it easier to get away with them.
      if err = Unwrap(err); err == nil {
         return false
      }
   }
}

As方法

As方法和Is方法类似,就是不断的进行 unwrap 进行比较,只要有一个相同就返回true,如果一直到底都不行就返回 false,成功的话会将err的值复制到target中,这样就不需要 使用error类型的断言了,也不存在包依赖问题了。

func As(err error, target interface{}) bool {
   if target == nil {
      panic("errors: target cannot be nil")
   }
   val := reflectlite.ValueOf(target)
   typ := val.Type()
   if typ.Kind() != reflectlite.Ptr || val.IsNil() {
      panic("errors: target must be a non-nil pointer")
   }
   if e := typ.Elem(); e.Kind() != reflectlite.Interface && !e.Implements(errorType) {
      panic("errors: *target must be interface or implement error")
   }
   targetType := typ.Elem()
   for err != nil {
      if reflectlite.TypeOf(err).AssignableTo(targetType) {
         val.Elem().Set(reflectlite.ValueOf(err))
         return true
      }
      if x, ok := err.(interface{ As(interface{}) bool }); ok && x.As(target) {
         return true
      }
      err = Unwrap(err)
   }
   return false
}

fmt.Errorf

go1.13为fmt.Errorf增加了一个%w的谓词,使用这个谓词,可以将error进行包装。其实底层逻辑就是生成了一个wrapError,然后包装指定的err,然后实现了Unwrap方法,然后返回这个新生成的error

fmt.Errorf("readFile open fail :%w", err)
type wrapError struct {
   msg string
   err error
}

func (e *wrapError) Error() string {
   return e.msg
}

func (e *wrapError) Unwrap() error {
   return e.err
}

看一下这个例子,就是使用fmt.Errorf来进行包装err的。

package main

import (
   "fmt"
   "io/ioutil"
)

func readFile(name string) ([]byte, error) {
   buf, err := ioutil.ReadFile(name)
   if err != nil {
      return nil, fmt.Errorf("readFile open fail :%w", err)
   }
   return buf, nil
}

func readConfig() ([]byte, error) {
   name := "/home/root/1.txt"
   config, err := readFile(name)
   return config, fmt.Errorf("readConfig could not read config : %w", err)
}

func main() {
   _, err := readConfig()
   if err != nil {
      fmt.Printf("%T | %v\n", err, err)
   }
}

输出结果:

类型是*fmt.wrapError,然后输出了包装后的err

*fmt.wrapError | readConfig could not read config : readFile open fail :open /home/root/1.txt: The system cannot find the path specified.

新特性缺点

go1.13新特性没有带来堆栈信息,如果想要有堆栈信息,还是继续使用pkg/errors包吧,但是我们可以使用Is/As方法来进行判断和类型转换,然后pkg/errors也支持go1.13,实现了Unwrap方法

参考

  • 极客时间[Go进阶训练营]