Golang通脉之错误处理

在实际工程项目中,总是通过程序的错误信息快速定位问题,但是又不希望错误处理代码写的冗余而又啰嗦。Go语言没有提供像JavaC#语言中的try...catch异常处理方式,而是通过函数返回值逐层往上抛。这种设计,鼓励在代码中显式的检查错误,而非忽略错误,好处就是避免漏掉本应处理的错误。但是带来一个弊端,让代码冗余。

什么是错误

错误指的是可能出现问题的地方出现了问题。如打开一个文件时失败,这种情况是在意料之中的 。

而异常指的是不应该出现问题的地方出现了问题。比如引用了空指针,这种情况在在意料之外的。可见,错误是业务过程的一部分,而异常不是 。

Go中的错误也是一种类型。错误用内置的error 类型表示。就像其他类型的,如int,float64,。错误值可以存储在变量中,从函数中返回,等等。

演示错误

尝试打开一个不存在的文件:

func main() {  
    f, err := os.Open("/test.txt")
    if err != nil {
        fmt.Println(err)
        return
    }
  //根据f进行文件的读或写
    fmt.Println(f.Name(), "opened successfully")
}

在os包中有打开文件的功能函数:

func Open(name string) (file *File, err error)

如果文件已经成功打开,那么Open函数将返回文件处理。如果在打开文件时出现错误,将返回一个非nil错误。

如果一个函数或方法返回一个错误,那么按照惯例,它必须是函数返回的最后一个值。因此,Open 函数返回的值是最后一个值。

处理错误的惯用方法是将返回的错误与nil进行比较。nil值表示没有发生错误,而非nil值表示出现错误。

运行结果:

open /test.txt: No such file or directory

得到一个错误,说明该文件不存在。

错误类型表示

Go 语言通过内置的错误接口提供了非常简单的错误处理机制。

它非常简单,只有一个 Error 方法用来返回具体的错误信息:

type error interface {
    Error() string
}

它包含一个带有Error()字符串的方法。任何实现这个接口的类型都可以作为一个错误使用。这个方法提供了对错误的描述。

当打印错误时,fmt.Println函数在内部调用Error() 方法来获取错误的描述。这就是错误描述是如何在一行中打印出来的。

从错误中提取更多信息的不同方法

在上面的例子中,仅仅是打印了错误的描述。如果想要的是导致错误的文件的实际路径。一种可能的方法是解析错误字符串,

open /test.txt: No such file or directory  

可以解析这个错误消息并从中获取文件路径"/test.txt"。但这是一个糟糕的方法。在新版本的语言中,错误描述可以随时更改,代码将会中断。

标准Go库使用不同的方式提供更多关于错误的信息。

断言底层结构类型,结构字段获取

如果仔细阅读打开函数的文档,可以看到它返回的是PathError类型的错误。PathError是一个struct类型,它在标准库中的实现如下,

type PathError struct {  
    Op   string
    Path string
    Err  error
}

func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }  

从上面的代码中,可以理解PathError通过声明Error() string方法实现了错误接口。该方法连接操作、路径和实际错误并返回它。这样就得到了错误信息,

open /test.txt: No such file or directory 

PathError结构的路径字段包含导致错误的文件的路径。修改上面示例,并打印出路径:

func main() {  
    f, err := os.Open("/test.txt")
    if err, ok := err.(*os.PathError); ok {
        fmt.Println("File at path", err.Path, "failed to open")
        return
    }
    fmt.Println(f.Name(), "opened successfully")
}

使用类型断言获得错误接口的基本值。然后用错误来打印路径.这个程序输出,

File at path /test.txt failed to open  

断言底层结构类型,使用方法获取

获得更多信息的第二种方法是断言底层类型,并通过调用struct类型的方法获取更多信息:

type DNSError struct {  
    ...
}

func (e *DNSError) Error() string {  
    ...
}
func (e *DNSError) Timeout() bool {  
    ... 
}
func (e *DNSError) Temporary() bool {  
    ... 
}

从上面的代码中可以看到,DNSError struct有两个方法Timeout() bool和Temporary() bool,它们返回一个布尔值,表示错误是由于超时还是临时的。

编写一个断言*DNSError类型的程序,并调用这些方法来确定错误是临时的还是超时的。

func main() {  
    addr, err := net.LookupHost("golangbot123.com")
    if err, ok := err.(*net.DNSError); ok {
        if err.Timeout() {
            fmt.Println("operation timed out")
        } else if err.Temporary() {
            fmt.Println("temporary error")
        } else {
            fmt.Println("generic error: ", err)
        }
        return
    }
    fmt.Println(addr)
}

在上面的程序中,尝试获取一个无效域名的ip地址,通过声明它来输入*net.DNSError来获得错误的潜在价值。

在例子中,错误既不是暂时的,也不是由于超时,因此程序会打印:

generic error:  lookup golangbot123.com: no such host  

如果错误是临时的或超时的,那么相应的If语句就会执行,可以适当地处理它。

直接比较

获得更多关于错误的详细信息的第三种方法是直接与类型错误的变量进行比较。

filepath包的Glob函数用于返回与模式匹配的所有文件的名称。当模式出现错误时,该函数将返回一个错误ErrBadPattern

filepath包中定义了ErrBadPattern,如下所述:

var ErrBadPattern = errors.New("syntax error in pattern")  

errors.New()用于创建新的错误。

当模式出现错误时,由Glob函数返回ErrBadPattern

func main() {  
    files, error := filepath.Glob("[")
    if error != nil && error == filepath.ErrBadPattern {
        fmt.Println(error)
        return
    }
    fmt.Println("matched files", files)
}

运行结果:

syntax error in pattern  

不要忽略错误

永远不要忽略一个错误。忽视错误会招致麻烦。下面一个示例,列出了与模式匹配的所有文件的名称,而忽略了错误处理代码。

func main() {  
    files, _ := filepath.Glob("[")
    fmt.Println("matched files", files)
}

使用行号中的空白标识符,忽略了Glob函数返回的错误:

matched files []  

由于忽略了这个错误,输出看起来好像没有文件匹配到这个模式,但是实际上这个模式本身是畸形的。所以不要忽略错误。

自定义错误

创建自定义错误可以使用errors包下的New()函数,以及fmt包下的:Errorf()函数。

//errors包:
func New(text string) error {}

//fmt包:
func Errorf(format string, a ...interface{}) error {}

下面提供了错误包中的新功能的实现。

// Package errors implements functions to manipulate errors.
  package errors

  // New returns an error that formats as the given text.
  func New(text string) error {
      return &errorString{text}
  }

  // errorString is a trivial implementation of error.
  type errorString struct {
      s string
  }

  func (e *errorString) Error() string {
      return e.s
  }

既然知道了New()函数是如何工作的,那么就使用它来创建一个自定义错误。

创建一个简单的程序,计算一个圆的面积,如果半径为负,将返回一个错误。

func circleArea(radius float64) (float64, error) {  
    if radius < 0 {
        return 0, errors.New("Area calculation failed, radius is less than zero")
    }
    return math.Pi * radius * radius, nil
}

func main() {  
    radius := -20.0
    area, err := circleArea(radius)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Printf("Area of circle %0.2f", area)
}

运行结果:

Area calculation failed, radius is less than zero 

使用Errorf向错误添加更多信息

上面的程序运行没有问题,但是如果要打印出导致错误的实际半径,就不好处理了。这就是fmt包的Errorf函数的用武之地。这个函数根据一个格式说明器格式化错误,并返回一个字符串作为值来满足错误。

使用Errorf函数,修改程序:

func circleArea(radius float64) (float64, error) {  
    if radius < 0 {
        return 0, fmt.Errorf("Area calculation failed, radius %0.2f is less than zero", radius)
    }
    return math.Pi * radius * radius, nil
}

func main() {  
    radius := -20.0
    area, err := circleArea(radius)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Printf("Area of circle %0.2f", area)
}

运行结果:

Area calculation failed, radius -20.00 is less than zero  

使用结构体和字段提供错误的更多信息

还可以使用将错误接口实现为错误的struct类型。这使得错误处理更加的灵活。在上述示例中,如果想要访问导致错误的半径,那么唯一的方法是解析错误描述区域计算失败,半径-20.00小于零。这不是一种正确的方法,因为如果描述发生了变化,那么代码就会中断。

前面提到“断言底层结构类型从struct字段获取更多信息”,并使用struct字段来提供对导致错误的半径的访问。可以创建一个实现错误接口的struct类型,并使用它的字段来提供关于错误的更多信息。

1、创建一个struct类型来表示错误。错误类型的命名约定是,名称应该以文本Error结束:

type areaError struct {  
    err    string
    radius float64
}

上面的struct类型有一个字段半径,它存储了为错误负责的半径的值,并且错误字段存储了实际的错误消息。

2、实现error 接口

func (e *areaError) Error() string {  
    return fmt.Sprintf("radius %0.2f: %s", e.radius, e.err)
}

在上面的代码片段中,使用一个指针接收器区域错误来实现错误接口的Error() string方法。这个方法打印出半径和错误描述。

type areaError struct {  
    err    string
    radius float64
}

func (e *areaError) Error() string {  
    return fmt.Sprintf("radius %0.2f: %s", e.radius, e.err)
}

func circleArea(radius float64) (float64, error) {  
    if radius < 0 {
        return 0, &areaError{"radius is negative", radius}
    }
    return math.Pi * radius * radius, nil
}

func main() {  
    radius := -20.0
    area, err := circleArea(radius)
    if err != nil {
        if err, ok := err.(*areaError); ok {
            fmt.Printf("Radius %0.2f is less than zero", err.radius)
            return
        }
        fmt.Println(err)
        return
    }
    fmt.Printf("Area of circle %0.2f", area)
}

程序输出:

Radius -20.00 is less than zero

使用结构体方法提供错误的更多信息

1、创建一个结构来表示错误。

type areaError struct {  
    err    string //error description
    length float64 //length which caused the error
    width  float64 //width which caused the error
}

上面的错误结构类型包含一个错误描述字段,以及导致错误的长度和宽度。

2、实现错误接口,并在错误类型上添加一些方法来提供关于错误的更多信息。

func (e *areaError) Error() string {  
    return e.err
}

func (e *areaError) lengthNegative() bool {  
    return e.length < 0
}

func (e *areaError) widthNegative() bool {  
    return e.width < 0
}

在上面的代码片段中,返回Error() string 方法的错误描述。当长度小于0时,lengthNegative() bool方法返回true;当宽度小于0时,widthNegative() bool方法返回true。这两种方法提供了更多关于误差的信息。

面积计算函数:

func rectArea(length, width float64) (float64, error) {  
    err := ""
    if length < 0 {
        err += "length is less than zero"
    }
    if width < 0 {
        if err == "" {
            err = "width is less than zero"
        } else {
            err += ", width is less than zero"
        }
    }
    if err != "" {
        return 0, &areaError{err, length, width}
    }
    return length * width, nil
}

上面的rectArea函数检查长度或宽度是否小于0,如果它返回一个错误消息,则返回矩形的面积为nil。

主函数:

func main() {  
    length, width := -5.0, -9.0
    area, err := rectArea(length, width)
    if err != nil {
        if err, ok := err.(*areaError); ok {
            if err.lengthNegative() {
                fmt.Printf("error: length %0.2f is less than zero\n", err.length)

            }
            if err.widthNegative() {
                fmt.Printf("error: width %0.2f is less than zero\n", err.width)

            }
        }
        fmt.Println(err)
        return
    }
    fmt.Println("area of rect", area)
}

运行结果:

error: length -5.00 is less than zero  
error: width -9.00 is less than zero 

错误断言

有了自定义的 error,并且携带了更多的错误信息后,就可以使用这些信息了。需要先把返回的 error 接口转换为自定义的错误类型,即类型断言。

下面代码中的 err.(*commonError) 就是类型断言在 error 接口上的应用,也可以称为 error 断言。

sum, err := add(-1, 2)
if cm,ok := err.(*commonError);ok{
   fmt.Println("错误代码为:",cm.errorCode,",错误信息为:",cm.errorMsg)
} else {
   fmt.Println(sum)
}

如果返回的 ok 为 true,说明 error 断言成功,正确返回了 *commonError 类型的变量 cm,所以就可以像示例中一样使用变量 cm 的 errorCode 和 errorMsg 字段信息了。

错误嵌套

Error Wrapping

error 接口虽然比较简洁,但是功能也比较弱。想象一下,假如有这样的需求:基于一个存在的 error 再生成一个 error,需要怎么做呢?这就是错误嵌套。

这种需求是存在的,比如调用一个函数,返回了一个错误信息 error,在不想丢失这个 error 的情况下,又想添加一些额外信息返回新的 error。这时候,首先想到的应该是自定义一个 struct,如下面的代码所示:

type MyError struct {
    err error
    msg string
}

这个结构体有两个字段,其中 error 类型的 err 字段用于存放已存在的 error,string 类型的 msg 字段用于存放新的错误信息,这种方式就是 error 的嵌套。

现在让 MyError 这个 struct 实现 error 接口,然后在初始化 MyError 的时候传递存在的 error 和新的错误信息:

func (e *MyError) Error() string {
    return e.err.Error() + e.msg
}
func main() {
    //err是一个存在的错误,可以从另外一个函数返回
    newErr := MyError{err, "数据上传问题"}
}

这种方式可以满足需求,但是非常烦琐,因为既要定义新的类型还要实现 error 接口。所以从 Go 语言 1.13 版本开始,Go 标准库新增了 Error Wrapping 功能,可以基于一个存在的 error 生成新的 error,并且可以保留原 error 信息:

e := errors.New("原始错误e")
w := fmt.Errorf("Wrap了一个错误:%w", e)
fmt.Println(w)

Go 语言没有提供 Wrap 函数,而是扩展了 fmt.Errorf 函数,然后加了一个 %w,通过这种方式,便可以生成 wrapping error。

errors.Unwrap 函数

既然 error 可以包裹嵌套生成一个新的 error,那么也可以被解开,即通过 errors.Unwrap 函数得到被嵌套的 error

Go 语言提供了 errors.Unwrap 用于获取被嵌套的 error,比如以上例子中的错误变量 w ,就可以对它进行 unwrap,获取被嵌套的原始错误 e:

fmt.Println(errors.Unwrap(w))

可以看到这样的信息,即“原始错误 e”。

原始错误e

errors.Is 函数

有了 Error Wrapping 后,会发现原来用的判断两个 error 是不是同一个 error 的方法失效了,比如 Go 语言标准库经常用到的如下代码中的方式:

if err == os.ErrExist

为什么会出现这种情况呢?由于 Go 语言的 Error Wrapping 功能,令人不知道返回的 err 是否被嵌套,又嵌套了几层?

于是 Go 语言为我们提供了 errors.Is 函数,用来判断两个 error 是否是同一个:

func Is(err, target error) bool

可以解释为:

如果 err 和 target 是同一个,那么返回 true。

如果 err 是一个 wrapping error,target 也包含在这个嵌套 error 链中的话,也返回 true

可以简单地概括为,两个 error 相等或 err 包含 target 的情况下返回 true,其余返回 false。用上面的示例判断错误 w 中是否包含错误 e:

fmt.Println(errors.Is(w,e))

errors.As 函数

同样的原因,有了 error 嵌套后,error 断言也不能用了,因为不知道一个 error 是否被嵌套,又嵌套了几层。所以 Go 语言为解决这个问题提供了 errors.As 函数,比如前面 error 断言的例子,可以使用 errors.As 函数重写,效果是一样的:

var cm *commonError
if errors.As(err,&cm){
   fmt.Println("错误代码为:",cm.errorCode,",错误信息为:",cm.errorMsg)
} else {
   fmt.Println(sum)
}

所以在 Go 语言提供的 Error Wrapping 能力下,要尽可能地使用 Is、As 这些函数做判断和转换。

Deferred 函数

在一个自定义函数中,打开了一个文件,然后需要关闭它以释放资源。不管代码执行了多少分支,是否出现了错误,文件是一定要关闭的,这样才能保证资源的释放。

如果这个事情由开发人员来做,随着业务逻辑的复杂会变得非常麻烦,而且还有可能会忘记关闭。基于这种情况,Go 语言提供了 defer 函数,可以保证文件关闭后一定会被执行,不管自定义的函数出现异常还是错误。

下面的代码是 Go 语言标准包 ioutil 中的 ReadFile 函数,它需要打开一个文件,然后通过 defer 关键字确保在 ReadFile 函数执行结束后,f.Close() 方法被执行,这样文件的资源才一定会释放。

func ReadFile(filename string) ([]byte, error) {
   f, err := os.Open(filename)
   if err != nil {
      return nil, err
   }
   defer f.Close()
   //省略无关代码
   return readAll(f, n)
}

defer 关键字用于修饰一个函数或者方法,使得该函数或者方法在返回前才会执行,也就说被延迟,但又可以保证一定会执行。

以上面的 ReadFile 函数为例,被 defer 修饰的 f.Close 方法延迟执行,也就是说会先执行 readAll(f, n),然后在整个 ReadFile 函数 return 之前执行 f.Close 方法。

defer 语句常被用于成对的操作,如文件的打开和关闭,加锁和释放锁,连接的建立和断开等。不管多么复杂的操作,都可以保证资源被正确地释放

panic()和recover()

Golang中引入两个内置函数panicrecover来触发和终止异常处理流程,同时引入关键字defer来延迟执行defer后面的函数。 一直等到包含defer语句的函数执行完毕时,延迟函数(defer后的函数)才会被执行,而不管包含defer语句的函数是通过return的正常结束,还是由于panic导致的异常结束。你可以在一个函数中执行多条defer语句,它们的执行顺序与声明顺序相反。 当程序运行时,如果遇到引用空指针、下标越界或显式调用panic函数等情况,则先触发panic函数的执行,然后调用延迟函数。调用者继续传递panic,因此该过程一直在调用栈中重复发生:函数停止执行,调用延迟执行函数等。如果一路在延迟函数中没有recover函数的调用,则会到达该协程的起点,该协程结束,然后终止其他所有协程,包括主协程(类似于C语言中的主线程,该协程ID为1)。

panic:

  1. 内建函数
  2. 假如函数F中书写了panic语句,会终止其后要执行的代码,在panic所在函数F内如果存在要执行的defer函数列表,按照defer的逆序执行
  3. 返回函数F的调用者G,在G中,调用函数F语句之后的代码不会执行,假如函数G中存在要执行的defer函数列表,按照defer的逆序执行,这里的defer 有点类似 try-catch-finally 中的 finally
  4. 直到goroutine整个退出,并报告错误

recover:

  1. 内建函数
  2. 用来控制一个goroutine的panic行为,捕获panic,从而影响应用的行为
  3. 一般的调用建议 a). 在defer函数中,通过recever来终止一个goroutine的panic过程,从而恢复正常代码的执行 b). 可以获取通过panic传递的error

简单来讲:Go中可以抛出一个panic的异常,然后在defer中通过recover捕获这个异常,然后正常处理

错误和异常从Golang机制上讲,就是error和panic的区别。很多其他语言也一样,比如C++/Java,没有error但有errno,没有panic但有throw。

Golang错误和异常是可以互相转换的:

  1. 错误转异常,比如程序逻辑上尝试请求某个URL,最多尝试三次,尝试三次的过程中请求失败是错误,尝试完第三次还不成功的话,失败就被提升为异常了。
  2. 异常转错误,比如panic触发的异常被recover恢复后,将返回值中error类型的变量进行赋值,以便上层函数继续走错误处理流程。

什么情况下用错误表达,什么情况下用异常表达,就得有一套规则,否则很容易出现一切皆错误或一切皆异常的情况。

以下给出异常处理的作用域(场景):

  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")

姿势五:错误逐层传递时,层层都加日志

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

说明:至于通过测试来发现故障,而不是日志,目前很多团队还很难做到。如果你或你的团队能做到,那么请忽略这个姿势。

姿势六:错误处理使用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函数会返回可以读取的字节数以及错误信息。对于这种情况,应该将读取到的字符串和错误信息一起打印出来。

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

异常处理的正确姿势

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

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

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

在Golang中,某个Goroutine如果panic了,并且没有recover,那么整个Golang进程就会异常退出。所以,一旦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 {/ 打印 && 错误处理 /}"代码块)。

错误封装的实践

用户自定义类型

重写的Go里自带的error类型,首先从一个自定义的错误类型开始,该错误类型将在程序中识别为error类型。因此,引入一个封装了Go的 error的新自定义Error类型。

type GoError struct {
   error
}

上下文数据

当在Go中说error是一个值时,它是字符串值 - 任何实现了Error() string函数的类型都可以视作error类型。将字符串值视为error会使跨层的error处理复杂化,因此处理error字符串信息并不是正确的方法。所以可以使用嵌套错误把字符串和错误码解耦:

type GoError struct {
   error
   Code    string
}

现在对error的处理将基于错误码Code字段而不是字符串。可以通过上下文数据进一步对错误字符串进行解耦,在上下文数据中可以使用i18n包进行国际化。

type GoError struct {
   error
   Code    string
   Data    map[string]interface{}
}

Data包含用于构造错误字符串的上下文数据。错误字符串可以通过数据模板化:

//i18N def
"InvalidParamValue": "Invalid parameter value '{{.actual}}', expected '{{.expected}}' for '{{.name}}'"

在i18N定义文件中,错误码Code将会映射到使用Data构建的模板化的错误字符串中。

原因(Causes)

error可能发生在任何一层,有必要为每一层提供处理error的选项,并在不丢失原始error值的情况下进一步使用附加的上下文信息对error进行包装。GoError结构体可以用Causes进一步封装,用来保存整个错误堆栈。

type GoError struct {
   error
   Code    string
   Data    map[string]interface{}
   Causes  []error
}

如果必须保存多个error数据,则causes是一个数组类型,并将其设置为基本error类型,以便在程序中包含该原因的第三方错误。

组件(Component)

标记层组件将有助于识别error发生在哪一层,并且可以避免不必要的error wrap。例如,如果service类型的error组件发生在服务层,则可能不需要wrap error。检查组件信息将有助于防止暴露给用户不应该通知的error,比如数据库error:

type GoError struct {
   error
   Code      string
   Data      map[string]interface{}
   Causes    []error
   Component ErrComponent
}

type ErrComponent string
const (
   ErrService  ErrComponent = "service"
   ErrRepo     ErrComponent = "repository"
   ErrLib      ErrComponent = "library"
)

响应类型(ResponseType)

添加一个错误响应类型这样可以支持error分类,以便于了解什么错误类型。例如,可以根据响应类型(如NotFound)对error进行分类,像DbRecordNotFoundResourceNotFoundUserNotFound等等的error都可以归类为 NotFound error。这在多层应用程序开发过程中非常有用,而且是可选的封装:

type GoError struct {
   error
   Code         string
   Data         map[string]interface{}
   Causes       []error
   Component    ErrComponent
   ResponseType ResponseErrType
}

type ResponseErrType string

const (
   BadRequest    ResponseErrType = "BadRequest"
   Forbidden     ResponseErrType = "Forbidden"
   NotFound      ResponseErrType = "NotFound"
   AlreadyExists ResponseErrType = "AlreadyExists"
)

重试

在少数情况下,出现error会进行重试。retry字段可以通过设置Retryable标记来决定是否要进行error重试:

type GoError struct {
   error
   Code         string
   Message      string
   Data         map[string]interface{}
   Causes       []error
   Component    ErrComponent
   ResponseType ResponseErrType
   Retryable    bool
}

GoError 接口

通过定义一个带有GoError实现的显式error接口,可以简化error检查:

package goerr

type Error interface {
   error

   Code() string
   Message() string
   Cause() error
   Causes() []error
   Data() map[string]interface{}
   String() string
   ResponseErrType() ResponseErrType
   SetResponseType(r ResponseErrType) Error
   Component() ErrComponent
   SetComponent(c ErrComponent) Error
   Retryable() bool
   SetRetryable() Error
}

抽象error

有了上述的封装方式,更重要的是对error进行抽象,将这些封装保存在同一地方,并提供error函数的可重用性

func ResourceNotFound(id, kind string, cause error) GoError {
   data := map[string]interface{}{"kind": kind, "id": id}
   return GoError{
      Code:         "ResourceNotFound",
      Data:         data,
      Causes:       []error{cause},
      Component:    ErrService,
      ResponseType: NotFound,
      Retryable:    false,
   }
}

这个error函数抽象了ResourceNotFound这个error,开发者可以使用这个函数来返回error对象而不是每次创建一个新的对象:

//UserService
user, err := u.repo.FindUser(ctx, userId)
if err != nil {
   if err.ResponseType == NotFound {
      return ResourceNotFound(userUid, "User", err)
   }
   return err
}

结论

我们演示了如何使用添加上下文数据的自定义Go的error类型,从而使得error在多层应用程序中更有意义。可以在这里[1]看到完整的代码实现和定义。

参考资料

[1] 这里: https://gist.github.com/prathabk/744367cbfc70435c56956f650612d64b

posted @ 2021-10-26 10:02    阅读(371)  评论(2编辑  收藏  举报
微信公众号:写bug记得加注释 个人博客:https://www.drunkery.cn