错误
错误
在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了。
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
函数有几个注意点
- 必须在
defer
中使用 - 多次使用也只会有一个能恢复
panic
- 闭包
recover
不会恢复外部函数的任何panic
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
级别的问题一般很少会显式的去触发,大多数情况都是被动触发。