虽然Go有一个简单的错误处理模型,但有时并不像人们想象的那样简单。
本文旨在解释什么是Go中的错误处理,以及评估如何选择处理错误的不同策略。
Go中的错误处理是什么样的?
在详细介绍不同的错误处理策略之前,我们先来看看Go中的错误是什么样的。
错误类型是一个内置的Go接口类型,定义如下。
// 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
}
换句话说,所有实现了Error()方法的东西都被认为是一个错误类型。
值得一提的是,很多时候我们所关心的是检查一个错误是否为nil。然而,在其他情况下,知道具体的错误是有意义的,也许是为了处理不同的错误情况。对于这些情况,我们如何创建这些错误以便于处理是很重要的。
现在不再赘述,让我们来看看Go中处理错误的不同策略。
哨兵错误
一个哨兵错误是一个命名的错误值。通常这些错误是导出的标识符,因为我们的想法是,使用你的API的用户可以将错误与这个给定值进行比较。一个例子是:
var CustomError = errors.New("this is fine")
现在我们已经定义了我们的哨兵错误,我们将分析处理这些类型错误的两种方法
检查error.Error输出
想象一下,我们正在使用一个特定的方法,在某些情况下可能会返回一个CustomError错误,我们想以不同于其他错误类型的方式来处理。我们可以想到的一个方法是通过使用error.Error()来比较错误字符串的输出。
作为一个例子,让我们假设我们实现了一个函数makeRquest,它利用了一个来自外部imaginary包的函数Request。
func makeRequest(...) error {
// ...
// This method may return the CustomError.
err := imaginary.Request("GET", url, payload)
// We just want to specifically handle the CustomError error. We'll
// ignore the rest of errors in this example.
if err.Error() == imaginary.CustomError.Error() {
// ...
}
}
你会发现,这可以满足我们的目的,所以我们可以认为这很不错。然而,这种类型的错误检查是相当脆弱的,因为如果imaginary包的维护者决定更新CustomError的字符串值,程序就会在所有像这样检查这个错误的地方中断。
所以,检查错误输出永远不应该被用于错误处理。只有用它来将其输出发送到日志文件或只是打印值供用户阅读才有意义。
用error.Is检查错误值
从Go 1.13开始,处理哨兵错误的一个更好的选择是使用标准库中的 errors.Is 函数。该函数现在看起来像下面这样。
func makeRequest(...) error {
// ...
// This method may return the CustomError.
err := apipkg.Request("GET", url, payload)
// Check whether the err value is the same as sentinel CustomError.
if errors.Is(err, imaginary.CustomError) {
// ..
}
}
正如你将在最后一节看到的,由于有了错误包装能力,这种错误处理甚至更有意义。
与检查error.Error()输出相比,这种方法的主要好处是,在这里我们将得到的错误与CustomError值进行比较。它不仅更干净,而且我们的代码自动变得更健壮,因为对CustomError字符串值的任何改变都不会导致我们的代码的破坏性改变。
我们已经看到了两种处理哨兵错误的方法。然而,它的使用有一些我们需要知道的限制。
主要的缺点是,哨兵错误是静态值,所以我们不能向它们添加动态或上下文信息。在有些情况下,除了静态错误本身,我们还想提供一些动态内容。例如,设想我们想返回一个错误,以反映在我们的数据集中找不到一个特定的食物以及它的名字。
var (
ErrFoodNotFound = errors.New("food not found")
foods = make(map[string]food)
)
func FindFood(name string) (food, error) {
f, ok := foods[name]
if !ok {
return food{}, fmt.Errorf("food %v, error: %v", name, ErrFoodNotFound)
}
return f, nil
}
正如你可能已经观察到的,每次执行的误差值都会有所不同,这取决于食物的名称。因此,不可能用前面提到的任何一种策略来比较误差值。
自定义错误类型
自定义错误是我们通过手动实现错误接口而创建的一种类型。一个例子是。
type CustomError struct{}
func (c CustomError) Error() string {
return "this is fine"
}
在Go 1.13之前,用户不得不使用类型断言来检查错误类型,但随着新的错误包的改造,现在我们有了 errors.As 函数来检查错误类型。
// Before go 1.13 we needed to apply type assertion to check the error type.
if _, ok := err.(CustomError); ok {
// ...
}
if errors.As(err, &CustomError{}) {
// ...
}
与哨兵错误相比,自定义错误类型的主要好处是,我们可以使用任何可能帮助用户发现问题的动态信息。
即便如此,代价是我们需要为我们想要实现的每个错误类型定义一个自定义结构。根据我们定义的错误数量,这可能会很快增加相当大的开销。
在Go 1.13引入包装错误功能之前,这种策略被广泛使用,我们将在下一节看到。它将允许我们做与自定义错误相同的事情,但具有更大的灵活性和简单性。
错误包装
当Go 1.13发布时,维护者决定扩展fmt.Errorf方法,以支持一个新的动词%w,它基本上是以类似于不再维护的github.com/pkg/errors包的Wrap方法的方式在引擎下执行错误包装。
正如我们在哨兵错误中看到的那样,拥有一个固定的字符串有时会成为一个问题。我们不能向其添加动态内容,因为它将根据动态值进行修改。
然而,我们可以通过使用一个包装好的错误来解决这个问题。让我们通过包装错误来更新哨兵错误中使用的例子。
var (
ErrFoodNotFound = errors.New("food not found")
foods = make(map[string]food)
)
func FindFood(name string) (food, error) {
f, ok := foods[name]
if !ok {
return food{}, fmt.Errorf("food %v, error: %w", name, ErrFoodNotFound)
}
return f, nil
}
你需要注意发现其中的细微差别。它只是在创建错误时加入了参数%w。这个新的错误会有动态值,但会保留原来的错误ErrFoodNotFound。因此,我们可以用下面的方法来处理包裹的错误。
err := FindFood("bananas")
// Handle the error if it's an ErrFoodNotFound one.
if errors.Is(err, ErrFoodNotFound) {
// ...
}
在这里,我们可以观察到使用 errors.Is 的全部潜力,按顺序解开第一个元素,寻找与第二个参数相匹配的错误。因此,我们可以通过使用%w来增加任意多的包装层,并且仍然能够与errors.Is进行比较。
因此,这种策略不仅在构建方式上更优雅,而且它允许我们以无缝的方式在我们的错误上使用动态值,同时仍然能够确定它所代表的错误。
结论
总结一下主要的收获,我们可以说,在Go中处理错误时,有不同的事情需要考虑到。
首先,如果我们不关心错误是什么,只需检查它的无效性;故事结束。但是,在很多时候,我们需要知道错误是什么,以便处理不同的错误情况。在这种情况下,我们主要应该考虑两种选择。
-
当我们不需要动态值时,哨兵错误。我们可以使用
errors.Is进行比较。 -
当我们需要将哨兵错误与动态信息结合起来时,包裹的错误。