grpc中的错误处理

0.1、索引

https://waterflow.link/articles/1665938704477

我们都知道当发起 http 请求的时候,服务端会返回一些 http 状态码,不管是成功还是失败。客户端可以根据服务端返回的状态码,判断服务器出现了哪些错误。

我们经常用到的比如下面这些:

同样的,当我们调用 gRPC 调用时,客户端会收到带有成功状态的响应或带有相应错误状态的错误。 客户端应用程序需要以能够处理所有潜在错误和错误条件的方式编写。 服务器应用程序要求您处理错误并生成具有相应状态代码的适当错误。

发生错误时,gRPC 会返回其错误状态代码之一以及可选的错误消息,该消息提供错误条件的更多详细信息。 状态对象由一个整数代码和一个字符串消息组成,这些消息对于不同语言的所有 gRPC 实现都是通用的。

gRPC 使用一组定义明确的 gRPC 特定状态代码。 这包括如下状态代码:

详细的状态 code、number 和解释可以参考这里:https://github.com/grpc/grpc/blob/master/doc/statuscodes.md

1、grpc 错误

之前的章节中我们写过关于简单搭建 grpc 的文章:https://waterflow.link/articles/1665674508275

我们在这个基础上稍微修改一下,看下下面的例子。

首先我们在服务端,修改下代码,在 service 的 Hello 方法中加个判断,如果客户端传过来的不是 hello,我们我们将返回 grpc 的标准错误。像下面这样:

func (h HelloService) Hello(ctx context.Context, args *String) (*String, error) {
	time.Sleep(time.Second)
	// 返回参数不合法的错误
	if args.GetValue() != "hello" {
		return nil, status.Error(codes.InvalidArgument, "请求参数错误")
	}
	reply := &String{Value: "hello:" + args.GetValue()}
	return reply, nil
}

我们客户端的代码像下面这样:

func unaryRpc(conn *grpc.ClientConn) {
	client := helloservice.NewHelloServiceClient(conn)
	ctx := context.Background()
	md := metadata.Pairs("authorization", "mytoken")
	ctx = metadata.NewOutgoingContext(ctx, md)
	// 调用Hello方法,并传入字符串hello
	reply, err := client.Hello(ctx, &helloservice.String{Value: "hello"})
	if err != nil {
		log.Fatal(err)
	}
	log.Println("unaryRpc recv: ", reply.Value)
}

我们开启下服务端,并运行客户端代码:

go run helloclient/main.go  
invoker request time duration:  1
2022/10/16 23:05:18 unaryRpc recv:  hello:hello

可以看到会输出正确的结果。现在我们修改下客户端代码:

func unaryRpc(conn *grpc.ClientConn) {
	client := helloservice.NewHelloServiceClient(conn)
	ctx := context.Background()
	md := metadata.Pairs("authorization", "mytoken")
	ctx = metadata.NewOutgoingContext(ctx, md)
	// 调用Hello方法,并传入字符串f**k
	reply, err := client.Hello(ctx, &helloservice.String{Value: "f**k"})
	if err != nil {
		log.Fatal(err)
	}
	log.Println("unaryRpc recv: ", reply.Value)
}

然后运行下客户端代码:

go run helloclient/main.go
invoker request time duration:  1
2022/10/16 23:14:13 rpc error: code = InvalidArgument desc = 请求参数错误
exit status 1

可以看到我们获取到了服务端返回的错误。

2、获取 grpc 错误类型

有时候客户端通过服务端返回的不同错误类型去做一些具体的处理,这个时候客户端可以这么写:

func unaryRpc(conn *grpc.ClientConn) {
	client := helloservice.NewHelloServiceClient(conn)
	ctx := context.Background()
	md := metadata.Pairs("authorization", "mytoken")
	ctx = metadata.NewOutgoingContext(ctx, md)
	reply, err := client.Hello(ctx, &helloservice.String{Value: "f**k"})
	if err != nil {
		fromError, ok := status.FromError(err)
		if !ok {
			log.Fatal(err)
		}
		// 判断服务端返回的是否是指定code的错误
		if fromError.Code() == codes.InvalidArgument {
			log.Fatal("invalid arguments")
		}
	}
	log.Println("unaryRpc recv: ", reply.Value)
}

我们可以看下 status.FromError 的返回结果:

我们重新执行下客户端代码:

go run helloclient/main.go
invoker request time duration:  1
2022/10/16 23:26:11 invalid arguments
exit status 1

可以看到,当服务端返回的是 codes.InvalidArgument 错误时,我们重新定义了错误。

3、获取 grpc 错误更详细的信息

当我们服务端返回 grpc 错误时,我们想带上一些自定义的详细错误信息,这个时候就可以像下面这样写:

func (h HelloService) Hello(ctx context.Context, args *String) (*String, error) {
	time.Sleep(time.Second)
	if args.GetValue() != "hello" {
		errorStatus := status.New(codes.InvalidArgument, "请求参数错误")
		details, err := errorStatus.WithDetails(&errdetails.BadRequest_FieldViolation{
			Field:       "string.value",
			Description: fmt.Sprintf("expect hello, get %s", args.GetValue()),
		})
		if err != nil {
			return nil, errorStatus.Err()
		}
		return nil, details.Err()
	}
	reply := &String{Value: "hello:" + args.GetValue()}
	return reply, nil
}

我们重点看下 WithDetails 方法:

然后我们修改下客户端代码:

func unaryRpc(conn *grpc.ClientConn) {
	client := helloservice.NewHelloServiceClient(conn)
	ctx := context.Background()
	md := metadata.Pairs("authorization", "mytoken")
	ctx = metadata.NewOutgoingContext(ctx, md)
	reply, err := client.Hello(ctx, &helloservice.String{Value: "f**k"})
	if err != nil {
		fromError, ok := status.FromError(err)
		if !ok {
			log.Fatal(err)
		}
		if fromError.Code() == codes.InvalidArgument {
			// 获取错误的详细信息,因为详细信息返回的是数组,所以这里我们需要遍历
			for _, detail := range fromError.Details() {
				detail = detail.(*proto.Message)
				log.Println(detail)
			}
			log.Fatal("invalid arguments")
		}
	}
	log.Println("unaryRpc recv: ", reply.Value)
}

接着重启下服务端,运行下客户端代码:

go run helloclient/main.go
invoker request time duration:  1
2022/10/16 23:58:51 field:"string.value"  description:"expect hello, get f**k"
2022/10/16 23:58:51 invalid arguments
exit status 1

可以看到详细信息打印出来了。

4、定义标准错误之外的错误

现实中我们可能会有这样的要求:

我们可以创建一个自定义测错误类:

package xerr

import (
	"fmt"
)

/**
常用通用固定错误
*/
type CodeError struct {
	errCode uint32
	errMsg  string
}

//返回给前端的错误码
func (e *CodeError) GetErrCode() uint32 {
	return e.errCode
}

//返回给前端显示端错误信息
func (e *CodeError) GetErrMsg() string {
	return e.errMsg
}

func (e *CodeError) Error() string {
	return fmt.Sprintf("ErrCode:%d,ErrMsg:%s", e.errCode, e.errMsg)
}

然后 grpc 服务端实现一个拦截器,目的是把自定义错误转换成 grpc 错误:

func LoggerInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {

	resp, err = handler(ctx, req)
	if err != nil {
		causeErr := errors.Cause(err)                // err类型
		if e, ok := causeErr.(*xerr.CodeError); ok { //自定义错误类型

			//转成grpc err
			err = status.Error(codes.Code(e.GetErrCode()), e.GetErrMsg())
		}

	}

	return resp, err
}

然后客户端处理错误代码的部分修改如下:

//错误返回

causeErr := errors.Cause(err)                // err类型
if e, ok := causeErr.(*xerr.CodeError); ok { //自定义错误类型
	//自定义CodeError
	errcode = e.GetErrCode()
	errmsg = e.GetErrMsg()
} else {
	errcode := uint32(500)
	errmsg := "系统错误"
}

其中用到的 errors.Cause 的作用就是递归获取根错误。

这其实就是 go-zero 中实现自定义错误的方式,大家可以自己写下试试吧。