golang异常机制

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

[toc]

前言

最近golang越来越火,自己的项目的后续项目也在陆续转成go语言,因为有着其他语言的基础,所以学习起来难度尚可,不过go的异常处理机制真的让我忍不住吐槽,我从一个业务后端开发的角度整理一下我的感想,借着这个机会也顺便整理一下相关知识点。

错误处理初体验

package main

import "fmt"
import "strconv"
import "github.com/go-redis/redis"

func main() {
 // 定义客户端对象,内部包含一个连接池
    var client = redis.NewClient(&redis.Options {
        Addr: "localhost:6379",
    })

    // 定义三个重要的整数变量值,默认都是零
    var val1, val2, val3 int

    // 获取第一个值
    valstr1, err := client.Get("value1").Result()
    if err == nil {
        val1, err = strconv.Atoi(valstr1)
        if err != nil {
            fmt.Println("value1 not a valid integer")
            return
        }
    } else if err != redis.Nil {
        fmt.Println("redis access error reason:" + err.Error())
        return
    }

    // 获取第二个值
    valstr2, err := client.Get("value2").Result()
    if err == nil {
        val2, err = strconv.Atoi(valstr2)
        if err != nil {
            fmt.Println("value1 not a valid integer")
            return
        }
    } else if err != redis.Nil {
        fmt.Println("redis access error reason:" + err.Error())
        return
    }

    // 保存第三个值
    val3 = val1 * val2
    ok, err := client.Set("value3",val3, 0).Result()
    if err != nil {
        fmt.Println("set value error reason:" + err.Error())
        return
    }
    fmt.Println(ok)
}

------
OK

可以看见,代码中存在大量的 if err!= nil的判断,因为 Go 语言中不轻易使用异常语句,所以对于任何可能出错的地方都需要判断返回值的错误信息。

上面代码中除了访问Redis需要判断之外,字符串转整数也需要判断。go语言的数据类型上有非常严格的控制,在开发过程中,尤其是与其他系统的交互过程中,报文类型的转换是非常常见的场景,导致代码中出现大量的err判断,代码可读性严重下降。

比如下面这一段,这是一个与其他系统交互报文的代码,几乎所有字段都要单独转换一下

item.AlarmNO, _ = utils.DesDecrypt(req.AlarmID, []byte(Key))
req.Lat, _ = utils.DesDecrypt(req.Lat, []byte(Key))
item.Lat, _ = strconv.ParseFloat(req.Lat, 64)
req.Lng, _ = utils.DesDecrypt(req.Lng, []byte(Key))
item.Lng, _ = strconv.ParseFloat(req.Lng, 64)
item.SmsPoiName, _ = utils.DesDecrypt(req.PoiName, []byte(Key))
item.SmsRoadInfo, _ = utils.DesDecrypt(req.RoadInfo, []byte(Key))

里面的“_”就是error,go语言允许使用这种方式“偷懒”,事实上确实被我拿来偷懒了,毕竟原本就要几百行的一个方法,我不希望因为if err!= nil再写几百行

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包里面,还提供了一个New()函数让我们方便地创建一个通用错误。

package errors

func New(text string) error {
    return &errorString{text}
}

type errorString struct {
    s string
}

func (e *errorString) Error() string {
    return e.s
}

注意这个结构体 errorString 是首字母小写的,意味着我们无法直接使用这个类型的名字来构造错误对象,而必须使用 New() 函数。

var err = errors.New("something happened")

如果你的错误字符串需要定制一些参数,可使用 fmt 包提供的 Errorf 函数

var thing = "something"
var err = fmt.Errorf("%s happened", thing)

自定义error

在web项目开发过程中,错误码的定义是一个非常常见的事情,这里看见一段代码封装的挺好,在这里贴一下代码

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)
}

异常与捕捉

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

可见,错误是业务过程的一部分,而异常不是。这个应该就是go的设计理念,但是这里我就有疑问了,在其他语言里我使用“null”、“None”、“false”等方法也可以做到,为什么这里要多一个error?

比如一个简单的查询

var name string
err = db.QueryRow("select name from user where id = ?", 222).Scan(&name)

如果用户不存在,则返回error,谁规定用户不存在是“错误”,很多业务里用户不存在是很正常的,这种设计谁能合理的解释一下?

异常捕获

很明显,go的error不是万能的,毕竟一个项目那么大,谁能保证自己能够预见所有可能的错误?所以go也提供的异常捕获的机制,不过官方非常不推荐使用。

比如在开发中最常见的json.Marshal(body)

在 json 序列化过程中,逻辑上需要递归处理 json 内部的各种类型,每一种容器类型内部都可能会遇到不能序列化的类型。如果对每个函数都使用返回错误的方式来编写代码,会显得非常繁琐。所以在内置的 json 包里也使用了 panic,然后在调用的最外层包裹了 recover 函数来进行恢复,最终统一返回一个 error 类型。

func (e *encodeState) marshal(v interface{}, opts encOpts) (err error) {
    defer func() {
        if r := recover(); r != nil {
            if je, ok := r.(jsonError); ok {
                err = je.error
            } else {
                panic(r)
            }
        }
    }()
    e.reflectValue(reflect.ValueOf(v), opts)
    return nil
}

你可以想象一下,内置 json 包的开发者在设计开发这个包的时候应该也是纠结的焦头烂额,最终还是使用了 panic 和 recover 来让自己的代码变的好看一些。

panic 和 recover

在 Go 语言中,程序中一般是使用错误来处理异常情况。对于程序中出现的大部分异常情况,错误就已经够用了。

但在有些情况,当程序发生异常时,无法继续运行。在这种情况下,我们会使用 panic 来终止程序。当函数发生 panic 时,它会终止运行,在执行完所有的延迟函数后,程序控制返回到该函数的调用方。这样的过程会一直持续下去,直到当前协程的所有函数都返回退出,然后程序会打印出 panic 信息,接着打印出堆栈跟踪(Stack Trace),最后程序终止。在编写一个示例程序后,我们就能很好地理解这个概念了。

当程序发生 panic 时,使用 recover 可以重新获得对该程序的控制。

可以认为 panic 和 recover 与其他语言中的 try-catch-finally 语句类似,只不过一般我们很少使用 panic 和 recover。

panic

内置的panic函数定义如下

func panic(v interface{})

举例

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 := "Elon"
    fullName(&firstName, nil)
    fmt.Println("returned normally from main")
}

运行结果打印如下

panic: runtime error: last name cannot be nil

goroutine 1 [running]:  
main.fullName(0x1040c128, 0x0)  
    /tmp/sandbox135038844/main.go:12 +0x120
main.main()  
    /tmp/sandbox135038844/main.go:20 +0x80

recover

当程序抛出panic,说明出现了致命错误,程序控制会一直到达顶层函数,并会打印出 panic 信息,然后是堆栈跟踪,最后终止程序。那么如果我们不希望因为一个异常就终止整个程序,可以使用recover来捕获异常
recover 是一个内建函数,用于重新获得 panic 协程的控制。

func recover() interface{}

只有在延迟函数的内部,调用 recover 才有用。在延迟函数内调用 recover,可以取到 panic 的错误信息,并且停止 panic 续发事件(Panicking Sequence),程序运行恢复正常。

比如

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 := "Elon"
    fullName(&firstName, nil)
    fmt.Println("returned normally from main")
}

程序返回结果

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

panic,recover 和 Go 协程

只有在相同的 Go 协程中调用 recover 才管用。recover 不能恢复一个不同协程的 panic。我们用一个例子来理解这一点。

package main

import (  
    "fmt"
    "time"
)

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

func a() {  
    defer recovery()
    fmt.Println("Inside A")
    go b()
    time.Sleep(1 * time.Second)
}

func b() {  
    fmt.Println("Inside B")
    panic("oh! B panicked")
}

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

程序输出结果

Inside A  
Inside B  
panic: oh! B panicked

goroutine 5 [running]:  
main.b()  
    /tmp/sandbox388039916/main.go:23 +0x80
created by main.a  
    /tmp/sandbox388039916/main.go:17 +0xc0

如果程序的第 17 行由 go b() 修改为 b(),就可以恢复 panic 了,因为 panic 发生在与 recover 相同的协程里。如果运行这个修改后的程序,会输出:

Inside A  
Inside B  
recovered: oh! B panicked  
normally returned from main

恢复后获得堆栈跟踪

当我们恢复 panic 时,我们就释放了它的堆栈跟踪。实际上,在上述程序里,恢复 panic 之后,我们就失去了堆栈跟踪。

有办法可以打印出堆栈跟踪,就是使用 Debug 包中的 PrintStack 函数。

package main

import (  
    "fmt"
    "runtime/debug"
)

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

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")
}

改后程序会输出

Recovered runtime error: index out of range  
goroutine 1 [running]:  
runtime/debug.Stack(0x1042beb8, 0x2, 0x2, 0x1c)  
    /usr/local/go/src/runtime/debug/stack.go:24 +0xc0
runtime/debug.PrintStack()  
    /usr/local/go/src/runtime/debug/stack.go:16 +0x20
main.r()  
    /tmp/sandbox949178097/main.go:11 +0xe0
panic(0xf0a80, 0x17cd50)  
    /usr/local/go/src/runtime/panic.go:491 +0x2c0
main.a()  
    /tmp/sandbox949178097/main.go:18 +0x80
main.main()  
    /tmp/sandbox949178097/main.go:23 +0x20
normally returned from main

这里需要注意defer的位置,一定要放到panic前面

错误与异常的正确使用方式

regexp包中有两个函数Compile和MustCompile,它们的声明如下:

func Compile(expr string) (*Regexp, error)
func MustCompile(str string) *Regexp

同样的功能,不同的设计:

Compile函数基于错误处理设计,将正则表达式编译成有效的可匹配格式,适用于用户输入场景。当用户输入的正则表达式不合法时,该函数会返回一个错误。

MustCompile函数基于异常处理设计,适用于硬编码场景。当调用者明确知道输入不会引起函数错误时,要求调用者检查这个错误是不必要和累赘的。我们应该假设函数的输入一直合法,当调用者输入了不应该出现的输入时,就触发panic异常。

什么情况下用错误表达,什么情况下用异常表达,就得有一套规则,否则很容易出现一切皆错误或一切皆异常的情况。

这里推荐一下这篇文章:Golang错误和异常处理的正确姿势

小结

学习go的时间不长,但是以前写过python,java,php,各种语言都有自己的优缺点,比如php一直被人们诟病的性能,但是牺牲性能换取了超高的产品开发迭代速率。go语言的优点也非常明显,比如他的部署等,但是在语言设计上真的无法认同,属于各种语言特性都有一点,但是又那么反人类的感觉。

然而大趋势在这里,只能慢慢去习惯了。

参考文章


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

本文来自:简书

感谢作者:豆腐匠

查看原文:golang异常机制

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

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