【Go语言踩坑系列(五)】错误与异常处理

声明

本系列文章并不会停留在Go语言的语法层面,更关注语言特性、学习和使用中出现的问题以及引起的一些思考。

为什么需要错误和异常处理

任何一行代码都可能存在不可预知的问题,而这些问题就是bug的根源。为了妥善处理这类问题,我们需要编写一些代码,这类代码被称为运维代码。通常情况下,我们需要发现问题、判断问题的种类、然后根据问题的种类,分别进行响应与处理。这些处理可能是写入日志、也可能是直接让代码停止运行,这些都视你的业务逻辑而定。这样一来,在我们编写了足够健壮的运维代码之后,我们便能快速的定位并解决问题。

错误处理方案的演化

单返回值

我们先看一个最原始的错误处理解决方案:Unix读取文件的API:

int open(const char *pathname, int flags);

如果成功打开这个文件,open()会返回一个int类型的文件描述符fd;如果打开失败,便会返回-1。为了正确处理该函数的错误,我们会编写以下代码:

if ((fd = open("/etc/hosts", O_RDONLY)) < 0) {
    printf("%s", "open failed")
    exit(1);
}

这样做会有什么问题呢?由于错误码和正确的业务逻辑混在一个返回值里,假如你忘了去判断fd的值,代码就会继续往下执行,就会把错误的-1当成正确的fd,代码就会发生不可预知的错误。除此之外,这种错误处理方式的语义也并不清晰。

那么,我们该如何解决这个问题呢?

多返回值

我们想了一下,一个返回值不行,那么搞两个返回值,把错误处理逻辑与正常逻辑区分开,代码逻辑就会变得更加清晰了。Go语言就是这样做的,我们通常能够看到如下代码:

func main() {
    res, err := json.Marshal(payload)
    if err != nil {
        return "", errors.New("序列化请求参数失败")
    }
}

通过将正确执行Marshal的返回结果与错误的返回结果分离,使代码语义更加清晰,而且这样会提醒程序员更加关注错误的返回值,而不会忘记进行错误处理。但是,这样仍然存在一个问题,会出现大量的类似代码:

if err != nil {
    // 错误处理逻辑
}

在Go语言的代码中,会出现大量对err的if判断逻辑,重复率过高,而且错误处理逻辑仍然和正常的代码逻辑混淆在一起,我们如果想进一步将错误与正常逻辑分离,该如何做呢?

try-catch

Java、PHP等语言提供了try-catch-finally的解决方案。

try {
    // 正常代码逻辑
} catch(\Exception $e) {
    // 错误处理逻辑
} finally {
    // 释放资源逻辑
}

try-catch彻底完成了对错误与正常代码逻辑的分离。我们用try代码块中包裹可能出现问题的代码,在catch中对这些问题代码统一进行错误处理。

资源的释放

finally代码块比较特殊,它被常常用来做一些资源及句柄的释放工作。如果没有finally,我们的代码可能会像这样:

func main() {
    mutex := sync.Mutex{}
    // 加锁
    mutex.Lock()
    res, err := json.Marshal("abc")
    if err != nil {
        // 释放锁资源
        mutex.Unlock()
        // ....其余错误处理逻辑
    }
    file, err := os.Open("abvc")
    if err != nil {
        // 释放锁资源
        mutex.Unlock()
        // ....其余错误处理逻辑
    }
    mutex.Unlock()
}

为了确保锁资源在代码结束之前一定要被释放,我们每次在错误处理逻辑中,都需要写一次mutex.Unlock代码,导致大量的代码冗余。finally代码块内的语句会在代码返回或者退出之前执行,而且是百分百会执行。这样,我们就可以把释放锁资源这一行代码放到finally块即可,且只用写一次,这样就解决了之前代码冗余率高的问题。在Go语言中,defer()也同样解决了这个问题。我们用Go中的defer语句改写一下上述代码:

func main() {
    mutex := sync.Mutex{}
    defer mutex.Unlock()
    mutex.Lock()
    res, err := json.Marshal("abc")
    if err != nil {
        // 错误处理
    }
    file, err := os.Open("abvc")
    if err != nil {
        // 错误处理
    }
}

这就是错误处理的演化过程了。我个人比较喜欢Java和PHP中的try-catch-finally语法。听说Go2.0要对代码冗余度高的问题进行优化,我们拭目以待吧。

Go错误处理的实现

接下来我们深入讲解Go语言中的错误处理实现。我们看一下之前讲过的例子中,json.Marshal方法的签名:

func Marshal(v interface{}) ([]byte, error)

我们重点关注最后一个error类型的参数,它是一个Go语言内置的接口类型。那么,我们为什么要用接口类型来抽象所有的错误类型呢?先别急,我们先自己想想。

简单版的实现

在我们对字符串进行marshal操作的过程中,可能会产生好多种类型的错误。为了在marshal函数内部区分不同的错误类型,我们简单粗暴一点,可能会进行如下的处理:

func (e *encodeState) marshal(v interface{}, opts encOpts) (errorMsg string) {
    // 操作1可能的错误
    if errType1 := doOp1(), errType1 != nil {
        err1 := errType1.getErrorMessage() // 获取errorType1的错误信息
        return err1
    }
    // 操作2可能的错误
    if errType2 := doOp2(), errType2 != nil {
        err2 := errType2.getErrMsg() // 方法名和errorType1不同
        return err2
    }
    return ""
}

我们分析一下上面这段代码,操作doOp1可能会发生errorType1类型的错误,我们要返回给调用者errorType1类型中错误的字符串信息;doOp2也同理。这样做确实可以,但是还是有一些麻烦,我们看看还有没有其他方案来优化一下。

抽象一下试试

我们先简单介绍一下,Go语言用一个接口类型抽象了所有错误类型:

type error interface {
    Error() string
}

这个接口定义了一个Error()方法,用于返回错误信息,我们先记下来,等会要用。同上个例子,我们给之前自定义的两种错误类型加点料,实现这个error接口:

type errType1 struct {}

// 实现接口方法
func (*errType1) Error() {
    fmt.Println("我是错误类型1的信息")
}

type errType2 struct {}

// 实现接口方法
func (*errType2) Error() {
    fmt.Println("我是错误类型1的信息")
}

然后在marshal()函数上稍作改动,使用这两种实现接口的错误类型:

func (e *encodeState) marshal(v interface{}, opts encOpts) (errorMsg string) {
    // 操作1可能的错误
    if errType1 := doOp1(), errType1 != nil {
        return errType1.Error()
    }
    // 操作2可能的错误
    if errType2 := doOp2(), errType2 != nil {
        return errType2.Error()
    }
    return ""
}

大家看到优势在哪里了吗?在我们调用每个错误类型的返回信息方法的时候,如果用我们一开始的方式,我们需要进入每一个错误类型的实现类中去翻看他的API,看看函数名是什么;而在第二种实现方案中,由于两种错误的实现类型均实现了Error()方法,这样,在marshal函数中如果想进行错误信息的获取,我们统一调用Error()函数,即可返回对应错误实现类的错误信息。

这其实就是一种依赖的倒置。调用方marshal()函数不再关注错误类型的具体实现类,里面有哪些方法,而转为依赖抽象的接口。下面给大家看一下与marshal函数相关的几种Go语言内部定义的错误类型,他们均实现了error接口中的Error()方法:

第一种错误类型:

type MarshalerError struct {
    Type       reflect.Type
    Err        error
    sourceFunc string
}

func (e *MarshalerError) Error() string {
    srcFunc := e.sourceFunc
    if srcFunc == "" {
        srcFunc = "MarshalJSON"
    }
    return "json: error calling " + srcFunc +
        " for type " + e.Type.String() +
        ": " + e.Err.Error()
}

第二种错误类型:

type UnsupportedValueError struct {
    Value reflect.Value
    Str   string
}

func (e *UnsupportedValueError) Error() string {
    return "json: unsupported value: " + e.Str
}

而其他语言对错误类型的抽象化处理,有些是用继承来实现的。如PHP中的根Exception与众多继承它的子异常类xxxException。在PHP中,我们用Exception即可接收所有的异常类型,并可以调用通用的$exception->getMessage()、$exception->getFile()等方法。

谈谈panic和recover

Go语言的panic和其他语言的error有点像。如果调用了panic,代码会立刻停止运行,一层一层向上冒泡并积累堆栈信息,直到调用栈顶结束,并打印出所有堆栈信息。

panic没什么好说的,而recover我们需要好好聊一聊。recover专门用来恢复panic。也就是说,如果你在panic之前声明了recover语句,那么你就可以在panic之后使用recover接收到panic的信息。但是问题又来了,我们panic不是直接就退出程序了吗,就算声明了recover也执行不了呀。这个时候,我们就需要配合defer来使用了。defer能够让程序在panic之后,仍然执行一段收尾的代码逻辑。这样一来,我们就可以通过recover获得panic的信息,并对信息作出识别与处理了。仍然举上述的marshal的源码的例子,这次是真的源码了,不是我编的:

func (e *encodeState) marshal(v interface{}, opts encOpts) (err error) {
    defer func() { // defer收尾
        if r := recover(); r != nil { // recover恢复案发现场
            if je, ok := r.(jsonError); ok { // 拿到panic的值,并转为错误来返回
                err = je.error
            } else {
                panic(r)
            }
        }
    }()
    e.reflectValue(reflect.ValueOf(v), opts)
    return nil
}

我们看到,源码中将defer与recover配合使用,直接改变了panic的运行逻辑。原本是panic之后会直接退出程序,这样一来,现在程序并不会直接退出,而是被转为了jsonError类型,并返回。通过使用recover捕获运行时的panic,可以让代码继续运行下去而不至于直接停止。

下期预告

【Go语言踩坑系列(六)】面向对象

关注我们

欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~

我来评几句
登录后评论

已发表评论数()

相关站点

热门文章