共计 6967 个字符,预计需要花费 18 分钟才能阅读完成。
大家好,我是煎鱼。
上一篇我在《如何对谬误进行编程?》给大家分享了 Go 创始人对 Go 谚语之一 Errors are values 的诠释。
本篇依然是谬误专场,是 Go 谚语中的 Don’t just check errors, handle them gracefully,原文章同名,作者是 @Dave Cheney。以下的“我”均指代原作者。
这条谚语和 Errors are value 关联性很强,是互解答的,和煎鱼一起学习吧!
谬误只是值
我花了很多工夫来思考解决 Go 程序中谬误的最佳办法。我真的心愿能有一种繁多的办法来处理错误,那样咱们就能够像教数学或字母表一样,通过背诵来教会所有的 Go 程序员。
最终我的论断是:没有繁多的办法来处理错误。相同,我认为 Go 的错误处理能够归为三个外围策略。
哨兵谬误
第一种错误处理的模式,咱们常称之为哨兵谬误(Sentinel errors)。
如下代码:
if err == ErrSomething {…}
这个名字来源于计算机编程中应用特定值来示意无奈进一步解决的做法。所以在 Go 中,咱们常应用特定的值来示意谬误。
例子包含:io.EOF 这样的值,或者像 syscall 包中更底层的谬误常量,如 syscall.ENOENT。
甚至还有一些标记着谬误没有产生的哨兵谬误,例如:
- go/build.NoGoError。
- path/filepath.Walk 中的 path/filepath.SkipDir。
应用哨兵值是最不灵便的错误处理策略,因为调用者必须应用等式运算符将后果与事后申明的值进行比拟。当你想提供更多的背景时,这就呈现了问题,因为返回不同的谬误会毁坏等式查看。
即便是像应用 fmt.Errorf 为谬误增加一些上下文这样有意义的货色,也会毁坏调用者的等式测验。相同,调用者将被迫查看谬误的 Error 办法的输入,看它是否与一个特定的字符串相匹配。
不要查看 error.Error 的输入
作为一个旁观者,我认为你永远不应该查看 error.Error 办法的输入。谬误接口上的 Error 办法是为人类存在的(意思是为人在浏览时的可读性),而不是为代码存在的。
那个字符串的内容属于日志文件,或者显示在屏幕上。你不应该试图通过查看它来扭转你的程序的行为。
我晓得有时这是不可能的,正如有人在 twitter 上指出的,这个倡议并不适用于编写测试。
尽管如此,在我看来,比拟谬误的字符串模式是一种代码坏滋味,你应该尽量避免它。
哨兵谬误成为你的公共 API 的一部分
如果你的公共函数或办法返回一个特定值的谬误,那么这个值必须是公开的,当然也须要在 API 文档中有所记录。
如果你的 API 定义了一个返回特定谬误的接口,那么该接口的所有实现都应被限度在仅返回该谬误,即便它们能够提供一个更具描述性的谬误。
咱们在 io.Reader 中能够看到这一点。像 io.Copy 这样的函数,须要读取器来实现精确地返回 io.EOF,以便向调用者收回没有数据的信号,但这并不是一个谬误。
哨兵谬误在两个包间建设了依赖
到目前为止,哨兵谬误值最蹩脚的问题是它们在两个包之间产生了源代码的依赖性。
举个例子:为了查看一个谬误是否等于 io.EOF,你的代码必须导入 io 包。
这个具体的例子听起来并不坏,因为它很常见,但设想一下,当你我的项目中的许多包导出谬误值,而你我的项目中的其余包必须导入这些谬误值以查看特定的谬误条件时,就会呈现显著的耦合。
我曾在一个大型项目中应用过这种模式,我能够通知你,不良设计的“幽灵”— 导入循环的模式 — 从未远离过咱们的头脑。
注:这个问题在 Go modules 下一个不小心就很显著,因为 grpc、grpc-gateway、etcd 长年就存在各种包版本的兼容性问题。一旦有依赖就会被动降级,而后利用就因为版本少了货色跑不起来了。
论断:防止哨兵谬误
倡议: 在你写的代码中防止应用哨兵谬误值 。
尽管在规范库中,有多数状况下会应用它们,但这并不是你应该模拟的模式。
如果有人要求你从你的包中导出一个谬误值,你应该礼貌地回绝,并倡议采纳其余办法,比方我接下来要探讨的那些形式。
谬误类型
第二种错误处理的模式,是谬误类型(Error types)的形式。
如下代码:
if err, ok := err.(SomeType); ok {…}
谬误类型指的是你创立的一个实现谬误接口的类型。在这个例子中,MyError 类型的三个字段别离代表:文件、代码行以及信息。
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 谬误是一个类型,调用者能够通过类型断言来从谬误中提取额定的上下文。
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
谬误类型的问题
所以调用者能够应用类型断言或类型转换,谬误类型必须被公开。
如果你的代码实现了一个接口,而这个接口的契约须要一个特定的谬误类型,那么这个接口的所有实现者都须要依赖定义谬误类型的包。
这种对包的类型的深刻理解,造成了与调用者的强耦合,使 API 变得很软弱。
论断:防止应用谬误类型
尽管谬误类型比哨兵谬误值要好,因为它们能够捕捉更多对于出错的上下文,但谬误类型也有许多谬误值的问题。
因而,我的倡议是防止应用谬误类型,或者至多防止将其作为公共 API 的一部分。
不通明的谬误
当初咱们来看看第三类错误处理。在我看来,本节讲的是最灵便的错误处理策略,因为它要求你的代码和调用者之间的耦合度最小。
我把这种格调称为不通明的错误处理(Opaque errors),因为尽管你晓得产生了谬误,但你没有能力看到谬误的外部。作为调用者,你所晓得的对于操作后果的所有信息是:它胜利了,或者它没有胜利。
这就是不通明的错误处理的全部内容 — 只是返回谬误,而不对其内容做任何假如。如果你采取这种立场,那么错误处理作为一种调试辅助伎俩就会变得十分有用。
如下代码:
import“github.com/quux/bar”func fn() error {x, err := bar.Foo()
if err != nil {return err}
// use x
}
例如:Foo 的契约没有保障它在谬误的上下文中会返回什么。Foo 的作者当初能够自在地用额定的上下文来正文通过它的谬误,而不毁坏它与调用者的契约。
为行为而不是类型断言谬误
在多数状况下,应用二分法(是否有谬误)来进行错误处理是不够的。
例如:与你过程之外的交互,如网络流动,须要调用者查看谬误的性质,以确定重试操作是否正当。
在这种状况下,与其断言谬误是一个特定的类型或值,咱们能够断言谬误实现了一个特定的行为。考虑一下这个例子。
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()}
咱们能够将任何谬误传递给 IsTemporary 办法,以确定该谬误是否能够被重试。
如果谬误没有实现 Temporary 接口;也就是说,它没有一个 Temporary 办法,那么谬误就不是长期的。
如果谬误的确实现了 Temporary,那么如果 Temporary 返回 true,调用者兴许能够重试操作。
这里的要害是,这个逻辑能够在不导入定义谬误的包的状况下实现,也不须要晓得 err 的底层类型,咱们只是对其行为感兴趣。
不要只是查看谬误,要优雅地解决它们
这让我想到了我想说的第二句 Go 谚语;不要只是查看谬误,要优雅地解决它们(Don’t just check errors, handle them gracefully)。
你能就上面这段代码提出一些问题吗?
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。
没有产生谬误的文件和行的信息。没有导致谬误的调用堆栈的堆栈跟踪。
这段代码的作者将被迫对他们的代码进行长时间的分析,以发现哪个代码门路引发了文件未找到的谬误。
Donovan 和 Kernighan 的《Go 编程语言》倡议你应用 fmt.Errorf 为谬误门路增加上下文。
func AuthenticateRequest(r *Request) error {err := authenticate(r.User)
if err != nil {return fmt.Errorf("authenticate failed: %v", err)
}
return nil
}
但正如咱们后面所看到的,这种模式与哨兵谬误值或类型断言的应用不兼容,因为将谬误值转换为一个字符串,与另一个字符串合并,而后用 fmt.Errorf 将其转换为一个谬误,会毁坏平等性,并毁坏原始谬误的任何上下文。
正文谬误
我想提出一种为谬误增加上下文的办法,也就是正文谬误(Annotating errors),也就是给谬误减少注解。
我将介绍一个简略的包。代码在 github.com/pkg/errors(在 Go1.13 起曾经被 Go 官网引入,失去了认可)。
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.Wrap,咱们会失去一个 K&D 格调的丑陋的谬误正文。
could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory
因为 errors.Wrap 产生一个谬误堆栈,咱们能够查看该堆栈以取得额定的调试信息。这又是同一个例子,但这次咱们用 errors.Print 替换 fmt.Println。
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.Open 局部,其余部分来自 os 包自身,它没有携带地位信息。
当初咱们曾经介绍了包裹谬误以产生堆栈的概念,咱们须要探讨相同的状况,即解包谬误。这就是 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
}
在这个例子中,如果在写的过程中产生了谬误,就会有一行写到日志文件中,指出产生谬误的文件和行,并且谬误也会返回给调用者,调用者可能会记录它,并返回它,始终到程序的顶部。
因而你在日志文件中失去了一堆反复的行,但在程序的顶部你失去了没有任何背景的原始谬误。有人应用 Java 吗?
func Write(w io.Write, buf []byte) error {_, err := w.Write(buf)
return errors.Wrap(err, "write failed")
}
应用 errors 包后,你将有能力为谬误值增加上下文,以一种人类和机器都能够查看的形式进行对值编程。
总结
谬误是你的包的公共 API 的一部分,看待它们要像看待你的公共 API 的任何其余局部一样审慎。
为了取得最大的灵活性,我倡议你尽量把所有的谬误都当作不通明的。在你无奈做到的状况下,为行为而不是类型或值断言谬误。
在你的程序中尽量减少哨兵谬误值的数量,并在谬误产生时用 error.Wrap 将其转换为不通明的谬误。
如果你须要查看的话,应用 errors.Cause 来复原底层谬误。
心愿每一位 Gopher 都学会如何更优雅的解决 Go 的谬误!
文章继续更新,能够微信搜【脑子进煎鱼了】浏览,本文 GitHub github.com/eddycjy/blog 已收录,学习 Go 语言能够看 Go 学习地图和路线,欢送 Star 催更。
Go 图书系列
- Go 语言入门系列:初探 Go 我的项目实战
- Go 语言编程之旅:深刻用 Go 做我的项目
- Go 语言设计哲学:理解 Go 的为什么和设计思考
- Go 语言进阶之旅:进一步深刻 Go 源码
更多浏览
- Go 想要加个箭头语法,这下更像 PHP 了!
- Go 错误处理新思路?用左侧函数和表达式