大家好,我是炸鱼。在上一篇文章中,我与大家分享了Go创始人对其中一条Go谚语的解读,Errorsisvalues,在《如何对错误进行编程?》。本文仍然致力于错误。在Go谚语中,不要只是检查错误,要优雅地处理错误。原文同名,作者@DaveCheney。以下“本人”均指原作者。这句谚语与Errorsarevalue,andtheyaremutuallyresponsible密切相关。和炸鱼一起学习吧!错误值得我花时间思考在Go程序中处理错误的最佳方法。我真的希望有一种单一的方法来处理错误,这样我们就可以像教数学或字母表一样通过背诵来教所有Go程序员。最终我的结论是:没有单一的方法来处理错误。相反,我认为Go的错误处理可以分为三个核心策略。哨兵错误第一种错误处理形式,我们常称为哨兵错误(Sentinelerrors)。如下代码:iferr==ErrSomething{...}名称来源于计算机编程中使用特定值来表示无法进行进一步处理的实践。所以在Go中,我们经常用具体的值来表示错误。示例包括:像io.EOF这样的值,或者像syscall包中的syscall.ENOENT这样的低级错误常量。甚至有一些哨兵错误表明错误没有发生,例如:go/build.NoGoError。path/filepath.SkipDir在path/filepath.Walk中。使用哨兵值是最不灵活的错误处理策略,因为调用者必须使用相等运算符将结果与预先声明的值进行比较。当您想要提供更多上下文时,这就会出现问题,因为返回不同的错误会破坏相等性检查。即使像使用fmt.Errorf向错误添加一些上下文这样有意义的事情也会破坏调用者的相等性检查。相反,调用者将被迫查看错误的Error方法的输出以查看它是否与特定字符串匹配。不要检查error.Error的输出作为旁观者,我认为您不应该检查error.Error方法的输出。Error接口上的Error方法是为人类而存在的(意味着阅读时人类的可读性),而不是为代码而存在。该字符串的内容属于日志文件,或者显示在屏幕上。你不应该试图通过检查来改变你的程序的行为。我知道有时这是不可能的,正如有人在推特上指出的那样,这个建议并不真正适用于编写测试。不过,在我看来,比较错误的字符串形式是一种代码味道,您应该尽量避免这种情况。哨兵错误成为您的公共API的一部分如果您的公共函数或方法返回具有特定值的错误,那么该值必须是公共的并且当然记录在API文档中。如果您的API定义了一个返回特定错误的接口,那么该接口的所有实现都应仅限于返回该错误,即使它们可以提供更具描述性的错误。我们可以在io.Reader中看到这一点。像io.Copy这样的函数要求读取器实现准确地返回io.EOF以向调用者发出没有数据的信号,但这不是错误。哨兵错误在两个包之间创建依赖关系到目前为止,哨兵错误值最严重的问题是它们在两个包之间创建了源代码依赖关系。例如:为了检查错误是否等于io.EOF,您的代码必须导入io包。这个具体的例子听起来不错,因为它很常见,但是想象一下,当你项目中的许多包导出错误值,而你项目中的其他包必须导入这些错误值来检查特定的错误情况时,就会有明显的耦合。在大型项目中使用过这种模式后,我可以告诉您糟糕设计的“幽灵”——以导入循环的形式——一直萦绕在我们的脑海中。注意:这个问题在Gomodules一不小心就很明显,因为grpc、grpc-gateway、etcd常年存在各种包版本的兼容性问题。一旦有依赖,就会被动升级,然后应用就会因为版本不足而无法运行。结论:避免哨兵错误忠告:避免在你编写的代码中使用哨兵错误值。虽然在标准库中有一些情况使用它们,但这不是您应该效仿的模式。如果有人要求您从包中导出错误值,您应该礼貌地拒绝并提出替代方案,例如我接下来要讨论的那些。错误类型错误处理的第二种形式是错误类型的方式。以下代码:iferr,ok:=err.(SomeType);ok{...}错误类型是指您创建的实现错误接口的类型。在此示例中,MyError类型的三个字段表示:文件、代码行和消息。类型MyError结构{MsgstringFilestringLineint}func(e*MyError)Error()string{returnfmt.Sprintf("%s:%d:%s",e.File,e.Line,e.Msg)}return&MyError{"Somethinghappened","server.go",42}因为MyError是一种类型,所以调用者可以通过类型断言从错误中提取额外的上下文。err:=something()switcherr:=err.(type){casenil://调用成功,无需记录*MyError:fmt.Println(“erroroccurredonline:”,err.Line)default://unknownerror}错误类型相对于错误值的一大改进是它们能够包装底层错误以提供更多上下文(上下文信息)。一个更好的例子是os.PathError类型,它在类型中记录了尝试的文件操作和文件路径。//PathError记录了一个错误和导致它的操作//和文件路径。typePathErrorstruct{OpstringPathstringErrerror//原因}func(e*PathError)Error()string错误类型的问题所以调用者可以使用类型断言或类型转换,并且必须公开错误类型。如果您的代码实现了一个接口,其约定需要特定的错误类型,那么该接口的所有实现者都需要依赖于定义错误类型的包。这种对包类型的深入了解与调用者产生了强耦合,使API变得脆弱。结论:避免错误类型虽然错误类型比哨兵错误值更好,因为它们捕获了更多有关出错内容的上下文,但错误类型也存在许多错误值的问题。所以我的建议是避免使用错误类型,或者至少避免将它们作为公共API的一部分。不透明错误现在我们来看第三种类型的错误处理。在我看来,这部分是关于最灵活的错误处理策略的,因为它需要最少的代码和调用者之间的耦合。我将这种风格称为不透明错误处理(Opaqueerrors),因为虽然你知道错误发生了,但你没有能力看到错误的内部。作为调用者,您对操作结果的了解是:成功或失败。这就是不透明错误处理的全部意义所在——只返回一个错误而不对其内容做任何假设。如果采取这种立场,错误处理作为调试辅助手段将变得非常有用。以下代码:import"github.com/quux/bar"funcfn()error{x,err:=bar.Foo()iferr!=nil{returnerr}//usex}例子:Foo的合约不是保证它在错误的上下文中返回的内容。Foo的作者现在可以自由地用额外的上下文来注释传递给它的错误,而不会破坏它与调用者的合同。针对行为而非类型断言错误在少数情况下,使用二分法(无论是否存在错误)进行错误处理是不够的。例如:与进程外部的交互,如网络活动,需要调用者查看错误的性质,以确定重试操作是否合理。在这种情况下,我们可以断言错误实现了特定的行为,而不是断言错误是特定类型或值。考虑这个例子。typetemporaryinterface{Temporary()bool}//如果err是临时的,IsTemporary返回真。funcIsTemporary(errerror)bool{te,ok:=err.(temporary)returnok&&te.Temporary()}错误被传递给IsTemporary方法以确定是否可以重试错误。如果报错没有实现Temporary接口;也就是说,它没有Temporary方法,那么这个错误就不是暂时的。如果错误确实实现了Temporary,则调用者可以在Temporary返回true时重试该操作。这里的要点是,可以在不导入定义错误的包的情况下实现此逻辑,并且在不知道err的底层类型的情况下,我们只对它的行为感兴趣。不要只是检查错误,优雅地处理它们这让我想到了我想说的第二个Go谚语;不要只是检查错误,优雅地处理它们。你能问一些关于下面代码的问题吗?funcAuthenticateRequest(r*Request)error{err:=authenticate(r.User)iferr!=nil{returnerr}returnnil}一个明显的建议是函数的五行可以替换为:returnauthenticate(r.User)但这是每个人都应该在代码审查中抓住的简单内容。更根本的是,这段代码的问题是我无法判断原始错误的来源。如果authenticate返回一个错误,AuthenticateRequest会将该错误返回给它的调用者,调用者可能会做同样的事情,依此类推。在程序的顶部,程序的主体会将错误打印到屏幕或日志文件中,打印的内容是:Nosuchfileordirectory。没有关于产生错误的文件和行的信息。没有导致错误的调用堆栈的堆栈跟踪。这段代码的作者将被迫长时间分析他们的代码,以发现哪个代码路径引发了找不到文件的错误。Donovan和Kernighan的《Go 编程语言》建议您使用fmt.Errorf将上下文添加到错误路径。funcAuthenticateRequest(r*Request)error{err:=authenticate(r.User)iferr!=nil{returnfmt.Errorf("authenticatefailed:%v",err)}returnnil}但正如我们之前看到的是的,此模式与哨兵错误值或类型断言的使用不兼容,因为将错误值转换为字符串,将其与另一个字符串合并,然后使用fmt.Errorf将其转换为错误会破坏相等性,并破坏任何上下文的原始错误。Annotatingerrors我想提出一种给错误添加上下文的方式,就是Annotatingerrors,也就是给错误添加注解。我将介绍一个简单的包。代码在github.com/pkg/errors(自Go1.13起正式引入并通过Go认证)。错误包有两个主要功能。//Wrapannotatescausewithamessage.funcWrap(causeerror,messagestring)error第一个函数是Wrap,它接受一个错误和一条消息,并产生一个新的错误。//Causeunwrapsanannotatederror.funcCause(errerror)error第二个函数是Cause,它接收一个可能已被包装的错误,并将其展开以恢复原始错误。使用这两个函数,我们现在可以注释任何错误并在需要检查时恢复底层错误。考虑这个将文件内容读入内存的函数示例。funcReadFile(pathstring)([]byte,error){f,err:=os.Open(path)iferr!=nil{returnnil,errors.Wrap(err,"openfailed")}延迟f.Close()buf,err:=ioutil.ReadAll(f)iferr!=nil{returnnil,errors.Wrap(err,"readfailed")}returnbuf,nil}我们将使用这个函数来写一个读配置文件的函数,然后从main中调用它。funcReadConfig()([]byte,error){home:=os.Getenv("HOME")config,err:=ReadFile(filepath.Join(home,".settings.xml"))返回配置,errors.Wrap(err,"couldnotreadconfig")}funcmain(){_,err:=ReadConfig()iferr!=nil{fmt.Println(err)os.Exit(1)}}如果ReadConfig代码路径失败,因为我们使用了errors.Wrap,所以我们得到了一个很好的K&D风格的错误注释。couldnotreadconfig:openfailed:open/Users/dfc/.settings.xml:nosuchfileordirectorybecauseerrors.Wrapproducesanerrorstackthatwecanexamineforadditionaldebugging信息。这里又是同一个例子,但这次我们用errors.Print替换fmt.Println。funcmain(){_,err:=ReadConfig()iferr!=nil{errors.Print(err)os.Exit(1)}}我们会得到这样的信息:readfile.go:27:couldnotreadconfigreadfile.go:14:openfailedopen/Users/dfc/.settings.xml:nosuchfileordirectory第一行来自ReadConfig,第二行来自ReadFile的os.Open部分,其余来自os包本身,不携带位置信息。既然我们已经介绍了包装错误以产生堆栈的概念,我们需要讨论相反的情况,即展开错误。这是errors.Cause函数的域。//如果err是临时的,IsTemporary返回真错误与特定值或类型匹配,您应该首先使用errors.Cause函数恢复原始错误。Handleerrorsonlyonce最后我想说的是你应该只处理一次错误。处理错误意味着检查错误值并做出决定。funcWrite(wio.Writer,buf[]byte){w.Write(buf)}如果你做出的决定少于一个,你将忽略错误。正如我们在这里看到的,来自w.Write的错误被丢弃了。但是对一个错误做出不止一个决定也是有问题的。funcWrite(wio.Writer,buf[]byte)error{_,err:=w.Write(buf)iferr!=nil{//注释错误转到日志文件log.Println("unabletowrite:",err)//unannotatederrorreturnedtocallerreturnerr}returnnil}在这个例子中,如果在写入时发生错误,则向日志文件写入一行,指出发生错误的文件和行,并且错误也是返回给调用者,调用者可以记录它并返回它,一直到程序的顶部。所以你在日志文件中得到了一堆重复的行,但是在程序的顶部你得到了没有任何上下文的原始错误。有人使用Java吗?funcWrite(wio.Write,buf[]byte)error{_,err:=w.Write(buf)returnerrors.Wrap(err,"writefailed")}使用errors包后,你将有能力为值添加上下文,以人类和机器都可以检查的方式编程值。摘要错误是包公共API的一部分,请像对待公共API的任何其他部分一样谨慎对待它们。为了获得最大的灵活性,我建议您尝试将所有错误都视为不透明的。在您不能的情况下,断言行为错误而不是类型或值。尽量减少程序中哨兵错误值的数量,并在它们发生时使用error.Wrap将它们转换为不透明的错误。如果需要检查,请使用errors.Cause来恢复底层错误。希望每个Gopher都能学会如何更优雅地处理Go错误!文章持续更新中。可以微信搜索【脑补炸鱼】阅读。本文已收录在GitHubgithub.com/eddycjy/blog中。学习Go语言可以看Go学习地图和路线。欢迎星星提醒。Go书系列Go语言入门系列:初探Go项目实战Go语言编程之旅:深入使用Go做项目Go语言设计哲学:理解Go的Why与设计思维Go语言进阶之旅:走得更远GoSourceCode阅读更多想添加箭头语法,这更像PHP!Go错误处理的新思路?使用左手函数和表达式
