grpc与http的错误传递 https://mp.weixin.qq.com/s/xZTEzPcqQl77NNGotqNTUA
grpc与http的错误传递
原创 王宇 学而思网校技术团队 2022-05-27 18:20 发表于北京
✦点击蓝字 关注我们✦
使用pb文件定义接口后, 通过 grpc 和 现有http业务 使用同一个pb结构, 发现他们结构之间是有差异的.
grpc中通常使用 error 返回值判断成功或失败, 而我们对 http 请求更习惯使用 body 体的 stat 来判断.
例如定义了如下pb结构
message GenerateTaskReply {
string name = 1;
uint32 age = 2;
}
grpc的返回值
grpc success
{
"name" : "小明",
"age" : 12
}
grpc fail
nil, error:[stat:code.Unknown, msg:"失败了"]
http 返回值
由于pb文件通常不会再记录 服务异常状态, 格式如下:
http 200
{
"name" : "小明",
"age" : 12
}
http 500
{
"code": 500,
"message": "service error"
"reason" : "ERROR_REASON",
}
而在我们现有系统中, 格式如下:
http code: 200
{
"stat": 1,
"msg" : "success",
"data": {
"name" : "小明",
"age" : 12
}
}
http code: 200
{
"stat": 10000,
"msg" : "失败了",
"data": {}
}
我们通过上面返回值看到, grpc 与 http 在错误判断上的不同, grpc是通过 error 判断, 而 http 是基于 body 体返回值做的判断, 而我们返回值 body 是使用pb管理, 对于grpc来说 错误是通过error来管理的, 这样就带来了无法共用的问题
对于grpc来说可以冗余字段设计的, 但是对于grpc 客户端是比较困惑的, 需要做额外的逻辑判断, 一般不建议使用此方式.
由此问题进行调研学习, 进行一个记录
- grpc 的错误传递
- grpc code码含义
- http 错误怎么做
1. grpc 错误传递
假如 server 端代码如下, 返回了标准的 error 错误
func (s *Server) SayHello(ctx context.Context, t *code.Request) (*code.Response, error) {
return &code.Response{Msg: "Hello world"}, errors.New("query db err, id=1: record not found")
}
client通过 grpc 调用, 返回对应的 response 和 error两个值
r, err := client.SayHello(ctx, &code.Request{})
if err != nil {
fmt.Printf("%+v\n", err) //rpc error: code = Unknown desc = query db err, id=1: record not found
grpcErr, _ := status.FromError(err)
fmt.Printf("%d\n", grpcErr.Code()) // Unknown code = 2
fmt.Printf("%+v\n", grpcErr.Message()) //query db err, id=1: record not found
} else {
fmt.Printf("%+v\n", r)
}
以上会打印错误, 因为我们使用的非标准 grpc 错误结构, 所以解码出来 grpc code 码为 Unknown, message是我们服务端返回的错误原因
grpc 提供了定义错误的方法, 第一个参数是可以指定 grpc code 的, 底层创建了 grpc 内置的 Status 对象
status.Error(codes.Canceled, "query db err, id=1: record not found")
grpc Status定义的错误结构如下:
type Status struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// The status code, which should be an enum value of [google.rpc.Code][google.rpc.Code].
Code int32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"`
// A developer-facing error message, which should be in English. Any
// user-facing error message should be localized and sent in the
// [google.rpc.Status.details][google.rpc.Status.details] field, or localized by the client.
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
// A list of messages that carry the error details. There is a common set of
// message types for APIs to use.
Details []*any.Any `protobuf:"bytes,3,rep,name=details,proto3" json:"details,omitempty"`
}
这个结构下文将反复提及, 描述为 Status 结构
Code 和 Message 上述案例中已涉及到了, Details 定义的是 any.Any 类型, 我们可以灵活的挂载一些其他自定义信息
通过上面例子, 我们知道了 grpc 如何发送接收错误了, 那么是如何传递错误的呢?
先上结论: grpc 传输是基于 http2 协议的, 错误传输是基于其 header 头来做的
下面我们看下 grpc-status、grpc-message 以及 grpc-status-details-bin 这几个头信息
grpc 服务端当执行 status.Error(codes.Canceled, "query db err, id=1: record not found") 时, 将映射到对应header头上面.
grpc@v1.45.0/internal/transport/http2_server.go:1011
//....
//st就是Status结构对象,
headerFields = append(headerFields, hpack.HeaderField{Name: "grpc-status", Value: strconv.Itoa(int(st.Code()))})
headerFields = append(headerFields, hpack.HeaderField{Name: "grpc-message", Value: encodeGrpcMessage(st.Message())})
//如果包含details定义, 则进行序列化并base64编码
if p := st.Proto(); p != nil && len(p.Details) > 0 {
stBytes, err := proto.Marshal(p)
if err != nil {
// TODO: return error instead, when callers are able to handle it.
logger.Errorf("transport: failed to marshal rpc status: %v, error: %v", p, err)
} else {
headerFields = append(headerFields, hpack.HeaderField{Name: "grpc-status-details-bin", Value: encodeBinHeader(stBytes)})
}
}
//...
grpc客户端接收header头, 将错误重新构造成 Status 结构
grpc@v1.45.0/internal/transport/http2_client.go:1321
for _, hf := range frame.Fields {
switch hf.Name {
//...
case "grpc-status":
code, err := strconv.ParseInt(hf.Value, 10, 32)
if err != nil {
se := status.New(codes.Internal, fmt.Sprintf("transport: malformed grpc-status: %v", err))
t.closeStream(s, se.Err(), true, http2.ErrCodeProtocol, se, nil, endStream)
return
}
rawStatusCode = codes.Code(uint32(code))
case "grpc-message":
grpcMessage = decodeGrpcMessage(hf.Value)
case "grpc-status-details-bin":
var err error
statusGen, err = decodeGRPCStatusDetails(hf.Value)
if err != nil {
headerError = fmt.Sprintf("transport: malformed grpc-status-details-bin: %v", err)
}
}
//如果header头 grpc-status-details-bin 为nil, 则构建code + message 错误
if statusGen == nil {
statusGen = status.New(rawStatusCode, grpcMessage)
}
// if client received END_STREAM from server while stream was still active, send RST_STREAM
rst := s.getState() == streamActive
t.closeStream(s, io.EOF, rst, http2.ErrCodeNo, statusGen, mdata, true)
核心点出现在 grpc-status-details-bin 头上, 如果不为空, 则可调用下面方法直接转换为 Status 的结构.
func decodeGRPCStatusDetails(rawDetails string) (*status.Status, error) {
//base64 decode 解码
v, err := decodeBinHeader(rawDetails)
if err != nil {
return nil, err
}
//对 Status 进行 Unmarshal
st := &spb.Status{}
if err = proto.Unmarshal(v, st); err != nil {
return nil, err
}
return status.FromProto(st), nil
}
下面是抓包的图, 这样看起来更直观
- grace-status 就是我们说的code码
- grpc-message 是我们的错误消息
- grpc-status-details-bin 包含我们错误定义的所有信息, value 是base64编码后的结果, 解码后得到
$query db err, id=1: record not found;(type.googleapis.com/google.rpc.ErrorInfo SERVICE_ERROR
最开始的我们提到了 Status结构 中有个 Details []*any.Any 类型, 我们可以灵活的挂载一些其他自定义信息, 现在根据 type 可以看到挂在了 type.googleapis.com/google.rpc.ErrorInfo 结构
我们看下 google.rpc.ErrorInfo 这个结构样子
我们看到这个结构里有map类型的Metadata, 可以很方便的携带我们自定义的数据, 下文将会使用到这个map
grpc返回错误时, 使用了grpc内置标准错误, 如下结构
//status.Error(codes.Canceled, "query db err, id=1: record not found"),
type Error struct {
s *Status
}
如果调用方想要获取到对应的Code码和 Message 内容, 还需要获取到Status的值, 通过 FromError 即可
grpc@v1.45.0/status/status.go:91
type Error struct {
s *Status
}
func FromError(err error) (s *Status, ok bool) {
if err == nil {
return nil, true
}
if se, ok := err.(interface {
GRPCStatus() *Status
}); ok {
return se.GRPCStatus(), true
}
return New(codes.Unknown, err.Error()), false
}
// GRPCStatus returns the Status represented by se.
type Error struct {
s *Status
}
func (e *Error) GRPCStatus() *Status {
return e.s
}
在Error中, 必须实现 GRPCStatus() 接口, 这里的 Error 是grpc官方默认的实现, 只是返回了 e.s, s就是对应的 Status 结构.
概括: 传输 error 使用了 Status 结构, 对应服务端进行编码, 通过header头传递.
客户端根据header头进行解码, 创建 Status 结构对象。
2.grpc code码
在返回错误 Status 结构中, 有一个字段为 code 码, grpc 预定义了一些常量, 不建议我们自行对它进行设置.
通常 code码对应于 http状态码, grpc header 头为上面提到的 grpc-status
在其中有这样一段话, 描述了关于定义错误码的一些想法, 仅供参考.
Google API 必须使用
google.rpc.Code
定义的规范错误代码。单个 API 应避免定义其他错误代码,因为开发人员不太可能编写用于处理大量错误代码的逻辑。作为参考,每个 API 调用平均处理 3 个错误代码意味着大多数应用的逻辑只是用于错误处理,这对开发人员而言并非好体验。
http状态码与grpc code码映射关系
相信读者已经了解了设计code码的初衷。
回到开头的问题, 如果我的项目同样提供 http 服务, 如果基于grpc code映射的http状态码做业务上面判断, 是不符合我们现有报警、重试等底层框架设计的.
我们不讨论哪种方法优劣
我们现有业务判断是通过body体的一个类似stat的字段, 而不是http状态码, 我们认为 http状态码反映的是整个服务的状态情况而不是业务上的某个状态, 所以我们认为这个设计是不符合我们需求的.
资料参考:
source code: grpc@v1.45.0/codes/codes.go:31
code码含义: https://grpc.github.io/grpc/core/md_doc_statuscodes.html
google error 错误规范: https://cloud.google.com/apis/design/errors
3.怎么设计我们的http响应
我们先看下 kratos 底层是如何包装grpc Status错误的.
服务端发送错误
//传递自定义的错误
type CustomError struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Code int32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"`
Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"`
Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"`
Metadata map[string]string `protobuf:"bytes,4,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
}
// WithMetadata with an MD formed by the mapping of key, value.
func (e *CustomError) WithMetadata(md map[string]string) *Error {
err := proto.Clone(e).(*Error)
err.Metadata = md
return err
}
// New returns an error object for the code, message.
func New(code int, reason, message string) *Error {
return &CustomError{
Code: int32(code),
Message: message,
Reason: reason,
}
}
//实现 GRPCStatus() 接口, 用于 FromError 获取 status 底层结构使用
//httpstatus.ToGRPCCode 将http状态码转换为grpc-code进行传递
//WithDetails增加 Details 拓展信息(我们后续将拓展的 stat 字段加入了 MetaData 数据中)
//最终转换成grpc标准的 Status 结构返回
func (e *CustomError) GRPCStatus() *status.Status {
s, _ := status.New(httpstatus.ToGRPCCode(int(e.Code)), e.Message).
WithDetails(&errdetails.ErrorInfo{
Reason: e.Reason,
Metadata: e.Metadata,
})
return s
}
上面自定义的 error 实现了 GRPCStatus 接口用于转换标准的 grpc error
我们看下grpc 底层如何使用的
func FromError(err error) (s *Status, ok bool) {
if err == nil {
return nil, true
}
if se, ok := err.(interface {
GRPCStatus() *Status
}); ok {
return se.GRPCStatus(), true
}
return New(codes.Unknown, err.Error()), false
}
客户端解析错误:
客户端解析错误就比较简单了, grpc 根据header转换为Status结构, Status 结构的信息转为我们自定义的错误.
func FromError(err error) *Error {
if err == nil {
return nil
}
if se := new(Error); errors.As(err, &se) {
return se
}
//调用FromError方法, 获取Grpc Status 结构
gs, ok := status.FromError(err)
if ok {
//对grpc code码进行转换为http状态码
ret := New(
httpstatus.FromGRPCCode(gs.Code()),
UnknownReason,
gs.Message(),
)
//解析附加Details信息
for _, detail := range gs.Details() {
switch d := detail.(type) {
case *errdetails.ErrorInfo:
ret.Reason = d.Reason
return ret.WithMetadata(d.Metadata)
}
}
return ret
}
//默认错误
return New(UnknownCode, UnknownReason, err.Error())
}
grpc 服务端响应时, 使用 GRPCStatus() 下 ToGRPCCode 方法响应将http code码转换为对应的grpc code码
而在接收时, 则需要使用 FromError() 下 FromGRPCCode 方法对grpc code 码转换为对应的 http code 码
回到开头提到的第三个问题上, 为了符合现有规范, 我们将按以下模式返回:
新增 stat 字段, 用于下发服务端相应状态.
新增 msg 字段, 用于友好的错误展示
1. 【客户端/服务端】实现错误定义pb
enum ErrorReason {
option (errors.default_code) = 500;
option (errors.default_stat) = 1;
option (errors.default_msg) = "服务错误";
//状态码=200 stat=2 msg="活动找不到错误"
SERVICE_ERROR = 0 [(errors.code) = 200, (errors.stat) = 2,(errors.msg) = "活动找不到错误"];
//状态码=404 stat=1 msg="服务错误"
RULE_NOT_FOUND = 1 [(errors.code) = 404];
}
2. 【客户端/服务端/插件】errors.proto 定义新增字段
extend google.protobuf.EnumOptions {
int32 default_code = 1108;
int32 default_stat = 1109;
string default_msg = 1110;
}
extend google.protobuf.EnumValueOptions {
int32 code = 1111;
int32 stat = 1112;
string msg = 1113;
}
3.protoc-gen-go-errors 插件
最终生成
func ErrorServiceError(format string, args ...interface{}) *errors.Error {
//将stat状态码携带进 Metadata 中, msg自定义
md := map[string]string{
"stat": "2",
}
return errors.New(200, ErrorReason_SERVICE_ERROR.String(), fmt.Sprintf(format, args...)).WithMetadata(md)
}
func ErrorMsgServiceError() *errors.Error {
//将stat状态码携带进 Metadata 中, 使用标准msg
md := map[string]string{
"stat": "2",
}
return errors.New(200, ErrorReason_SERVICE_ERROR.String(), "活动找不到错误").WithMetadata(md)
}
func IsServiceError(err error) bool {
if err == nil {
return false
}
e := errors.FromError(err)
stat := e.Metadata["stat"]
//stat 或 reason 判断满足其一 && 状态码符合
return (e.Reason == ErrorReason_SERVICE_ERROR.String() || stat == "2") && e.Code == 200
}
protoc 插件开发参考: https://taoshu.in/go/create-protoc-plugin.html
插件的目标是方便错误返回与判断.
在返回错误时, 自动将对应的 stat 和 msg 装填到 metadata
而在错误判断时, 提供了 Reason 和 stat 两种判断方式 (grpc 不需要关注 stat)
关于返回值部分
首先确定返回的基本模版, 与我们现有业务一致即可
response 结构定义
type HttpStandardResponse struct {
Stat int32 `json:"stat"` //业务状态 1成功 0失败
Code int32 `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data"`
}
服务端对消息体进行 HttpStandardResponse 结构编码
//错误编码
//errors metadata中获取stat, 作为顶级字段响应返回
func ErrorEncoderHandler(w netHttp.ResponseWriter, r *netHttp.Request, err error) {
se := errors.FromError(err)
statCode, _ := strconv.Atoi(se.Metadata["stat"])
response := &codec.HttpStandardResponse{
Stat: int32(statCode),
Code: se.Code,
Msg: se.Message,
Data: struct{}{},
}
codeObj, _ := http.CodecForRequest(r, "Accept")
body, err := codeObj.Marshal(response)
if err != nil {
w.WriteHeader(netHttp.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/"+codeObj.Name())
w.WriteHeader(int(se.Code))
_, _ = w.Write(body)
}
//响应解码
func ResponseEncoderHandler(w netHttp.ResponseWriter, r *netHttp.Request, v interface{}) error {
response := &codec.HttpStandardResponse{
Stat: codec.STAT_SUCCESS,
Code: netHttp.StatusOK,
Msg: codec.STAT_SUCCESS_MSG,
Data: v,
}
codeObj, _ := http.CodecForRequest(r, "Accept")
data, err := codeObj.Marshal(response)
if err != nil {
return err
}
w.Header().Set("Content-Type", "application/"+codeObj.Name())
_, err = w.Write(data)
if err != nil {
return err
}
return nil
}
客户端对 HttpStandardResponse 结构响应值进行解码
客户端对 body 体进行转换, 如果 stat!=1 则将 body 体的 stat message 转换为errors错误.
body体中的 data 转为 pb 结构
//调用包含data结构, 使用此方法, 解析层级为data下数据
//如果服务端使用encoder, 则会再pb定义上装填到data字段, 原则上pb response不定义data字段
//如果pb文件必须保留data字段, 则需额外指定 response_body: "data"
//
// option (google.api.http) = {
// get: "/Course/Courseinfo/getxx/xx",
// response_body: "data",
// };
func ResponseDecoderHandler(ctx context.Context, res *netHttp.Response, v interface{}) error {
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil {
return err
}
response := &codec.HttpStandardResponse{}
if err = http.CodecForResponse(res).Unmarshal(data, response); err == nil {
if err = errorDecoder(ctx, res, response); err != nil {
return err
}
} else {
return errors.Errorf(res.StatusCode, errors.UnknownReason, err.Error())
}
//解出最外层data, 然后转换为pb格式文件
byteData, _ := json.Marshal(response.Data)
return http.CodecForResponse(res).Unmarshal(byteData, v)
}
// ErrorDecoder is an HTTP error decoder.
func errorDecoder(ctx context.Context, res *netHttp.Response, httpStandardResponse *codec.HttpStandardResponse) error {
if httpStandardResponse.Stat == codec.STAT_SUCCESS {
return nil
}
md := map[string]string{
"stat": strconv.Itoa(int(httpStandardResponse.Stat)),
}
return errors.New(res.StatusCode, "", httpStandardResponse.Msg).WithMetadata(md)
}
//兼容error状态码异常情况下获取
func ErrorDecoderHandler(ctx context.Context, res *netHttp.Response) error {
if res.StatusCode >= 200 && res.StatusCode <= 299 {
return nil
}
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err == nil {
httpStandardResponse := &codec.HttpStandardResponse{}
if err = http.CodecForResponse(res).Unmarshal(data, httpStandardResponse); err != nil {
return errors.Errorf(res.StatusCode, errors.UnknownReason, err.Error())
}
md := map[string]string{
"stat": strconv.Itoa(int(httpStandardResponse.Stat)),
}
return errors.New(res.StatusCode, "", httpStandardResponse.Msg).WithMetadata(md)
}
return errors.Errorf(res.StatusCode, errors.UnknownReason, err.Error())
}
- 最终交互图:
可能的疑问:
- 是否可以模仿 grpc 通过header头传递方式错误消息?
在某种程度是可以的, 但是我们面临兼容现有项目和不同接入端, 解析也要比json繁琐.
- 有些场景无法 unwarp data 怎么办?例如:message GetXXReply { map<string, XXInfo> data = 3;}类似这种data结构不是固定的 key value 形式, 无法直接将data这一层级忽略掉, 经过 encoder 后回包含两层 data, 类似于
{
"data":{
"data":{
"2":{
"key1":"val1",
"key2":"val2"
}
}
}
}
通过上面的代码我们可以解出第一层, 第二层 data 可以在指定 option.http 时候指定 response_body: "data", 在解析时将会指定字段解析, 类似: photo.Unmarshal(byteBody, reply.data)