最新消息: USBMI致力于为网友们分享Windows、安卓、IOS等主流手机系统相关的资讯以及评测、同时提供相关教程、应用、软件下载等服务。

浅谈 GO 语言错误处理

IT圈 admin 4浏览 0评论

浅谈 GO 语言错误处理

go 的异常处理一直都是一种让人感觉奇怪的设计,本文用较多的篇幅和大家一起聊聊go 的异常处理的一些姿势

一、error 是什么玩意

话不多说 ,先放下源码(也就几行)

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
}

我们简单解释一下~

  • 在 builtin包 中定义了 error 的接口,接口中只有 Error() string 的方法
  • errors 包中定义了 ErrorString 结构体,这个结构体只有一个string 类型的值, 通过复写 Error方法实现了 error 接口
  • errors 包提供了一个获取 error 对象的 New 方法,支持复写 string 类型的值,可以通过 e.Error() 获取对应的错误值

go 的 error ,就这么几行代码。。。 真的简洁

它支持了错误信息的写入以及获取。但是在实际工作中,只记录错误信息,往往是不够的。

在这里,有个地方需要我们注意,我们可以发现,在 New 方法中,返回的是 errorString 结构体的指针,而不是值。 这个 & 实际上十分的关键。

我们可以通过写代码进行对比:

package mainimport ("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 返回的对象我们进行对比,都是相同的。这就会导致对象引用错误的问题。

二、error 和 exception 哪种更好?

现在大多数主流语言都是使用的 exception ,比如 C++, JAVA, PHP 等语言。
那么 go 语言为什么要放弃掉 exception 呢?这是个值得我们思考的问题。我们先来看看几种常见语言的 exception 一般是怎么处理的。
我们先看看 C++ 和 PHP,它们引进了 exception, 但是在 throw 的时候,调用方并不确定会不会有异常会抛出,在语言层次是无法自动识别的,但是现在的IDE会为我们自动识别并且给与提示。
所以在使用这个的时候,如果没有处理抛出的异常,程序就会中止,并抛出致命错误。

而 Java 就比较严格一点。Java 有两种类型的异常。

  • 非检查异常
    • RuntimeException是所有不受检查异常的基类
    • 不需要进行方法申明或者在方法内手动 catch
  • 检查异常类
    • Exception是所有被检查异常的基类
    • 需要进行方法申明或者在方法内手动catch

例如看看这几个例子:

这样会直接报错,无法编译



这几种方式就是正常的使用方式

我们可以发现,异常这种东西不同语言都有不同的限制。Java算是比较严格使用的。而像PHP这种 如果不用IDE,你很难知道你调用的方法到底会不会抛异常,所以很多人为了保底,会在写的那一层加上 try catch (当大家都这么想的时候,世界也就乱了)

然后是老生常谈的话题了,exception 应该什么时候用? 写的不好确实可能会 try catch 满天飞。十分的不优雅。
所以在使用 exception 确实会存在很多问题。尤其是不规范使用。

异常的使用宗旨是:软件执行过程中遇到非预期的情况

好比说,网络请求超时,文件打开失败,参数验证不正确等。每个公司的规范都有所不同,有的公司喜欢全局捕捉,也有的公司喜欢每个控制器方法都单独放一个 try catch (个人觉得全局捕捉比较好,代码好看一点)

当然,这些语言不仅提供了异常,同时也提供了错误,在业务中我们却很少去使用错误。

异常真的好用吗?身边的朋友都说挺好用的,好用为什么要放弃呢?

举Java为栗子,我们可以发现Java 异常不再是异常,而是变得司空见惯了。它们从良性到灾难性都有使用,异常的严重性由函数的调用者来区分。

而 go 为了区分这两种的区别,搞出了 error 以及 panic 的机制。真正将灾难性错误区分开。并且可以直观的让程序员检查是否有错误发生,相对比较明显(即使可以强制忽略)

三、Go语言的异常处理

上面我们提到,go 是基于 error + panic + recover + defer 来处理异常的,一般来说,对于真正意外的情况,比如那些表示不可恢复的程序错误,例如索引越界、不可恢复的环境问题、栈溢出,我们才会使用 panic + recover 的组合。对于其他的错误情况,我们应该是期望使用 error 来进行判定。
使用 error 有什么好处呢?

  • 简单
  • 没有隐藏的控制流
  • 完全由你控制error
  • 考虑失败,而不是考虑成功
  • error are values

简单举个使用的栗子:

package mainimport ("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 mainimport ("fmt"
)func main() {// 需要放在 panic 触发的方法前定义defer func() {if err := recover(); err != nil {fmt.Println(err) // 捕获到 panicreturn}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

四、error 最佳实践

在探讨最佳实践时,我们先了解几个概念

3.1 什么是 sentinel error ?

又称预定义特定错误, 在 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 mainimport ("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 , 但是应该避免去使用它,这并不是我们应该去效仿的模式。

3.2 自定义错误?

我们先看看几行代码:

package mainimport "fmt"type McError struct { // 自定义错误相关结构体Line intFile stringMsg stringCode 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 succeededcase *McError:// hitfmt.Println("error msg is ", err.Msg)default:// unknown error}
}

go 自带的 error 只有 errmsg ,一般来说并不满足业务需求,我们更加希望可以带上一些额外的信息来帮助我们去定位问题。因此我们可以通过实现 error 接口来丰富其使用范围,例如上面的栗子。

我们通过重新定义了一个新的结构体 McError ,并通过实现 error 接口来达到丰富错误码相关信息

很多人会采用断言的方式来判别是原生的 error 还是我们自定义的 error。如果判断是自定义 error 则去做一些想做的事情。这种方式我们称之为 Error types。

与错误值相比,错误类型的一大改进是它们能够包装底层错误以提供更多上下文。官方 os 包中就有类似的作法:

package ostype PathError struct {Op   stringPath stringErr  error
}func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }

这种作法的弊端是 调用者要使用类型断言和类型 switch,就要让自定义的 error 变为 public。这种模型会导致和调用者产生强耦合,从而导致 API 变得脆弱。

虽然源码中有这样的做法,但是实际上我们应该尽量避免使用 error types,当然,它相比于sentinel errors 更好。因为他可以提供更多出错相关的上下文。但是两者之间还是存在的许多相同的问题。

3.2 探索最好的实践?

  • 下面的代码有哪些问题呢?
func fn1() error {_, err := test(-3)if err != nil {return err}return nil
}
func fn2() error {_, err := test(-3)if err == nil {// todo somethingreturn 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 // positivereturn &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 somethingreturn 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 mainimport ("errors""fmt"
)func main() {err := errors.New("MClink")fmt.Println(err.Error()) // MClinkerr2 := 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 mainimport ("fmt""github.com/pkg/errors"
)func main() {err := errors.New("MClink")err2 := errors.Wrap(err, "MClink2")err3 := errors.Wrap(err2, "MClink3")fmt.Println(err) // MClinkfmt.Println(err2) // MClink2: MClinkfmt.Println(err3) // MClink3: MClink2: MClink
}

我们可以发现,每次调用 Wrap 函数,都是将我们每次增加的错误信息叠加到 原来的错误信息里面。此时你可能会问,这样的好处是什么呢?和 fn4() 的最大区别是,我记录了错误信息,但是没有真正去打日志。我可以叠加错误信息,最终在顶层进行日志统一打入。并且在 go 的官方库还支持 了 UnWarp() 进行解套。

最后我们再来看看 fn5()。fn5() 的特殊之处就是返回了一个布尔值的指针。这是个十分奇怪的姿势。
由于指针的特殊性,导致其返回值不但包含了布尔值的特性,还包含了指针的特性,因此我们在判断的时候,需要去考虑两个特性,导致程序更加的臃肿。纯粹是闲的发慌。

3.3 什么是最好的实践

3.3.1 聊聊 Opaque errors

所谓的不透明错误处理,就是调用者只需要知道调用成功或者失败,但是没有能力看到错误的内部,这种方式的好处就是代码和调用者之间的耦合最少。比如这样:

package mainimport ("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 的实现是不需要引入包的,所以很好的跟自定义包进行了解耦,好比说我只给你一个判断入口,你只要问我是不是就好了。你不需要把我放进你的家里。

3.3.2 几个原则

在使用的时候我们应该遵循这几个原则:

  • 套娃的机制最好是在业务层代码中去实现,不要在一些工具库或者高复用的代码中去实现,因为这种复用性高的代码,你无法判别别人在使用的时候是否会不会去 warp ,为了避免重复 的warp ,尽量返回根错误值
  • 如果你处理了一个错误,那么这个错误就不能继续抛给上一层,而应该返回 nil
  • 如果函数/方法不打算处理错误,那么应该用足够的上下文就包装这个错误。
  • 简化代码,修正代码的坏味道

五、Go 1.13 error 新特性

4.1 Unwarp()

package errorsimport ("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 mainimport (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 MClinkfmt.Println(err3) // in the world young man is MClinkerr4 := errors2.Unwrap(err3)fmt.Println(err4) // young man is MClink
}

需要注意的是,我们不要把 wrap 方法和 Unwrap 当成一个搭配,他们并不是一对情侣, wrap 是基于 pkg/errors (非官方库),而 Unwrap 是基于官方库 (errors) 的。因此在上面的栗子,我们使用的是 fmt.Error() 而不是 wrap 。不要看他们长得完全不一样,但是其实 fmt.Error() 和 errors.Unwrap() 才是一堆

4.2 Is()

用来判断传入的 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 mainimport (errors "errors""fmt"
)func main() {err := errors.New("MClink")err2 := fmt.Errorf("young man is %w", err)fmt.Println(errors.Is(err, err2)) // falsefmt.Println(errors.Is(err2, err)) // true
}

只有你要判断的人等于目标或者是目标的祖先,结果才会是 true

4.3 As()

在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,一层层的获取errerr = Unwrap(err)}return false
}

同样我们写一个简单的栗子:

package mainimport (errors "errors""fmt"
)func main() {err := errors.New("MClink")err2 := fmt.Errorf("young man is %w", err)fmt.Println(err) // MClinkfmt.Println(errors.As(err2, &err)) // truefmt.Println(err) // young man is MClink
}

is 和 as 的区别就是,一个只是判断用的,另一个则是转换使用。

浅谈 GO 语言错误处理

go 的异常处理一直都是一种让人感觉奇怪的设计,本文用较多的篇幅和大家一起聊聊go 的异常处理的一些姿势

一、error 是什么玩意

话不多说 ,先放下源码(也就几行)

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
}

我们简单解释一下~

  • 在 builtin包 中定义了 error 的接口,接口中只有 Error() string 的方法
  • errors 包中定义了 ErrorString 结构体,这个结构体只有一个string 类型的值, 通过复写 Error方法实现了 error 接口
  • errors 包提供了一个获取 error 对象的 New 方法,支持复写 string 类型的值,可以通过 e.Error() 获取对应的错误值

go 的 error ,就这么几行代码。。。 真的简洁

它支持了错误信息的写入以及获取。但是在实际工作中,只记录错误信息,往往是不够的。

在这里,有个地方需要我们注意,我们可以发现,在 New 方法中,返回的是 errorString 结构体的指针,而不是值。 这个 & 实际上十分的关键。

我们可以通过写代码进行对比:

package mainimport ("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 返回的对象我们进行对比,都是相同的。这就会导致对象引用错误的问题。

二、error 和 exception 哪种更好?

现在大多数主流语言都是使用的 exception ,比如 C++, JAVA, PHP 等语言。
那么 go 语言为什么要放弃掉 exception 呢?这是个值得我们思考的问题。我们先来看看几种常见语言的 exception 一般是怎么处理的。
我们先看看 C++ 和 PHP,它们引进了 exception, 但是在 throw 的时候,调用方并不确定会不会有异常会抛出,在语言层次是无法自动识别的,但是现在的IDE会为我们自动识别并且给与提示。
所以在使用这个的时候,如果没有处理抛出的异常,程序就会中止,并抛出致命错误。

而 Java 就比较严格一点。Java 有两种类型的异常。

  • 非检查异常
    • RuntimeException是所有不受检查异常的基类
    • 不需要进行方法申明或者在方法内手动 catch
  • 检查异常类
    • Exception是所有被检查异常的基类
    • 需要进行方法申明或者在方法内手动catch

例如看看这几个例子:

这样会直接报错,无法编译



这几种方式就是正常的使用方式

我们可以发现,异常这种东西不同语言都有不同的限制。Java算是比较严格使用的。而像PHP这种 如果不用IDE,你很难知道你调用的方法到底会不会抛异常,所以很多人为了保底,会在写的那一层加上 try catch (当大家都这么想的时候,世界也就乱了)

然后是老生常谈的话题了,exception 应该什么时候用? 写的不好确实可能会 try catch 满天飞。十分的不优雅。
所以在使用 exception 确实会存在很多问题。尤其是不规范使用。

异常的使用宗旨是:软件执行过程中遇到非预期的情况

好比说,网络请求超时,文件打开失败,参数验证不正确等。每个公司的规范都有所不同,有的公司喜欢全局捕捉,也有的公司喜欢每个控制器方法都单独放一个 try catch (个人觉得全局捕捉比较好,代码好看一点)

当然,这些语言不仅提供了异常,同时也提供了错误,在业务中我们却很少去使用错误。

异常真的好用吗?身边的朋友都说挺好用的,好用为什么要放弃呢?

举Java为栗子,我们可以发现Java 异常不再是异常,而是变得司空见惯了。它们从良性到灾难性都有使用,异常的严重性由函数的调用者来区分。

而 go 为了区分这两种的区别,搞出了 error 以及 panic 的机制。真正将灾难性错误区分开。并且可以直观的让程序员检查是否有错误发生,相对比较明显(即使可以强制忽略)

三、Go语言的异常处理

上面我们提到,go 是基于 error + panic + recover + defer 来处理异常的,一般来说,对于真正意外的情况,比如那些表示不可恢复的程序错误,例如索引越界、不可恢复的环境问题、栈溢出,我们才会使用 panic + recover 的组合。对于其他的错误情况,我们应该是期望使用 error 来进行判定。
使用 error 有什么好处呢?

  • 简单
  • 没有隐藏的控制流
  • 完全由你控制error
  • 考虑失败,而不是考虑成功
  • error are values

简单举个使用的栗子:

package mainimport ("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 mainimport ("fmt"
)func main() {// 需要放在 panic 触发的方法前定义defer func() {if err := recover(); err != nil {fmt.Println(err) // 捕获到 panicreturn}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

四、error 最佳实践

在探讨最佳实践时,我们先了解几个概念

3.1 什么是 sentinel error ?

又称预定义特定错误, 在 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 mainimport ("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 , 但是应该避免去使用它,这并不是我们应该去效仿的模式。

3.2 自定义错误?

我们先看看几行代码:

package mainimport "fmt"type McError struct { // 自定义错误相关结构体Line intFile stringMsg stringCode 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 succeededcase *McError:// hitfmt.Println("error msg is ", err.Msg)default:// unknown error}
}

go 自带的 error 只有 errmsg ,一般来说并不满足业务需求,我们更加希望可以带上一些额外的信息来帮助我们去定位问题。因此我们可以通过实现 error 接口来丰富其使用范围,例如上面的栗子。

我们通过重新定义了一个新的结构体 McError ,并通过实现 error 接口来达到丰富错误码相关信息

很多人会采用断言的方式来判别是原生的 error 还是我们自定义的 error。如果判断是自定义 error 则去做一些想做的事情。这种方式我们称之为 Error types。

与错误值相比,错误类型的一大改进是它们能够包装底层错误以提供更多上下文。官方 os 包中就有类似的作法:

package ostype PathError struct {Op   stringPath stringErr  error
}func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }

这种作法的弊端是 调用者要使用类型断言和类型 switch,就要让自定义的 error 变为 public。这种模型会导致和调用者产生强耦合,从而导致 API 变得脆弱。

虽然源码中有这样的做法,但是实际上我们应该尽量避免使用 error types,当然,它相比于sentinel errors 更好。因为他可以提供更多出错相关的上下文。但是两者之间还是存在的许多相同的问题。

3.2 探索最好的实践?

  • 下面的代码有哪些问题呢?
func fn1() error {_, err := test(-3)if err != nil {return err}return nil
}
func fn2() error {_, err := test(-3)if err == nil {// todo somethingreturn 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 // positivereturn &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 somethingreturn 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 mainimport ("errors""fmt"
)func main() {err := errors.New("MClink")fmt.Println(err.Error()) // MClinkerr2 := 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 mainimport ("fmt""github.com/pkg/errors"
)func main() {err := errors.New("MClink")err2 := errors.Wrap(err, "MClink2")err3 := errors.Wrap(err2, "MClink3")fmt.Println(err) // MClinkfmt.Println(err2) // MClink2: MClinkfmt.Println(err3) // MClink3: MClink2: MClink
}

我们可以发现,每次调用 Wrap 函数,都是将我们每次增加的错误信息叠加到 原来的错误信息里面。此时你可能会问,这样的好处是什么呢?和 fn4() 的最大区别是,我记录了错误信息,但是没有真正去打日志。我可以叠加错误信息,最终在顶层进行日志统一打入。并且在 go 的官方库还支持 了 UnWarp() 进行解套。

最后我们再来看看 fn5()。fn5() 的特殊之处就是返回了一个布尔值的指针。这是个十分奇怪的姿势。
由于指针的特殊性,导致其返回值不但包含了布尔值的特性,还包含了指针的特性,因此我们在判断的时候,需要去考虑两个特性,导致程序更加的臃肿。纯粹是闲的发慌。

3.3 什么是最好的实践

3.3.1 聊聊 Opaque errors

所谓的不透明错误处理,就是调用者只需要知道调用成功或者失败,但是没有能力看到错误的内部,这种方式的好处就是代码和调用者之间的耦合最少。比如这样:

package mainimport ("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 的实现是不需要引入包的,所以很好的跟自定义包进行了解耦,好比说我只给你一个判断入口,你只要问我是不是就好了。你不需要把我放进你的家里。

3.3.2 几个原则

在使用的时候我们应该遵循这几个原则:

  • 套娃的机制最好是在业务层代码中去实现,不要在一些工具库或者高复用的代码中去实现,因为这种复用性高的代码,你无法判别别人在使用的时候是否会不会去 warp ,为了避免重复 的warp ,尽量返回根错误值
  • 如果你处理了一个错误,那么这个错误就不能继续抛给上一层,而应该返回 nil
  • 如果函数/方法不打算处理错误,那么应该用足够的上下文就包装这个错误。
  • 简化代码,修正代码的坏味道

五、Go 1.13 error 新特性

4.1 Unwarp()

package errorsimport ("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 mainimport (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 MClinkfmt.Println(err3) // in the world young man is MClinkerr4 := errors2.Unwrap(err3)fmt.Println(err4) // young man is MClink
}

需要注意的是,我们不要把 wrap 方法和 Unwrap 当成一个搭配,他们并不是一对情侣, wrap 是基于 pkg/errors (非官方库),而 Unwrap 是基于官方库 (errors) 的。因此在上面的栗子,我们使用的是 fmt.Error() 而不是 wrap 。不要看他们长得完全不一样,但是其实 fmt.Error() 和 errors.Unwrap() 才是一堆

4.2 Is()

用来判断传入的 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 mainimport (errors "errors""fmt"
)func main() {err := errors.New("MClink")err2 := fmt.Errorf("young man is %w", err)fmt.Println(errors.Is(err, err2)) // falsefmt.Println(errors.Is(err2, err)) // true
}

只有你要判断的人等于目标或者是目标的祖先,结果才会是 true

4.3 As()

在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,一层层的获取errerr = Unwrap(err)}return false
}

同样我们写一个简单的栗子:

package mainimport (errors "errors""fmt"
)func main() {err := errors.New("MClink")err2 := fmt.Errorf("young man is %w", err)fmt.Println(err) // MClinkfmt.Println(errors.As(err2, &err)) // truefmt.Println(err) // young man is MClink
}

is 和 as 的区别就是,一个只是判断用的,另一个则是转换使用。

与本文相关的文章

发布评论

评论列表 (0)

  1. 暂无评论