在使用 Go 开发的后台服务中,对于错误处理,一直以来都有多种不同的方案,本文探讨并提出一种从服务内到服务外的错误传递、返回和回溯的完整方案,还请读者们一起讨论。

问题提出

在后台开发中,针对错误处理,有三个维度的问题需要解决:

  • 函数内部的错误处理: 这指的是一个函数在执行过程中遇到各种错误时的错误处理。这是一个语言级的问题
  • 函数/模块的错误信息返回: 一个函数在操作错误之后,要怎么将这个错误信息优雅地返回,方便调用方(也要优雅地)处理。这也是一个语言级的问题
  • 服务/系统的错误信息返回: 微服务/系统在处理失败时,如何返回一个友好的错误信息,依然是需要让调用方优雅地理解和处理。这是一个服务级的问题,适用于任何语言

针对这三个维度的问题,笔者准备写三篇文章一一说明。首先本文就是第一篇:函数内部的错误处理

高级语言的错误处理机制

一个面向过程的函数,在不同的处理过程中需要 handle 不同的错误信息;一个面向对象的函数,针对一个操作所返回的不同类型的错误,有可能需要进行不同的处理。此外,在遇到错误时,也可以使用断言的方式,快速中止函数流程,大大提高代码的可读性。

在许多高级语言中都提供了 try ... catch 的语法,函数内部可以通过这种方案,实现一个统一的错误处理逻辑。而即便是 C 这种 “中级语言”,虽然没有 try catch,但是程序员也可以使用宏定义配合 goto LABEL 的方式,来实现某种程度上的错误断言和处理。

Go 的错误断言

在 Go 的情况就比较尴尬了。我们先来看断言,我们的目的是,仅使用一行代码就能够检查错误并终止当前函数。由于没有 throw、没有宏,如果要实现一行断言,有两种方法。

方法一:单行 if + return

第一种是把 if 的错误判断写在一行内,比如:

    if err != nil { return err }

这种方法有值得商榷的点:

  • 虽然符合 Go 的代码规范,但是在实操中,if 语句中的花括号不换行这一点还是非常有争议的,并且笔者在实际代码中也很少见到过
  • 代码不够直观,大致浏览代码的时候,断言代码不显眼,而且在花括号中除了 return 之外也没法别的了,原因是 Go 的规范中强烈不建议使用 ; 来分隔多条语句(if 条件判断除外)

因此,笔者强烈不建议这么做。

方法二:panic + recover

第二种方法是借用 panic 函数,结合 recover 来实现,如以下代码所示:

func SomeProcess() (err error)
    defer func() {
        if e := recover(); e != nil {
            err = e.(error)
        }
    }()

    assert := func(cond bool, e error) {
        if !cond {
            panic(e)
        }
    }

    // ...

    err = DoSomething()
    assert(err == nil, fmt.Errorf("DoSomething() error: %w", err))

    // ...
}

这种方法好不好呢?我们要分情况看

首先,panic 的设计原意,是在当程序或协程遇到严重错误,完全无法继续运行下去的时候,才会调用(_比如段错误、共享资源竞争错误_)。这相当于 Linux 中 FATAL 级别的错误日志,用这种机制,仅仅用来进行普通的错误处理(ERROR 级别),杀鸡用牛刀了。

其次,panic 调用本身,相比于普通的业务逻辑的系统开销是比较大的。而错误处理这种事情,可能是常态化逻辑,频繁的 panic - recover 操作,也会大大降低系统的吞吐。

但是话虽这么说,使用 panic 来断言的方案,虽然在业务逻辑中基本上不用,但在测试场景下则是非常常见的。测试嘛,用牛刀有何不可?稍微大一点的系统开销也没啥问题。对于 Go 来说,非常热门的单元测试框架 goconvey 就是使用 panic 机制来实现单元测试中的断言,用的人都说好。

结论建议

综上,在 Go 中,对于业务代码,笔者是不建议采用断言的,遇到错误的时候建议还是老老实实采用这种格式:

if err := DoSomething(); err != nil {
    // ...
}

而在单测代码中,则完全可以大大方方地采用类似于 goconvey 之类基于 panic 机制的断言。

Go 的 try ... catch

众所周知,Go(当前版本 1.17)是没有 try ... catch 的,而且从官方的态度而言,短时间内也没有明确的计划。但是程序员有这个需求呀。这里也催生出了集中解决方案

defer 函数

笔者采用的方法,是将需要返回的 err 变量在函数内部全局化,然后结合 defer 统一处理:

func SomeProcess() (err error) { // <-- 注意,err 变量必须在这里有定义
    defer func() {
        if err == nil {
            return
        }

        // 这下面的逻辑,就当作 catch 作用了
        if errors.Is(err, somepkg.ErrRecordNotExist) {
            err = nil        // 这里是举一个例子,有可能捕获到某些错误,对于该函数而言不算错误,因此 err = nil
        } else if errors.Like(err, somepkg.ErrConnectionClosed) {
            // ...            // 或者是说遇到连接断开的操作时,可能需要做一些重连操作之类的;甚至乎还可以在这里重连成功之后,重新拉起一次请求
        } else {
            // ...
        }
    }()

    // ...

    if err = DoSomething(); err != nil {
        return
    }

    // ...
}

这种方案要特别注意变量作用域问题。

比如前面的 if err = DoSomething(); err != nil { 行,如果我们将 err = ... 改为 err := ...,那么这一行中的 err 变量和函数最前面定义的 (err error) 不是同一个变量,因此即便在此处发生了错误,但是在 defer 函数中无法捕获到 err 变量了。

try ... catch 方面,笔者其实没有特别好的方法来模拟,即便是上面的方法也有一个很让人头疼的问题:defer 写法导致错误处理前置,而正常逻辑后置了。

命名的错误处理函数

要解决前文提及的 defer 写法导致错误处理前置的问题,有第一种解决方法是比较常规的,那就是将 defer 后面的匿名函数改成一个命名函数,抽象出一个专门的错误处理函数。这个时候我们可以将上一段函数进行这样的改造:

func SomeProcess() error {
    // ...

    if err = DoSomething(); err != nil {
        return unifiedError(err)
    }

    // ...
}

func unifiedError(err error) error {
    if errors.Is(err, somepkg.ErrRecordNotExist) {
        return nil        // 有可能捕获到某些错误,对于该函数而言不算错误,因此 err = nil

    } else if errors.Like(err, somepkg.ErrConnectionClosed) {
        return fmt.Errorf("handle XXX error: %w", err)

    // ...

    } else {
        return err
    }
}

这样就舒服一些了,至少逻辑前置,错误处理后置。不过读者肯定会发现——这不是什么语言都可以这么搞嘛?诚然,这怎么看都不像是对 try ... catch 的模拟,但这种方法依然很推荐,特别是错误处理代码很长的时候。

goto LABEL

理论上,我们可以通过 goto 语句,将错误处理后置,比如:

func SomeProcess() error {
    // ...

    if err = DoSomething(); err != nil {
        goto ERR
    }

    // ...

    return nil

ERR:
    // ...
}

C 语言比较熟悉的同学可能会觉得很亲切,因为在 Linux 内核中就有大量这种写法。这种写法呢,笔者其实说不出具体不好的地方,但是这个看起来很像 C 的写法,其实限制很多,反而比起 C 而言,需要注意的地方也更多:

  • 仅限于 ANSI-C 的话,要求所有的局部变量都需要前置声明,这就避免了因为变量作用域而带来的同名变量覆盖;但 Go 需要注意这个问题。
  • C 支持宏定义,配合前文可以实现断言,使得错误处理语句可以做得比较优雅;而 Go 不支持
  • Go 经常有很多匿名函数,匿名函数无法 goto 到外层函数的标签,这也限制了 goto 的使用

不过笔者倒也不是不支持使用 goto,只是觉得在现有机制下,还是使用前两种模式比较符合 Go 的习惯。


下一篇文章是《如何在 Go 中优雅的处理和返回错误(2)——函数/模块的错误信息返回》,笔者详细整理了 Go 1.13 之后的 error wrapping 功能,敬请期待~~


本文章采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。

本文最早发布于云+社区,也是amc的博客。

原作者: amc,欢迎转载,但请注明出处。

原文标题:《如何在 Go 中优雅的处理和返回错误(1)——函数内部的错误处理》

发布日期:2021-09-30

原文链接:https://segmentfault.com/a/1190000040762538


amc
924 声望223 粉丝

电子和互联网深耕多年,拥有丰富的嵌入式和服务器开发经验。现负责腾讯心悦俱乐部后台开发