错误

寒江蓑笠翁大约 26 分钟

错误

在Go中的异常有三种级别:

  • error:部分流程出错,需要处理
  • panic:很严重的问题,程序应该在处理完问题后立即退出
  • fatal:非常致命的问题,程序应该立即退出

准确的来说,Go并没有异常,更多的是通过错误来体现,同样的,Go中也并没有try-catch-finally这种语句,Go创始人希望能够将错误可控,他们不希望干什么事情都需要嵌套一堆try-catch,所以大多数情况会将其作为函数的返回值来返回,例如下方代码例子:

func main() {
	// 打开一个文件
	if file, err := os.Open("README.txt"); err != nil {
		fmt.Println(err)
        return
	}
    fmt.Println(file.Name())
}

这段代码的意图很明显,打开一个名为README.txt的文件,如果打开失败函数将会返回一个错误,输出错误信息,如果错误为nil的话那么就是打开成功,输出文件名。

看起来似乎是要比try-catch简洁一些,那如果有特别多的函数调用,将会到处都充斥着if err != nil 这种判断语句,比如下面的例子,这是一个计算文件哈希值的demo,在这一小段代码中总共出现了三次if err != nil

func main() {
	sum, err := checksum("main.go")
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(sum)
}

func checksum(path string) (string, error) {
	file, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer file.Close()

	hash := sha256.New()
	_, err = io.Copy(hash, file)
	if err != nil {
		return "", err
	}

	var hexSum [64]byte
	sum := hash.Sum(nil)
	hex.Encode(hexSum[:], sum)

	return string(hexSum[:]), nil
}

正因如此,外界对于Go最诟病的点就在错误处理上,Go源代码里if err != nil就占了相当一部分。Rust同样也是返回错误值,但没有人会去说它这一点,因为它通过语法糖的方式解决了这类问题,与Rust相比之下,Go的语法糖不能说很多,只能说是几乎没有。

不过我们看待事物要辩证的来看,凡事都是有好有坏的,Go的错误处理的优点有几个

  • 心智负担小:有错误就处理,不处理就返回
  • 可读性:因为处理的方式非常简单,大部分情况下都很容易读懂代码
  • 易于调试:每一个错误都是通过函数调用的返回值产生的,可以一层一层往回找到,很少会出现突然冒出一个错误却不知道是从哪里来的这种情况

不过缺点也不少

  • 错误中没有堆栈信息(需要第三方包解决或者自己封装)
  • 丑陋,重复代码多(看个人喜好)
  • 自定义错误是通过var来声明的,它是一个变量而不是常量(确实不应该)
  • 变量遮蔽问题

社区中有关于Go错误处理的提案和讨论自从Go诞生以来就从未停止过,有这么一句玩笑话:如果你能接受Go的错误处理,那么你就是一个合格的Gopher了。

提示

这里有两篇Go团队关于错误处理的文章,感兴趣可以看看


error

error属于是一种正常的流程错误,它的出现是可以被接受的,大多数情况下应该对其进行处理,当然也可以忽略不管,error的严重级别不足以停止整个程序的运行。error本身是一个预定义的接口,该接口下只有一个方法Error(),该方法的返回值是字符串,用于输出错误信息。

type error interface {
   Error() string
}

error在历史上也有过大改,在1.13版本时Go团队推出了链式错误,且提供了更加完善的错误检查机制,接下来都会一一介绍。


创建

创建一个error有以下几种方法,第一种是使用errors包下的New函数。

err := errors.New("这是一个错误")

第二种是使用fmt包下的Errorf函数,可以得到一个格式化参数的error。

err := fmt.Errorf("这是%d个格式化参数的的错误", 1)

下面是一个完整的例子

func sumPositive(i, j int) (int, error) {
   if i <= 0 || j <= 0 {
      return -1, errors.New("必须是正整数")
   }
   return i + j, nil
}

大部分情况,为了更好的维护性,一般都不会临时创建error,而是会将常用的error当作全局变量使用,例如下方节选自os\erros.go文件的代码

var (
	ErrInvalid = fs.ErrInvalid // "invalid argument"

	ErrPermission = fs.ErrPermission // "permission denied"
	ErrExist      = fs.ErrExist      // "file already exists"
	ErrNotExist   = fs.ErrNotExist   // "file does not exist"
	ErrClosed     = fs.ErrClosed     // "file already closed"

	ErrNoDeadline       = errNoDeadline()       // "file type does not support deadline"
	ErrDeadlineExceeded = errDeadlineExceeded() // "i/o timeout"
)

可以看到它们都是被var定义的变量


自定义错误

通过实现Error()方法,可以很轻易的自定义error,例如erros包下的errorString就是一个很简单的实现。

func New(text string) error {
   return &errorString{text}
}

// errorString结构体
type errorString struct {
   s string
}

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

因为errorString实现太过于简单,表达能力不足,所以很多开源库包括官方库都会选择自定义error,以满足不同的错误需求。


传递

在一些情况中,调用者调用的函数返回了一个错误,但是调用者本身不负责处理错误,于是也将错误作为返回值返回,抛给上一层调用者,这个过程叫传递,错误在传递的过程中可能会层层包装,当上层调用者想要判断错误的类型来做出不同的处理时,可能会无法判别错误的类别或者误判,而链式错误正是为了解决这种情况而出现的。

type wrapError struct {
   msg string
   err error
}

func (e *wrapError) Error() string {
   return e.msg
}

func (e *wrapError) Unwrap() error {
   return e.err
}

wrappError同样实现了error接口,也多了一个方法Unwrap,用于返回其内部对于原error的引用,层层包装下就形成了一条错误链表,顺着链表上寻找,很容易就能找到原始错误。由于该结构体并不对外暴露,所以只能使用fmt.Errorf函数来进行创建,例如

err := errors.New("这是一个原始错误")
wrapErr := fmt.Errorf("错误,%w", err)

使用时,必须使用%w格式动词,且参数只能是一个有效的error。


处理

错误处理中的最后一步就是如何处理和检查错误,errors包提供了几个方便函数用于处理错误。

func Unwrap(err error) error

errors.Unwrap()函数用于解包一个错误链,其内部实现也很简单

func Unwrap(err error) error {
   u, ok := err.(interface { // 类型断言,是否实现该方法
      Unwrap() error
   })
   if !ok { //没有实现说明是一个基础的error
      return nil
   }
   return u.Unwrap() // 否则调用Unwrap
}

解包后会返回当前错误链所包裹的错误,被包裹的错误可能依旧是一个错误链,如果想要在错误链中找到对应的值或类型,可以递归进行查找匹配,不过标准库已经提供好了类似的函数。

func Is(err, target error) bool 

errors.Is函数的作用是判断错误链中是否包含指定的错误,例子如下

var originalErr = errors.New("this is an error")

func wrap1() error { // 包裹原始错误
   return fmt.Errorf("wrapp error %w", wrap2())
}

func wrap2() error { // 原始错误
   return originalErr
}

func main() {
   err := wrap1()
   if errors.Is(err, originalErr) { // 如果使用if err == originalErr 将会是false
      fmt.Println("original")
   }
}

所以在判断错误时,不应该使用==操作符,而是应该使用errors.Is()


func As(err error, target any) bool

errors.As()函数的作用是在错误链中寻找第一个类型匹配的错误,并将值赋值给传入的err。有些情况下需要将error类型的错误转换为具体的错误实现类型,以获得更详细的错误细节,而对一个错误链使用类型断言是无效的,因为原始错误是被结构体包裹起来的,这也是为什么需要As函数的原因。例子如下

type TimeError struct { // 自定义error
   Msg  string
   Time time.Time //记录发生错误的时间
}

func (m TimeError) Error() string {
   return m.Msg
}

func NewMyError(msg string) error {
   return &TimeError{
      Msg:  msg,
      Time: time.Now(),
   }
}

func wrap1() error { // 包裹原始错误
   return fmt.Errorf("wrapp error %w", wrap2())
}

func wrap2() error { // 原始错误
   return NewMyError("original error")
}

func main() {
   var myerr *TimeError
   err := wrap1()
   // 检查错误链中是否有*TimeError类型的错误
   if errors.As(err, &myerr) { // 输出TimeError的时间
      fmt.Println("original", myerr.Time)
   }
}

target必须是指向error的指针,由于在创建结构体时返回的是结构体指针,所以error实际上*TimeError类型的,那么target就必须是**TimeError类型的。

不过官方提供的errors包其实并不够用,因为它没有堆栈信息,不能定位,一般会比较推荐使用官方的另一个增强包

github.com/pkg/errors

例子

import (
	"fmt"
	"github.com/pkg/errors"
)

func Do() error {
	return errors.New("error")
}

func main() {
	if err := Do(); err != nil {
		fmt.Printf("%+v", err)
	}
}

输出

some unexpected error happened
main.Do
        D:/WorkSpace/Code/GoLeran/golearn/main.go:9
main.main
        D:/WorkSpace/Code/GoLeran/golearn/main.go:13
runtime.main
        D:/WorkSpace/Library/go/root/go1.21.3/src/runtime/proc.go:267
runtime.goexit
        D:/WorkSpace/Library/go/root/go1.21.3/src/runtime/asm_amd64.s:1650

通过格式化输出,就可以看到堆栈信息了,默认情况下是不会输出堆栈的。这个包相当于是标准库errors包的加强版,同样都是官方写的,不知道为什么没有并入标准库。


panic

panic中文译为恐慌,表示十分严重的程序问题,程序需要立即停止来处理该问题,否则程序立即停止运行并输出堆栈信息,panic是Go是运行时异常的表达形式,通常在一些危险操作中会出现,主要是为了及时止损,从而避免造成更加严重的后果。不过panic在退出之前会做好程序的善后工作,同时panic也可以被恢复来保证程序继续运行。


下方是一个向nil的map写入值的例子,肯定会触发panic

func main() {
   var dic map[string]int
   dic["a"] = 'a'
}
panic: assignment to entry in nil map

提示

只要任一协程发生panic,如果不将其捕获的话,整个程序都会崩溃

创建

显式的创建panic十分简单,使用内置函数panic即可,函数签名如下

func panic(v any)

panic函数接收一个类型为 any的参数v,当输出错误堆栈信息时,v也会被输出。使用例子如下

func main() {
	initDataBase("", 0)
}

func initDataBase(host string, port int) {
	if len(host) == 0 || port == 0 {
		panic("非法的数据链接参数")
	}
    // ...其他的逻辑 
}

当初始化数据库连接失败时,程序就不应该启动,因为没有数据库程序就运行的毫无意义,所以此处应该抛出panic

panic: 非法的数据链接参数

善后

程序因为panic退出之前会做一些善后工作,例如执行defer语句。

func main() {
   defer fmt.Println("A")
   defer fmt.Println("B")
   fmt.Println("C")
   panic("panic")
   defer fmt.Println("D")
}

输出为

C
B
A
panic: panic

并且上游函数的defer语句同样会执行,例子如下

func main() {
   defer fmt.Println("A")
   defer fmt.Println("B")
   fmt.Println("C")
   dangerOp()
   defer fmt.Println("D")
}

func dangerOp() {
   defer fmt.Println(1)
   defer fmt.Println(2)
   panic("panic")
   defer fmt.Println(3)
}

输出

C
2
1
B
A
panic: panic

defer中也可以嵌套panic,下面是一个比较复杂的例子

func main() {
	defer fmt.Println("A")
	defer func() {
		func() {
			panic("panicA")
			defer fmt.Println("E")
		}()
	}()
	fmt.Println("C")
	dangerOp()
	defer fmt.Println("D")
}

func dangerOp() {
	defer fmt.Println(1)
	defer fmt.Println(2)
	panic("panicB")
	defer fmt.Println(3)
}

defer中嵌套的panic 执行顺序依旧一致,发生panic时后续的逻辑将无法执行。

C
2                                                           
1                                                           
A                                                           
panic: panicB                                               
        panic: panicA  

综上所述,当发生panic时,会立即退出所在函数,并且执行当前函数的善后工作,例如defer,然后层层上抛,上游函数同样的也进行善后工作,直到程序停止运行。


当子协程发生panic时,不会触发当前协程的善后工作,如果直到子协程退出都没有恢复panic,那么程序将会直接停止运行。

var waitGroup sync.WaitGroup

func main() {
	demo()
}

func demo() {
	waitGroup.Add(1)
	defer func() {
		fmt.Println("A")
	}()
	fmt.Println("C")
	go dangerOp()
	waitGroup.Wait() // 父协程阻塞等待子协程执行完毕
	defer fmt.Println("D")
}
func dangerOp() {
	defer fmt.Println(1)
	defer fmt.Println(2)
	panic("panicB")
	defer fmt.Println(3)
	waitGroup.Done()
}

输出为

C
2
1
panic: panicB

可以看到demo()中的defer语句一个都没有执行,程序就直接退出了。需要注意的是,如果没有waitGroup来阻塞父协程的话,demo()的执行速度可能会快于子协程的执行速度,输出的结果就会变得非常有迷惑性,下面稍微修改一下代码

func main() {
	demo()
}

func demo() {
	defer func() {
		// 父协程善后工作要花费20ms
		time.Sleep(time.Millisecond * 20)
		fmt.Println("A")
	}()
	fmt.Println("C")
	go dangerOp()
	defer fmt.Println("D")
}
func dangerOp() {
	// 子协程要执行一些逻辑,要花费1ms
	time.Sleep(time.Millisecond)
	defer fmt.Println(1)
	defer fmt.Println(2)
	panic("panicB")
	defer fmt.Println(3)
}

输出为

C
D
2
1
panic: panicB

在本例中,当子协程发生panic时,父协程早已完成的函数的执行,进入了善后工作,在执行最后一个defer时,碰巧遇到了子协程发生panic,所以程序就直接退出运行。


恢复

当发生panic时,使用内置函数recover()可以及时的处理并且保证程序继续运行,必须要在defer语句中运行,使用示例如下。

func main() {
   dangerOp()
   fmt.Println("程序正常退出")
}

func dangerOp() {
   defer func() {
      if err := recover(); err != nil {
         fmt.Println(err)
         fmt.Println("panic恢复")
      }
   }()
   panic("发生panic")
}

调用者完全不知道dangerOp()函数内部发生了panic,程序执行剩下的逻辑后正常退出,所以输出如下

发生panic
panic恢复
程序正常退出

但事实上recover()的使用有许多隐含的陷阱。例如在defer中再次闭包使用recover

func main() {
	dangerOp()
	fmt.Println("程序正常退出")
}

func dangerOp() {
	defer func() {
		func() {
			if err := recover(); err != nil {
				fmt.Println(err)
				fmt.Println("panic恢复")
			}
		}()
	}()
	panic("发生panic")
}

闭包函数可以看作调用了一个函数,panic是向上传递而不是向下,自然闭包函数也就无法恢复panic,所以输出如下。

panic: 发生panic    

除此之外,还有一种很极端的情况,那就是panic()的参数是nil

func main() {
   dangerOp()
   fmt.Println("程序正常退出")
}

func dangerOp() {
   defer func() {
      if err := recover(); err != nil {
         fmt.Println(err)
         fmt.Println("panic恢复")
      }
   }()
   panic(nil)
}

这种情况panic确实会恢复,但是不会输出任何的错误信息。

输出

程序正常退出

总的来说recover函数有几个注意点

  1. 必须在defer中使用
  2. 多次使用也只会有一个能恢复panic
  3. 闭包recover不会恢复外部函数的任何panic
  4. panic的参数禁止使用nil

fatal

fatal是一种极其严重的问题,当发生fatal时,程序需要立刻停止运行,不会执行任何善后工作,通常情况下是调用os包下的Exit函数退出程序,如下所示

func main() {
	dangerOp("")
}

func dangerOp(str string) {
	if len(str) == 0 {
		fmt.Println("fatal")
		os.Exit(1)
	}
	fmt.Println("正常逻辑")
}

输出

fatal

fatal级别的问题一般很少会显式的去触发,大多数情况都是被动触发。