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

二、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 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

四、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 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 , 但是应该避免去使用它,这并不是我们应该去效仿的模式。

3.2 自定义错误?

我们先看看几行代码:

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 更好。因为他可以提供更多出错相关的上下文。但是两者之间还是存在的许多相同的问题。

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

3.3 什么是最好的实践
3.3.1 聊聊 Opaque errors

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

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

3.3.2 几个原则

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

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

五、Go 1.13 error 新特性

4.1 Unwarp()

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() 才是一堆

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

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,一层层的获取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 的区别就是,一个只是判断用的,另一个则是转换使用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

MClink

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值