Go Errors | 不仅仅检查错误,更要优雅地处理它们

2020年12月9日 250点热度 0人点赞 0条评论

这篇文章摘自DAVE在日本东京举行的GoCon春季会议上的演讲。

图片

Errors仅仅就是返回值

我花了很多时间思考在Go程序中处理错误的最佳方法。我真的希望有一个单一的方法来处理错误,我们可以教所有Go程序员死记硬背,就像我们可能教数学,或字母表一样。

然而,我得出的结论是,处理错误没有单一的方法。相反,我认为Go的错误处理可以分为三个核心策略,下文揭晓。

Sentinel errors

错误处理的第一类是我所说的Sentinel errors(哨兵错误)。

if err == ErrSomething { … }

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

示例包括类似值io.EOF或低级错误(如syscall包中的常量)syscall.ENOENT

甚至存在前哨错误,表示未发生错误,例如go/build.NoGoError, and path/filepath.SkipDir from path/filepath.Walk.

使用哨兵值是最不灵活的错误处理策略,因为调用方必须使用相等运算符将结果与预先声明的值进行比较。当您想提供更多上下文时,这会带来一个问题,因为返回不同的错误将破坏相等性检查。

甚至fmt.Errorf在向错误中添加上下文等含义也将使调用方的相等性测试失败。相反,调用方将在输出被迫查看errorError方法,看它是否一个特定的字符串。

切勿检查error.Error的输出

顺便说一句,我认为代码不应该检查错误或者错误方法。Error接口上的 Error 方法是为人而不是代码而设计的。该字符串的内容属于日志文件,或显示在屏幕上。你不应该试图通过检查来改变程序的行为。

我知道有时候这是不可能的,正如有人在twitter上指出的,这个建议不适用于编写测试。更重要的是,在我看来,您应该尽量避免比较一个错误的字符串。

Sentinel errors 成为公共 API 的一部分

如果您的公共函数或方法返回特定值的错误,则该值必须是公共的,并且必须记录在案。但同时会增加API的表面积。

如果您的API定义了一个返回特定错误的接口,则该接口的所有实现都将被限制为仅返回该错误,即使它们可以提供更具描述性的错误。

我们看到了io.Reader、io.Copy的实现,他们是通过返回io.EOF信号以不再向调用者发出信号,但这不是error

Sentinel errors 在两个软件包之间创建了依赖关系

到目前为止,sentinel errors最糟糕的问题是它们在两个包之间创建了源代码依赖关系。例如,检查错误是否等于io.EOF,您的代码必须导入io包。

这个特定的例子听起来并不那么糟糕,因为它非常常见,但是想象一下,当项目中的许多包导出错误值时,存在耦合,项目中的其他包必须导入这些错误值才能检查特定的错误条件。

我曾在一个大型项目中玩弄过这种模式,我可以告诉你,不良设计的幽灵——以导入循环的形式——从未远离我们的脑海。

结论:避免哨兵错误

因此,我的建议是避免在编写的代码中使用Sentinel errors。在某些情况下,标准库中会使用它们,但这不是您应该模仿的模式。

如果有人要求您从程序包中导出错误值,则应该礼貌地拒绝,而建议其他方法,例如我将在下面讨论的方法。

Error 类型

错误类型是我要讨论的Go错误处理的第二种形式。

if err, ok := err.(SomeType); ok { … }

错误类型是您创建的实现错误接口的类型。在此示例中,MyError类型定义了FileLine,以及一条解释发生了什么的消息字段Msg

type MyError struct {
        Msg string
        File string
        Line int
}

func (e *MyError) Error() string {
        return fmt.Sprintf("%s:%d: %s”, e.File, e.Line, e.Msg)
}

return &MyError{"
Something happened", “server.go", 42}

因为MyError error是类型,所以调用者可以使用类型断言从错误中提取额外的上下文。

err := something()
switch err := err.(type) {
case nil:
        // call succeeded, nothing to do
case *MyError:
        fmt.Println(“error occurred on line:”, err.Line)
default:
// unknown error
}

错误类型相对于错误值的一大改进是它们包装基本错误以提供更多上下文的能力。

一个很好的例子就是这种os.PathError类型,它在尝试执行的操作以及试图使用文件中携带了潜在错误。

// PathError records an error and the operation
// and file path that caused it.
type PathError struct {
        Op   string
        Path string
        Err  error // the cause
}

func (e *PathError) Error() string

Error 类型的问题

调用者可以使用类型断言或类型开关,但是error类型必须公开。

如果您的代码实现了一个接口,该接口的约定需要特定的错误类型,那么该接口的所有实现者都需要依赖于定义错误类型的包。

这种对包类型的深入了解会导致与调用方的强耦合,从而导致API变得不可用。

结论:避免 Error 类型

尽管Error类型比哨兵错误值更好,但是由于Error类型可以捕获有关错误原因的更多上下文,因此错误类型具有许多错误值问题。

所以我的建议还是避免error类型,或者至少避免使它们成为公共API的一部分。

Opaque errors

现在我们来讨论第三类错误处理。在我看来,这是最灵活的错误处理机制,因为它要求代码和调用者之间的耦合最少。

我将这种风格称为不透明错误处理,因为虽然您知道发生了错误,但您没有能力看到错误的内部。作为调用者,关于操作的结果,您所知道的就是它起作用了,或者没有起作用。

这就是不透明错误处理的全部功能–只需返回错误而不假设其内容。如果采用这种方式,那么错误处理作为调试辅助工具会变得更加有用。

import “github.com/quux/bar”

func fn() error {
        x, err := bar.Foo()
        if err != nil {
                return err
        }
        // use x
}

例如,Foo函数不保证在出现错误的情况下会返回什么。Foo的作者现在可以自由地用额外的上下文来注释通过它传递的错误,而不会破坏它与调用者的约定。

断言行为 error,而不是类型

在少数情况下,这种二进制错误处理方法是不够的。

例如,与进程外的世界进行交互(如网络活动),需要调用方调查错误的性质,以确定重试该操作是否合理。

在这种情况下,我们可以断言错误实现了特定的行为,而不是断言错误是特定的类型或值。考虑这个例子:

type temporary interface {
        Temporary() bool
}

// IsTemporary returns true if err is temporary.
func IsTemporary(err error) bool {
        te, ok := err.(temporary)
        return ok && te.Temporary()
}

我们可以将任何错误传递给istemorary,以确定是否可以重试该错误。

如果错误没有实现临时接口;也就是说,它没有临时方法,那么错误不是临时的。

如果错误确实实现了Temporary,那么如果Temporary返回true,那么调用者也许可以重试该操作。

这里的关键是,这个逻辑可以在不导入定义错误的包或者实际上不了解 error 的底层类型的情况下实现——我们只对它的行为感兴趣。

不要只是检查错误,请妥善处理

这让我想到第二条格言,我想谈谈;不要只是检查错误,优雅地处理它们。你能对下面的代码提出一些问题吗?

func AuthenticateRequest(r *Request) error {
        err := authenticate(r.User)
        if err != nil {
                return err
        }
        return nil
}

一个明智的建议是使用如下代码进行替换:

return authenticate(r.User)

但这是每个人在代码审查中都应该注意的简单问题。更根本的问题是,这段代码的问题是我无法分辨最初的错误来自何处。

如果authenticate返回错误,则AuthenticateRequest会将错误返回给调用方,调用者可能也会这样做,依此类推。在程序的顶部,程序的主体将把错误打印到屏幕或日志文件中,打印出来的只是:No such file or directory.

图片

没有生成错误的文件和行的信息。没有导致错误的调用堆栈的堆栈跟踪。这段代码的作者将被迫进行长时间的代码分割,以发现是哪个代码路径触发了文件未找到错误。

go编程语言建议您使用fmt.Errorf

func AuthenticateRequest(r *Request) error {
        err := authenticate(r.User)
        if err != nil {
                return fmt.Errorf("authenticate failed: %v", err)
        }
        return nil
}

但是正如我们前面看到的,这种模式与sentinel错误值或类型断言的使用不兼容,因为将错误值转换为字符串,将其与另一个字符串合并,然后将其转换回具有正式错误破坏相等并破坏原始错误中的任何上下文。

为 error 添加上下文

我想建议一种方法来为错误添加上下文,为此,我将介绍一个简单的包。代码github.com/pkg/errors. 错误包有两个主要功能:

// Wrap annotates cause with a message.
func Wrap(cause error, message string) error

第一个函数是Wrap,它接受一个错误和一个消息并生成一个新的错误。

 // Cause unwraps an annotated error.
func Cause(err error) error

第二个函数是Cause,它获取一个可能已被包装的错误,并将其展开以恢复原始错误。

使用这两个函数,我们现在可以包裹任何错误,并在需要检查时恢复底层错误。看下面这个将文件内容读入内存的函数的示例。

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
}

我们将使用这个函数编写一个函数来读取配置文件,然后从main调用它。

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

func main() {
        _, err := ReadConfig()
        if err != nil {
                fmt.Println(err)
                os.Exit(1)
        }
}

如果ReadConfig代码路径失败,因为我们使用了errors。我们在 K&D 样式中得到了一个包裹良好的错误。

could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory

因为errors.Wrap包裹了堆栈错误信息,理论上我们可以拿到详细堆栈错误信息,方法很简单,我们只需要把fmt.Println替换为errors.Print

func main() {
        _, err := ReadConfig()
        if err != nil {
                errors.Print(err)
                os.Exit(1)
        }
}

我们就可以得到如下的错误信息:

readfile.go:27: could not read config
readfile.go:14: open failed
open /Users/dfc/.settings.xml: no such file or directory

第一行来自ReadConfig,第二行来自操作系统打开ReadFile的一部分,其余部分来自os包本身,它不包含错误位置信息。

现在我们引入了wrap错误以生成堆栈的概念,现在我们需要讨论相反的情况,即unwrapping它们。这是errors.Cause功能。

// IsTemporary returns true if err is temporary.
func IsTemporary(err error) bool {
        te, ok := errors.Cause(err).(temporary)
        return ok && te.Temporary()
}

当您需要使用特定的错误类型进行恢复时,应该首先使用errors.Cause来获取特定的错误类型。

仅仅处理一次错误

最后,我想说的是,你应该只处理一次错误。处理错误意味着检查错误值并做出决定。

func Write(w io.Writer, buf []byte) {
        w.Write(buf)
}

如果你没有对错误进行判断处理,你就忽略了这个错误。正如我们在这里看到的,来自w.Write的错误正在被丢弃。但是对一个错误做出多次判断也是有问题的。

func Write(w io.Writer, buf []byte) error {
        _, err := w.Write(buf)
        if err != nil {
                // annotated error goes to log file
                log.Println("unable to write:", err)

                // unannotated error returned to caller
                return err
        }
        return nil
}

在本例中,如果在写入过程中发生错误,则会将一行写入日志文件,记录发生错误的文件和行,并且错误也会返回给调用者,调用者可能会记录并返回,一直返回到程序的最上层。因此,在日志文件中有一堆重复的行,但是在程序的顶部,您会得到没有任何上下文的原始错误。

func Write(w io.Write, buf []byte) error {
        _, err := w.Write(buf)
        return errors.Wrap(err, "write failed")
}

通过使用errors包,您可以向错误值添加上下文,这种方式既可以由人也可以由机器检查。

总结

总之,error包是公共API的一部分,要像对待公共API的任何其他部分一样对待它们。

为了获得最大的灵活性,我建议您尽量将所有错误视为不透明的。在您不能这样做的情况下,断言行为错误,而不是操作类型或返回值。

最小化程序中的sentinel错误值的数量,一旦出现错误就用errors.Wrap将错误包装为不透明的错误。

最后,如果需要检查底层错误,则使用errors.Cause恢复错误。

推荐


并非每个容器内部都能包含一个操作系统

深入探究 K8S ConfigMap 和 Secret


原创不易,随手关注或者”在看“,诚挚感谢!

图片

28820Go Errors | 不仅仅检查错误,更要优雅地处理它们

这个人很懒,什么都没留下

文章评论