序言

错误 和 异常 是两个不同的概念,非常容易混淆。很多程序员习惯将一切非正常情况都看做错误,而不区分错误和异常,即使程序中可能有异常抛出,也将异常及时捕获并转换成错误。从表面上看,一切皆错误的思路更简单,而异常的引入仅仅增加了额外的复杂度。

但事实并非如此。众所周知,Golang 遵循“少即是多”的设计哲学,追求简洁优雅,就是说如果异常价值不大,就不会将异常加入到语言特性中。

错误 和 异常 处理是程序的重要组成部分,我们先看看下面几个问题:

  1. 错误 和 异常 如何区分?
  2. 错误处理的方式有哪几种?
  3. 什么时候需要使用异常终止程序?
  4. 什么时候需要捕获异常?
  5. ...

如果你对这几个问题的答案不是太清楚,那么就抽一点时间看看本文,或许能给你一些启发。

基础知识

错误指的是可能出现问题的地方出现了问题,比如打开一个文件时失败,这种情况在人们的意料之中;而异常指的是不应该出现问题的地方出现了问题,比如引用了空指针,这种情况在人们的意料之外。可见,错误是业务过程的一部分,而异常不是

Golang 中引入 error 接口类型作为错误处理的标准模式,如果函数要返回错误,则返回值类型列表中肯定包含 error。error 处理过程类似于 C 语言中的错误码,可逐层返回,直到被处理。

Golang 中引入两个内置函数 panic 和 recover 来触发和终止异常处理流程,同时引入关键字 defer 来延迟执行 defer 后面的函数。

一直等到包含 defer 语句的函数执行完毕时,延迟函数(defer后的函数)才会被执行,而不管包含 defer 语句的函数是通过 return 的正常结束,还是由于 panic 导致的异常结束。你可以在一个函数中执行多条 defer 语句,它们的执行顺序与声明顺序相反。

当程序运行时,如果遇到引用空指针、下标越界或显式调用 panic 函数等情况,则先触发 panic 函数的执行,然后调用延迟函数。调用者继续传递 panic,因此该过程一直在调用栈中重复发生:函数停止执行,调用延迟执行函数等。如果一路在延迟函数中没有 recover 函数的调用,则会到达该协程的起点,该协程结束,然后终止其他所有协程,包括主协程(类似于 C 语言中的主线程,该协程 ID 为 1)。

错误和异常从 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)
if err != nil {
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() {
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
}

姿势七:当尝试几次可以避免失败时,不要立即返回错误

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

两个案例:

  1. 我们平时上网时,尝试请求某个 URL,有时第一次没有响应,当我们再次刷新时,就有了惊喜。
  2. 团队的一个 QA 曾经建议当 Neutron 的 attach 操作失败时,最好尝试三次,这在当时的环境下验证果然是有效的。

姿势八:当上层函数不关心错误时,建议不返回 error

对于一些资源清理相关的函数(destroy/delete/clear),如果子函数出错,打印日志即可,而无需将错误进一步反馈到上层函数,因为一般情况下,上层函数是不关心执行结果的,或者即使关心也无能为力,于是我们建议将相关函数设计为不返回 error。

姿势九:当发生错误时,不忽略有用的返回值

通常,当函数返回 non-nil 的 error 时,其他的返回值是未定义的(undefined),这些未定义的返回值应该被忽略。然而,有少部分函数在发生错误时,仍然会返回一些有用的返回值。比如,当读取文件发生错误时,Read 函数会返回可以读取的字节数以及错误信息。对于这种情况,应该将读取到的字符串和错误信息一起打印出来。

说明:对函数的返回值要有清晰的说明,以便于其他人使用。

异常处理的正确姿势

姿势一:在程序开发阶段,坚持速错

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

姿势二:在程序部署后,应恢复异常避免程序终止

在 Golang 中,虽然有类似 Erlang 进程的 Goroutine,但需要强调的是 Erlang 的挂,只是 Erlang 进程的异常退出,不会导致整个 Erlang 节点退出,所以它挂的影响层面比较低,而 Goroutine 如果 panic 了,并且没有 recover,那么整个 Golang 进程(类似 Erlang 节点)就会异常退出。所以,一旦 Golang 程序部署后,在任何情况下发生的异常都不应该导致程序异常退出,我们在上层函数中加一个延迟执行的 recover 调用来达到这个目的,并且是否进行 recover 需要根据环境变量或配置文件来定,默认需要 recover。

这个姿势类似于 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 为例,阐述了 错误 和 异常 的区别,并且分享了很多 错误 和 异常 处理的正确姿势,这些姿势可以单独使用,也可以组合使用,希望对大家有一点启发。

延伸阅读:

[Go] panic 和 recover

摘自:

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

后记

针对 业务方法 之间 业务错误 处理这一块,其实还可以参考一下先前的文章:

如何设计PHP业务模块(函数/方法)返回结果的结构?

[Go] 如何正确地 抛出 错误 和 异常(error/panic/recover)?的更多相关文章

  1. 《从零开始学Swift》学习笔记(Day54)——抛出错误

    原创文章,欢迎转载.转载请注明:关东升的博客 能放到try后面调用函数或方法都是有要求的,他们是有可能抛出错误,在这些函数或方法声明的参数后面要加上throws关键字,表示这个函数或方法可以抛出错误. ...

  2. Python错误 -- try/except/finally 、调用栈、记录错误、抛出错误

    Bug:程序编写有问题造成的错误,称之为Bug.    debug:调试 注意:bug是程序本身有问题.有缺陷.系统漏洞 异常:完全无法在程序运行中预测的错误,例如写入文件的时候,磁盘满了,写不进去了 ...

  3. PostgreSQL 抛出错误信息(错误行号)

    抛出错误行号是我们在写SQL中常用到的,在SQL Server和Oracle中都很简单,但是在PostgreSQL怎么实现呢?在网上查了下资料只有pg_exception_context包含错误行,我 ...

  4. 错误try……except……else……finally 记录错误logging 抛出错误raise

    1.错误处理机制 try--except--finally 格式: try: 可能出错的代码 except xxx1Error as e: 处理1 except xxx2Error as e: 处理2 ...

  5. shiro配置unauthorizedUrl,无权限抛出无权限异常,但是不跳转

    在使用shiro配置无授权信息的url的时候,发现这样的一个scenario,配置好unauthorizedUrl后仍然无法跳转,然后就在网上开始找,找了原因以及解决方案 原因,先post一个源码: ...

  6. 【JAVASE】Java同一时候抛出多个异常

    Java有异常抛出后.跳出程序.一般无法运行接下来的代码. 大家做登陆功能.常常会实username和password的登陆校验,username或者password错误.假设通常是提示usernam ...

  7. @Required 注释应用于 bean 属性的 setter 方法,它表明受影响的 bean 属性在配置时必须放在 XML 配置文件中,否则容器就会抛出一个 BeanInitializationException 异常。

    @Required 注释应用于 bean 属性的 setter 方法,它表明受影响的 bean 属性在配置时必须放在 XML 配置文件中,否则容器就会抛出一个 BeanInitializationEx ...

  8. c# throw抛出上一个异常

    catch(exception e) { throw; } 不仅抛出这次的异常,也抛出之前的异常. 用法示例:函数A调用函数B,A用到此throw时,B中发生的异常也会继承过来. catch(exce ...

  9. .NET WebAPI 正确抛出错误详细信息

    try { ... } catch (Exception e) { //在webapi中要想抛出异常必须这样抛出,否则之抛出一个默认500的异常 var resp = new HttpResponse ...

随机推荐

  1. python中的__call__

    如果python中的一个类定义了 __call__ 方法,那么这个类它的实例就可以作为函数调用,也就是实现了 () 运算符,即可调用对象协议 下面是一个简单的例子: class TmpTest: de ...

  2. 第10月第20天 afnetwork like MKNetworkEngine http post

    1. + (AFHTTPRequestOperation *)requestSellerWithCompletion:(requestFinishedCompletionBlock)successBl ...

  3. 利用VBS下载EXE文件手法记录

    1.信息来源 疑似朝鲜通过鱼叉攻击韩国统一部记者的APT事件整理 https://mp.weixin.qq.com/s/4IFV31MBNbANnCVaJj7ZPQ https://twitter.c ...

  4. readb(), readw(), readl(),writeb(), writew(), writel() 宏函数【转】

    转自:http://www.netfoucs.com/article/hustyangju/70429.html readb(), readw(), readl()函数功能:从内存映射的 I/O 空间 ...

  5. nginx tomcat 自动部署python脚本【转】

    #!/usr/bin/env python #--coding:utf8-- import sys,subprocess,os,datetime,paramiko,re local_path='/ho ...

  6. linux磁盘空间查看inode

    服务器一般是要求长期连续运行的,自动执行任务生成的各种文件及日志,可能使空间占满,从而造成业务故障,所以要定时清理. 一般来说,Linux空间占满有如两种情况: 1.空间被占满了 用df -k 可以看 ...

  7. 查看nginx | apache | php | tengine | tomcat版本的信息以及如何隐藏版本信息【转】

    转自: 查看nginx | apache | php | tengine | tomcat版本的信息以及如何隐藏版本信息 - 追马 - 51CTO技术博客http://lovelace.blog.51 ...

  8. vue 兼容IE报错解决方案

    IE 页面空白 报错信息 此时页面一片空白 报错原因 Babel 默认只转换新的 JavaScript 语法(如箭头函数),而不转换新的 API ,比如 Iterator.Generator.Set. ...

  9. Service Mesh 及其主流开源实现解析(转)

    什么是 Service mesh Service Mesh 直译过来是 服务网格,目的是解决系统架构微服务化后的服务间通信和治理问题.服务网格由 sidecar 节点组成.在介绍 service me ...

  10. h5手势密码开发(使用jq)

    直接上代码: 目录结构: 本次开用到的技术jq,以及引入的jq插件jquery.gesture.password.min.js index.html <!DOCTYPE html> < ...