Alan Hou的个人博客

云原生系列Go语言篇-错误处理

本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。

从其它语言转Go最大的挑战之一就是错误处理。对那些习惯了使用异常的开发者,Go采取的方法有些离经叛道。但Go语言使用的方法蕴含着牢不可破的软件工程原理。本章中,我们学习如何在Go中处理错误。我们会学习Go系统中会停止执行的错误处理panicrecover

错误处理基础

函数一章我们简单地讲到,Go以error类型值作为函数最后一个返回值来进行错误处理。这只是一种惯例,但这一惯例已深入人心不应打破它。在函数执行正常时,使用nil来返回错误参数。如果出错则返回一个错误值。然后调用函数会检查错误返回值是否为nil,处理返回的错误或是返回自己的错误。带错误处理的简单函数如下所示:

通过调用errors包中的New函数用字符串新建了一个错误。错误消息首字母不大写,也不应以标点或新行结尾。大部分情况下在返回非nil错误时返回值应为其零值。在学到哨兵错误会发现存在例外。

与带异常的编程语言不同,Go语言并没有特别的结构用于检测是否返回了错误。在函数返回时,使用if语句来检查错误变量看其是否为nil:

error是一内置接口,其中只定义了一个方法:

只要实现了该接口就可以视为错误。函数返回nil来表示没有错误的原因是nil是任意接口类型的零值。

Go使用错误返回而没有抛出异常有两个主要原因。首先,异常至少通过代码添加了一个新的代码路径。这些路径有时没清晰,尤其对于那些函数不包含异常声明的编程语言。这会在异常处理不当时出现意料外的代码崩溃,甚至更糟的情况,代码没崩溃,数据初始化、修改或存储错误。

另一个原因更难发现,但演示了Go的特性是如何组织的。Go编译器要求读取所有变量。让错误有返回值会强制开发者检查、处理错误,或是显式地通过下划线(_)来忽略返回的错误值。

注:函数一章中讲到,虽然无法忽略函数返回的部分值,但也可以忽略其所有返回值。如果忽略所有返回值,也就能同时忽略掉错误。大部分情况下忽略函数的返回值都不太好。除了使用类似fmt.Println,请避免这么做。

异常处理代码可能更短,但行数更少并不一定会让代码更易于掌握或维护。可以看到,地道的Go代码讲究代码清晰,多几行也没关系。

另一个值得注意的是Go语言的控制流。错误处理缩进于if语句内,但业务逻辑则不是。这在视觉上体现了哪些代码位于“黄金路线”上,哪些代码用于异常处理。

用字符串表示简单错误

Go标准提供了两种方式由字符串创建错误。第一个是errors.New函数。它接收一个string返回error。在对返回错误实例调用Error方法时会返回这一字符吕。如果对fmt.Println传递错误,它会自动调用Error方法:

第二种方式是使用fmt.Errorf函数。这一函数可以使用与fmt.Printf相同的格式化动词来创建错误。类似errors.New,在对返回错误实例调用Error方法时会返回该字符串:

哨兵错误

有些错误发出的由于当前状态的问题无法继续处理的信号。在“别只是检查错误,还有优雅处理”的博客文章中,在Go社区活跃了多年的开发者Dave Cheney,创造了哨兵错误一词用于描述如下错误:

这一名称源自计算机编程实践,使用具体的值来标识无法做进一步处理。因此在Go中,我们使用特定值来标识错误。

哨兵错误是包级别所声明的极少的几个变量之一。按照惯例,它们的名称以Err开头(其中有一个特别的例外io.EOF)。它们应为只读,Go编译器无法进行这一强制,修改它们的值是一种编程错误。

哨兵错误通常用于表示无法开始或继续处理。例如,标准库包含一个处理ZIP文件的包,archive/zip。该懈定义了几个哨兵错误,包括在传入的不是ZIP文件时返回的ErrFormat。可在The Go Playground中调试这段代码:

标准库中有关哨兵错误的另一个例子是crypto/rsa包中的rsa.ErrMessageTooLong。它表示因对所提供的公钥消息过长而无法加密。在上下文一章中会讲解哨兵错误context.Canceled

在定哨兵错误时请确保需要使用它。一旦定义,就成了对外API的一部分,需要保障在所有向后兼容的发行版中能使用它。最好是复用标准库已有的错误或是定义一种包含导致错误返回的条件的错误类型(在下一节中会讲到)。但如果表示指定状态的错误条件达到了,此时又无法做进一步处理且需要额外的信息来解释该错误状态,那么哨兵错误就是正确的选择。

哨兵错误如何进行检测呢?在前面的代码中使用了==来对调用函数显式表明会返回哨兵错误的情况进行检测。在下一节中我们会讨论如何对其它场景检测哨兵错误。

对哨兵错误使用常量
常量错误一文中, Dave Cheney提议常量可以成为很有用的哨兵错误。在包中的代码会像下面这样(在模块、包和导入一章中会讨论如何创建包): 

然后这样使用:

这看上去你是函数调用 ,但实际是将字符串字面量转换成实现了error 接口的类型。ErrFoo 和ErrBar的值是无法修改的。初看上去这是一个很好的方案。

但在实践中这不是一种地道的做法。如果使用相同类型跨包创建常量错误,两个错误在错误字符串相同时会相等。在字符串字面量为相同值时也会相等。而errors.New所创建的错误只在其为自身或显式将其值赋给变量时才会相等。我们几乎不会将不同包中的错误设为相等,否则为什么声明两个不同的错误呢?(可以在每个包中创建非公错误类型来避免这一问题,但这本身就变繁琐了。)

哨兵错误模式是Go设计哲学的又一案例。哨兵错误很稀有,因此可通过约定俗成来处理,无需动用语言规则。确实,它们是公共的包级变量。这使得它们是可变的,但不太可能有人会误对包级公有变量重新赋值。总之,这是其它特性和模式所处理的罕见情况。Go语言的哲学是尽量保持语言简洁,相信开发者和工具而不是去新增特性。

到目前为止我们所接触的错误都是字符串。但Go的错误可以包含更多的信息。我们来看是怎么办到的。

错误是值

error 是接口,我们可以定义自己的错误来包含更日志或错误处理信息。例如,你可能想让错误包含一个状态码来表示向用户报告哪种错误。这避免了用字符串比较(文本可能发生改变)来决定错误原因。下面看实操。首先,定义一个表示状态码的枚举:

接着,定义一个StatusErr 来存放该值:

现在就可以使用StatusErr 来提供有关错误的更多详情了:

即使用是自定义了错误类型,保持对错误结果使用error 作为返回类型。这样可以在函数中返回不同类型的错误,而函数调用方又不需要依赖于具体的错误类型。

如若使用自己的错误类型,请确保不要返回未初始化的实例。下面看会有什么结果。在The Go Playground上运行这段代码:

运行这段程序的输出如下:

这里并不是指针类型与值类型的问题。如果将genErr 声明为*StatusErr,结果还是一样。这里err 不是nil的原因在于error 是一个接口。在类型、方法和接口一章中讨论过,要使接口为nil,其底层的类型及值都必须是nil。不论genErr 是否为指针,接口的底层类型部分都不是nil

有两种解决方案。最常见的方法是在函数成功调用将错误值显式返回nil

这样的好处是无需通读代码来确保return 语句的错误变量定义正确。

另一种方法是保证本地存储error 的变量都是错误类型:

警告:在使用自定义错误时,不要定义类型为自定义错误的变量。要么在没有错误时显式返回nil要么将变量类型定义为error

类型、方法和接口一章中讲到,不要使用类型断言或类型转换来访问自定义错误的字段和方法。而应使用errors.As,在Is和As小节中会进行讨论。

封装错误

在通过代码传递错误时,常常希望对其添加信息。可能是接收到错误的函数名或是其所尝试执行的操作。在原错误基础上添加信息,这称为对错误的封装。在有一系列的封装错误时,称为错误树。

Go标准库中有一个封装错误的函数,我们已经见过。fmt.Errorf有一个特殊的动词,%w。使用它可创建包含另一个错误的格式化字符串同时也包含原错误的格式化字符串。通常在错误格式字符串的最后写上: %w,来封装传入fmt.Errorf的最后一个参数。

标准库还提供了解封装错误的函数Unwrap ,位于errors 包中。将错误传递给它,会在有封装错误时返回该错误。如果没有,则返回nil。下面有一个简短程序演示使用fmt.Errorf进行封装和使用errors.Unwrap进行解封装。可在The Go Playground中运行这段代码:

运行这段程序,得到的输出如下:

注:通常不会直接调用errors.Unwrap。而是会使用errors.Is 和errors.As来查询具体的封装错误。我们会在下一节中讲解这两个函数。

如果希望使用自定义类型封装错误,该错误类型需要实现Unwrap方法。这个方法不接收参数,返回一个error。下面使用此前定义的错误来进行演示:

这时我们就可以使用StatusErr 来封装下面的错误了:

不是所有错误都需要进行封装。库可以返回表示无法继续处理的错误,但错误消息所包含的实现细节在程序的其它部分中用不到。这时创建新错误并返回是可以接受的。理解这种场景,决定需要返回什么内容。

小贴士:如果希望新建包含另一个错误中消息的错误,而又不想做封装,使用fmt.Errorf来创建错误,并使用%v来替换%w

封装多个错误

有时同一个函数生成多个应返回的错误。例如,如果编写了一个验证结构体中错误的函数,最好为每个无效字段返回一个错误。因标准函数签名返回的是error而非[]error,所以需要将多个错误合并为单个错误。这正是errors.Join函数的作用。

另一种合并多个错误的方式是对fmt.Errorf传递多个%w动词:

可以自己实现支持多个封装错误的error类型。这要实现Unwrap方法,把返回的error替换为[]error

封闭错误是获取错误额外信息的有用方式,但它也带来了问题。如果封装了哨兵错误,无法使用==进行检测,也不能使用类型断言或类型switch语句来匹配封装的自定义错误。Go通过errors包中的两个函数解决这一问题,IsAs

检测所返回的错误或其封闭的任何错误是否匹配具体的哨兵错误实例,使用errors.Is。它接收两个参数,检测的错误以及进行比较的实例。如果错误树中的错误与所提供的哨兵错误相匹配errors.Is函数返回true。我们编写一个简短程序来查看errors.Is的使用。可以The Go Playground中自行运行:

运行这段程序的输出为:

默认errors.Is使用==来比较封装的错误与具体的错误。如果无法用于所定义的错误类型(例如该错误无法进行比较),则需对该实现一个Is方法:

reflect.DeepEqual函数在复合类型一章中提到过。它可以比较所有类型,包括切片在内。)

定义自己的Is方法另一种用途是比较不同实例的错误。你可能会对自己的错误进行模式匹配,指定匹配某些带相同字段错误的过滤器实例。下面定义一个错误类型ResourceErr

如果希望在设置了任意字段时进行两个ResourceErr实例的匹配,可以编写一个自定义的Is方法:

这时就会发现,比如不管code是什么,所有的错误都指向数据库:

可在The Go Playground中调试这段代码。

errors.As函数可以检测返回错误(或其封装的错误)是否匹配具体的类型。这接收两个参数。第一个是做检测的错误,第二个是所查找类型的变量。如果该函数返回true,就是在错误树中找到了匹配的错误,并且匹配错误赋给了第二个参数。如果该函数返回false,在错误树中没有发现匹配。我们使用进行MyErr测试:

注意我们使用var声明设置为特定类型零值的变量。然后将变量的指针传入errors.As

errors.As的第二个参数不一定要传错误类型变量的指针。也可传入接口指针来查找符合该接口的错误:

上例使用了匿名接口,但可接受任意接口类型。

警告:如果errors.As的第二个参数不是错误指针或接口指针,方法会崩溃。

就像我们使用Is方法来重载默认的errors.Is比较,可以对自定义错误添加As方法来重载默认的errors.As比较。实现As并不轻量,要求使用反射(我们会在恶龙三剑客:反射、Unsafe 和 Cgo一章中讨论反射)。仅在特别情形才使用它,比如希望匹配不同类型的错误。

小贴士:在查找具体的实例或具体值时使用errors.Is。在查找具体类型时使用errors.As

通过defer封装错误

有时会发现使用相同的消息封装多个错误:

这时就可以使用defer来简化代码:

需要对返回值进行命名,才能在defer函数里引用err。如果对某个返回值命名,必须对所有的都进行命名,因此对于没有显式赋的字符串返回值可使用下划线。

defer闭包中,代码检测是否返回了错误。如若返回,它将错误赋给新错误,其中封装了表示了哪个函数检测到该错误的消息。

这种模式非常适用于对每个错误封装相同消息的情况。如果想要自定义封装错误包含更多细节,那么在每个fmt.Errorf中放入具体消息和通用消息。

panic和recover

前面章节里提到了panic崩溃不会深入到其中的细节。panic类似于Java或Python中的Error。它是由Go运行时不无法确定下一步做什么时生成的一种状态。可能是由于编程错误(比如读取切片结尾之后的信息)或是环境问题(比如内存不足)。在发生panic崩溃时,当前函数立即退出,与当前函数关联的defer会开始运行。在这些延迟执行完成后,与调用函数关联的defer等会运行,直至到达main。然后程序会带消息和栈追踪退出。

如果在程序中存在无法恢复的场景,可以创建自己的panic。内置函数panic接收一个参数,可为任意类型。通常是字符串。我们来编写一个小程序并在The Go Playground中进行运行:

运行这段代码会得到如下输出:

可以看到panic打印了消息,后面接栈追踪信息。

Go提供了一种捕获panic的方式来实现优雅关闭或是防止出现关闭。在defer中调用内置的recover函数来查看是否发生了panic。如果有panic,会返回赋给panic的值。一旦出现了recover,执行会继续。我们来看另一个示例。在The Go Playground中运行:

使用recover有特定的模式。我们通过defer注册一个函数来处理潜在的panic。我们在if语句中调用recover,查看是否找到了非nil值。必须在defer中调用recover,因为出现了panic时,只会运行defer函数。

运行这段代码的输出如下:

虽然panicrecover很像其它语言中的异常处理,但目的并不在此。为致命场景预留panic并使用recover来优雅地处理这些场景。如果程序panic,对于尝试在panic之后继续执行要格外小心。在发生了panic后很少会希望保持程序运行。如果panic触发的原因是内存或磁盘不足这样的电脑资源不足,最安全的方式是使用recover来记录该场景进行软件监控并通过os.Exit(1)进行关闭。如果导致panic的是程序错误,可以尝试继续,但很有可能会再次碰到相同问题。在前面的示例程序中,检测除以0并在传入0时返回错误才是地道的做法。

我们不依赖于panicrecover的原因是recover并没有清晰表明失败的原因。它只是保证在出现错误时,可以打印消息并继续。地道的Go更倾向于显式指出可能的错误,优先于简短代码处理所有场景却没有做任何说明。

有一种场景推荐使用recover。如果为第三方创建库,不要让panic逃出公有API的界限。如果可能出现panic,公有函数应使用recover来将panic转化为一个错误并返回,然后让调用代码决定如何处理。

注:虽然Go内置的HTTP服务对处理器的panic使用了recover,但David Symonds在2015的一条GitHub评论中提到,Go彼时认为这是一个失误

从 Error 中获取栈追踪

Go语言新手更喜欢使用panicrecover的一个原因是他们想在出错时获取到栈追踪。默认Go并没有进行提供。我们展示过可以使用错误封装来手动构建一个调用栈,但有带错误类型的第三方库自支生成这些栈(参见模块、包和导入一章学习在程序中加入第三方代码)。最知名的第三方库提供了使用栈追踪封装错误的函数。

默认不会打印出栈追踪。如果要查看栈追踪信息,使用fmt.Printf和详情输出动词(%+v)。参见官方文档了解更多信息。

虽然pkg/errors的知名度很高,但已不再维护,已进行存档。如果使用不再提供支持的库让你不适,作者提供了类似的库

注:在错误中有栈追踪时,输出包含程序所编译电脑中文件的完整路径。如果不希望暴露路径,在构建代码时使用-trimpath标记。这会将完整路径替换为包。

练习

查看github.com/learning-go-book-2e/sample_code/exercise中的代码。读者要在每个练习中修改这段代码。它可正常运行,但需要添加错误处理来进行改进。

  1. 创建一个哨兵错误来表示无效ID。在main()中使用errors.Is来检查该哨兵错误,在找到错误时打印一条消息。
  2. 自定义一个错误类型来表示空字段错误。这个错误应包含空Employee字段的名称。在main()中,使用errors.As检查该错误。打印包含字段名的消息。
  3. 把返回找到的第一条错误,换成返回包含验证期间发现的所有错误的单条错误。更新main()中的代码来恰当地报告多条错误。

小结

本章讲解了Go中的错误是什么,如何自定义错误以及如何进行检查。我们还学习了panicrecover。下一章中会讨论包和模块,如何在程序中使用第三方代码,以及如何发布代码供他人使用。

 

 

退出移动版