Go中的"err != nil"好烦?教你怎么优雅地处理error

Posted by jintang on 2021-04-28

前言

相信对于习惯了使用try catch方式捕获exception方式的php、java等程序员来说,go的error处理可以说是非常困扰:

  • 每调一个方法就要判断一次err != nil。
  • 如果底层函数出错,只在上层打印错误信息,会丢失调用栈,不知道最开始的错误发生在哪里。
  • 如果通过字符串追加的方式,加入调用栈信息,那么错误类型会丢失,无法像 if err == io.EOF 这样判断是什么错误。

go语言的设计理念是简单高效,error处理其实也有他的设计哲学,只有理解了才能使用好它。

Errors VS Exception

通常我们理解的错误,是应用程序的控制和处理能力之外,而且是在程序运行时不允许出现的状况,否则程序无法运行了。错误不是异常,也不应该试图去处理的。
对于异常,是程序在运行中出现不符合预期的情况及与正常流程不同的状况。

基本上引入了Exception机制的语言,都会使用try catch的异常处理模型,可以很好地把”正常的代码”与”发生异常之后的处理代码”隔离开来,catch到的异常要求我们进行相应的处理,如果你能处理就处理,不能或不处理就继续往上抛。

1
2
3
4
5
6
7
try {
// coding...
throw new MyException("这是我的异常");
// coding...
} catch (MyException $e) { // 想要处理的异常就使用catch来捕获,再做相应的处理
// 处理代码
}

对于错误与异常,各种编程语言对两者的理解都不太一样,在业界都有许多相关的争论。在我看来,哪种方式都有其设计思想,不管是哪种方式都没有完美,只有你有没有用好它。

PHP中的Exception

PHP中的异常都是需要程序员手动抛出的,php本身是不会主动抛出异常。在PHP中任何自身的错误或者是非正常的代码都会当做错误对待,并不会以异常的形式抛出。

1
2
3
4
5
try {
$a = 1/0;
} catch (Exception $e) {
echo $e->getMessage();
}

但是其实我们还是希望php会把一些不可预见的异常抛出,好让我们可以捕获到然后做相应的处理。所以这一点一直被诟病:我能手动抛出的异常,证明我事先知道此处有异常,属于预料之中的,不是不符合预期的情况。

另外,当程序出现一些异常的情况,如果处理不了,你会使用throw往上抛出,可是php在声明函数的时候,无法声明该函数会发生什么异常,所以别人使用的时候,也不知道应该怎么捕获(catch里面应该使用哪个异常类)。

这个问题在php语言层级上是解决不了的,但是IDE通过代码注解可以在一定程度上弥补一下。

ide异常提示

上图中的world方法通过注解@throws声明了一个异常,在hello方法中调用world()方法时,ide给予提示”Unhandler \IQueue\exceptions\MessageQueueException”。只不过这种提示不是约束,面对偷懒的程序员,根本不去添加注解,也没有办法。

不过对于未捕获的异常和出现错误时,php也有兜底的方案:register_shutdown_function,set_error_handler,set_exception_handler三个函数,但感觉只是弥补异常机制设计上的不足而已,具体的用法这里就不展开介绍了。

Java中的Exception

在Java语言中,引入了 checked exception,方法的所有者必须申明,调用者必须处理。

1
2
3
4
5
class A {
public void callA() throws Exception { // 函数声明时,声明抛出具体的异常
// coding...
}
}

也就是说,如果你声明一个方法时,可以在方法后面声明你需要抛出的异常,如果调用者不尝试try catch就会报错。

ide异常提示

在异常和错误的理解上,php与java是不同的,java会把很多和预期不一致的行为当做异常来进行捕获,比如 1/0 这样的除0错误,在java中看来就是一个java.lang.ArithmeticException。可以看出,java的异常机制确实有比php要更加完善一些。

是不是就完美了呢?其实也不是:Java中的异常又显得太过于复杂,它们从良性到灾难性都有使用,异常的严重性由函数的调用者来区分。

C语言的异常处理

在C语言中,没有异常处理机制,但是习惯用法是:返回值为 int 表示,通常会定义一些宏来表示值的意义,包括成功、失败或者其他状态。
PHP源码中的c函数
以上是PHP源码中,standard/file.c 中其中一个方法,用于拷贝文件,其返回值的使用FAILURE的宏来表示失败,SUCCESS表示成功。

这种类型的函数,需要判断返回值为失败时进行相应的处理。这种思路其实是简单的,但是C语言函数只能返回单值,在一些既需要返回值,也需要知道是否有异常的情况就支持不了。

Go语言的Errors

Go 的处理异常逻辑是不引入 exception,支持多参数返回,所以你很容易的在函数签名中带上实现了 error interface 的对象,交由调用者来判定。
如果一个函数返回了 (value, error),你不能对这个 value 做任何假设,必须先判定 error并且要求立即处理。当真正需要终止时,Go使用painc机制来制造fatal error。

Go的错误模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

func dosomething() (int, error) {
return 1, nil
}

func main() {
value, err := dosomething();
// 立即处理,立即处理,立即处理,重要事情说三遍
if err != nil {
// handle
return
}
fmt.println(value);
}

Go的error是什么?

下面是Go的error interface的定义:

1
2
3
4
5
// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
Error() string
}

从定义看出,Go中的error是一个简单的接口,接口定义了一个Error()方法,其返回值为string。

当你想要的到一个error对象,你可以使用errors.New()的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
// because the former will succeed if err wraps an *fs.PathError.
package errors

// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
func New(text string) error {
return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
s string
}

errors.New()方法中,先是实例化了一个errorString的结构体,并且取其指针地址返回。为什么是返回指针,不是返回结构体对象本身呢? 就是为了避免定义两个同样错误内容的不同自定义错误做==判定的时候会相等,但是指针的话,就是比较两个不一样的地址,就不会相等。

可以看出,Go中的error其实就是一个普通值(Errors are values)。

Go Errors的错误类型

对于Exception机制,它是通过在try中抛出异常,然后使用catch()来捕获后进行相应的处理。相对地,Go Errors是通过返回特定的错误,并且通过错误判断后进行处理。

下面介绍一下几种常见的错误类型。

预定义错误(Sentinel Error)

预定义的特定错误,我们叫为 sentinel error,这个名字来源于计算机编程中使用一个特定值来表示不可能进行进一步处理的做法。所以对于 Go,我们使用特定的值来表示错误。

当我们想要预定义错误,我们可以这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

var (
ZJTenantCodeMissing = errors.New("zhijian: missing tenant code.");
ZJUserIDMissing = errors.New("zhijian: missing user id.");
// ...
)

type User struct {
UserID string
UserName string
}

func getUser(UserID string) (User, error) {
if UserID == '' {
return ZJUserIDMissing
}
// coding...
}

func main() {
user, err := getUser('123')
if err == ZJUserIDMissing {
// 处理user id missing
}
}

通过预定义错误,我们可以在想要的场景下返回,同时因为是自己定义的错误,当然也知道怎么处理。这种预定义错误的方式,在官方或开源第三方包也随处可见,比如常用的io包中的io.EOF,用于判断读文件是否已经结束:

1
var EOF = errors.New("EOF")

但是其实预定义值是最不灵活的错误处理策略,因为调用方必须使用 == 将结果与预先声明的值进行比较,错误判断依赖的是error.Error()输出的值。当您想要提供更多的上下文时,这就出现了一个问题,因为返回一个不同的错误将破坏相等性检查。

甚至是一些有意义的 fmt.Errorf 携带一些上下文,也会破坏调用者的 == ,调用者将被迫查看 error.Error() 方法的输出,以查看它是否与特定的字符串匹配。

所以,Go错误处理的一个原则是:不依赖检查 error.Error 的输出,不应该依赖检测 error.Error 的输出,这个输出的字符串仅用于记录日志、输出到 stdout 中。

另外,由于预定义错误会在你的公开函数中返回,所以预定义错误也是公开的,需要有对应的文档,这样会增加你API的表面积。我们都有这样一个共识:暴露的API表面积越大,越容易出bug,API应该是在满足功能的前提下,尽可能收敛。

结论:应该避免使用预定义错误。尽管标准库中有一些使用预定义错误的做法,但还是不应该去模仿这样的做法。

自定义错误(Error Types)

Go的error其实是一个接口,那么只要实现这个接口即可得到Error Type。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package schedule

import "fmt"

type SchedulePlanNodeError struct {
NodeID string
ItemId string
Msg string
}

func (e *SchedulePlanNodeError) Error() string {
return fmt.Sprintf("%s - %s: %s", e.NodeID, e.ItemID, e.Msg)
}

func (e *SchedulePlanNodeError) test() error {
return &SchedulePlanNodeError("进度计划节点ID", "检查项ID", "实际完成时间不能为空")
}

当我们得到某个方法返回的error时,可以使用类型断言(err.(type))放在switch语句中进行判断,错误的判断是依赖错误的类型,断言转换成这个类型来获取更多的上下文信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

func main() {
err := test()
switch err := err.(type) { // err.(type) 接口类型断言
case nil:
// nothing to do
case *SchedulePlanNodeError:
fmt.Println("进度计划节点ID:", err.NodeID, "报错")
default:
// ...
}
}

与预定义错误相比,自定义错误的一大改进是它们能够包装底层错误以提供更多上下文。调用者要使用类型断言和类型 switch,就要让自定义的 error 变为 public。

结论:避免使用自定义错误,虽然错误类型比 预定义错误 更好,因为它们可以捕获关于出错的更多上下文,但是仍然有与预定义错误一样,同样将错误的类型暴露给了外部,例如标准库中的 o.PathError

不透明错误(Opaque errors)

前面了两种方式都因为容易暴露API的公共部分,那到底应该怎么样才能减少暴露,但有能够完成错误判断呢?

下面看一个这样的例子:

1
2
3
4
5
6
7
8
9
10
package schedule

type myerror interface {
wrong() bool
}

func IsMyError(err error) bool {
m, ok := err.(myerror)
return ok && m.wrong()
}

在schedule包中,通过定义一个不公开的myerror接口(注意是小写字母开头),然后暴露一个IsMyError()方法,用于判断参数err是否为myerror错误。
这样一来,schedule包中提供的方法都可以返回myerror这样的错误类型,调用者可以凭IsMyError()方法来判断。

这样的设计的关键是,可以在不导入定义错误的包或者实际上不了解 err 的底层类型的情况下实现——我们只对它的行为感兴趣。我们称之为不透明的错误处理,这种方式最大的特点就是只返回错误,暴露错误判定接口,不返回类型,这样可以减少 API 的暴露。

结论:不透明错误是最灵活的错误处理策略,通过断言错误实现了特定的行为,而不是断言错误是特定的类型或值。

Go错误处理的最佳实践

介绍完几种错误的类型,我们再来看看Go的错误如何处理才是最佳实践呢?回归到文章标题,那些烦人的err != nil判断如何才能有效消除。

缩进流

为了让无错误的正常流程代码成为一条直线,我们约定把错误处理的代码缩进, 推荐做法:

1
2
3
4
5
6
7
// 正常流程代码

file, err := os.Open(path)
if err != nil {
// 缩进的错误处理代码
}
// 正常流程代码

下面代码缩进方式,会使正常流程代码不在一条直线上,不推荐做法:

1
2
3
4
5
6
7
// 正常流程代码

file, err := os.Open(path)
if err == nil {
// 正常流程代码
}
// 缩进的错误处理代码

减少 err != nil 代码的方法

多余的err != nil判断

看看下面这段代码:

1
2
3
4
5
6
7
func CheckUser(user User) error {
err := checkRight(user)
if err != nil {
return err
}
return nil
}

上面代码的问题在于:其实authenticate()方法是返回一个err,当成功时候返回的是nil,不成功返回err,那么其实这里的 err!= nil的判断是多余的

1
2
3
func CheckUserRight(user User) error {
return checkRight(user)
}

在写go代码时候,如果一股脑地遵循go的错误模型,拿到一个err就马上判断然后处理,其实细细想想,有些场景有些代码是没必要的,都是自己多写了的。

暂存err

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 统计文件行数
func countLine(r io.Reader) (int, error) {
var (
br = bufio.NewReader(r)
lines int
err error
)

for {
// 读取到换行符就说明是一行
_, err = br.ReadString('\n')
lines++
if err != nil {
break
}
}

// 当错误是 EOF 的时候说明文件读取完毕了
if err != io.EOF {
return 0, err
}

return lines, err
}

改进后:

1
2
3
4
5
6
7
8
9
10
11
12
func countLineOpt(r io.Reader) (int, error) {
var (
sc = bufio.NewScanner(r)
lines int
)

for sc.Scan() {
lines++
}

return lines, sc.Err()
}

对比两个函数的处理我们可以发现,countLineOpt使用了scanner的一个Scan方法,里面做了很多处理,包括过程中发生的错误暂存起来,到最后提供一个sc.Err()的方法来返回错误。使用 sc.Scan 之后一个 if err 的判断都没有,极大的简化了代码。

再看看下面一个例子,要求往缓冲区里面写入一段数据,并且以”start#”开头和以”#end”结尾:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func writeBuff(w io.Writer, data []byte) error {

_, err := fmt.Fprint(w, "start#")
if err != nil {
return err
}

for b, _ := range data {
_, err := fmt.Fprint(w, b)
if err != nil {
return err
}
}

_, err = fmt.Fprint(w, "#end")
if err != nil {
return err
}

return nil
}

在writeBuff()函数中,分别在写入前缀字符、写入数据和写入后缀字符时,均做了一次 err!=nil 的判断。再看下改进版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type errWriter struct {
w io.Writer
err error
}

func (ew *errWriter) Write(buf []byte) (int, error) {
if ew.err != nil {
return 0, ew.err
}
var n int
n, ew.err = ew.w.Write(buf)
return n, ew.err
}

func writeBuff2(w io.Writer, data []byte) error {
ew := &errWriter{w: w}
fmt.Fprint(ew, "start#")
for b, _ := range data {
fmt.Fprint(w, b)
}
fmt.Fprint(w, "#end")
return ew.err
}

改进版本中,引入了errWriter结构体,在重写的Write方法中,使用结构体中的err来暂存ew.w.write()时发生的错误,就这样将重复的逻辑进行了封装。所以writeBuff2整个函数中都没有做任何 err!= nil 判断,仅在最后返回errWriter暂存的那个err即可,大大地简化了代码。

Wrap errors

回顾刚刚那个方法:

1
2
3
func CheckUserRight(user User) error {
return checkRight(user)
}

如果 checkRight 返回错误,则 CheckUserRight 会将直接把错误返回给调用方,调用者可能也会这样做,依此类推。在程序的顶部,打印出的错误信息你可能不能理解,因为中途你没有对错误做任何处理,没有导致错误的调用堆栈的堆栈跟踪。

然后你就会使用fmt.Errorf()来添加错误信息:

1
2
3
4
5
6
7
func CheckUserRight(user User) error {
err := checkRight(user)
if err != nil {
return fmt.Errorf("check right err: %v", err)
}
return nil
}

这种方式有个问题,这与前面的sentinel error和type assertions的使用不兼容了,因为添加完”check right err”这段错误信息之后,并且与checkRight返回的err的错误信息合并成一个新的err,破坏了原始错误,通过错误值判断就失效了。

那不用fmt.Errorf()合并错误信息可以吗?我们经常发现类似的代码,在错误处理中,带了两个任务: 记录日志并且再次返回错误。

1
2
3
4
5
6
7
8
func CheckUserRight(user User) error {
err := checkRight(user)
if err != nil {
log.Println("check right err")
return err
}
return nil
}

这样的代码是很糟糕的:

  • 第一:因为记录日志其实跟这个错误处理没有什么关系,只是便于开发者调试,在程序的顶部仍然是得不到调用堆栈的跟踪信息。
  • 第二:Go 错误处理的一个原则是”you should only handle errors once”,意味着发生错误时,你能处理就直接处理(处理完后应该是一个正常的代码,最后应该返回nil),不能处理就往上抛,直接返回错误。
  • 第三:万一只顾打了日志,却忘记返回err,后序的程序因为没有错误返回视为成功,导致程序出现逻辑问题。

总结关于错误日志处理的规则:

  1. 错误要被日志记录
  2. 应用程序处理错误,保证100%的完整性
  3. 之后不再报告当前错误

我们基于上面提到暂存err的思路,我们可以把调用链路上的错误打包一起返回,具体可以使用pkg/errors这个第三包来实现。

pkg/errors

推荐第三方包:github.com/pkg/errors,主要使用三个方法:

  • Wrap: Wrap返回一个错误,该错误在调用Wrap的点处带有堆栈跟踪的err注释,并提供了消息。 如果err为nil,则Wrap返回nil。
  • Cause: 返回错误的根本原因。递归拿到最里层的 error, 用于和error常量比较或类型断言成自定义 struct type.
  • WithMessage: WithMessage用新消息注释err。 如果err为nil,则WithMessage返回nil。
1
func Wrap(err error, message string) error
1
func Cause(err error) error
1
func WithMessage(err error, message string) error

优雅地处理错误的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package main

import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"github.com/pkg/errors"
)

func ReadFile(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, errors.Wrap(err, "open failed")
}
defer f.Close()
buf, err := ioutil.ReadAll(f)
if err != nil {
return nil, errors.Wrap(err, "read failed")
}
return buf, nil
}

func ReadConfig() ([]byte, error) {
home := os.Getenv("HOME")
config, err := ReadFile(filepath.Join(home, ".settings.xml"))
return config, errors.WithMessage(err, "cound not read config")
}

func main() {
_, err := ReadConfig()
if err != nil {
fmt.Printf("original err:%T %v\n", errors.Cause(err), errors.Cause(err))
fmt.Printf("stack trace:\n %+v\n",err) // %+v 可以在打印的时候打印完整的堆栈信息
os.Exit(1)
}
}

执行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
original err:*os.PathError open /Users/jaych/.settings.xml: no such file or directory
stack trace:
open /Users/jaych/.settings.xml: no such file or directory
open failed
main.ReadFile
/Users/jaych/go/error_handle/wrap_errors/main.go:15
main.ReadConfig
/Users/jaych/go/error_handle/wrap_errors/main.go:27
main.main
/Users/jaych/go/error_handle/wrap_errors/main.go:32
runtime.main
/Users/jaych/app/go/src/runtime/proc.go:204
runtime.goexit
/Users/jaych/app/go/src/runtime/asm_amd64.s:1374
cound not read config

从代码上也非常简洁,处理的非常优雅,最终不管是错误信息还是堆栈信息,还可以添加自定义的上下文,同时也完全满足上面提出的关于错误日志处理的规则

go 1.13的error新特性

go1.13为 errors 和 fmt 标准库包引入了新特性,以简化处理包含其他错误的错误。其中最重要的是: 包含另一个错误的 error 可以实现返回底层错误的 Unwrap 方法。如果 e1.Unwrap() 返回 e2,那么我们说 e1 包装 e2,您可以展开 e1 以获得 e2。
按照此约定,我们可以为上面的 QueryError 类型指定一个 Unwrap 方法,该方法返回其包含的错误:

1
func (e *QueryError) Unwrap() error { reutrn e.Err }

go1.13 errors 包包含两个用于检查错误的新函数:IsAs。具体用法参考官方文档^0^。

总结

Go语言没有引入Exception机制,是通过返回错误,判断错误类型,然后进行错误处理。

Go语言的错误是一个接口,我们可以利用errors.New()来得到sentinel error,sentinel error是依赖错误值(==等值比较)来判断错误类型
也可以通过实现error接口来自定义错误类型,我们称之为Error Type,自定义错误可以通过type assertion来断言成具体的自定义错误,从而获取更多的上下文信息。
为了减少公共API表面积,只暴露错误判定接口,不返回类型,我们称之为不透明错误。

为了让正常流程代码在一条直线上,推荐使用缩进流方式,仅在 if err != nil {} 里面编写错误处理的代码。

我们可以利用暂存err的方式,减少 err != nil 代码,比如在循环中封装好处理方法(sc.Scan()),把过程中的错误暂存到指定的结构体中的err中,最后提供Err()方法把错误一起返回。基于这种暂存err的思路,我们可以在发成错误的时候使用wrap error的方式把错误打包后返回,可以借助第三方包pkg/errors中的Wrap、Cause和WithMessage等方法。

Go错误处理的原则:

  1. 当发生错误情况时,你应该制作一件事情:你能够处理就处理,不能处理应该向上返回。不应该导出打日志同时又把错误向上返回。
  2. 处理完后,错误不再是错误,返回的err应该是nil。
  3. 选择 wrap error 是只有 applications 可以选择应用的策略。具有最高可重用性的包只能返回根错误值。此机制与 Go 标准库中使用的相同(kit 库的 sql.ErrNoRows)。

感谢看官,理解尚浅,如有理解不对,欢迎指正多多交流。