Golang异常处理的最佳实践?

906 阅读9分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第1天,点击查看活动详情

前言:在Golang中似乎并没有类似Java Spring中的全局异常处理器,自己在写一些函数时有时候也不是很清楚是不是应该把异常作为返回值返回,在调用一些函数所返回的异常时通常就直接进行经典逻辑if err != nil了,最近看过一些代码并自己实践过觉得有必要进行总结一下,于是就有了这个文章。

常用函数解析

panic

函数签名:func panic(v any)

panic函数一般在出现一些比较严重的异常的时候调用,比如服务器启动异常,数据库加载异常。这时候程序肯定不能继续运行了,那么就需要使用panic来停止当前goroutine的运行。

recover

函数签名:func recover() any

recover函数与panic函数相对应,recover可以对panic的goroutine进行处理,需要注意的是recover只有在defer才能生效,并且只能处理自己goroutine中的panic

panic搭配recover的使用示例:

package main

import (
	"errors"
	"fmt"
)

func main() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("calm down and eat a banana")
		}
	}()
	panic(errors.New("explosion"))
}

// 控制台输出:
//
// calm down and eat a banana
//
// Process finished with the exit code 0

errors.New

函数签名:func New(text string) error

创建一个格式为给定文本的异常(errorString)。

fmt.Errorf

函数签名:func Errorf(format string, a ...any) error

创建一个errorString类型的error或者使用%w将一个error添加到包装链(错误链)中。

errors.Unwrap

函数签名:func Unwrap(err error) error

如果传入的error的类型有Unwrap方法(即:wrapError类型)就会返回传入的error调用Unwrap方法的结果,可以简单理解为从包装链中拆一层包装。

package main

import (
	"errors"
	"fmt"
)

func main() {
	errA := errors.New("errorA")
	errB := fmt.Errorf("errorB %w", errA)
	errC := fmt.Errorf("errorC %w", errB)
	fmt.Println(errC) // errorC errorB errorA
    
	errB = errors.Unwrap(errC)
	errA = errors.Unwrap(errB)
	fmt.Println(errA) // errorA
}

errors.Is

函数签名:func Is(err, target error) bool

判断包装链上是否存在给定的error

package main

import (
	"errors"
	"fmt"
)

func main() {
	errA := errors.New("errorA")
	errB := fmt.Errorf("errorB %w", errA)
	errC := fmt.Errorf("errorC %w", errB)
	fmt.Println(errC) // errorC errorB errorA

	hasErrA := errors.Is(errC, errA)
	fmt.Println(hasErrA) // true
	hasErrD := errors.Is(errC, errors.New("errorD"))
	fmt.Println(hasErrD) // false
}

errors.As

函数签名:func As(err error, target any) bool

在包装链上寻找和target类型匹配的error,如果找到就将找到的error赋值给target(多个匹配项取第一个)并返回true,否则返回false

注意:target必须实现error接口或者是接口类型,并且不能为空指针,否则会panic

package main

import (
	"errors"
	"fmt"
)

type myErr struct {
	name string
}

func (e *myErr) Error() string {
	return e.name
}

func main() {
	errA := &myErr{name: "errorA"}
	errB := fmt.Errorf("errorB %w", errA)
	errC := fmt.Errorf("errorC %w", errB)
	var errD *myErr
    // 由于errB和errC都是wrapError类型的所以和errD不匹配,最终匹配到了最开始的errA
	as := errors.As(errC, &errD)
	fmt.Println(as)   // true
	fmt.Println(errD) // errorA
}

log.Fatal

函数签名:func Fatal(v ...any)

等于Print()后面跟上os.Exit(1)

业务中的异常处理

在实际业务中,一般会给每一个异常一个错误码,比如用户已存在是10001,用户名或密码错误是10002。这些错误码一般会和我们定义的错误信息一起返回,交给前端显示给用户。因此如果建立一个优雅的返回响应逻辑就显得非常重要了,最近在看kitex-examplebizdemo的时候觉得作者的处理方式很好,所以这里拿来参考,并加上一些我自己个人的理解。

首先我们来看errno.go,他定义在/easy_note/pkg/errno/文件夹下,这个go文件定义了业务异常的主要处理逻辑。

// errno.go
package errno

import (
	"errors"
	"fmt"
)

const (
	SuccessCode             = 0
	ServiceErrCode          = 10001
	ParamErrCode            = 10002
	LoginErrCode            = 10003
	UserNotExistErrCode     = 10004
	UserAlreadyExistErrCode = 10005
)

type ErrNo struct {
	ErrCode int64
	ErrMsg  string
}

func (e ErrNo) Error() string { // 自定义Error,实现接口
	return fmt.Sprintf("err_code=%d, err_msg=%s", e.ErrCode, e.ErrMsg)
}

func NewErrNo(code int64, msg string) ErrNo {
	return ErrNo{code, msg}
}

func (e ErrNo) WithMessage(msg string) ErrNo {
	e.ErrMsg = msg
	return e
}

var (
	Success             = NewErrNo(SuccessCode, "Success")
	ServiceErr          = NewErrNo(ServiceErrCode, "Service is unable to start successfully")
	ParamErr            = NewErrNo(ParamErrCode, "Wrong Parameter has been given")
	LoginErr            = NewErrNo(LoginErrCode, "Wrong username or password")
	UserNotExistErr     = NewErrNo(UserNotExistErrCode, "User does not exists")
	UserAlreadyExistErr = NewErrNo(UserAlreadyExistErrCode, "User already exists")
)

// ConvertErr convert error to Errno
func ConvertErr(err error) ErrNo {
	Err := ErrNo{}
	if errors.As(err, &Err) {
		return Err
	}

	s := ServiceErr
	s.ErrMsg = err.Error()
	return s
}

首先需要注意的是,这里先定义了ErrNo这个结构体,包含有int64类型的ErrCodestring类型的ErrMsg字段,然后这个结构体通过重写Error方法实现了error接口,并且定义了一个WithMessage方法,允许添加或修改ErrMsg

NewErrNo则是一个“构造方法”,再通过构造方法定义了这个小demo中所涉及到的一些业务异常,Success这里也是一种业务异常,因为本质上构造出的这些信息都是需要响应给client的。

另外一个需要注意的一点是其中定义了一个ServiceErr,即服务异常,不像其他的ParamErrLoginErr这是一个比较概括或者说泛化的异常,我理解的是不需要向用户展示过多的业务细节,而可以在一些具体的出错环节通过日志或者其他方式将具体的错误记录下来,对外只要返回ServiceErr就可以了。举个栗子:比如在生成jwt的时候出现了异常,如果返回“JWT生成错误”,用户可能会是黑人问号脸hh,所以我们只需要打个log然后返回业务异常就可以了,但是这里 ConvertErr 方法只是把其他类型的异常转换为 ServiceErr,被转换的异常的信息还会保留。

另外一个重要的方法是ConvertErr,它接收一个error并返回我们创建的ErrNo,见名知意这个方法是将error转换成我们自定义的ErrNo,可以看到他使用了我们之前讲的errors.As方法,如果传进来的错误链中存在我们的自定义的ErrNo类型的话就会将最顶层的异常返回,如果是我们定义的ErrNo之外的异常就直接作为ServiveErr返回,和之前的理解似乎差不多?

QQ截图20221015233640.png

看完了errno.go,可能还是有点抽象,接下来我们就看一下这个bizdemo所提供的一个用户注册功能的完整流程,希望可以让我们对刚才说的这些概念进行更好的实践。

首先先理一下这个bizdemo的用户注册流程,easy_note是一个微服务项目,demoapi服务通过rpc调用demouser服务来完成注册功能。

那么就从demoapi服务开始了,首先是接收请求参数并进行最基本的参数校验,可以看到这里如果出现像参数绑定异常或者参数异常就直接调用SendResponse方法返回。

QQ截图20221014213453.png

SendResponse方法的定义如下:

QQ截图20221015225942.png

这个方法会将我们定义的业务异常,例如errno.ParamErr或者没有定义的其他异常使用刚刚重点看的errno.CovertErr方法进行包装,在执行这个方法后,如果是我们定义的业务异常则什么都不会变,如果我们没有定义的其他异常则都会变成定义的ServiceErr这个“万金油”,最终都以JSON格式返回给前端。

接下来会通过rpc.CreateUser方法对demouser服务进行rpc调用并返回一个error,这个error我们一会再看,先走注册流程,rpc.CreateUser这个方法中的userClient.CreateUser是进行实际rpc调用的方法,他会去调用demouser服务的一个同名的CreateUser方法,两个方法如下所示:

  • demoapi服务的rpc.CreateUser方法

QQ截图20221015224248.png

  • demouser服务的CreateUser方法

QQ截图20221015224739.png

那么咱们就继续按照注册流程通过demoapiCreateUser方法调用到了demouser服务的CreateUser方法,可以看到这个方法是会返回一个error的,但是几个returnerror返回的都是nil,说明实际返回error也是nil。那么咱们就看返回nil之前的pack.BuildBaseResp会对出现的error做些什么,pack.BuildBaseResp及其相关方法定义如下:

QQ截图20221015225414.png

诶,有没有发现这个方法的处理逻辑和咱们刚刚分析的dempapi服务的SendResponse方法很像,都是接收一个error参数,判断这个error是不是我们自己定义的业务异常,如果是就构建成标准的响应格式然后返回,如果不是就统统变成业务异常中的ServiceErr,保存异常信息然后构建响应并返回。这里的BuildBaseRespbaseResp的处理逻辑和SendResponse以及其调用的CovertErr是一样的,只不过SendResponse是返回的给前端的JSON响应,这里的BuildBaseResp是返回给rpc调用者(也就是demoapi服务)的响应。

那么我们该顺着响应返回demoapi服务了,刚才提到返回的error始终是nil,而是把出现的异常等信息保存在了响应体中进行返回了。回到demoapi服务的CreateUser方法,我们可以看到作者判断如果响应的StatusCode如果不是0(即成功Success)就会使用响应中的业务异常码和信息(因为BuildBaseResp已经把所有的异常包装为业务异常了)创建一个业务异常然后返回给调用者。

终于我们返回到了最初的起点,我们只需继续刚才的SendResponse流程即可,由于rpc.CreateUser返回的全是新定义的业务异常,所以SendResponse会把错误码以及异常信息取出直接以Json数据返回给前端,并且在demouser服务已经全部包装为咱们自己定义的业务异常,所以是没有问题的。

QQ截图20221015232423.png

以上就是以一个简单的注册流程对异常处理的过程进行分析的全部过程,希望可以在一定程度上帮助到读者,以上的分析只是我的个人观点,如果有更优雅的处理方式或者哪块有不正确的欢迎一起讨论ww。

参考列表: