Go36-19,20-错误处理

骑士救兵 · · 631 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

错误处理(上)

错误处理到现在为止应该已经接触过几次了。比如,声明error类型的变量err,或是调用errors包中的New函数。

error类型

error类型是一个接口类型,是一个Go语言的内建类型。在这个接口类型的声明中只包含了一个方法Error。这个方法不接受任何参数,但是会返回一个string类型的结果。它的作用是返回错误信息的字符串表示形式。使用error类型的方式通常是,在函数声明的结果列表的最后,声明一个该类型的结果,同时在调用这个函数之后,先判断它返回的最后一个结果值是否“不为nil”。如果值“不为nil”,就需要进入错误处理。否则就是继续正常的流程。示例如下:

package main

import "fmt"

func echo(request string) (response string, err error) {
    if request == "" {
        err = fmt.Errorf("空字符串")  // 这里底层也是调用下面的New,但是支持字符串格式化
        // 如果是纯字符串,可以直接调用errors包里的New函数
        // err = errors.New("empty request")
        return
    }
    response = fmt.Sprintf("echo:%s", request)
    return
}

func main() {
    for _, req := range []string{"", "Hello"} {
        fmt.Printf("request: %s\n", req)
        resp, err := echo(req)
        if err != nil {
            fmt.Printf("error: %s\n", err)
            continue
        }
        fmt.Printf("response: %s\n", resp)
    }
}

在echo函数和main函数中,我都使用到了卫述语句。卫述语句,就是被用来检查后续操作的前置条件并进行相应处理的语句。在进行错误处理的时候经常会用到卫述语句,以至于“我的程序满屏都是卫述语句,简直是太难看了!”(这里我有同感)。

错误判断

由于error是一个接口类型,所以即使同为error类型的错误值,它们的实际类型也可能不同。错误判断的做法一般是如下的3种:

  1. 对于类型在已知范围内的一系列错误值,一般使用类型断言表达式或类型switch语句来判断
  2. 对于已有相应变量且类型相同的一系列错误值,一般直接使用判等操作来判断
  3. 对于没有相应变量且类型未知的一系列错误值,只能使用其错误信息的字符串表示形式来做判断

对于上面的3种情况,接下来分别展开。

第一种情况
类型在已知范围内的错误值是最容易分辨的。拿os包中的几个代表错误的类型os.PathError、os.LinkError、os.SyscallError和os/exec.Error举例,它们的指针类型都是error接口的实现类型,同时它们也都包含了一个名叫Err,类型为error接口类型的代表潜在错误的字段。
如果得到一个error类型值,并且知道该值的实际类型肯定是它们中的某一个,那就可以用类型switch语句去做判断。示例如下:

package main

import (
    "fmt"
    "os"
    "os/exec"
)

// underlyingError 会返回已知的操作系统相关错误的潜在错误值。
func underlyingError(err error) error {
    switch err := err.(type) {
    case *os.PathError:
        return err.Err
    case *os.LinkError:
        return err.Err
    case *os.SyscallError:
        return err.Err
    case *exec.Error:
        return err.Err
    }
    return err
}

func main() {
    r, w, err := os.Pipe()
    if err != nil {
        fmt.Fprintf(os.Stderr, "unexpected error: %s\n", err)
        return
    }
    // 人为制造 *os.PathError 类型的错误。
    r.Close()
    _, err = w.Write([]byte("hi"))
    if err != nil {
        uError := underlyingError(err)
        fmt.Fprintf(os.Stderr, "underlying error: %s (type: %T)\n", uError, uError)
    }
}

函数underlyingError的作用是,获取和返回已知的操作系统相关错误的潜在错误值。里面用switch做类型判断,如果是已知的那些类型,这些类型都会有Err字段,直接返回Err字段的值。如果case子句都没有被选中,那么就是一个其他的类型,直接返回传入的参数err,即放弃获取潜在错误值。

第二种情况
在Go语言的标准库中也有不少以相同方式创建的同类型的错误值。还拿os包来说,其中不少的错误值都是通过调用errors.New函数来初始化的,比如:os.ErrClosed、os.ErrInvalid以及os.ErrPermission。与之前的那些错误类型不同,这几个都是已经定义好的、确切的错误值。os包中的代码有时候会把它们当做潜在错误值,封装进前面那些错误类型的值中。
如果我们在操作文件系统的时候得到了一个错误值,并且知道该值的潜在错误值肯定是上述值中的某一个,那么就可以用普通的switch语句去做判断。这里比较难理解,示例如下:

package main

import (
    "fmt"
    "os"
    "os/exec"
)

// underlyingError 会返回已知的操作系统相关错误的潜在错误值。
func underlyingError(err error) error {
    switch err := err.(type) {
    case *os.PathError:
        return err.Err
    case *os.LinkError:
        return err.Err
    case *os.SyscallError:
        return err.Err
    case *exec.Error:
        return err.Err
    }
    return err
}

func main() {
    paths := []string{
        os.Args[0],           // 当前的源码文件或可执行文件。
        "/it/must/not/exist", // 肯定不存在的目录。
        os.DevNull,           // 肯定存在的目录。
    }
    printError := func(i int, err error) {
        if err == nil {
            fmt.Println("nil error")
            return
        }
        err = underlyingError(err)  // 先去获取潜在错误值
        // 然后对错误值进行判等来分辨
        switch err {
        case os.ErrClosed:
            fmt.Printf("case: %s\n", os.ErrClosed)
            fmt.Printf("error(closed)[%d]: %s\n", i, err)
        case os.ErrInvalid:
            fmt.Printf("case: %s\n", os.ErrInvalid)
            fmt.Printf("error(invalid)[%d]: %s\n", i, err)
        case os.ErrPermission:
            fmt.Printf("case: %s\n", os.ErrPermission)
            fmt.Printf("error(permission)[%d]: %s\n", i, err)
        default:
            fmt.Println("case not fount")
            fmt.Printf("error(unknow)[%d]: %s\n", i, err)
        }
    }
    var f *os.File
    var index int
    var err error
    {
        index = 0
        f, err = os.Open(paths[index])
        if err != nil {
            fmt.Printf("unexpected error: %s\n", err)
            return
        }
        // 人为制造潜在错误为 os.ErrClosed 的错误。
        f.Close()
        _, err = f.Read([]byte{})
        printError(index, err)
    }
    {
        index = 1
        // 人为制造 os.ErrInvalid 错误。
        f, _ = os.Open(paths[index])
        _, err = f.Stat()
        printError(index, err)
    }
    {
        index = 2
        // 人为制造潜在错误为 os.ErrPermission 的错误。
        _, err = exec.LookPath(paths[index])
        printError(index, err)
    }
    if f != nil {
        f.Close()
    }
}

这里会用到上一个例子里的underlyingError函数。printError变量代表的函数会接受一个error类型的参数值,该值代表某个文件操作的相关错误。先用underlyingError函数得到它的潜在错误值(也可能类型都不符合得到的是原来的错误值),然后用switch语句对错误值进行判等操作。如此来分辨出具体的错误。

第三种情况
对于上面的两种情况,都有明确的方式来解决。但是,如果对一个错误的函数并不清楚,那只能通过它拥有的错误信息去判断了。总是能够通过错误值的Error方法拿到它的错误信息,就是错误信息的字符串表示形式。还是os包,里面就有做这种判断的函数,比如:os.IsExist、os.IsNotExist和os.IsPermission。
这里的例子和上面那个差不多,这次用了if来做判断(case和if都可以用),示例如下:

package main

import (
    "fmt"
    "os"
    "os/exec"
    "runtime"
)

func main() {
    paths := []string{
        runtime.GOROOT(),     // 当前环境下的Go语言根目录。
        "/it/must/not/exist", // 肯定不存在的目录。
        os.DevNull,           // 肯定存在的目录。
    }
    printError2 := func(i int, err error) {
        if err == nil {
            fmt.Println("nil error")
            return
        }
        if os.IsExist(err) {
            fmt.Printf("error(exist)[%d]: %s\n", i, err)
        } else if os.IsNotExist(err) {
            fmt.Printf("error(not exist)[%d]: %s\n", i, err)
        } else if os.IsPermission(err) {
            fmt.Printf("error(permission)[%d]: %s\n", i, err)
        } else {
            fmt.Printf("error(other)[%d]: %s\n", i, err)
        }
    }
    var f *os.File
    var index int
    var err error
    {
        index = 0
        err = os.Mkdir(paths[index], 0700)
        printError2(index, err)
    }
    {
        index = 1
        f, err = os.Open(paths[index])
        printError2(index, err)
    }
    {
        index = 2
        _, err = exec.LookPath(paths[index])
        printError2(index, err)
    }
    if f != nil {
        f.Close()
    }
}

这里的代码里看不出什么,这种情况是获取错误的字符串表示形式然后做判断。这里做判断的就是os.IsExist、os.IsNotExist和os.IsPermission这3个函数。具体看os.IsNotExist做了什么,这个去源码里看一下:

// 转去调用一个内部的方法
func IsNotExist(err error) bool {
    return isNotExist(err)
}

// 再转去调用字符串分析的方法
func isNotExist(err error) bool {
    return checkErrMessageContent(err, "does not exist", "not found",
        "has been removed", "no parent")
}

// 这个函数就是看看错误信息里是否有特定的字符串
func checkErrMessageContent(err error, msgs ...string) bool {
    if err == nil {
        return false
    }
    // 第一个例子就开始用的这个函数,就是从源码里超的
    err = underlyingError(err)  
    for _, msg := range msgs {
        if contains(err.Error(), msg) {
            return true
        }
    }
    return false
}

这里看到了,我们的代码里用用做判断的函数,在源码里具体做的事情就是获取错误信息的字符串表示信息,然后去判断是否包含了特定的字符串。

总结

这篇主要就是讲错误类型的判断,并且用os包举例了3种判断错误类型的方法。
第一种类型断言,就是直接用类型断言判断错误的类型。error类型是一个接口类型,这里要用类型断言判断出该类型的动态类型,通过这个动态类型来分辨。
第二种错误值判等,通过错误值来判断,这里的错误值是已知的,所以使用判等来进行判断。
第三种分析错误值,其实还是通过错误值来判断,但是这里的错误值不确定。例子里用了os包中提供的方法来进行判断,其底层就是检查字符串是否包含特定的字符。
另外,用于判断的语句,类型断言应该还是用case比较合适。其他情况case和if都可以用来做判断。

错误处理(下)

在上篇中,主要是从使用者的角度看“怎样处理错误值”。这篇,要从建造者的角度关心“怎么才能给予使用者恰当的错误值”。

构建错误值体系的基本方式有两种:

  • 创建立体的错误类型体系
  • 创建扁平的错误值列表

错误类型体系

由于在Go语言中实现接口是非侵入式的,所以可以做的很灵活。比如,在标准库的net代码包中,有一个名为Error的接口类型。它算是内建接口类型error的一个扩展接口,因为error是net.Error的嵌入接口。net.Error接口除了拥有error接口的Error方法外,还有两者自己什么的方法:Timeout和Temporary。net包中有很多错误类型都实现了net.Error接口,比如下面这些:

  • *net.OpError
  • *net.AddrError
  • net.UnknownNetworkError

这些错误类型就是一个树形结构,内建接口error就是根节点,而net.Error接口就是就是第一级子节点。
当我们细看net包中的这些具体错误类型的实现时,还会发现,与os包中的一些错误类型类似,它们也都有一个名为Err、类型为error接口类型的字段,代表的也是当前错误的潜在错误。
所以,这些错误类型的值缠绵还有另外一种关系,即:链式关系。比如,使用者调用net.DialTCP之类的函数是,net包的代码可能会返回给他一个 *net.OpError 类型的错误值,这个表示用于操作不当造成了一个错误。同时,这些代码还会把一个 *net.AddrError 或 net.UnknownNetworkError 类型的值赋值该错误值的Err字段,以表示导致这个错误的潜在原因。所以,如果此处的潜在错误值的Err字段也有非nil值,那么就指明了更深层次的错误原因。如此一级有一级就像链条指向了问题的根源。
以上这些内容总结成一句话就是,用类型建立起树形结构的错误体系,用统一字段建立起可追根溯源的链式错误关联。这是Go语言标准库给予我们的优秀范本,非常有借鉴意义。
不过要注意,如果不想让包外代码改动你返回的错误值的话,字段名称一定要小写。可以通过暴露某些方法让包外代码可以进一步获取错误信息,比如写一个Ere方法返回私有的err字段的值。下面的扁平化方式就不得不暴露字段给包外代码,这会带来一些问题。
小结
错误类型体系是立体的,从整体上看它往往呈现出树形的结构。通过接口间的嵌套以及接口的实现,就可以构建出一棵错误类型树。通过这棵树,使用者就可以一步步地确定错误值的种类。
另外,为了追根溯源,还可以在错误类型中,统一安放一个可以代表潜在错误的字段。这叫做链式的错误关联,可以帮助使用者找到错误的根源。

扁平的错误值列表

这个就简单得多了。当我们只是想预先创建一些代表已知错误的错误值的时候,用扁平化的方法就是可以了。
由于error是接口类型,所以通过error.New函数生成的错误值只能被赋值给变量,不能给常量。又由于这些变量需要给包外的代码使用,所以访问权限只能公开(首字母大写)。
这就带来了一个问题,如果有恶意代码改变了这些公开变量的值,那么程序的功能就会受到影响。因为在这种情况下,我们一般就是通过判等操作来判断拿到的凑之具体是哪一个错误,如果值被改变了,就会影响到判等操作的结果。这里光看文字没啥感觉,下面有两个示例。
示例1:

package main

import (
    "fmt"
    "os"
    "os/exec"
)

func main() {
    _, err := exec.LookPath(os.DevNull)
    fmt.Printf("error: %s\n", err)
    if execErr, ok := err.(*exec.Error); ok {
        // 这里修改了err里的值,因为字段名Name和Err是大写的
        execErr.Name = os.TempDir()
        execErr.Err = os.ErrNotExist
    }
    fmt.Printf("error: %s\n", err)  // err还是开头的err,但是值被修改了
}

示例2:

package main

import (
    "fmt"
    "os"
    "errors"
)

func main() {
    err := os.ErrPermission
    // 现在的判断是正确的
    if os.IsPermission(err) {
        fmt.Printf("error(permission): %s\n", err)
    } else {
        fmt.Printf("error(other): %s\n", err)
    }
    // 由于字段名是大写的,就可以修改了。
    // os.ErrPermission = os.ErrExist  // 这句怕看不懂,其实就是改掉原本的值
    os.ErrPermission = errors.New("可以是任意内容啊")  // 把原值改掉,改成什么不重要
    // 这次再判断err类型就不一样了。err还是开头的err,但是判断结果不一样了
    if os.IsPermission(err) {
        fmt.Printf("error(permission): %s\n", err)
    } else {
        fmt.Printf("error(other): %s\n", err)
    }
}

这两个示例其实就是一个情况,字段名大写了,于是就暴露出来,可以修改了。示例1中if语句内是这里所说的恶意代码,示例2中 os.ErrPermission = os.ErrExist 是这里所说的恶意代码。原本以为不改不就OK了?但是在这里的问题是err的值被改了,但是没有看到显示的修改err的代码。这个问题就很严重了,问题难以被发现。
解决方案有两个:
方案一,先私有化变量,然后编写公开的用于获取错误值以及用于判等的错误值的函数。就是像上节错误类型体系的最后说的那么做。
方案二,此方案存在于syscall包中。该包中有一个类型叫Errno,该类型代表了系统调用是可能发生的底层错误。这个错误类型是error接口的实现类型,同时也是对内建类型uintptr的再定义类型。由于uintptr可以常量的类型,所以syscall.Error就可以是常量。syscall包中声明有大量的Errno类型的常量,包外的代码可以获取到这些大写的常量的值,但是无法改标这些常量。
下面是方案二所说的,定义了int类型Errno,并且实现了error接口。自定义这类错误的示例:

package main

import (
    "fmt"
    "strconv"
)

// Errno 代表某种错误的类型。
type Errno int

// error接口类型,需要实现一个Error方法,这个方法不接受任何参数,但是会返回一个string类型的结果
func (e Errno) Error() string {
    return "errno " + strconv.Itoa(int(e))
}

func main() {
    const (
        ERR0 = Errno(0)
        ERR1 = Errno(1)
        ERR2 = Errno(2)
    )
    var myErr error = Errno(0)
    switch myErr {
    case ERR0:
        fmt.Println("ERR0")
    case ERR1:
        fmt.Println("ERR1")
    case ERR2:
        fmt.Println("ERR2")
    }
}

小结
方案一:使用私有变量,使错误值不可见也不可改,然后编写公开的函数返回私有变量的值。
方案二:使用常量,这样可见但是不可改,需要像syscall那样声明新的类型来实现error接口。
总之,扁平的错误值列表虽然相对简单,但是你需要知道其中的隐患以及解决方案。


有疑问加站长微信联系(非本文作者)

本文来自:51CTO博客

感谢作者:骑士救兵

查看原文:Go36-19,20-错误处理

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

631 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传