go 的异常处理一直都是一种让人感觉奇怪的设计,本文用较多的篇幅和大家一起聊聊go 的异常处理的一些姿势
话不多说 ,先放下源码(也就几行)
package builtin
// 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
}
package errors
// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
func New(text string) error {
return &errorString{text}
}
// errorString is a trivial implementation of error.
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
我们简单解释一下~
go 的 error ,就这么几行代码。。。 真的简洁
它支持了错误信息的写入以及获取。但是在实际工作中,只记录错误信息,往往是不够的。
在这里,有个地方需要我们注意,我们可以发现,在 New 方法中,返回的是 errorString 结构体的指针,而不是值。 这个 & 实际上十分的关键。
我们可以通过写代码进行对比:
package main
import (
"errors"
"fmt"
)
type errorStringTest struct {
s string
}
func (e errorStringTest) Error() string {
return e.s
}
func NewError (text string) error{
return errorStringTest{s: text} // 这里不返回指针
}
func main() {
if NewError("MClink") == NewError("MClink") {
fmt.Println("equal")
} else {
fmt.Println("no equal")
}
if errors.New("Study") == errors.New("Study") {
fmt.Println("equal")
} else {
fmt.Println("no equal")
}
}
打印的结果是
equal
no equal
我们可以发现,如果不返回指针,那么每次 new 返回的对象我们进行对比,都是相同的。这就会导致对象引用错误的问题。
现在大多数主流语言都是使用的 exception ,比如 C++, JAVA, PHP 等语言。
那么 go 语言为什么要放弃掉 exception 呢?这是个值得我们思考的问题。我们先来看看几种常见语言的 exception 一般是怎么处理的。
我们先看看 C++ 和 PHP,它们引进了 exception, 但是在 throw 的时候,调用方并不确定会不会有异常会抛出,在语言层次是无法自动识别的,但是现在的IDE会为我们自动识别并且给与提示。
所以在使用这个的时候,如果没有处理抛出的异常,程序就会中止,并抛出致命错误。
而 Java 就比较严格一点。Java 有两种类型的异常。
这样会直接报错,无法编译
这几种方式就是正常的使用方式
我们可以发现,异常这种东西不同语言都有不同的限制。Java算是比较严格使用的。而像PHP这种 如果不用IDE,你很难知道你调用的方法到底会不会抛异常,所以很多人为了保底,会在写的那一层加上 try catch (当大家都这么想的时候,世界也就乱了)
然后是老生常谈的话题了,exception 应该什么时候用? 写的不好确实可能会 try catch 满天飞。十分的不优雅。
所以在使用 exception 确实会存在很多问题。尤其是不规范使用。
异常的使用宗旨是:软件执行过程中遇到非预期的情况
好比说,网络请求超时,文件打开失败,参数验证不正确等。每个公司的规范都有所不同,有的公司喜欢全局捕捉,也有的公司喜欢每个控制器方法都单独放一个 try catch (个人觉得全局捕捉比较好,代码好看一点)
当然,这些语言不仅提供了异常,同时也提供了错误,在业务中我们却很少去使用错误。
异常真的好用吗?身边的朋友都说挺好用的,好用为什么要放弃呢?
举Java为栗子,我们可以发现Java 异常不再是异常,而是变得司空见惯了。它们从良性到灾难性都有使用,异常的严重性由函数的调用者来区分。
而 go 为了区分这两种的区别,搞出了 error 以及 panic 的机制。真正将灾难性错误区分开。并且可以直观的让程序员检查是否有错误发生,相对比较明显(即使可以强制忽略)
上面我们提到,go 是基于 error + panic + recover + defer 来处理异常的,一般来说,对于真正意外的情况,比如那些表示不可恢复的程序错误,例如索引越界、不可恢复的环境问题、栈溢出,我们才会使用 panic + recover 的组合。对于其他的错误情况,我们应该是期望使用 error 来进行判定。
使用 error 有什么好处呢?
简单举个使用的栗子:
package main
import (
"errors"
"fmt"
)
func main() {
res , err := test(-3)
if err != nil {
fmt.Println(err.Error())
return
}
fmt.Println(res)
}
// 数字判断
func test(num int64) (string, error){
if num > 0 {
return "positive", nil
}
return "", errors.New("the value is not positive")
}
这是一个简单的 error 使用栗子,一般来说,基于 go 语言特有的多返回值优势,所以你很容易的在函数签名中带上实现了 error interface 的对象,交由调用者来判定。如果一个函数返回了 value, error,你不能对这个 value 做任何假设,必须先判定 error。唯一可以忽略 error 的是,就是你连 value 也不关心。
我们再简单举一个panic 的栗子
package main
import (
"fmt"
)
func main() {
// 需要放在 panic 触发的方法前定义
defer func() {
if err := recover(); err != nil {
fmt.Println(err) // 捕获到 panic
return
}
fmt.Println("success")
}()
res , err := test(-3)
if err != nil {
fmt.Println(err.Error())
return
}
fmt.Println(res)
}
func test(num int64) (string, error){
if num > 0 {
return "positive", nil
}
panic("panic: the value is not positive")
// 这里开始后面的代码都不会被执行
}
一般来说, recover 需要和 defer 进行搭配使用,因为 panic 的之后对应的方法就开始中断结束了,此时在panic之前定义的 defer 会执行。
要强调的是 go 的 panic 机制和其他的 exception 不同,当我们抛出异常的时候,相当于你把 exception 扔给了调用者来处理, 对于 go 的 panic 来说,就是程序挂逼了,因为我们不能指望调用者会来解决 panic, 一旦发生了 panic 意味着代码不能继续运行。
通过使用多个返回值和一个简单的约定, go 语言 解决了让程序员知道什么时候出了问题,并且为真正的异常情况保留了 panic
在探讨最佳实践时,我们先了解几个概念
又称预定义特定错误, 在 go 的源码包中充斥了很多的预定义错误,例如 io.EOF
package io
// EOF is the error returned by Read when no more input is available.
// Functions should return EOF only to signal a graceful end of input.
// If the EOF occurs unexpectedly in a structured data stream,
// the appropriate error is either ErrUnexpectedEOF or some other error
// giving more detail.
var EOF = errors.New("EOF")
一般是这么使用的
for {
line, err := reader.ReadBytes('\n')
if err == io.EOF {
break
}
println(string(line))
c.Write(line)
}
我们可以发现, 使用 sentinel 值是最不灵活的错误处理策略,因为调用方必须使用 == 将结果与预先声明的值进行比较。当您想要提供更多的上下文时,这就出现了一个问题,因为返回一个不同的错误将破坏相等性检查。
甚至当你使用一些有意义的 fmt.Errorf 携带一些上下文,也会破坏调用者的 == ,调用者将被迫查看 error.Error() 方法的输出,以查看它是否与特定的字符串匹配。比如说这样:
package main
import (
"fmt"
"math/rand"
"strings"
)
func main() {
err := test()
if err != nil {
if strings.Contains(err.Error(), "Test") {
fmt.Println(err.Error())
return
}
fmt.Println("no hit")
}
}
func test() error{
return fmt.Errorf("Test: %d ", rand.Int())
}
但是我们应该不依赖检查 error.Error 的输出,Error 方法存在于 error 接口主要用于方便程序员使用,但不是程序(编写测试可能会依赖这个返回)。这个输出的字符串用于记录日志、输出到 stdout 等。
当然, sentinel errors 不仅会有这些问题。一旦你使用了它,那么必定会在两个包直接建立依赖关系,比如说检查错误是否为 io.EOF ,那么你的代码就必须导入 io 包,当项目中的许多包导出错误值时,就会存在耦合,项目中的其他包也必须去导入这些错误值才能检查特定的错误条件。
不仅这样,sentinel errors 还会有维护成本,你的公共函数或者方法返回了一个特定的错误,那么这个值必须是公共的,也就无法不去做文档记录了
综述,虽然 go 的源码中充斥着不少 sentinel errors , 但是应该避免去使用它,这并不是我们应该去效仿的模式。
我们先看看几行代码:
package main
import "fmt"
type McError struct { // 自定义错误相关结构体
Line int
File string
Msg string
Code int
}
func (e *McError) Error() string { // 实现了 error 接口
return fmt.Sprintf("File %s, Line %d, Msg %s , Code %d" , e.File, e.Line, e.Msg, e.Code)
}
func getErr() error { // 返回结构体实例
return &McError{
Line: 200,
File: "test.go",
Msg: "server busy",
Code: 500,
}
}
func main() {
err := getErr()
switch err:= err.(type) { // 类型断言
case nil:
// call succeeded
case *McError:
// hit
fmt.Println("error msg is ", err.Msg)
default:
// unknown error
}
}
go 自带的 error 只有 errmsg ,一般来说并不满足业务需求,我们更加希望可以带上一些额外的信息来帮助我们去定位问题。因此我们可以通过实现 error 接口来丰富其使用范围,例如上面的栗子。
我们通过重新定义了一个新的结构体 McError ,并通过实现 error 接口来达到丰富错误码相关信息
很多人会采用断言的方式来判别是原生的 error 还是我们自定义的 error。如果判断是自定义 error 则去做一些想做的事情。这种方式我们称之为 Error types。
与错误值相比,错误类型的一大改进是它们能够包装底层错误以提供更多上下文。官方 os 包中就有类似的作法:
package os
type PathError struct {
Op string
Path string
Err error
}
func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }
这种作法的弊端是 调用者要使用类型断言和类型 switch,就要让自定义的 error 变为 public。这种模型会导致和调用者产生强耦合,从而导致 API 变得脆弱。
虽然源码中有这样的做法,但是实际上我们应该尽量避免使用 error types,当然,它相比于sentinel errors 更好。因为他可以提供更多出错相关的上下文。但是两者之间还是存在的许多相同的问题。
func fn1() error {
_, err := test(-3)
if err != nil {
return err
}
return nil
}
func fn2() error {
_, err := test(-3)
if err == nil {
// todo something
return nil
}
return err
}
func fn3() error {
_, err := test(-3)
if err != nil {
return fmt.Errorf("xxx : %v", err)
}
return nil
}
func fn4() error {
_, err := test(-3)
if err != nil {
log.Println("xxx : ", err)
return err
}
return nil
}
func fn5(num int64) {
pos := isPositive(num)
if pos == nil {
fmt.Println(num, "is neither")
return
}
if *pos {
fmt.Println(num, "is positive")
} else {
fmt.Println(num, "is negative")
}
}
func isPositive(num int64) *bool {
if num == 0 {
return nil
}
res := num > -1 // positive
return &res
}
不要急,我们一个一个来看。
fn1() 看起来像不像脱了裤子来放屁呢?明明test的返回和 fn1的返回是同类型的,为什么还要判断是 error 就返回 error, 是 nil 就返回 nil。它的效果其实跟下面是一样的。
func fn1() error {
_, err := test(-3)
return err
}
其实这种问题代码不单单只有在 go 出现,其他语言也可能有类似的写法,例如:
xxx := test()
if xxx == false {
return false
}
return true
在工作过程中我就看到过不少这种废话代码。
然后我们看看 fn2(), 判断 err 是 nil 就在对应的花括号写逻辑,不是就返回 err ,对比一下下面这种写法,你觉得哪种会更加舒服
func fn2() error {
_, err := test(-3)
if err != nil {
return err
}
//todo something
return nil
}
当你的业务代码比较多时,将它放在 if 的花括号里面其实一点都不好看,而且如果里面还有多层嵌套 if 的话,会让代码变的很难读,我们都并不喜欢多层if 嵌套,因此我们会尽量的让 if 平铺起来。这样在阅读整个流程代码时会更加的顺畅。
举一个其他例子:
if n > 0 {
if n<3 {
fmt.Println("MClink")
}
} else {
if n < -1 {
fmt.Println("Study")
}
}
if n > 0 && n < 3 {
fmt.Println("MClink")
}
if n < -1 {
fmt.Println("Study")
}
两种写法的结果是一样的,但是从可读性来看,明显第二种更加的清晰
接下来我们再看看 fn3() 。表面看着似乎没有啥问题,只是对错误信息进行了修饰,比如说这样
package main
import (
"errors"
"fmt"
)
func main() {
err := errors.New("MClink")
fmt.Println(err.Error()) // MClink
err2 := fmt.Errorf("this is %s", err)
fmt.Println(err2.Error()) // this is MClink
}
我们可以发现,使用了 fmt.Errorf 获得的 err 不再是原来的 err ,当你通过这种方式去处理后,sentinel error 的比较就会失效。这是一个隐藏的隐患,因为从语法上来说,它并没有问题。
接下来轮到了 fn4() 了,它好像没做啥事啊,只是打印了一下日志。是的它没有错,我们需要探讨的是,如果我们在调用栈每个方法里面都对 error 进行记录,那么会重复打印许多的错误日志,这是种不大优雅的行为。
如何将整个调用栈的错误信息记录成一条错误日志呢?
其实, warp 方法就起到作用了,官方 errors 包并没有这个方法。你需要从
“github.com/pkg/errors” 获取。它的作用就是对错误进行“套娃”, 你没听错,是真的套娃。例如:
package main
import (
"fmt"
"github.com/pkg/errors"
)
func main() {
err := errors.New("MClink")
err2 := errors.Wrap(err, "MClink2")
err3 := errors.Wrap(err2, "MClink3")
fmt.Println(err) // MClink
fmt.Println(err2) // MClink2: MClink
fmt.Println(err3) // MClink3: MClink2: MClink
}
我们可以发现,每次调用 Wrap 函数,都是将我们每次增加的错误信息叠加到 原来的错误信息里面。此时你可能会问,这样的好处是什么呢?和 fn4() 的最大区别是,我记录了错误信息,但是没有真正去打日志。我可以叠加错误信息,最终在顶层进行日志统一打入。并且在 go 的官方库还支持 了 UnWarp() 进行解套。
最后我们再来看看 fn5()。fn5() 的特殊之处就是返回了一个布尔值的指针。这是个十分奇怪的姿势。
由于指针的特殊性,导致其返回值不但包含了布尔值的特性,还包含了指针的特性,因此我们在判断的时候,需要去考虑两个特性,导致程序更加的臃肿。纯粹是闲的发慌。
所谓的不透明错误处理,就是调用者只需要知道调用成功或者失败,但是没有能力看到错误的内部,这种方式的好处就是代码和调用者之间的耦合最少。比如这样:
package main
import (
"errors"
"fmt"
)
func main() {
_ = fn()
}
func fn() error {
s, err := test(-1)
if err != nil {
return err
}
fmt.Println(s)
return nil
}
func test(n int) (string, error) {
if n > 0 {
return "positive", nil
}
return "", errors.New("is not positive")
}
对于 test() 的返回结果,我们不对 value 进行假设,先判断 error ,如果 error 不为 nil, 则直接将该 error 返回给上层调用者。这种处理方式不但对代码流程来说更加简洁,也可以将底层的错误直接原生返回到顶层调用函数。
但是这种方式也不能满足所有的场景,比如说一个 http request 失败的原因可能有很多种,比如说连接超时,资源不存在,或者是服务端发生了错误,无权访问等等。比如说连接超时的时候我们希望可以增加重试机制。那么这种场景就不适合上面这种处理方式了。在这种情况下,我们可以断言错误实现了特定的行为,而不是断言错误是特定的类型或值。可以参考这样的例子:
type timeout interface {
Timeout() bool // 是否超时
}
func IsTimeout (err error) bool {
to, ok := err.(timeout)
return ok && to.Timeout() // 断言为 timeout 接口的实现并且结果为超时
}
这种方式和之前我们聊的 switch 有什么区别呢,最大的区别就是可以不导入自定义错误的包。因为 go 的实现是不需要引入包的,所以很好的跟自定义包进行了解耦,好比说我只给你一个判断入口,你只要问我是不是就好了。你不需要把我放进你的家里。
在使用的时候我们应该遵循这几个原则:
package errors
import (
"internal/reflectlite"
)
// Unwrap returns the result of calling the Unwrap method on err, if err's
// type contains an Unwrap method returning error.
// Otherwise, Unwrap returns nil.
func Unwrap(err error) error {
// 类型判断该 error 是否有被 wrap 过
u, ok := err.(interface {
Unwrap() error
})
// 没有
if !ok {
return nil
}
// 有的话,调用上一层 error 的 unwrap
return u.Unwrap()
}
比如说这个栗子:
package main
import (
errors2 "errors"
"fmt"
"github.com/pkg/errors"
)
func main() {
err := errors.New("MClink")
err2 := fmt.Errorf("young man is %w", err)
err3 := fmt.Errorf("in the world %w", err2)
fmt.Println(err2) // young man is MClink
fmt.Println(err3) // in the world young man is MClink
err4 := errors2.Unwrap(err3)
fmt.Println(err4) // young man is MClink
}
需要注意的是,我们不要把 wrap 方法和 Unwrap 当成一个搭配,他们并不是一对情侣, wrap 是基于 pkg/errors (非官方库),而 Unwrap 是基于官方库 (errors) 的。因此在上面的栗子,我们使用的是 fmt.Error() 而不是 wrap 。不要看他们长得完全不一样,但是其实 fmt.Error() 和 errors.Unwrap() 才是一堆
用来判断传入的 err 和 target error 关系,如果 target error 的错误链中包含 err ,那么返回 true,否则返回 false
func Is(err, target error) bool {
if target == nil {
return err == target
}
isComparable := reflectlite.TypeOf(target).Comparable()
for {
if isComparable && err == target { // 相同就直接返回
return true
}
if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) { // 断言成功并且 判断是否和 target 相同
return true
}
// TODO: consider supporting target.Is(err). This would allow
// user-definable predicates, but also may allow for coping with sloppy
// APIs, thereby making it easier to get away with them.
if err = Unwrap(err); err == nil { // 该 err 如果没有 wrap 过,则直接返回 false,否则会继续循环
return false
}
}
}
其实就是一层层反嵌套,剥开然后一个个的和target比较,相等就返回true。
我们可以简单看看一个使用栗子:
package main
import (
errors "errors"
"fmt"
)
func main() {
err := errors.New("MClink")
err2 := fmt.Errorf("young man is %w", err)
fmt.Println(errors.Is(err, err2)) // false
fmt.Println(errors.Is(err2, err)) // true
}
只有你要判断的人等于目标或者是目标的祖先,结果才会是 true
在Go 1.13之前没有wrapping error的时候,我们如果要转换 error,一般都是使用type assertion 或者 type switch,也就是类型断言。
源码如下:
func As(err error, target interface{}) bool {
if target == nil {
panic("errors: target cannot be nil")
}
val := reflectlite.ValueOf(target)
typ := val.Type()
// 确保target必须是一个非nil指针
if typ.Kind() != reflectlite.Ptr || val.IsNil() {
panic("errors: target must be a non-nil pointer")
}
// 确保target是一个接口或者实现了error接口
if e := typ.Elem(); e.Kind() != reflectlite.Interface && !e.Implements(errorType) {
panic("errors: *target must be interface or implement error")
}
targetType := typ.Elem()
for err != nil {
if reflectlite.TypeOf(err).AssignableTo(targetType) {
val.Elem().Set(reflectlite.ValueOf(err))
return true
}
if x, ok := err.(interface{ As(interface{}) bool }); ok && x.As(target) {
return true
}
// 不停的Unwrap,一层层的获取err
err = Unwrap(err)
}
return false
}
同样我们写一个简单的栗子:
package main
import (
errors "errors"
"fmt"
)
func main() {
err := errors.New("MClink")
err2 := fmt.Errorf("young man is %w", err)
fmt.Println(err) // MClink
fmt.Println(errors.As(err2, &err)) // true
fmt.Println(err) // young man is MClink
}
is 和 as 的区别就是,一个只是判断用的,另一个则是转换使用。