likes
comments
collection
share

go项目开发中关于error实践总结

作者站长头像
站长
· 阅读数 12

使用 go 开发,会经常和 error 打交道,但至今,官方提供的 error 实在是无力吐槽,虽然从 1.13 提供了一个 WrapUnWrap 方式,但是还是无法满足我们日常开发的需求。所以我们不得不对它进行二次封装。今天和大家分享下,在我们实际的项目开发中的一些关于 error 实践经验。

首先来说说实际开发中我们对 error 的需求:

err 需求

  1. 我们希望 error 要带栈信息,方便出错时能定位到代码是哪一行出错;
  2. 原始错误不能直接暴露给前端(比如db错误)等,所以需要支持错误包装功能,包装过的错误用于产品侧显示,原始错误用于系统日志;
  3. 可以定义错误码,用以区归类不同的错误类别,比如参数验证类,系统类错误。

这三个需求是我们在做业务项目常规需求,官方的 error 肯定没法直接使用,所以实际中我们自行封装了一些 error 辅助类,来帮我们满足如上的需求。

其实行业里这种错误包比较知名的:pkg/error,但我们发觉这个包也有一些缺陷,比如它把包装错误和原始错误信息合并在一起输出,而不是分开。这导致上述的第2小点无法满足;并且它提供的栈信息是全链路的,栈的路径会比较多,而在实际操作中我们发觉,我们最迫切需要的也仅仅只是 error 产生的那一行,后续的栈其实并不是那么重要,而且栈的深度如果只有一层,也能减少系统的错误日志量,节省带宽资源。

介于此,我们打算实现自己的 error 错误封装。

err 设计

我们自定义了一个 CodeErr 类型,并实现了 Error() 接口函数,这个错误类型,有三个成员属性,code: 错误码,msg: 包装的错误消息,cause:真正的原始错误。

type CodeErr struct {
   code  int
   msg   string
   cause error
}

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

func (e *CodeErr) Code() int {
    return e.code
}

func (e *CodeErr) Cause() error { return e.cause }

func (e *CodeErr) Unwrap() error { return e.cause }

func (e *CodeErr) Format(s fmt.State, verb rune) {
    switch verb {
        case 'v':
         if s.Flag('+') {
                _, _ = fmt.Fprintf(s, "%+v \n", e.Cause())
                _, _ = io.WriteString(s, e.msg)
                return
            }
   
            fallthrough
        case 's', 'q':
        _, _ = io.WriteString(s, e.Error())
    }
}

注意,通过 Error() 接口返回的错误 msg 是包装的描述信息,而不是原始错误,这是有意设计的。这样设计我们就可以直接很多时候把错误返回给前端。

除此外,我们还继承 Format 接口,重写了 %+v flag,方便我们输出原始的错误日志。即:

默认输出包装过的友好错误,用于直接返回给前端:

// 输出包装错误, 内部调用 Error() 方法
fmt.Println(err) 

使用%+v输出原始错误:

// 输出原始错误,日志上报
fmt.Sprintf("%+v", err)

错误栈

光有上面的带错误码 error 还不够,我们还希望要有栈信息,所以我们还需要封装另一个错误类型。

type withStack struct {
   error
   *stack
}

完整代码请参考:github.com/ntt360/gin/…

我们借鉴了 pkg/error 的栈封装方式,但有所改进,即 %+v 输出的时候,仅会输出原始错误,而不会同时输出包装过的错误。此外栈的默认深度是1,即默认只提供错误触发的所在行(但可全局自定义)。

// 全局设置栈的深度
gin.SetErrStackNum(num int)

此外,我们之所以把栈错误和代码错误分开,主要是考虑代码复用性,可能有时候你单纯想要一个带栈的 error.

错误分类

目前来说,我们把服务的错误主要分类两大类:

  1. 参数类错误
  2. 系统类错误

我们并不是为每一种错误都定义了错误类型,而是把服务的错误分为了常用的两类。参数类错误用于说明接口参数验证类别错误;而系统类错误表示错误由内部产生的,而非来自用户。

就目前而言,我们觉得已经足够,我们并没在把服务端错误再细分,比如:db errorredis error 等等,因为原始的错误已经可以很详细描述这是什么错误,我们目前没有归类的需求,而且不管是哪种错误,都需要我们去定位排查。所以同归为系统错误。

因此,我们定义了两类错误码:

CodeServerErr     = 1 // 服务器错误
CodeParamNotValid = 2 // 参数验证失败

错误函数封装

有了上述基础错误定义以及错误类别,为了在项目中,方便使用,我们还定义了一系列的错误函数来辅助我们使用。

// 带栈的错误
func WithStack(err error) error

// NewCodeErrf 自定义Code码的错误消息
func NewCodeErrf(code int, format string, a ...any) error

func WrapCodeErrf(err error, code int, format string, a ...any) error

func WrapParamErrf(err error, format string, a ...any) error

func WrapSysErrf(err error, format string, a ...any) error

func WrapDefaultSysErr(err error) error

// NewParamErrf 参数类型错误,自定义消息内容,支持格式化内容
func NewParamErrf(format string, a ...any) error

// DefaultSysErr 默认系统错误,即提供默认的错误码,和错误描述
func DefaultSysErr() error

// NewSysErrf 系统类型错误,支持自定义错误格式
func NewSysErrf(format string, a ...any) error

完整代码来自于项目:github.com/ntt360/gin/…,我们将在下节中分享具体的使用场景。

实际使用

下面是我们在实际项目中如何利用上述封装,实际使用场景:

参数类错误

1. 仅修改错误描述

很多时候,服务端需要验证接口请求参数,并返回前端错误。这时候可以:

if len(ctx.Query("size")) == 0 {
    return e.NewParamErrf("size must required")
}

NewParamErrf() 函数用于包装一个友好的错误消息,方便前端展示:

{
  "errno": 2,
  "msg": "size must required",
  "data": null
}

而且在开发控制台,则会输出详细的错误栈信息,方便开发调试:

 <nil> 
size must required
test/app/http/controllers/home.Index
        /Users/xxxx/IdeaProjects/test/app/http/controllers/home/home.go:15

所有的错误封装,都带有栈信息,这方便定位错误。

2. 包装原始错误,返回参数错误

如果已经存在一个既有的错误,我们希望包装错误描述,返回参数错误类型,那么可以使用:

err = e.WrapParamErrf(err, "the param not valid")

同理,前端将会输出:

{
  "errno": 2,
  "msg": "the param not valid",
  "data": null
}

而内部栈错误,和之前类似。

系统类错误

很多时候系统内部会产生一些错误,比如数据库异常,网络超时等等。那么可以使用如下一些函数:

1. 默认系统错误

err = e.DefaultSysErr()

前端输出:

{
  "errno": 1,
  "msg": "server err",
  "data": null
}

而控制台则会输出类似:

 <nil> 
server err
test/app/http/controllers/home.Index
        /Users/xxxxx/IdeaProjects/test/app/http/controllers/home/home.go:14

DefaultSysErr() 返回的是系统默认错误模板,如果存在原始错误,那么可以使用系统类包装函数系列。

2 系统包装错误

err = app.DbR(ctx).Raw("select * from user limit 1").First(&models.User).Error
if err != nil {
    return e.WrapSysErrf(err, "db err")
}

前端将返回:

{
  "errno": 1,
  "msg": "db err",
  "data": null
}

控制台栈信息错误类似,会输出原始的数据库错误。

自定义错误码

有时候,你可能希望既定义错误,也希望修改错误码,那么可以使用:

func WrapCodeErrf(err error, code int, format string, a ...any)

func NewCodeErrf(code int, format string, a ...any) error

这些函数方便你包装错误消息,也同时方便你修改 errno,用法和之前方法类似。

栈错误

如果有时候,你仅仅希望包装一个栈错误,那么你可以单独使用:

e.WithStack(err)

WithStack() 仅对 err 包装一个错误栈,不会提供友好的错误描述,和错误码包装,如果要考虑前端输出,还需要你自己来组织。