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

声明

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

为何须要错误和异常处理

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

错误处理方案的演化

单返回值

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

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判断逻辑,重复率太高,并且错误处理逻辑仍然和正常的代码逻辑混淆在一块儿,咱们若是想进一步将错误与正常逻辑分离,该如何作呢?spa

try-catch

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

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

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

资源的释放

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语言踩坑系列(六)】面向对象

关注咱们

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

Nosay

相关文章
相关标签/搜索