6

如何在 Go 中优雅的处理和返回错误(1)——函数内部的错误处理

 2 years ago
source link: https://segmentfault.com/a/1190000040762538
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

在使用 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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK