Golang中Error处理方案

/ go / 没有评论 / 1750浏览

Golang中Error处理方案

error 与 try catch 对比

有人说if err写得繁琐,而我却觉得更直接/直白,好处就是维护性更好,更多的代码不代表不易维护。当然我也没觉得java那样的try catch有什么问题,不过现在我更喜欢if err。我们来对比一下两者的优劣,从写法和排错方法两个方面。

写法

在编写被调用函数方两者基本差别不大,一边需要写err 一边需要throw。在调用方try的方式可以一次捕获多个throws,而err就需要繁琐的写多次(刚刚说了,多次也有好处:直白)。当然try也有坏处,try会导致代码缩进一层。

排错

try看问题必须看错误堆栈,而我认为堆栈中80%的字符都是无用的。在另一篇[文章](Upspin 中的错误处理 —— 来自 Rob Pike )中也有此观点

用户和实现者

让错误对终端用户有用并且保持简洁,与让错误对实现者而言信息丰富并且可供分析,二者之间存在矛盾。常常是实现者胜出,而错误变得过于冗余,达到了包含堆栈跟踪或者其他淹没式细节的程度。

Upspin 的错误试图让用户和实现者都满意。报告的错误适度简洁,关注于用户应该觉得有用的信息。但它们还包含内部详细信息,例如方法实现者可以获取诊断信息,但又不会把用户淹没。在实践中,我们发现这种权衡工作良好。

相反,类似于堆栈跟踪的错误在这两方面上都更糟糕。用户没有上下文可以理解堆栈跟踪,而如果服务端错误被传给客户端的话,那么看到堆栈跟踪的实现者会很难看到应该出现的信息。这就是为什么 Upspin 错误嵌套相当于操作跟踪(显示系统元素路径),而不是执行跟踪(显示代码执行路径)。这个区别至关重要。

对于那些堆栈跟踪可能会有用的场景,我们允许使用 “debug” 标签来构建 errors 包,这将会允许打印堆栈跟踪。这个工作良好,但是值得注意的是,我们几乎从不使用这个功能。相反,errors 包的默认行为已经够好了,避免了堆栈跟踪的开销和不堪入目。

那么没有堆栈,怎么确定错误呢?

到底需不需要堆栈

为了解释不需要堆栈信息也能定位问题,我先提出一个概念:**一个函数应该是完善的,确定的。**为了更好理解, 先看例子。

// 糟糕的代码
// 入参模糊, 当发生错误, 必须依赖上层参数才能找到错误.
func Insert(data interface{}) (err error) {
    err = mysql.Insert(data)
    if err != nil {
        return
    }
    return
}
// 正确的代码
// 确定性: 入参确定
func InsertUser(user *User) (err error) {
    // 完善性: 函数应该自己能够判断一个入参是否非法并立刻返回错误
    if user == nil {
        err = ...
        return
    }
    if user.UserName == "" {
        err = ...
        return
    }
    err = mysql.Insert(user)
    if err != nil {
        return
    }
    return
}

完善的: 自己管理自己的入参,函数应该自己能够判断一个入参是否非法并立刻返回错误,不要等到最后报一个模棱两可的错。

确定的: 一个函数做一件事情,并且入参和出参是确定的,这样无论多少个调用者来调用这个函数,都不会因为入参不同而需要上层调用者信息来排查问题。

可以看到,排错的简易程度和有误错误堆栈并没有必然关系,和编写代码的人却有必然关系。

error的问题

官方error包的缺陷也很明显,由于没有错误堆栈,如果只有一个string提示的话,当程序比较复杂,层级较多时,我们很难定位到是那一层出错了。

那么又矛盾了,那还是需要错误堆栈的呀?

程序猿至少需要看到两个信息才能排错:

如何实现错误路线呢? 可以参考gopkg中errors包的做法: Warp. 至于代码位置可以用runtime.Caller获取.

下面来具体看看代码这么写.

优化error

定义

type Error struct {
    code  uint32
    msg   string
    where string
}

func (e *Error) Error() string {
    return fmt.Sprintf("code = %d ; msg = %s", e.code, e.msg)
}

至于为什么需要code, 稍后再讲.

主要方法有两个

func New(code int, msg string) *Error {
    // 获取代码位置, 代码就不贴了, 不是重点.
    where := caller(1)
    return &{code:code, msg:msg, where: where}
}

主要看Wrap方法 Wrap为错误添加一个附加信息, 一般填写操作.

func Wrap(err error, msg string) *Error {
    var where string
    var code int
    switch t:=err.(type){
        case *Error:
        // 继承where和code
        where = t.where
        code = t.code
        // 拼接上之前的错误
        msg = msg + ":: " + t.msg
        default:
        where =  caller(1)
    }

    return &{code: code, msg: msg, where: where}
}

使用

我们模拟几种报错

// 子数据层
func InsertA(id int) (err error) {
    if id == 0 {
        err = error.NewCoder(400, "id不能为空")
        return
    }
    return
}

// 数据层
func InsertA(aid int, bid int) (err error) {
    err = InsertA(aid)
    if err != nil {
        // 使用wrap方法为错误添加一个附加信息
        err = error.Wrap(err, "InsertA")
        return
    }

    return
}

// controll层
func Main() {
    err = InsertAB(a, b)
    println(err)
}

最终的错误会是这样:

{
  code: 400,
  msg: "InsertA:: id不能为空",
  where: "/root/go/src/error/error.go:9"
}

如何使用上面的信息(如何打印, 如何返回给用户)就由你来定了. 其中msg也反映了调用的路径. 这对于开发者有用, 但是用户并不关心, 如果要返回给用户看的话, 最好处理下msg: 很简单, split(":: "), 并获取最后一段即可. 比如这里, 用户只需要看到 "id不能为空".

code

我使用code来"匹配错误". 在其他包中, 匹配错误的方式有很多:

if err == io.NotExist{
}

if error.Is(err, io.NotExist){
}

当然他们都没有错,只是使用场景不同,在更偏向业务的层面,我推荐使用code去匹配错误, 原因如下

最后

当然这个方案并不完美,不过希望能给你带来一点灵感。

还有更多文章提供的解决方案: