Golang错误和异常处理的正确姿势
Golang错误和异常处理的正确姿势
错误和异常是两个不同的概念,⾮常容易混淆。很多程序员习惯将⼀切⾮正常情况都看做错误,⽽不区分错误和异常,即使程序中可能有异常抛出,也将异常及时捕获并转换成错误。从表⾯上看,⼀切皆错误的思路更简单,⽽异常的引⼊仅仅增加了但事实并⾮如此。众所周知,Golang遵循“少即是多”的设计哲学,追求简洁优雅,就是说如果异常价值不⼤,就不会将异常加⼊到语⾔特性中。
错误和异常处理是程序的重要组成部分,我们先看看下⾯⼏个问题:
1. 错误和异常如何区分?
2. 错误处理的⽅式有哪⼏种?
3. 什么时候需要使⽤异常终⽌程序?
4. 什么时候需要捕获异常?
5. ...
如果你对这⼏个问题的答案不是太清楚,那么就抽⼀点时间看看本⽂,或许能给你⼀些启发。
face-to-exception.png
基础知识
错误指的是可能出现问题的地⽅出现了问题,⽐如打开⼀个⽂件时失败,这种情况在⼈们的意料之中;⽽异常指的是不应该出现问题的地⽅出现了问题,⽐如引⽤了空指针,这种情况在⼈们的意料之外。可见,错误是业务过程的⼀部分,⽽异常不是
Golang中引⼊error接⼝类型作为错误处理的标准模式,如果函数要返回错误,则返回值类型列表中肯定包含error。error处理过程类似于C语⾔中的错误码,可逐层返回,直到被处理。
Golang中引⼊两个内置函数panic和recover来触发和终⽌异常处理流程,同时引⼊关键字defer来延迟执⾏defer后⾯的函数。
⼀直等到包含defer语句的函数执⾏完毕时,延迟函数(defer后的函数)才会被执⾏,⽽不管包含defer语句的函数是通过return的正常结束,还是由于panic导致的异常结束。你可以在⼀个函数中执⾏多条defer语句,它们的执⾏顺序与声明顺序相反。当程序运⾏时,如果遇到引⽤空指针、下标越界或显式调⽤panic函数等情况,则先触发panic函数的执⾏,然后调⽤延迟函数。调⽤者继续传递panic,因此该过程⼀直在调⽤栈中重复发⽣:函数停⽌执⾏,调⽤延迟执⾏函数等。如果⼀路在延迟函数
错误和异常从Golang机制上讲,就是error和panic的区别。很多其他语⾔也⼀样,⽐如C++/Java,没有error但有errno,没有panic但有throw。
Golang错误和异常是可以互相转换的:
1. 错误转异常,⽐如程序逻辑上尝试请求某个URL,最多尝试三次,尝试三次的过程中请求失败是错误,尝试完第三次还不成功的话,失败就被提升为异常了。
2. 异常转错误,⽐如panic触发的异常被recover恢复后,将返回值中error类型的变量进⾏赋值,以便上层函数继续⾛错误处理流程。
⼀个启⽰
regexp包中有两个函数Compile和MustCompile,它们的声明如下:
func Compile(expr string) (*Regexp, error)
func MustCompile(str string) *Regexp
同样的功能,不同的设计:
1. Compile函数基于错误处理设计,将正则表达式编译成有效的可匹配格式,适⽤于⽤户输⼊场景。当⽤户输⼊的正则表达式不合法时,该函数会返回⼀个错误。
2. MustCompile函数基于异常处理设计,适⽤于硬编码场景。当调⽤者明确知道输⼊不会引起函数错误时,要求调⽤者检查这个错误是不必要和累赘的。我们应该假设函数的输⼊⼀直合法,当调⽤者输⼊了不应该出现的输⼊时,就触发panic异常于是我们得到⼀个启⽰:什么情况下⽤错误表达,什么情况下⽤异常表达,就得有⼀套规则,否则很容易出现⼀切皆错误或⼀切皆异常的情况。
在这个启⽰下,我们给出异常处理的作⽤域(场景):
1. 空指针引⽤
2. 下标越界
3. 除数为0
4. 不应该出现的分⽀,⽐如default
5. 输⼊不应该引起函数错误
其他场景我们使⽤错误处理,这使得我们的函数接⼝很精炼。对于异常,我们可以选择在⼀个合适的上游去recover,并打印堆栈信息,使得部署后的程序不会终⽌。
说明: Golang错误处理⽅式⼀直是很多⼈诟病的地⽅,有些⼈吐槽说⼀半的代码都是"if err != nil { / 打印 && 错误处理 / }",严重影响正常的处理逻辑。当我们区分错误和异常,根据规则设计函数,就会⼤⼤提⾼可读性和可维护性。
错误处理的正确姿势
姿势⼀:失败的原因只有⼀个时,不使⽤error
我们看⼀个案例:
func (self *AgentContext) CheckHostType(host_type string) error {
switch host_type {
case "virtual_machine":
return nil
case "bare_metal":
return nil
}
return errors.New("CheckHostType ERROR:" + host_type)
}
我们可以看出,该函数失败的原因只有⼀个,所以返回值的类型应该为bool,⽽不是error,重构⼀下代码:
func (self *AgentContext) IsValidHostType(hostType string) bool {
return hostType == "virtual_machine" || hostType == "bare_metal"
}
说明:⼤多数情况,导致失败的原因不⽌⼀种,尤其是对I/O操作⽽⾔,⽤户需要了解更多的错误信息,这时的返回值类型不再是简单的bool,⽽是error。
姿势⼆:没有失败时,不使⽤error
error在Golang中是如此的流⾏,以⾄于很多⼈设计函数时不管三七⼆⼗⼀都使⽤error,即使没有⼀个失败原因。
我们看⼀下⽰例代码:
func (self *CniParam) setTenantId() error {
self.TenantId = self.PodNs
return nil
}
对于上⾯的函数设计,就会有下⾯的调⽤代码:
err := self.setTenantId()
if err != nil {
// log
// free resource
return errors.New(...)
}
根据我们的正确姿势,重构⼀下代码:
func (self *CniParam) setTenantId() {
self.TenantId = self.PodNs
}
于是调⽤代码变为:
self.setTenantId()
姿势三:error应放在返回值类型列表的最后
对于返回值类型error,⽤来传递错误信息,在Golang中通常放在最后⼀个。
resp, err := http.Get(url)
return nill, err
}
bool作为返回值类型时也⼀样。
value, ok := cache.Lookup(key)
if !ok {
// ...cache[key] does not exist…
}
姿势四:错误值统⼀定义,⽽不是跟着感觉⾛
很多⼈写代码时,到处return errors.New(value),⽽错误value在表达同⼀个含义时也可能形式不同,⽐如“记录不存在”的错误value可能为:
1. "record is not existed."
2. "record is not exist!"
3. "###record is not existed!!!"
4. ...
这使得相同的错误value撒在⼀⼤⽚代码⾥,当上层函数要对特定错误value进⾏统⼀处理时,需要漫游所有下层代码,以保证错误value统⼀,不幸的是有时会有漏⽹之鱼,⽽且这种⽅式严重阻碍了错误value的重构。于是,我们可以参考C/C++的错误码定义⽂件,在Golang的每个包中增加⼀个错误对象定义⽂件,如下所⽰:
var ERR_EOF = errors.New("EOF")
var ERR_CLOSED_PIPE = errors.New("io: read/write on closed pipe")
var ERR_NO_PROGRESS = errors.New("multiple Read calls return no data or error")
var ERR_SHORT_BUFFER = errors.New("short buffer")
var ERR_SHORT_WRITE = errors.New("short write")
var ERR_UNEXPECTED_EOF = errors.New("unexpected EOF")
说明:笔者对于常量更喜欢C/C++的“全⼤写+下划线分割”的命名⽅式,读者可以根据团队的命名规范或个⼈喜好定制。
姿势五:错误逐层传递时,层层都加⽇志
根据笔者经验,层层都加⽇志⾮常⽅便故障定位。
说明:⾄于通过测试来发现故障,⽽不是⽇志,⽬前很多团队还很难做到。如果你或你的团队能做到,那么请忽略这个姿势:)
姿势六:错误处理使⽤defer
我们⼀般通过判断error的值来处理错误,如果当前操作失败,需要将本函数中已经create的资源destroy掉,⽰例代码如下:
func deferDemo() error {
err := createResource1()
if err != nil {
return ERR_CREATE_RESOURCE1_FAILED
}
err = createResource2()
if err != nil {
destroyResource1()
return ERR_CREATE_RESOURCE2_FAILED
}
err = createResource3()
正则匹配到第一个关键字就停止if err != nil {
destroyResource1()
destroyResource2()
return ERR_CREATE_RESOURCE3_FAILED
}
err = createResource4()
if err != nil {
destroyResource1()
destroyResource2()
destroyResource3()
return ERR_CREATE_RESOURCE4_FAILED
}
return nil
}
当Golang的代码执⾏时,如果遇到defer的闭包调⽤,则压⼊堆栈。当函数返回时,会按照后进先出的顺序调⽤闭包。
对于闭包的参数是值传递,⽽对于外部变量却是引⽤传递,所以闭包中的外部变量err的值就变成外部函数返回时最新的err值。
根据这个结论,我们重构上⾯的⽰例代码:
func deferDemo() error {
err := createResource1()
if err != nil {
return ERR_CREATE_RESOURCE1_FAILED
}
defer func() {
if err != nil {
destroyResource1()
}
}()
err = createResource2()
if err != nil {
return ERR_CREATE_RESOURCE2_FAILED
}
defer func() {
destroyResource2()
}
}()
err = createResource3()
if err != nil {
return ERR_CREATE_RESOURCE3_FAILED
}
defer func() {
if err != nil {
destroyResource3()
}
}()
err = createResource4()
if err != nil {
return ERR_CREATE_RESOURCE4_FAILED
}
return nil
}
姿势七:当尝试⼏次可以避免失败时,不要⽴即返回错误
如果错误的发⽣是偶然性的,或由不可预知的问题导致。⼀个明智的选择是重新尝试失败的操作,有时第⼆次或第三次尝试时会成功。在重试时,我们需要限制重试的时间间隔或重试的次数,防⽌⽆限制的重试。
两个案例:
1. 我们平时上⽹时,尝试请求某个URL,有时第⼀次没有响应,当我们再次刷新时,就有了惊喜。
2. 团队的⼀个QA曾经建议当Neutron的attach操作失败时,最好尝试三次,这在当时的环境下验证果然是有效的。
姿势⼋:当上层函数不关⼼错误时,建议不返回error
对于⼀些资源清理相关的函数(destroy/delete/clear),如果⼦函数出错,打印⽇志即可,⽽⽆需将错误进⼀步反馈到上层函数,因为⼀般情况下,上层函数是不关⼼执⾏结果的,或者即使关⼼也⽆能为⼒,于是我们建议将相关函数设计为不返回err 姿势九:当发⽣错误时,不忽略有⽤的返回值
通常,当函数返回non-nil的error时,其他的返回值是未定义的(undefined),这些未定义的返回值应该被忽略。然⽽,有少部分函数在发⽣错误时,仍然会返回⼀些有⽤的返回值。⽐如,当读取⽂件发⽣错误时,Read函数会返回可以读取的字节数以及
说明:对函数的返回值要有清晰的说明,以便于其他⼈使⽤。
异常处理的正确姿势
姿势⼀:在程序开发阶段,坚持速错
去年学习Erlang的时候,建⽴了速错的理念,简单来讲就是“让它挂”,只有挂了你才会第⼀时间知道错误。在早期开发以及任何发布阶段之前,最简单的同时也可能是最好的⽅法是调⽤panic函数来中断程序的执⾏以强制发⽣错误,使得该错误不会被姿势⼆:在程序部署后,应恢复异常避免程序终⽌
在Golang中,虽然有类似Erlang进程的Goroutine,但需要强调的是Erlang的挂,只是Erlang进程的异常退出,不会导致整个Erlang节点退出,所以它挂的影响层⾯⽐较低,⽽Goroutine如果panic了,并且没有recover,那么整个Golang进程(类似Erla 这个姿势类似于C语⾔中的断⾔,但还是有区别:⼀般在Release版本中,断⾔被定义为空⽽失效,但需要有if校验存在进⾏异常保护,尽管契约式设计中不建议这样做。在Golang中,recover完全可以终⽌异常展开过程,省时省⼒。
我们在调⽤recover的延迟函数中以最合理的⽅式响应该异常:
1. 打印堆栈的异常调⽤信息和关键的业务信息,以便这些问题保留可见;
2. 将异常转换为错误,以便调⽤者让程序恢复到健康状态并继续安全运⾏。
我们看⼀个简单的例⼦:
func funcA() error {
defer func() {
if p := recover(); p != nil {
fmt.Printf("panic recover! p: %v", p)
debug.PrintStack()
}
}()
return funcB()
}
func funcB() error {
/
/ simulation
panic("foo")
return errors.New("success")
}
func test() {
err := funcA()
if err == nil {
fmt.Printf("err is nil\\n")
} else {
fmt.Printf("err is %v\\n", err)
}
}
我们期望test函数的输出是:
err is foo
实际上test函数的输出是:
err is nil
原因是panic异常处理机制不会⾃动将错误信息传递给error,所以要在funcA函数中进⾏显式的传递,代码如下所⽰:
func funcA() (err error) {
defer func() {
if p := recover(); p != nil {
fmt.Println("panic recover! p:", p)
str, ok := p.(string)
if ok {
err = errors.New(str)
} else {
err = errors.New("panic")
}
debug.PrintStack()
}
}()
return funcB()
}
姿势三:对于不应该出现的分⽀,使⽤异常处理
当某些不应该发⽣的场景发⽣时,我们就应该调⽤panic函数来触发异常。⽐如,当程序到达了某条逻辑上不可能到达的路径:
switch s := suit(drawCard()); s {
case "Spades":
// ...
case "Hearts":
// ...
case "Diamonds":
// ...
case "Clubs":
// ...
default:
panic(fmt.Sprintf("invalid suit %v", s))
}
姿势四:针对⼊参不应该有问题的函数,使⽤panic设计
⼊参不应该有问题⼀般指的是硬编码,我们先看“⼀个启⽰”⼀节中提到的两个函数(Compile和MustCompile),其中MustCompile函数是对Compile函数的包装:
func MustCompile(str string) *Regexp {
regexp, error := Compile(str)
if error != nil {
panic(`regexp: Compile(` + quote(str) + `): ` + error.Error())
}
return regexp
}
所以,对于同时⽀持⽤户输⼊场景和硬编码场景的情况,⼀般⽀持硬编码场景的函数是对⽀持⽤户输⼊场景函数的包装。
对于只⽀持硬编码单⼀场景的情况,函数设计时直接使⽤panic,即返回值类型列表中不会有error,这使得函数的调⽤处理⾮常⽅便(没有了乏味的"if err != nil {/ 打印 && 错误处理 /}"代码块)。⼩结
本⽂以Golang为例,阐述了错误和异常的区别,并且分享了很多错误和异常处理的正确姿势,这些姿势可以单独使⽤,也可以组合使⽤,希望对⼤家有⼀点启发。
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论