go error 错误处理实践总结

二维码
| Feb 15, 2021 | 原创

go 官方提供的 error 处理已经不是被人第一次吐槽了, 虽然在 go1.13 做过一些升级加持,但是用起来还是比较不尽人意,所以开发者大多都会自行封装或者使用第三方的封装包,如 github.com/pkg/errors

github.com/pkg/errors 包解决了开发人员面临最大的一个痛点:错误堆栈信息。这个库封装的 error 使用 %+v 标记便可以输出原始的错误堆栈信息:

fmt.printf("%+v", err)

我们在自己的业务中也使用了这个包,不过在实际开发中,我们发觉有时候不仅仅是希望有错误堆栈信息,我们同时希望错误的msg信息是比较友好的产品语义输出,并且仍然希望同时保留原始的错误信息方便排查问题。

如,我们有一个数据库查询错误输出使用上述的 error 包进行封装:

var id = 10
var m model.User
err := app.Db().First(&m, "id = ?", id).Error
if err != nil {
  return errors.Wrap(err, fmt.Sprintf("%d user not exist", id))
}

上述代码我们希望从原始数据库找到 id=10 的用户,如果没有找到或者数据库连接失败会触发上述的err 错误。这是后我们使用 pkg/errors 库封装了原始的错误信息进行错误返回,并且自定义了错误 msg。但当我们使用 err.Error() 方法获取错误的消息内容时,我们会发觉,该错误信息不光是包括自定的错误信息,还包括原始的数据库错误信息:

10 user not exist : Err Not Found

但我们希望是自定义的错误用于直接产品UI输出,原始错误和栈信息用于服务本身错误追踪,虽然 pkg/errors 库提供了一个 err.Cause() 方法可以输出原始的错误内容,但缺少一个仅输出自定义消息的方法,我们看看这个库的 Error() 实现便一目了然:

func (w *withMessage) Error() string { return w.msg + ": " + w.cause.Error() }

上述代码我们可以看到,该函数返回的字符串由自定义 w.msg 内容和原始错误信息 w.cause.Error() 两部分组成。为了解决这个问题,我们尝试 fork 了这个库:github.com/ntt360/errors,然后对该部分进行重写,我们希望 Error() 方法只返回自定义的 msg 错误,原始错误由该库提供的 Cause() 函数进行返回。

func (w *withMessage) Error() string { return w.msg }

这样做的好处是一个 err 同时可以满足产品逻辑的错误设定,又能同时保留原始的错误堆栈方便追查问题。默认的 Error() 方法移除了原始错误的输出,也避免了开发者的疏忽导致服务器程序的敏感信息直接被泄露。

关于自定义错误

虽然我们基于上述的 pkg/errors 二次封装的库能够满足大部分使用场景,但还是一些特殊场景需要自定义错误。如我们在设计用户登录接口时,可能面临如下不同的用户登录错误状态:

状态码:0  操作成功
状态码:1  用户名或密码错误
状态码:2  用户没有权限
状态码:3  用户不存在
状态码:4  用户未登录

抛开restful不谈,如果我们希望前端针对这些不同的错误状态提示用户不同的操作行为,那么我们就需要返回不同的错误状态码。这时候我们需要设计一个返回不同状态码的自定义错误类型。

package err

type RspError struct {
	code int
	msg  string
	err error
}

func New(code int, msg string) error {
	return &RspError{
		code: code,
		msg:  msg,
	}
}

func (r *RspError) Error() string {
	return r.msg
}

func (r RspError) CodeNum() int {
	return r.code
}

基于上面的 RspError 我们自定义一些错误类型:

const (
	codeNoErr = iota
	codeUserPwdErr
	codeUserNotPermission
)

var (
	NoErr = New(codeNoErr, "success")
	UserPwdErr = New(codeUserPwdErr, "the username or password error")
)

当我们在做具体的接口登录校验逻辑时直接返回这些自定义错误即可:

if notValid {
  return UserPwdErr
}

我们可以使用 go 提供的错误类型监测函数: IsAs 类操作这些错误:

if errors.Is(e, err.UserPwdErr) {
  // 用户密码错误
} else if errors.Is(e, err.UserNoPermission) {
  // 用户没有权限
}

使用 As 转换错误数据类型:

var re *err.RspError
if errors.As(re, &re) {
  fmt.Printf("err code is : %d, err msg: %s", re.CodeNum(), re.Error())
}