Go Error 处理最佳实践

270次阅读  |  发布于2年以前

错误处理一直以一是编程必需要面对的问题,错误处理如果做的好的话,代码的稳定性会很好。不同的语言有不同的出现处理的方式。Go 语言也一样,在本篇文章中,我们来讨论一下 Go 语言的错误处理方式。

一、Error vs Exception

1.1 Error

错误是程序中可能出现的问题,比如连接数据库失败,连接网络失败等,在程序设计中,错误处理是业务的一部分。

Go 内建一个 error 接口类型作为 go 的错误标准处理

http://golang.org/pkg/builtin/#error

// 接口定义
type error interface {
   Error() string
}

http://golang.org/src/pkg/errors/errors.go

// 实现
func New(text string) error {
   return &errorString{text}
}

type errorString struct {
   s string
}

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

1.2 Exception

异常是指在不该出现问题的地方出现问题,是预料之外的,比如空指针引用,下标越界,向空 map 添加键值等

1.3 panic

对于真正意外的情况,那些表示不可恢复的程序错误,不可恢复才使用 panic。对于其他的错误情况,我们应该是期望使用 error 来进行判定

go 源代码很多地方写 panic, 但是工程实践业务代码不要主动写 panic,理论上 panic 只存在于 server 启动阶段,比如 config 文件解析失败,端口监听失败等等,所有业务逻辑禁止主动 panic,所有异步的 goroutine 都要用 recover 去兜底处理。

1.4 总结

二、Go 处理错误的三种方式

2.1 经典 Go 逻辑

直观的返回 error

// ZooTour struct
type ZooTour interface {
    Enter() error
    VisitPanda(panda *Panda) error
    Leave() error
}

// 分步处理,每个步骤可以针对具体返回结果进行处理
func Tour(t ZooTour1, panda *Panda) error {
    if err := t.Enter(); err != nil {
        return errors.WithMessage(err, "Enter failed.")
    }
    if err := t.VisitPanda(); err != nil {
        return errors.WithMessage(err, "VisitPanda failed.")
    }
    // ...

    return nil
}

2.2 屏蔽过程中的 error 的处理

将 error 保存到对象内部,处理逻辑交给每个方法,本质上仍是顺序执行。标准库的bufiodatabase/sql包中的Rows等都是这样实现的,有兴趣可以去看下源码

type ZooTour interface {
    Enter() error
    VisitPanda(panda *Panda) error
    Leave() error
    Err() error
}

func Tour(t ZooTour, panda *Panda) error {

    t.Enter()
    t.VisitPanda(panda)
    t.Leave()

    // 集中编写业务逻辑代码,最后统一处理error
    if err := t.Err(); err != nil {
        return errors.WithMessage(err, "ZooTour failed")
    }
    return nil
}

2.3 利用函数式编程延迟运行

分离关注点 - 遍历访问用数据结构定义运行顺序,根据场景选择,如顺序、逆序、二叉树树遍历等。运行逻辑将代码的控制流逻辑抽离,灵活调整。kubernetes 中的 visitor 对此就有很多种扩展方式,分离了数据和行为,有兴趣可以去扩展阅读

type Walker interface {
    Next MyFunc
}
type SliceWalker struct {
    index int
    funs []MyFunc
}

func NewEnterFunc() MyFunc {
    return func(t ZooTour) error {
        return t.Enter()
    }
}

func BreakOnError(t ZooTour, walker Walker) error {
    for {
        f := walker.Next()
        if f == nil {
            break
        }
        if err := f(t); err := nil {
          // 遇到错误break或者continue继续执行
      }
    }
}

2.4 三种方式对比

上面这三个例子,是 Go 项目处理错误使用频率最高的三种方式,也可以应用在 error 以外的处理逻辑。

三、分层下的 Error Handling

3.1 一个常见的三层调用

// controller
if err := mode.ParamCheck(param); err != nil {
    log.Errorf("param=%+v", param)
    return errs.ErrInvalidParam
}

return mode.ListTestName("")

// service
_, err := dao.GetTestName(ctx, settleId)
    if err != nil {
    log.Errorf("GetTestName failed. err: %v", err)
    return errs.ErrDatabase
}

// dao
if err != nil {
    log.Errorf("GetTestDao failed. uery: %s error(%v)", sql, err)
}

3.2 问题总结

3.3 Wrap erros

Go 相关的错误处理方法很多,但大多为过渡方案,这里就不一一分析了(类似 github.com/juju/errors 库,有兴趣可以了解)。这里我以 github.com/pkg/errors 为例,这个也是官方 Proposal 的重点参考对象。

  1. 错误要被日志记录。
  2. 应用程序处理错误,保证 100%完整性。
  3. 之后不再报告当前错误(错误只被处理一次)。

github.com/pkg/errors 包主要包含以下几个方法,如果我们要新生成一个错误,可以使用 New 函数,生成的错误,自带调用堆栈信息。如果有一个现成的 error ,我们需要对他进行再次包装处理,这时候有三个函数可以选择(WithMessage/WithStack/Wrapf)。其次,如果需要对源错误类型进行自定义判断可以使用 Cause,可以获得最根本的错误原因。

// 新生成一个错误, 带堆栈信息
func New(message string) error

// 只附加新的信息
func WithMessage(err error, message string) error

// 只附加调用堆栈信息
func WithStack(err error) error

// 同时附加堆栈和信息
func Wrapf(err error, format string, args ...interface{}) error

// 获得最根本的错误原因
func Cause(err error) error

以常见的一个三层架构为例:

    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, errors.Wrapf(ierror.ErrNotFound, "query:%s", query)
        }
        return nil, errors.Wrapf(ierror.ErrDatabase,
            "query: %s error(%v)", query, err)
    }
    bills, err := a.Dao.GetName(ctx, param)
    if err != nil {
        return result, errors.WithMessage(err, "GetName failed")
    }
// 请求响应组装
func (Format) Handle(next ihttp.MiddleFunc) ihttp.MiddleFunc {
    return func(ctx context.Context, req *http.Request, rsp *ihttp.Response) error {
        format := &format{Time: time.Now().Unix()}
        err := next(ctx, req, rsp)
        format.Data = rsp.Data
        if err != nil {
            format.Code, format.Msg = errCodes(ctx, err)
        }
        rsp.Data = format
        return nil
    }
}

// 获取错误码
func errCodes(ctx context.Context, err error) (int, string) {
    if err != nil {
        log.CtxErrorf(ctx, "error: [%+v]", err)
    }
    var myError = new(erro.IError)
    if errors.As(err, &myError) {
        return myError.Code, myError.Msg
    }

    return code.ServerError, i18n.CodeMessage(code.ServerError)
}
_, err := os.Open(path)
if err != nil {
   return errors.Wrapf(err, "Open failed. [%s]", path)
}

最终效果样例:

3.4 关键点总结

  1. MyError 作为全局 error 的底层实现,保存具体的错误码和错误信息;
  2. MyError 向上返回错误时,第一次先用 Wrap 初始化堆栈,后续用 WithMessage 增加堆栈信息;
  3. 要判断 error 是否为指定的错误时,可以使用 errors.Cause 获取 root error,再进行和 sentinel error 判定;
  4. github.com/pkg/errors 和标准库的 error 完全兼容,可以先替换、后续改造历史遗留的代码;
  5. 打印 error 的堆栈需要用%+v,而原来的%v 依旧为普通字符串方法;同时也要注意日志采集工具是否支持多行匹配;
  6. log error 级别的打印栈,warn 和 info 可不打印堆栈;
  7. 可结合统一错误码使用:https://google-cloud.gitbook.io/api-design-guide/errors

四、errgroup 集中错误处理

官方的 ErrGroup 非常简单,其实就是解决小型多任务并发任务。基本用法 golang.org/x/sync/errgroup 包下定义了一个 Group struct,它就是我们要介绍的 ErrGroup 并发原语,底层也是基于 WaitGroup 实现的。在使用 ErrGroup 时,我们要用到三个方法,分别是 WithContext、Go 和 Wait。

4.1 背景

通常,在写业务代码性能优化时经常将一个通用的父任务拆成几个小任务并发执行。此时需要将一个大的任务拆成几个小任务并发执行,来提高QPS,我们需要再业务代码里嵌入以下逻辑,但这种方式存在问题:

1 . 每个请求都开启 goroutinue,会有一定的性能开销。

4.2 errgroup函数签名

type Group
    func WithContext(ctx context.Context) (*Group, context.Context)
    func (g *Group) Go(f func() error)
    func (g *Group) Wait() error

整个包就一个 Group 结构体

4.3 使用案例

注意这里有一个坑,在后面的代码中不要把 ctx 当做父 context 又传给下游,因为 errgroup 取消了,这个 context 就没用了,会导致下游复用的时候出错

func TestErrgroup() {
   eg, ctx := errgroup.WithContext(context.Background())
   for i := 0; i < 100; i++ {
      i := i
      eg.Go(func() error {
         time.Sleep(2 * time.Second)
         select {
         case <-ctx.Done():
            fmt.Println("Canceled:", i)
            return nil
         default:
            fmt.Println("End:", i)
            return nil
         }})}
   if err := eg.Wait(); err != nil {
      log.Fatal(err)
   }
}

4.4 errgroup拓展包

[B 站拓展包]

type Group struct {
   err     error
   wg      sync.WaitGroup
   errOnce sync.Once

   workerOnce sync.Once
   ch         chan func(ctx context.Context) error
   chs        []func(ctx context.Context) error

   ctx    context.Context
   cancel func()
}

func WithContext(ctx context.Context) *Group {
   return &Group{ctx: ctx}
}
func (g *Group) Go(f func(ctx context.Context) error) {
   g.wg.Add(1)
   if g.ch != nil {
      select {
      case g.ch <- f:
      default:
         g.chs = append(g.chs, f)
      }
      return
   }
   go g.do(f)
}
func (g *Group) GOMAXPROCS(n int) {
   if n <= 0 {
      panic("errgroup: GOMAXPROCS must great than 0")
   }
   g.workerOnce.Do(func() {
      g.ch = make(chan func(context.Context) error, n)
      for i := 0; i < n; i++ {
         go func() {
            for f := range g.ch {
               g.do(f)
            }
         }()
      }
   })
}

整个流程梳理下来其实就是启动一个固定数量的并发池消费任务,Go 函数其实是向管道中发送任务的生产者,这个设计中有意思的是他的协程生命周期的控制,他的控制方式是每发送一个任务都进行 WaitGroup 加一,在最后结束时的 wait 函数中进行等待,等待所有的请求都处理完才会关闭管道,返出错误。

tips:

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8