[golang] 错误处理

字符串类型的错误

// simple string-based error
err1 := errors.New("math: square root of negative number")

// with formatting
err2 := fmt.Errorf("math: square root of negative number %g", x)

自定义错误

自定义错误可携带额外信息,比如错误码等。

先定义一个错误码枚举:

// 定义枚举前需要先定义类型
type Status int

// 定义枚举
const (
	InvalidLogin = iota + 1
	NotFound
)

然后使用上面的枚举来实现自定义错误:

type StatusErr struct {
	Status  Status
	Message string
}

func (se StatusErr) Error() string {
	return se.Message
}

测试:

func foo() error {
	id := 1000
	return StatusErr{
		Status:  InvalidLogin,
		Message: fmt.Sprintf("status error with user id %d", id),
	}
}

func main() {
	err := foo()
	switch e := err.(type) {
	case StatusErr:
		fmt.Println("error with status code:", e.Status)
	default:
		fmt.Println(e)
	}
}

// error with status code: 1

上面示例代码中,注意两点:

  • 即使使用自定义的错误,也应该返回内置错误类型 error,这样调用方可以不依赖具体的错误类型,更加解耦
  • 当需要时,再还原原来的类型以获取其中的额外信息

哨兵错误

有一种错误,Sentinel 错误,约定以 Err 开头,定义在包作用域内,当调用方接收到该类型错误后,表示程序无需继续往下,应该终止执行。

比如标准库中解压时文件类型不对的错误 ErrorFormat:

func main() {
	data := []byte("blahblah...")
	notAZipFile := bytes.NewReader(data)
	_, err := zip.NewReader(notAZipFile, int64((len(data))))
	if err == zip.ErrFormat {
		fmt.Println("invalid file type!")
	}
}

查看标准库中的实现,可看到其就是普通的 error 类型,只是命名按照了上述约定:

// /xxx/go/1.16.5/libexec/src/archive/zip/reader.go

var (
	ErrFormat    = errors.New("zip: not a valid zip file")
	ErrAlgorithm = errors.New("zip: unsupported compression algorithm")
	ErrChecksum  = errors.New("zip: checksum error")
)

Error 的包装

接收到 error 后,可添加点信息在上面,再将其返回出去。这样可形成一条错误链。

前面涉及到的 fmt.Errorf 支持一个 %wwrap)参数,可接入一个 error 类型从而在其上面进行二次包装。

func bar() error {
	err := foo()
	return fmt.Errorf("some more info on err, the source err is :%w", err)
}

%v(value) 则只是将原错误信息的文本形成新文本,而不会有包装操作。

对应地,通过 errors.Unwrap 可解出被封装的原始错误信息:

func main() {
	err := bar()
	if err != nil {
		fmt.Println(err)
		if sourceErr := errors.Unwrap(err); sourceErr != nil {
			fmt.Println(sourceErr)
		}
	}
}

// some more info on err, the source err is :status error with user id 1000
// status error with user id 1000

自定义错误要支持 Unsrap 需要实现该方法:

	type StatusError struct {
		Status Status
		Message string
+		err error
	}
	
+	func (se StatusError) Unwrap()error{
+		return se.err
+	}

IsAs

既然错误可以二次包装,那么问题来了,如果对哨兵错误进行了包装,调用方如何知道错误中包含了哨兵错误呢。Go 中提供了两个方法 [errors.Is](http://errors.Is)[errors.As](http://errors.As) 来解决这个问题。

func fileChecker(name string) error {
	f, err := os.Open(name)
	if err != nil {
		return fmt.Errorf("in fileChecker: %w", err)
	}
	defer f.Close()
	return nil
}

func main() {
	err := fileChecker("xxx.txt")
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			fmt.Println("file not exit")
		}
	}
}

内部实现上,errors.Is 是通过不断 Unwrap 错误并与之进行对比来判断的,所以,自定义错误要支持对比,需要实现 Is 方法:

type MyErr struct {
	Status int
}

func (me MyErr)Error()string{
	return fmt.Sprintf("error code: %d",me.Status)
}

func (me MyErr) Is(target error)bool{
	if me2,ok:=target.(MyErr);ok{
		return  reflect.DeepEqual(me,me2)
	}
	return false
}

As 判断一个错误是不是你要找的类型,该方法接收两个参数,一个是具体的错误,另一个是想要找的类型的一个指标,成功的话,说明在错误链上打到了对应类型错误并赋值给了这个指针。

func main() {
	err:=AFuncThatReturnsAnErr()
	var myErr MyErr
	if errors.As(err,&myErr){
		fmt.Println(myErr.Code)
	}
}

所以前面在讲自定义错误的时候,除了使用 err.(type) 来还原错误本身的类型,也可以用这里提到的 As

同理,也可以自己实现自定义错误的 As 方法来决定其中的细节,不过一般场景不涉及。

总结:

  • 使用 [errors.Is](http://errors.Is) 来查找具体某个错误值
  • 使用 [errors.As](http://errors.As) 来判断具体某个错误类型

结合 defer 来包装错误

函数中如果有多个地方都需要进行同样的错误包装,比如像下面这样:

func foo(i int)(string,error)  {
	_,err:=f1(i)
	if err=nil{
		return "",fmt.Errorf("in foo:%w",err)
	}
	_,err:=f2(i)
	if err=nil{
		return "",fmt.Errorf("in foo:%w",err)
	}
	_,err:=f3(i)
	if err=nil{
		return "",fmt.Errorf("in foo:%w",err)
	}
	return "",nil
}

联想到之前介绍过的 defer 可做一些收尾工作,可利用其在函数返回时来统一进行错误包装,优化掉这里的冗余代码:

func foo(i int)(_ string,err error)  {
	defer func() {
		if err!=nil{
			 err=fmt.Errorf("in foo:%w",err)
		}
	}()

	_,err:=f1(i)
	if err=nil{
		return "",err
	}
	_,err:=f2(i)
	if err=nil{
		return "",err
	}
	return f3(i)
}

注意使用 defer 进行返回时,需要为返回变量声明名称,这样才能在 defer 中使用。

发生 panic 及恢复

Go 程序中发生异常时会生成 panic,比如试图访问超出 slice 边界的元素,内存溢出等。

除了 runtime 会发生 panic,程序中也可根据需要来生成:

func genPanic(msg string) {
	panic(msg)
}

func main() {
	genPanic("blah")
}

运行结果:

$ go run main.go
panic: blah

goroutine 1 [running]:
main.genPanic(...)
        /xxx/main.go:4
main.main()
        /xxx/main.go:8 +0x50
exit status 2
make: *** [run] Error 1

可通过 recover 来处理 panic,结合 defer 来用,让程序优雅退出而不是崩溃:

func div60(i int) {
	defer func() {
		if v := recover(); v != nil {
			fmt.Println(v)
		}
	}()
	fmt.Println(60 / i)
}

func main() {
	for _, val := range []int{1, 2, 0, 6} {
		div60(val)
	}
}

运行结果:

go run main.go
60
30
runtime error: integer divide by zero
10

与其他语言的 try catch 不同,发生 panic 后不建议通过 recover 让程序还继续运行,而是需要根据 panic 进行对应善后。比如,如果是内存溢出,在 recover 时及时落日志上报监控保存现场然后通过 os.Exit(1) 退出程序。

不应该通过 panic recover 模式来获取出错的堆栈信息,recover 只是单纯提供一种在发生错误时,将信息打印出来,然后程序能够正常继续而不崩掉的机制。如果想打印出错误时的堆栈信息,一是可以通过包装错误自己实现,二是可用现成三方库。使用 fmt.Printf 时,也可通过 %+v 打印 stack trace,但注意此时会打印程序文件的绝对路径,真实生产环境中一般想要隐藏掉这些信息,可在编译时加上 -trimpath 参数。