gRPC的错误处理实践
共 3671字,需浏览 8分钟
·
2021-11-19 09:12
基于石墨文档基于K8S的Go微服务实践,我们这次把该内容中的错误码做了一个详细的介绍。
0 背景
我们内部系统全部统一采用gRPC
协议和protobuf
编解码。统一的好处在于不需要在做任何协议、编解码转换,这样就可以使我们所有业务采用同一个protobuf
仓库,基于CI/CD
工具实现许多自动化功能。
我们要求所有服务提供者提前在独立的路径下定义好接口和错误码的protobuf
文件,然后提交到GitLab
,我们通过GitLab CI
的check
阶段对变更的protobuf
文件做format
、lint
、breaking
检查。然后在build
阶段,会基于protobuf
文件中的注释自动产生文档,并推送至内部的微服务管理系统接口平台中,还会根据protobuf
文件自动构建Go/PHP/Node/Java
等多种语言的桩代码和错误码,并推送到指定对应的中心化仓库。推送到仓库后,我们就可以通过各语言的包管理工具拉取客户端、服务端的gRPC和错误码的依赖,不需要口头约定对接数据的定义,也不需要通过IM
工具传递对接数据的定义文件,极大的简化了对接成本。
1 判断Error的错误原理
要了解怎么处理gRPC
的error
之前,我们首先来看下Go
普通的error
是怎么处理的。
我们在判断一个error
的根因,需要根因error
是一个固定地址的指针类型,这样我们才能够使用官方的errors.Is
方法判断他是否为根因。以下是一个代码示例:
我们先看这个代码errors.Is(wrapNewPointerError(), fmt.Errorf("i am error"))
的执行步骤,首先构造了一个error
,然后使用官方%w
的方式将error
进行了包装,我们在使用errors.Is
方法判断的时候,底层函数会将error
解包来判断两个error
的地址是否一致。
因此我们第一个errors.Is
执行的是个false
。在使用这个代码errors.Is(wrapConstantPointerError(), sentinelErr)
,因为是固定地址的error
,所以判断根因错误的时候,执行的是true
。
2 gRPC网络传输的Error
我们客户端在获取到gRPC
的error
的时候,是否可以使用上文说的官方errors.Is
进行判断呢。如果我们直接使用该方法,通过判断error地址是否相等,是无法做到的。原因是因为我们在使用gRPC
的时候,在远程调用过程中,客户端获取的服务端返回的error
,在tcp
传递的时候实际上是一串文本。客户端拿到这个文本,是要将其反序列化转换为error
,在这个反序列化的过程中,其实是new
了一个新的error
地址,这样就无法判断error
地址是否相等。
为了更好的解释gRPC
网络传输的error
,以下描述了整个error
的处理流程。
客户端通过 invoker
方法将请求发送到服务端。服务端通过 processUnaryRPC
方法,获取到用户代码的error
信息。服务端通过 status.FromError
方法,将error
转化为status.Status
。服务端通过 WriteStatus
方法将status.Status
里的数据,写入到grpc-status
、grpc-message
、grpc-status-details-bin
的header
头里。客户端通过网络获取到这些 header
头,使用strconv.ParseInt
解析到grpc-status
信息、decodeGrpcMessage
解析到grpc-message
信息、decodeGRPCStatusDetails
解析为grpc-status-details-bin
信息。客户端通过 a.Status().Err()
获取到用户代码的错误。
为了方便理解,我们抓个包,看下error
具体的报文情况。
3 检查gRPC的error信息第一版本
通过上文描述,我们已经了解了gRPC
在网络中如何传输error
,可以看到new
出来的error
是无法判等的。所以我们就想到,使用工具提前生成好error
,这样error
的地址是不会改变的。这样我们就可以使用errors.Is
的方法去检查根因error
。
首先我们可以将错误码编写在proto
里,注释,如下所示:
syntax = "proto3";
package engineering.helloworld;
option go_package = "engineering/helloworld;helloworld";
// @plugins=protoc-gen-go-errors
// 错误
enum Error {
// 未知类型
// @code=UNKNOWN
RESOURCE_ERR_UNKNOWN = 0;
// 找不到资源
// @code=NOT_FOUND
RESOURCE_ERR_NOT_FOUND = 1;
// 获取列表数据出错
// @code=INTERNAL
RESOURCE_ERR_LIST_MYSQL = 2;
// 获取详情数据出错
// @code=INTERNAL
RESOURCE_ERR_INFO_MYSQL = 3;
}
然后我们可以通过执行proto
错误插件,生成固定地址的error
,将error
注册到全局map
里,同时我们还可以根据@code
的注释,生成gRPC
的状态码。
func init() {
resourceErrUnknown = eerrors.New(int(codes.Unknown), "engineering.helloworld.RESOURCE_ERR_UNKNOWN", Error_RESOURCE_ERR_UNKNOWN.String())
eerrors.Register(resourceErrUnknown)
resourceErrNotFound = eerrors.New(int(codes.NotFound), "engineering.helloworld.RESOURCE_ERR_NOT_FOUND", Error_RESOURCE_ERR_NOT_FOUND.String())
eerrors.Register(resourceErrNotFound)
resourceErrListMysql = eerrors.New(int(codes.Internal), "engineering.helloworld.RESOURCE_ERR_LIST_MYSQL", Error_RESOURCE_ERR_LIST_MYSQL.String())
eerrors.Register(resourceErrListMysql)
resourceErrInfoMysql = eerrors.New(int(codes.Internal), "engineering.helloworld.RESOURCE_ERR_INFO_MYSQL", Error_RESOURCE_ERR_INFO_MYSQL.String())
eerrors.Register(resourceErrInfoMysql)
}
func ResourceErrUnknown() eerrors.Error {
return resourceErrUnknown
}
....
接着我们在获取gRPC error
后,需要使用FromError
方法,转换为我们proto
生成的error
。在这个转换过程中,我们会从之前注册的全局error map
里,通过reason
方法,找到对应的error
,返回给用户。用户这个时候就可以通过errors.Is
来判断根因。
4 检查gRPC的Error信息第二版本
按以上方案,确实可以解决根因问题,但该error
,无法携带message
,metadata
信息。这就导致我们,很难准确定位一些问题。所以这个时候,我们需要在error
里做一些扩展,增加两个方法。
这种方式可以让我们携带信息,但是他会对原有的error
错误做一次克隆,导致了error
的地址变化,无法在通过error
判等的方式进行校验是否是根因。
这个时候,我们只能通过errors.Is
中的(interface{ Is(error) bool })
断言方式,在我们自定义的error
中,增加一个Is
方法来判断。
通过这种方式,我们不仅可以判断根因,并且还可以将error
里携带更多排查有用的信息。
5 演示gRPC的Error的处理
为了更好的演示error,我们将error处理的方式做成了工具,通过执行脚本,我们就可以下载到对应的工具
bash <(curl -L https://raw.githubusercontent.com/gotomicro/egoctl/main/getlatest.sh)
通过该工具,就可以执行我们ego error
的演示代码
5.1 生成error、grpc的pb文件
我们在该演示代码目录下执行make gen
,可以生成对应的error
、grpc
的pb
文件,如下所示。
这些error
为了防止其他人不小心篡改,获取error
的时候,都是用方法来获取,如下所示。
func ResourceErrUnknown() eerrors.Error {
return resourceErrUnknown
}
我们在server
里根据客户端发送的error
,返回我们proto
生成的error
信息。
我们在client
里,判断是否是这个error
,并记录error
里的错误信息。
5.2 执行指令
在目录下执行make svc
,我们可以启动服务端
然后在目录下,我们在执行make cli
,我们可以启动客户端
执行完后,可以看到如下日志:
服务端展示:
客户端展示:
可以看到客户端红框里,就是我们业务代码里记录的日志。我们通过官方的errors.Is判断,能够很优雅的做一些业务逻辑处理。
5.3 错误码查看
错误码,我们可以全部放在proto
里管理。那么我们就可以很方便在proto
里查看错误码,或者做的更好一点,将proto
生成更好看的错误码文档。
自此我们将错误码进行了详细的介绍,下次我们会介绍gRPC
如何做单元测试和mock
服务的实践,如何通过proto
文件生成单元测试代码。
6 鸣谢
感谢kratos
的error
的处理和生成工具,通过学习它的代码和思想,我们将框架Ego
基于error
处理做了更多的改进,例如通过proto
的注解生成grpc
错误码,生成固定地址的error
。并且我们做了更多的proto
工具,可以通过proto
文件生成单元测试代码、API文档等。
7 相关链接
项目演示代码:https://github.com/gotomicro/go-engineering/tree/main/chapter_grpc_error/egoerror 项目框架:https://github.com/gotomicro/ego 石墨文档基于K8S的Go微服务实践 proto生成插error件:https://github.com/gotomicro/ego/tree/master/cmd/protoc-gen-go-errors 框架对error的处理:https://github.com/gotomicro/ego/blob/master/core/eerrors/errors.go 常量error:https://dave.cheney.net/2016/04/07/constant-errors Go1.13Error Wrapping分析:https://www.flysnow.org/2019/09/06/go1.13-error-wrapping.html