GoLang 异常处理

2018-09-15 language golang

Golang 中的错误处理是一个被大家经常拿出来讨论的话题(另外一个是泛型),这里简单介绍其使用方法。

简介

GoLang 没有提供像 Java 的 try...catch 异常处理方式,而是通过函数返回值逐层往上,通过这种方式鼓励工程师显式检查错误而非忽略错误,好处是避免漏掉本应处理的错误,但使得代码比较繁杂。

如下是一个简单的示例。

package main

import (
    "errors"
    "fmt"
)

func Sqrt(f float64) (float64, error) {
    if f < 0 {
        return 0, errors.New("math: square root of negative number")
    }

    return f, nil
}

func main() {
    if res, err := Sqrt(-1); err != nil {
        fmt.Println(err)
    } else {
        fmt.Println(res)
    }
}

在函数中,通过 errors.New() 新建并返回一个错误信息;在调用方,如果返回的结果为 nil 则输出错误,在 fmt 中处理 error 时会调用 Error() 方法输出错误信息。

在 GoLang 中有如下的使用方式。

简单定义

可以在某个包里通过 errors.New() 新建一个对象,在代码其它地方可以直接引用,比较常用的是 io.EOF,用于判断 io 读取是否结束,其定义为。

package io

var EOF = errors.New("EOF")

在代码中可以通过如下方式使用。

if _, err := file.Read(buf); err == nil {
    log.Printf("%s", buf)
} else if err == io.EOF {
    log.Println("Read all file")
    return nil
} else {
    log.Printf("Read file failed, %v.\n", err)
    return err
}

如果要打印额外信息,也可以通过 fmt.Errorf() 格式化输出新的错误信息。

Wrap

原始在 github.com/pkg/errors 包中使用,后来在 Go 1.13 中新增了 Error Wrapping 的功能,不过没有提供 Wrap 函数,而是通过扩展 fmt.Errorf%w 参数实现。

除此之外,为了便于处理嵌套错误,同时包含了如下三个工具函数:

  • Unwrap() 返回被嵌套的错误,如果嵌套多层只返回第一层错误。
  • Is() 原来通过 err == io.EOF 判断,引入 Wrap 后会判断多层嵌套是否有该错误。
  • As() 原来通过断言判断错误类型,通过该方法可以处理嵌套。

如下是简单示例。

// 嵌套错误
ori := errors.New("original error")
err := fmt.Errorf("wrapped with: %w", ori)
fmt.Println(err)                // wrapped with: original error
fmt.Println(errors.Unwrap(err)) // original error

// 判断错误是否为某个具体错误类型
if err == io.EOF {
    return err
}
if errors.Is(err, io.EOF) {
    return err
}

// 进行类型转换,原始只能通过类型断言判断
if perr, ok := err.(*os.PathError); ok {
    fmt.Println(perr.Path)
}
var perr *os.PathError
if errors.As(err, &perr) {
    fmt.Println(perr.Path)
}

实现接口

在 GoLang 标准包中提供的错误处理功能是一个接口,定义如下。

type error interface {
    Error() string
}

也就是说,对于内置的 error 来说,只要实现该接口即可,例如在 net 包中定义了与网络相关的错误。

package net

// An Error represents a network error.
type Error interface {
	error
	Timeout() bool // Is the error a timeout?
	Temporary() bool
}

// A ParseError is the error type of literal network address parsers.
type ParseError struct {
	Type string
	Text string
}

func (e *ParseError) Error() string { return "invalid " + e.Type + ": " + e.Text }
func (e *ParseError) Timeout() bool   { return false }
func (e *ParseError) Temporary() bool { return false }

那么在代码中就可以通过如下逻辑进行处理。

if e, ok := err.(net.Error); ok && e.Temporary() {
    time.Sleep(1e9)
    continue
} else {
    return err
}

除了上述的接口方式定义,如果比较简单,还可以直接使用结构体,这样可以直接使用结构体中定义的公共变量。

总结

如上,在 GoLang 中有很多种方式来声明错误类型:

  • errors.New() 简单静态字符串错误;
  • fmt.Errorf() 格式化的错误字符串;
  • 单独实现有 Error() 方法的自定义类型;
  • pkg/errors 中的 Wrapped Errors 机制。

在选择时,一般基于如下的考量:A) 对于简单的错误信息,可以使用 errors.New() 或者 fmt.Errorf() ;B) 而当用户需要检测并处理这一错误时,可以使用自定义类型并实现 Error() 接口,然后用户使用断言判断;也可以通过 errors.New() 新建变量,然后用户判断。

异常

错误和异常是两个不同的概念,非常容易混淆,通常是将所有类型其看做错误,即使程序中可能有异常抛出,也将异常及时捕获并转换成错误。

  • 错误和异常如何区分?错误通常是指业务正常处理流程中出现了问题,例如文件打开失败、入参为空指针等;而异常非意料中的问题出现,例如内存申请失败等。
  • 错误处理的方式有哪几种?GoLang 采用类似 C 的错误码,用于逐层返回,直到被处理,业务中需要根据具体类型进行处理。
  • 什么时候需要使用异常终止程序?通常是程序受到了影响,无法正常运行时,不如就直接退出程序,例如空指针、下标越界等。
  • 什么时候需要捕获异常?同样要具体分析,可以在任务处理的最外层捕获,这样任务失败不影响其它任务执行,例如 HTTP 请求。

Go 追求的是简洁优雅,没有提供传统的 try ... catch ... finally 这种异常处理方式,引入的是 defer panic recover 。也就是在 Go 中抛出一个 panic 异常,然后在 defer 中通过 recover 捕获这个异常,然后正常处理。

机制介绍

其中 recover() 是 GoLang 的内建函数,可以让进入 panic 流程中的协程恢复过来,该函数仅在 defer 中有效,正常调用 recover() 会返回 nil 并且没有其它任何效果,如果当前协程执行了 panic,那么调用 recover() 可以捕获到 panic 的输入值,并且恢复正常执行。

正常是无需对 panic 的程序做任何处理,但有时需要从中恢复,至少可以在程序崩溃前做些操作。

示例

Go 对待异常 (准确说是panic) 态度是:没有全面否定异常的存在,但极不鼓励多用异常。

package main

import "fmt"

func main() {
    defer func() {
        if err := recover(); err != nil {
            fmt.Println(err)
        }
        fmt.Println("Process panic done")
    }()

    foobar()
}

func foobar() {
    fmt.Println("Before panic")
    panic("Panicing ...")
    fmt.Println("After panic")
}

通过 go run main.go 执行会输出如下内容。

Before panic
Panicing ...
Process panic done

也就是说,在 Panic 之后的内容不会再执行。

最佳实践

显示区分

可以通过函数名显示区分错误和异常,例如,在 regexp 包中有两个函数 Compile MustCompile,它们的声明如下:

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

同样的功能,不同的设计:

  • Compile() 基于错误处理设计,适用于用户输入场景,当用户输入的正则表达式不合法时,该函数会返回一个错误。
  • MustCompile() 基于异常处理设计,适用于硬编码场景,调用者明确知道输入不会引起函数错误,如果出现则直接触发异常。

也就是说,必须要明确什么是错误什么是异常,否则很容易出现一切皆错误或一切皆异常的情况。