目录
【error类型】
error的基本用法
error.Is 用法
封装自定义错误结构体
error.As 用法
错误行为特征检视策略
【异常panic和recover】
panic
recover
panic 和 os.Exit
如何正确应对panic
【error类型】
error的基本用法
在Go语言中,一般使用 error 这个接口类型表示错误,并且通常将 error 类型返回值放在返回值列表的末尾,比如下面这样:
package main
import (
"errors"
"fmt"
"time"
)
func main() {
if caclResult, err := cacl(-1, 2); err == nil {
fmt.Println(caclResult)
} else {
fmt.Println(err)
}
}
func cacl(a int, b int) (int, error) {
if a < 0 || b < 0 {
return -1, errors.New("a或者b不能小于0")
}
return a + b, nil
}
error 接口类型是 Go 原生内置的类型,它的定义如下:
// $GOROOT/src/builtin/builtin.go
type interface error {
Error() string
}
Go 在标准库中提供了两种构造错误值的方法: errors.New 和 fmt.Errorf,这两种方法只限于以字符串形式返回错误信息。
err := errors.New("your first demo error")
errWithCtx = fmt.Errorf("index %d is out of bounds", i)
也可以使用 error 类型来处理错误,比如标准库中的 net 包就定义了包含错误上下文的错误类型:
// $GOROOT/src/net/net.go
type OpError struct {
Op string
Net string
Source Addr
Addr Addr
Err error //包含错误上下文的错误类型
}
一般在函数中不关心返回错误值携带的具体上下文信息,只要发生错误就进入唯一的错误处理执行路径,比如下面这段代码:
package main
import (
"errors"
"fmt"
)
func main() {
fmt.Println(getDbInfo())
}
func getDbInfo() (string, error) {
err := checkDbConnect()
if err != nil { //此处不关心返回错误值携带的具体上下文信息,只要发生错误就返回
return "", err
}
return "mysql", nil
}
func checkDbConnect() error {
foo := false
if !foo {
return errors.New("数据库连接失败")
}
return nil
}
也可以判断具体的错误类型后再处理相关业务逻辑:
package main
import (
"errors"
"fmt"
)
var (
ErrDbConnectFailed = errors.New("数据库连接失败")
ErrDbUsernameError = errors.New("数据库用户名错误")
ErrDbPasswordError = errors.New("数据库密码错误")
ErrCodeTest = fmt.Errorf("code:%d", 200)
)
func main() {
fmt.Println(ErrCodeTest) //code:200
fmt.Printf("%T\n", ErrCodeTest) //*errors.errorString
fmt.Printf("%T\n", ErrDbConnectFailed) //*errors.errorString
fmt.Println(getDbInfo())
updateDb()
}
// 判断具体的错误类型再处理相关逻辑
func updateDb() {
err := checkDbConnect()
if err != nil {
switch err {
case ErrDbConnectFailed:
fmt.Println("处理数据库连接失败的逻辑...")
return
case ErrDbUsernameError:
fmt.Println("处理数据库用户名错误的逻辑...")
return
case ErrDbPasswordError:
fmt.Println("处理数据库密码错误的逻辑...")
return
}
}
fmt.Println("操作处理完成")
}
func checkDbConnect() error {
foo := false
if !foo {
return ErrDbConnectFailed
}
return nil
}
再比如,校验年龄是否合法:
package main
import (
"errors"
"fmt"
)
func main() {
if err := checkAge(-1); err != nil {
fmt.Println(err) //年龄是:-1,不合法
return
}
fmt.Println("这里不会执行")
}
func checkAge(age int) error {
if age < 0 {
//return errors.New("年龄不合法") //返回error对象
return fmt.Errorf("年龄是:%d,不合法", age)
}
fmt.Println("年龄是:", age)
return nil
}
error.Is 用法
上面代码中错误信息耦合到了业务中,不利于维护。从 Go 1.13 版本开始,标准库 errors 包提供了 Is 函数 用于错误处理方对错误值的检视,上面的代码可以改造如下:
func updateDbV2() {
err := checkDbConnect()
if errors.Is(err, ErrDbConnectFailed) {
fmt.Println("处理数据库连接失败的逻辑...")
return
}
if errors.Is(err, ErrDbUsernameError) {
fmt.Println("处理数据库用户名错误的逻辑...")
return
}
if errors.Is(err, ErrDbPasswordError) {
fmt.Println("处理数据库密码错误的逻辑...")
return
}
fmt.Println("操作处理完成")
}
使用 errors.Is 可以根据错误链找到最底层的错误信息,并判断它们是否是同一类。再看下面的代码:
func errorIsDemo() {
//使用 fmt.Errorf 对错误变量包装
err1 := fmt.Errorf("err1: %w", ErrDbConnectFailed) //基于 ErrDbConnectFailed 包装出 err1
err2 := fmt.Errorf("err2: %w", err1) //基于 err1 包装出 err2
fmt.Println(err1) //输出: err1: 数据库连接失败
fmt.Println(err2) //输出: err2: err1: 数据库连接失败
fmt.Println(err2 == ErrDbConnectFailed) //false
fmt.Println(errors.Is(err1, ErrDbConnectFailed)) //true
fmt.Println(errors.Is(err2, ErrDbConnectFailed)) //true
fmt.Println(errors.Is(err2, err1)) //true
fmt.Println(errors.Is(err1, err2)) //false
//可以使用 errors.Unwrap 获取被嵌套的 error
fmt.Println(errors.Unwrap(err1)) //数据库连接失败
fmt.Println(errors.Unwrap(err2)) //err1: 数据库连接失败
}
因此, 在 Go 1.13 及后续版本中建议使用 errors.Is 方法去检视某个错误值是否属于某个预期的错误值,而尽量避免使用 if/switch 判断。
封装自定义错误结构体
如果遇到错误的地方需要提供更多“上下文”信息,可以自定义一个错误结构体,一般以 XXXError形式命名,比如标准库里面 json 包的一个方法的实现:
// $GOROOT/src/encoding/json/decode.go
type UnmarshalTypeError struct {
Value string
Type reflect.Type
Offset int64
Struct string
Field string
}
// $GOROOT/src/encoding/json/decode.go
func (d *decodeState) addErrorContext(err error) error {
if d.errorContext.Struct != nil || len(d.errorContext.FieldStack) > 0 {
switch err := err.(type) {
case *UnmarshalTypeError:
err.Struct = d.errorContext.Struct.Name()
err.Field = strings.Join(d.errorContext.FieldStack, ".")
return err
}
}
return err
}
error.As 用法
从 Go 1.13 版本开始,标准库 errors 包提供了As函数给错误处理方检视错误值。As函数类似于通过类型断言判断一个 error 类型变量是否为其它自定义错误类型。
// 类似 if e, ok := err.(*MyError); ok { … }
var e *MyError
if errors.As(err, &e) {
// 如果err类型为 *MyError,变量e将被设置为对应的错误值
}
和 erros.Is 类似,errors.As 也可以根据错误链找到最底层的错误信息。
// 自定义一个error结构体
type MyError struct {
errCode int //错误码
errMsg string //错误信息
}
func (e *MyError) Error() string {
return fmt.Sprintf("错误码:%d,错误信息:%v", e.errCode, e.errMsg)
}
func errorAsDemo() {
var err = &MyError{errCode: 500, errMsg: "服务器异常"}
err1 := fmt.Errorf("err1: %w", err)
err2 := fmt.Errorf("err2: %w", err1)
fmt.Println(err, " -- ", err1, "--", err2) //错误码:500,错误信息:服务器异常 -- err1: 错误码:500,错误信息:服务器异常 -- err2: err1: 错误码:500,错误信息:服务器异常
var e *MyError
fmt.Println(errors.As(err2, &e)) //true
fmt.Println(errors.As(err1, &e)) //true
fmt.Println(errors.As(err2, &err1)) //true
fmt.Println(errors.As(err1, &err2)) //true
}
同样的,如果使用的是 Go 1.13 及后续版本,就尽量使用 errors.As 方法去检视某个错误值是否是某自定义错误类型的实例。
错误行为特征检视策略
将某个包中的错误类型归类,统一提取出一些公共的错误行为特征,并将这些错误行为特征放入一个公开的接口类型中。这种方式也被叫做错误行为特征检视策略。比如标准库 net.Error 接口就口包含两个用于判断错误行为特征的方法,错误处理方只需要依赖这个公共接口就可以检视具体错误值的错误行为特征信息,并根据这些信息做出后续错误处理分支选择的决策。
// $GOROOT/src/net/net.go
type Error interface {
error
Timeout() bool //用来判断是否是超时错误
Temporary() bool //用于判断是否是临时错误
}
为了保证函数的健壮性,需要注意下面几个原则:
- 不要相信任何外部输入的参数:函数需要对所有输入的参数进行合法性的检查。一旦发现问题,立即终止函数的执行,返回预设的错误值。
- 不要忽略任何一个错误:调用标准库或第三方包的函数或方法时不能假定它一定会成功,需要显式地检查这些调用返回的错误值,一旦发现错误就要及时终止函数执行,防止错误继续传播。
- 不要认为异常不会发生:异常不是错误,错误是可预期的,但异常却是少见的、意料之外的,比如除以0;虽然少见,但不能认为异常不会发生,所以需要关注对异常的捕捉和恢复。
【异常panic和recover】
panic
在PHP或者Java程序中,异常一般使用 try...catch...finally 表示,而在 Go 语言中用 panic 表示异常,但又和PHP的 try...catch...finally 不完全一样。在 Go 中 panic 主要有两类来源,一类是来自 Go 运行时,另一类则是 Go 开发人员通过 panic 函数主动触发的。panic可以接收一个 interface{} 类型的参数,也就是任意类型。如果异常出现了但没有被捕获并恢复,就会终止程序的执行,即便出现异常的位置不在主Goroutine 中也会这样。
package main
func main() {
println("main start")
user()
println("main end")
}
func user() {
println("user start")
goods()
println("user end")
}
func goods() {
println("goods start")
panic("在 goods 中触发了 panic")
info() //Unreachable code
println("goods end") //Unreachable code
}
func info() {
println("info start")
println("info end")
}
可以看到,函数的调用次序为 main -> user -> goods -> info, 在 goods 函数中手动触发 panic,不会再往后执行,不仅 info 没有任何执行,并且 main、user、goods 的 end 部分也都没有执行。无论在哪个 Goroutine 中发生未被恢复的 panic 整个程序都将崩溃退出。
recover
Go 中可以通过 recover 函数捕捉 panic 并恢复程序正常执行,recover 是 Go 内置的专门用于恢复 panic 的函数,它必须被放在一个 defer 函数中才能生效。如果 recover 捕捉到 panic,它就会返回以 panic 的具体内容为错误上下文信息的错误值。如果没有 panic 发生,那么 recover 将返回 nil。
修改上面的测试代码如下:
func goods() {
defer func() {
if e := recover(); e != nil {
fmt.Println("recover the panic:", e)
}
}()
println("goods start")
panic(errors.New("在 goods 中触发了 panic"))
info() //Unreachable code
println("goods end") //Unreachable code
}
修改之后,main、user 的 end 部分可以执行了。 因此如果作为 API 函数的作者,一定不要将 panic 当作错误返回给 API 调用者。
panic 和 os.Exit
• panic ⽤于不可以恢复的错误
• panic 退出前会执⾏ defer 指定的内容• os.Exit 退出时不会调⽤ defer 指定的函数
• os.Exit 退出时不输出当前调⽤栈信息
现在把上面的panic改成os.Exit试试:
func goods() {
defer func() {
if e := recover(); e != nil {
fmt.Println("recover the panic:", e)
}
}()
println("goods start")
//panic(errors.New("在 goods 中触发了 panic"))
os.Exit(-1)
info() //Unreachable code
println("goods end") //Unreachable code
}
如何正确应对panic
并不是在所有场景下都应该使用panic和recover,因为很多函数非常简单,根本不会出现 panic 情况,如果增加了 panic 捕获和恢复反倒会增加函数的复杂性,也会增加开发的实现过程。而且在 Go语言的函数和defer用法_浮尘笔记的博客-CSDN博客 中也说过,带有 defer 的函数执行开销会比不带 defer 的函数的执行开销大一些。所以应该从以下几个方面考虑使用panic的场景:
(1)针对各种应用对 panic 忍受度的差异,采取的应对策略也应该不同。
比如后端 HTTP 服务器这样的关键系统就需要在特定位置捕捉并恢复 panic 以保证服务器整体的健壮度,Go 标准库中的 http server 就是这样设计的。它采用的是每个客户端连接都使用一个单独的 Goroutine 进行处理并发模型,客户端一旦与 http server 连接成功,http server 就会为这个连接新创建一个 Goroutine,并在这 Goroutine 中执行对应连接的 serve 方法来处理这条连接上的客户端请求。为了保证处理某一个客户端连接的 Goroutine 出现 panic 时不影响到 http server 主 Goroutine 的运行,Go 标准库在 serve 方法中加入了对 panic 的捕捉与恢复。
下面是 serve 方法的部分代码片段:
// $GOROOT/src/net/http/server.go
func (c *conn) serve(ctx context.Context) {
c.remoteAddr = c.rwc.RemoteAddr().String()
ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr())
// serve 方法在一开始处就设置了 defer 函数,并在该函数中捕捉并恢复了可能出现的 panic。
// 这样即便处理某个客户端连接的 Goroutine 出现 panic,处理其他连接 Goroutine 以及 http server 自身都不会受到影响
defer func() {
if err := recover(); err != nil && err != ErrAbortHandler {
//...
}
if !c.hijacked() {
//...
}
}()
}
(2)使用panic提示潜在 bug
Go 语言标准库中没有提供断言之类的辅助函数,但可以使用 panic 模拟断言对潜在 bug 的提示功能。在 Go 标准库中,大多数panic 的使用都是充当类似断言的作用的。
比如下面就是标准库 encoding/json 包使用 panic 指示潜在 bug 的一个例子:
// $GOROOT/src/encoding/json/decode.go
// 当一些本不该发生的事情导致我们结束处理时,phasePanicMsg将被用作panic消息
// 它可以指示JSON解码器中的bug,或者在解码器执行时还有其他代码正在修改数据切片。
const phasePanicMsg = "JSON decoder out of sync - data changing underfoot?"
func (d *decodeState) valueQuoted() interface{} {
switch d.opcode {
default: //如果程序执行流进入了 default 分支就会引发 panic,用来提示开发人员:这里很可能是一个 bug
panic(phasePanicMsg)
case scanBeginArray, scanBeginObject:
//...
case scanBeginLiteral:
//...
}
return unquotedValue{}
}
再比如, json 包的 encode.go 中也有使用 panic 做潜在 bug 提示的例子:
// $GOROOT/src/encoding/json/encode.go
func (w *reflectWithString) resolve() error {
switch w.k.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int6
w.ks = strconv.FormatInt(w.k.Int(), 10)
return nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.
w.ks = strconv.FormatUint(w.k.Uint(), 10)
return nil
}
panic("unexpected map key type") //这行代码就相当于一个“代码逻辑不会走到这里”的断言,一旦触发就表示很可能是一个 bug
}
最后再补充一点,有些时候不要对 panic 进行 recover,因为出现了panic就说明程序存在问题,需要解决。具体如果应对也应该视具体情况而定。
源代码:https://gitee.com/rxbook/go-demo-2023/tree/master/basic/go02