YoloKokura

本文译自Go's Error Handling Is a Form of Storytelling,这篇文章讲述了Golang中异常的用法,这种把异常处理当作叙事手法的视角很新奇,文章也提供了一些写error message的范例。但这篇文章不包括Go error的实现原理,可以选择性的阅读。

原文翻译如下:

第一次见到Go代码的时候,我被大量的异常检查吓住了。刚刚从Python转过来,而Python的一个教条就是“请求宽恕比请求许可更容易”(原文:It's easier to ask for forgiveness than permission),所以Go中到处都是这样的代码块,对我而言实在刺眼:

go
if err != nil {
    return err
}

有那么一段时间,我想要逃避这种异常检查,粗暴地省略所有的error,然后直接处理函数的返回值:

go
res, _ := getResultOrFail()
// 这样真的行得通吗?

当然,随着时间推理,我有好几次被这种误用困扰。Go不是Java,而且两者都不是Python。那些语言中的空指针错误(Null Pointer Exception, NPE)让人头疼,但是也成了编程中的一部分。出现NPE时,有关线程会崩溃,但你应用程序的其他部分却还能运行(除非出现了内存泄漏或者死锁,但这就是题外话了)。在Go中,NPE将导致整个应用程序的运行时崩溃。我觉得这种新颖的方式能让你更认真的对待异常处理,也能减少运行时panic的概率。

又及,要是你的Go代码中有80%都包含异常处理,这说明你80%的代码可能会随时崩溃。用典型的契诃夫风格(Chekhov fashion)来看,要是你的代码里存在出错的可能,那么早晚会出错。

因此,我开始慢慢习惯在代码里增加那一小段异常处理。过了一段时间,在自动化工具的帮助下,我不再那么在意这种机制了。然而,当我写下:

go
if err != nil {
    return err
}

我并没有很好的改善我的代码。我开始意识到我需要检查错误日志,然后我得到了类似这样的结果:

ERROR: not found

到底是什么没被找到呢?这段信息来自哪里?这时我突然明白,Go的异常处理如此独特,很大一部分在于它给了开发者一个叙事的机会。要是你简单地把异常返回给调用者,你基本上相当于没有返回。在某个时间,这个异常会塞满整个调用栈,直到有人决定来处理它,比如把它记录在log文件里。不论是谁要去检查日志(很可能是你本人),他都会被这种语焉不详的错误信息惹毛。

把故事讲好的技巧在于给尽可能给异常增加有意义的上下文信息。在Go中,增加异常的上下文意味着当你遇到异常时,把你正在做的事情增加到异常的message中。Go中的异常类型是一个简单的接口(interface),它暴露了一个返回字符串的Error()方法。因此,实际上所有的异常都等同于字符串(当然你乐意的话也可以让它变得更复杂)。

除开直接返回异常,我们可以用fmt包中的Errorf方法来扩展异常信息。这个方法接受一个格式化字符串,并用这个字符串来生成一个新的异常。你传递给这个格式化字符串的参数不必是异常类型,但你最好传递异常:

go
res, err := getResult(id)
if err != nil {
    return nil, fmt.Errorf("obtaining result for id %s: %w", id, err)
}

fmt.Errorf使你能够在格式化字符串中使用%w,被创造的新异常将会包裹(wrap)源异常(内部会指向这个源异常)。如果你以后想要检查这个异常是否和另一个知名异常相同,这么做就会很实用:

go
if errors.Is(err, sql.ErrNoRows) {
    // do something
    // 即使sql.ErrNoRows被多次包括,这个判断也能正常执行
}

关键在于好好写message

(标题原文Crafting a good message is key,这里总让我联想到La La Land的那一句A bit of madness is key😂)

异常信息应该易于拼接。调用链上游的人很可能包裹这个异常,并在前面插入他们自己的上下文信息。因此,你的信息最好足够简洁,而且描述的是异常发生时代码在执行什么工作。别用这样的词:failed, cannot, won't等等。要让日志的读者清楚地明白,当这个异常发生时,有些事情没被正常执行。下面是个不错的例子:

connecting to the DB

调用链上游的人可能会这么包裹它:

fetching order status: connecting to the DB

也许还会进一步包裹:

tracking parcel location: fetching order status: connecting to the DB

上面这样的简单信息能让读者清楚哪里出错了:当一个用户在网站上查看他的包裹位置时,系统试图连接到数据库,但是连接失败了。这笔下面这种信息清楚多了:

could not track location: unable to fetch order status: DB connection failed

甚至更糟糕:

error while tracking location: error while fetch order status: DB connection failed

这里还有一些知名代码库里的糟糕的异常消息例子

在你的代码里,异常上下文也在叙事

多年以前我还是个新手开发者,我喜欢在代码里面到处放注释,特别是那种容易出现异常,或者打乱预期逻辑的地方。把这种注释写成异常信息上下文会让它们更有用,毕竟它们本质上就是异常信息的上下文:

go
jobID, err := store.PollNextJob()
if err != nil {
	return nil, fmt.Errorf("polling for next job: %w", err)
}

owner, err := store.FindOwnerByJobID(jobID)
if err != nil {
	return nil, fmt.Errorf("fetching job owner for job %s: %w", jobID err)
}

j := jobs.New(jobID, owner)
res, err := j.Start()
if err != nil {
	return nil, fmt.Errorf("starting job %s: %w", jobID err)
}

// etc ...

在我看来,这不光能简化未来的debug,还能增加冗长代码的可读性。

Tags: