这篇文章摘自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
在向错误中添加上下文等含义也将使调用方的相等性测试失败。相反,调用方将在输出被迫查看error
的Error
方法,看它是否一个特定的字符串。
切勿检查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
类型定义了File
和Line
,以及一条解释发生了什么的消息字段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
恢复错误。
推荐
原创不易,随手关注或者”在看“,诚挚感谢!
文章评论