Go语言的错误处理

21

序言

错误处理在每个语言中都是一项重要内容。众所周知,通常写程序时遇到的分为异常与错误两种,Golang中也不例外。Golang遵循『少即是多』的设计哲学,错误处理也力求简洁明了,在错误处理上采用了类似c语言的错误处理方案,另外在错误之外也有异常的概念,Golang中引入两个内置函数panic和recover来触发和终止异常处理流程。

基础知识

错误指的是可能出现问题的地方出现了问题,比如打开一个文件时可能失败,这种情况在人们的意料之中 ;而异常指的是不应该出现问题的地方出现了问题,比如引用了空指针,这种情况在人们的意料之外。可见, 错误是业务逻辑的一部分,而异常不是

我们知道在C语言里面是通过返回-1或者NULL之类的信息来表示错误,但是对于使用者来说,不查看相应的API说明文档,根本搞不清楚这个返回值究竟代表什么意思,比如返回0是成功还是失败?针对这样情况Golang中引入error接口类型作为错误处理的标准模式,如果函数要返回错误,则返回值类型列表中肯定包含error;Golang中引入两个内置函数panic和recover来触发和终止异常处理流程,同时引入关键字defer来延迟执行defer后面的函数。一直等到包含defer语句的函数执行完毕时,延迟函数(defer后的函数)才会被执行,而不管包含defer语句的函数是通过return的正常结束,还是由于panic导致的异常结束。你可以在一个函数中执行多条defer语句,它们的执行顺序与声明顺序相反。

程序运行时若出现了空指针引用、数组下标越界等异常情况,则会触发Golang中panic函数的执行,程序会中断运行,并立即执行在该goroutine中被延迟的函数,如果不做捕获,程序会崩溃。

错误和异常从Golang机制上讲,就是error和panic的区别。很多其他语言也一样,比如C++/Java,没有error但有errno,没有panic但有throw,但panic的适用场景有一些不同。由于panic会引起程序的崩溃,因此panic一般用于严重错误。

错误处理

我们编写一个简单的程序,该程序试图打开一个不存在的文件:

package main

import (  
    "fmt"
    "os"
)

func main() {  
    f, err := os.Open("/test.txt")
    if err != nil {
        fmt.Println("error:",err)
        return
    }
    fmt.Println(f.Name(), "open successfully")
}

可以看到我们的程序调用了os包的Open方法,该方法定义如下:

// Open opens the named file for reading. If successful, methods on
// the returned file can be used for reading; the associated file
// descriptor has mode O_RDONLY.
// If there is an error, it will be of type *PathError.
func Open(name string) (*File, error) {
    return OpenFile(name, O_RDONLY, 0)
}

参考注释可以知道如果这个方法正常返回的话会返回一个可读的文件句柄和一个值为 nil 的错误,如果该方法未能成功打开文件会返回一个*PathError类型的错误。

如果一个函数 或方法 返回了错误,按照Go的惯例,错误会作为最后一个值返回。于是 Open 函数也是将 err 作为最后一个返回值返回。

在Go语言中,处理错误时通常都是将返回的错误与 nil 比较。nil 值表示了没有错误发生,而非 nil 值表示出现了错误。于是有个我们上面那行代码:

if err != nil {
        fmt.Println("error:",err)
        return
    }

如果你阅读任何一个Go语言的工程,会发现类似这样的代码随处可见,Go语言就是用这种简单的形式处理代码中出现的错误。
我们在playground中执行,发现结果显示

error: open /test.txt: No such file or directory

可以发现我们有效的检测并处理了程序中打开一个不存在文件所导致的错误,在示例中我们仅仅是输出该错误并返回。

上面提到Open方法出现错误会返回一个*PathError类型的错误,这个类型具体是什么情况呢?别急,我们先来看一下Go中错误具体是怎么实现的。

error类型

Go中返回的error类型究竟是什么呢?看源码发现error类型是一个非常简单的接口类型,具体如下

// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
    Error() string
}

error 有了一个签名为 Error() string 的方法。所有实现该接口的类型都可以当作一个错误类型。Error() 方法给出了错误的描述。
fmt.Println 在打印错误时,会在内部调用 Error() string 方法来得到该错误的描述。上一节示例中的错误描述就是这样打印出的。

自定义错误类型

现在我们回到刚才代码里的*PathError类型,首先显而易见os.Open方法返回的错误是一个error类型,故我们可以知道PathError类型一定实现了error类型,也就是说实现了Error方法。现在我们看下具体实现

type PathError struct {
    Op   string
    Path string
    Err  error
}

func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }

可以看到PathError类型实现了Error方法,该方法返回文件操作、路径及error字符串的拼接返回值。

为什么需要自定义错误类型呢,试想一下如果一个错误我们拿到的仅仅是错误的字符串描述,那显然无法从错误中获取更多的信息或者做一些逻辑相关的校验,这样我们就可以通过自定义错误的结构体,通过实现Error()来使该结构体成为一个错误类型,使用时做一下类型推荐,我们就可以从返回的错误通过结构体中的一些成员就可以做逻辑校验或者错误分类等工作。例如:

package main

import (  
    "fmt"
    "os"
)

func main() {  
    f, err := os.Open("/test.txt")
    if err, ok := err.(*os.PathError); ok {
        fmt.Println("File at path", err.Path, "failed to open")
        return
    }
    fmt.Println(f.Name(), "opened successfully")
}

上面代码中我们通过将error类型推断为实际的PathError类型,就可以拿到发生错误的Op、Path等数据,更有助于实际场景中错误的处理。

我们组现在拉通了一套错误类型和错误码规范,之前工程里写的时候都是通过在代码中的controller里面去根据不同情况去返回,这种处理方法有很多缺点,例如下层仅返回一个error类型,上层怎么判断该错误是哪种错误,该使用哪种错误码呢?另外就是程序中靠程序员写死某个逻辑错误码为xxxx,使程序缺乏稳定性,错误码返回也较为随心所欲,因此我也去自定义了错误,具体如下:

var (
    ErrSuccess           = StandardError{0, "成功"}
    ErrUnrecognized      = StandardError{-1, "未知错误"}
    ErrAccessForbid      = StandardError{1000, "没有访问权限"}
    ErrNamePwdIncorrect  = StandardError{1001, "用户名或密码错误"}
    ErrAuthExpired       = StandardError{1002, "证书过期"}
    ErrAuthInvalid       = StandardError{1003, "无效签名"}
    ErrClientInnerError  = StandardError{4000, "客户端内部错误"}
    ErrParamError        = StandardError{4001, "参数错误"}
    ErrReqForbidden      = StandardError{4003, "请求被拒绝"}
    ErrPathNotFount      = StandardError{4004, "请求路径不存在"}
    ErrMethodIncorrect   = StandardError{4005, "请求方法错误"}
    ErrTimeout           = StandardError{4006, "服务超时"}
    ErrServerUnavailable = StandardError{5000, "服务不可用"}
    ErrDbQueryError      = StandardError{5001, "数据库查询错误"}
)

//StandardError 标准错误,包含错误码和错误信息
type StandardError struct {
    ErrorCode int    `json:"errorCode"`
    ErrorMsg  string `json:"errorMsg"`
}

// Error 实现了 Error接口
func (err StandardError) Error() string {
    return fmt.Sprintf("errorCode: %d, errorMsg %s", err.ErrorCode, err.ErrorMsg)
}

这样通过直接取StandardError的ErrorCode就可以知道应该返回的错误信息及错误码,调用时候也较为方便,并且做到了标准化,解决了之前项目中错误处理的问题。

断言错误行为

有时候仅仅断言自定义错误类型可能在某些情况下不够方便,可以通过调用自定义错误的方法来获取更多信息,例如标准库中的net包中的DNSError

type DNSError struct {
    Err         string // description of the error
    Name        string // name looked for
    Server      string // server used
    IsTimeout   bool   // if true, timed out; not all timeouts set this
    IsTemporary bool   // if true, error is temporary; not all errors set this
}

func (e *DNSError) Timeout() bool { return e.IsTimeout }

func (e *DNSError) Temporary() bool { return e.IsTimeout || e.IsTemporary }

可以看到不仅仅自定义了DNSError的错误类型,还为该错误添加了两个方法用来让调用者判断定该错误是临时性错误,还是由超时导致的。

package main

import (
    "fmt"
    "net"
)

func main() {
    addr, err := net.LookupHost("gygolang.com")
    if err, ok := err.(*net.DNSError); ok {
        if err.Timeout() {
            fmt.Println("operation timed out")
        } else if err.Temporary() {
            fmt.Println("temporary error")
        } else {
            fmt.Println("generic error: ", err)
        }
        return
    }
    fmt.Println(addr)
}

上述代码中,我们试图获取 golangbot123.com(无效的域名) 的 ip。然后通过 *net.DNSError 的类型断言,获取到了错误的底层值。然后用错误的行为检查了该错误是由超时引起的,还是一个临时性错误。

异常处理

什么时候使用panic

需要注意的是,你应该尽可能地使用错误,而不是使用 panic 和 recover。只有当程序不能继续运行的时候,才应该使用 panic 和 recover 机制。

panic 有两个合理的用例:

  • 发生了一个不能恢复的错误,此时程序不能继续运行。 一个例子就是 web 服务器无法绑定所要求的端口。在这种情况下,就应该使用 panic,因为如果不能绑定端口,啥也做不了。
  • 发生了一个编程上的错误。 假如我们有一个接收指针参数的方法,而其他人使用 nil 作为参数调用了它。在这种情况下,我们可以使用 panic,因为这是一个编程错误:用 nil 参数调用了一个只能接收合法指针的方法。

panic

内置的panic函数定义如下

func panic(v interface{})

当程序终止时,会打印传入 panic 的参数。我们一起看一个例子加深下对panic的理解

package main

import (  
    "fmt"
)

func fullName(firstName *string, lastName *string) {  
    if firstName == nil {
        panic("runtime error: first name cannot be nil")
    }
    if lastName == nil {
        panic("runtime error: last name cannot be nil")
    }
    fmt.Printf("%s %s\n", *firstName, *lastName)
    fmt.Println("returned normally from fullName")
}

func main() {  
    firstName := "foo"
    fullName(&firstName, nil)
    fmt.Println("returned normally from main")
}

上面的程序很简单,如果firstName和lastName有任何一个为空程序便会panic并打印出不同的信息,程序输出如下:

panic: runtime error: last name cannot be nil

goroutine 1 [running]:
main.fullName(0x1042ff98, 0x0)
    /tmp/sandbox038383853/main.go:12 +0x140
main.main()
    /tmp/sandbox038383853/main.go:20 +0x40

出现panic时,程序终止运行,打印出传入 panic 的参数,接着打印出堆栈跟踪。程序首先会打印出传入 panic 函数的信息:

panic: runtime error: last name cannot be nil

然后打印堆栈信息,首先打印堆栈中的第一项

main.fullName(0x1042ff98, 0x0)
    /tmp/sandbox038383853/main.go:12 +0x140

接着打印堆栈中下一项

main.main()
    /tmp/sandbox038383853/main.go:20 +0x40

在这个例子中这一项就是栈顶了,于是结束打印。

发生panic时的延迟函数

当函数发生 panic 时,它会终止运行,在执行完所有的延迟函数后,程序控制返回到该函数的调用方。这样的过程会一直持续下去,直到当前协程的所有函数都返回退出,然后程序会打印出 panic 信息,接着打印出堆栈跟踪,最后程序终止

在上面的例子中,我们没有延迟调用任何函数。如果有延迟函数,会先调用它,然后程序控制返回到函数调用方。我们来修改上面的示例,使用一个延迟语句。

package main

import (  
    "fmt"
)

func fullName(firstName *string, lastName *string) {  
    defer fmt.Println("deferred call in fullName")
    if firstName == nil {
        panic("runtime error: first name cannot be nil")
    }
    if lastName == nil {
        panic("runtime error: last name cannot be nil")
    }
    fmt.Printf("%s %s\n", *firstName, *lastName)
    fmt.Println("returned normally from fullName")
}

func main() {  
    defer fmt.Println("deferred call in main")
    firstName := "foo"
    fullName(&firstName, nil)
    fmt.Println("returned normally from main")
}

可以看到输出如下:

deferred call in fullName
deferred call in main
panic: runtime error: last name cannot be nil

goroutine 1 [running]:
main.fullName(0x1042ff90, 0x0)
    /tmp/sandbox170416077/main.go:13 +0x220
main.main()
    /tmp/sandbox170416077/main.go:22 +0xc0

程序退出之前先执行了延迟函数。

recover

程序发生panic后会崩溃,recover用于重新获得 panic 协程的控制。内建的recover函数定义如下

func recover() interface{}

只有在延迟函数的内部,调用 recover 才有用。在延迟函数内调用 recover,可以取到 panic 的错误信息,并且停止 panic 续发事件(Panicking Sequence),程序运行恢复正常。如果在延迟函数的外部调用 recover,就不能停止 panic 续发事件。
我们来修改一下程序,在发生 panic 之后,使用 recover 来恢复正常的运行。

package main

import (  
    "fmt"
)

func recoverName() {  
    if r := recover(); r!= nil {
        fmt.Println("recovered from ", r)
    }
}

func fullName(firstName *string, lastName *string) {  
    defer recoverName()
    if firstName == nil {
        panic("runtime error: first name cannot be nil")
    }
    if lastName == nil {
        panic("runtime error: last name cannot be nil")
    }
    fmt.Printf("%s %s\n", *firstName, *lastName)
    fmt.Println("returned normally from fullName")
}

func main() {  
    defer fmt.Println("deferred call in main")
    firstName := "foo"
    fullName(&firstName, nil)
    fmt.Println("returned normally from main")
}

当 fullName 发生 panic 时,会调用延迟函数 recoverName(),它使用了 recover() 来停止 panic 续发事件。程序会输出

recovered from  runtime error: last name cannot be nil
returned normally from main
deferred call in main

当程序发生 panic 时,会调用延迟函数 recoverName,它反过来会调用 recover() 来重新获得 panic 协程的控制。在执行完 recover() 之后,panic 会停止,程序控制返回到调用方(在这里就是 main 函数),程序在发生 panic 之后,会继续正常地运行。程序会打印 returned normally from main,之后是 deferred call in main。

运行时panic

运行时错误也会导致 panic。这等价于调用了内置函数 panic,其参数由接口类型 runtime.Error 给出。

package main

import (  
    "fmt"
)

func a() {  
    n := []int{5, 7, 4}
    fmt.Println(n[3])
    fmt.Println("normally returned from a")
}
func main() {  
    a()
    fmt.Println("normally returned from main")
}

上述代码是一个典型的数组越界造成的panic,程序输出如下:

panic: runtime error: index out of range

goroutine 1 [running]:
main.a()
    /tmp/sandbox100501727/main.go:9 +0x20
main.main()
    /tmp/sandbox100501727/main.go:13 +0x20

可以看到和我们刚才手动出发panic没什么不同,只是会打印运行时错误。
那是否可以恢复一个运行时 panic?当然是可以的,也跟刚才恢复panic的方法一样,在延迟函数中调用recover即可:

ackage main

import (  
    "fmt"
)

func r() {  
    if r := recover(); r != nil {
        fmt.Println("Recovered", r)
    }
}

func a() {  
    defer r()
    n := []int{5, 7, 4}
    fmt.Println(n[3])
    fmt.Println("normally returned from a")
}

func main() {  
    a()
    fmt.Println("normally returned from main")
}

错误与异常的转化

错误与异常有时候可以进行转化,

  • 错误转异常,比如程序逻辑上尝试请求某个URL,最多尝试三次,尝试三次的过程中请求失败是错误,尝试完第三次还不成功的话,失败就被提升为异常了。
  • 异常转错误,比如panic触发的异常被recover恢复后,将返回值中error类型的变量进行赋值,以便上层函数继续走错误处理流程。

例如我们工程中使用的Gin框架里有这么两个函数:

// Get returns the value for the given key, ie: (value, true).
// If the value does not exists it returns (nil, false)
func (c *Context) Get(key string) (value interface{}, exists bool) {
    value, exists = c.Keys[key]
    return
}

// MustGet returns the value for the given key if it exists, otherwise it panics.
func (c *Context) MustGet(key string) interface{} {
    if value, exists := c.Get(key); exists {
        return value
    }
    panic("Key \"" + key + "\" does not exist")
}

可以看到同样的功能不同的设计:

  1. Get函数基于错误设计,如果用户的参数中无法取到某参数会返回一个bool类型的错误提示。
  2. MustGet基于异常设计,如果无法取到某参数程序会panic,用于强制取到某参数的硬编码场景。

可以看到错误跟异常可以进行转化,具体怎么转化要看业务场景来定。

如何正确且优雅地处理错误

error应放在返回值类型列表的最后。

之前看到项目里有错误在中间或者第一个返回的,这是非常不符合规范的。

错误值统一定义,而不是随心所欲的去写。

参考之前章节我们组内拉通的错误码和错误信息。

不要忽略错误

可能有些时候有些程序员犯懒写了这样的代码

foo, _ := getResult(1)

忽略了错误,也就不需要进行校验了,但这是很危险的,一旦某一个错误被忽略没处理很可能造成下面的程序出bug甚至直接panic。

不要去直接校验错误字符串

比如我们最早的os.Open函数,我们去校验错误能这样写吗?

if err.Error == "No such file or directory"

这样显然不行,代码很挫,而且字符判断很不保险,怎么办呢?用上文讲的自定义错误去做。

小结

本文详述了Go中错误与异常的概念及其处理方法,希望对大家能有启发。

参考资料

https://studygolang.com/artic...

你可能感兴趣的

熊猫不烧香 · 2018年12月20日

博主 写的很棒啊 期待更新

回复

载入中...