[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语言的优点也非常明显,比如他的部署等,但是在语言设计上真的无法认同,属于各种语言特性都有一点,但是又那么反人类的感觉。
然而大趋势在这里,只能慢慢去习惯了。
参考文章