go 错误与异常及处理

https://www.jianshu.com/p/f30da01eea97

错误与异常

错误和异常是两个不同的概念,非常容易混淆。

  • 错误指的是可能出现问题的地方出现了问题;
  • 异常指的是不应该出现问题的地方出现了问题。

错误和异常从Golang 机制上讲,就是 errorpanic 的区别。

regexp 包中有两个函数 CompileMustCompile,它们的声明如下:

func Compile(expr string) (*Regexp, error)
func MustCompile(str string) *Regexp

同样的功能,不同的设计:
Compile 函数基于错误处理设计,将正则表达式编译成有效的可匹配格式,适用于用户输入场景。当用户输入的正则表达式不合法时,该函数会返回一个错误。
MustCompile 函数基于异常处理设计,适用于硬编码场景。当调用者明确知道输入不会引起函数错误时,要求调用者检查这个错误是不必要和累赘的。我们应该假设函数的输入一直合法,当调用者输入了不应该出现的输入时,就触发 panic 异常。
从上面 regexp 中的两个函数中我们得到一个启示:

  • 什么情况下用错误表达?
  • 什么情况下用异常表达?

得有一套规则,否则很容易出现一切皆错误或一切皆异常的情况。

在这个启示下,我们给出 异常处理 的作用域(场景):

  • 空指针引用
  • 下标越界
  • 除数为0
  • 不应该出现的分支,比如 switch default
  • 输入不应该引起函数错误

其他场景我们使用错误处理,这使得我们的函数接口很精炼。对于异常,我们可以选择在一个合适的上游去 recover,并打印堆栈信息,使得部署后的程序不会终止。

错误处理正确姿势

1 失败的原因只有一个时,不使用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

2 没有失败时,不使用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 完全是多余。

3 error应放在返回值类型列表的最后

对于返回值类型 error,用来传递错误信息,在Golang中通常放在最后一个。

resp, err := http.Get(url)
if err != nil {
    return nill, err
}

bool 作为返回值类型时也一样。

value, ok := cache.Lookup(key) 
if !ok {
    // ...cache[key] does not exist… 
}
4 错误值统一定义

很多人写代码时,到处 return errors.New(value),而错误 value 在表达同一个含义时也可能形式不同,比如“记录不存在”的错误value可能为:
“record is not existed.”
“record is not exist!”
“###record is not existed!!!”
当上游想捕捉 这个错误时统一处理时,需要漫游所有下层代码,以保证错误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")
5 错误逐层传递时,层层都加日志

层层都加日志非常方便故障定位。

6 错误处理使用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 的闭包调用,则压入堆栈。当函数返回时,会按照后进先出的顺序调用闭包。(在 return 后面定义的 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 闭包函数
    }
    defer func() {
        if err != nil {
            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
}
7 当尝试几次可以避免失败时,不要立即返回错误

如果错误的发生是偶然性的,或由不可预知的问题导致。一个明智的选择是重新尝试失败的操作,有时第二次或第三次尝试时会成功。在重试时,我们需要限制重试的时间间隔或重试的次数,防止无限制的重试。

两个案例:

  1. 我们平时上网时,尝试请求某个URL,有时第一次没有响应,当我们再次刷新时,就有了惊喜。
  2. 团队的一个QA曾经建议当Neutron的attach操作失败时,最好尝试三次,这在当时的环境下验证果然是有效的。
8 当上层函数不关心错误时,建议不返回error
9 当发生错误时,不忽略有用的返回值

有少部分函数在发生错误时,仍然会返回一些有用的返回值。比如,当读取文件发生错误时,Read函数会返回可以读取的字节数以及错误信息。对于这种情况,应该将读取到的字符串和错误信息一起打印出来。

异常处理正确姿势

1 在程序开发阶段,坚持速错

简单来讲就是“让它挂”,只有挂了你才会第一时间知道错误。在早期开发以及任何发布阶段之前,最简单的同时也可能是最好的方法是调用 panic 函数来中断程序的执行以强制发生错误,使得该错误不会被忽略,因而能够被尽快修复。

2 程序部署后,应恢复异常避免程序终止

一旦Golang程序部署后,在任何情况下发生的异常都不应该导致程序异常退出,我们在上层函数中加一个延迟执行的recover 调用来达到这个目的,并且是否进行 recover 需要根据环境变量或配置文件来定,默认需要 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 {
    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, 实际输出为: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()
}
3 对于不应该出现的分支,使用异常处理

当某些不应该发生的场景发生时,我们就应该调用 panic 函数来触发异常。比如,当程序到达了某条逻辑上不可能到达的路径:

switch s := suit(drawCard()); s {
    case "Spades":
    // ...
    case "Hearts":
    // ...
    case "Diamonds":
    // ... 
    case "Clubs":
    // ...
    default:
        panic(fmt.Sprintf("invalid suit %v", s))   // 不该出现的逻辑分支 使用 panic 触发异常
}
4 针对入参不应该有问题的函数,使用panic设计

入参不应该有问题一般指的是硬编码,我们先看“一个启示”一节中提到的两个函数(CompileMustCompile),其中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 { 打印 && 错误处理 }"代码块)。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值