自定义gRPC handler处理钩子

业务中需要对各个接口定义统一的返回结构,例如

{
    "code":200,
    "message":"error message",
    "data":"response"
}

官方推荐的方法为在proto文件中每个response,都按照标准格式来定义。但这种方法会增加开发成本。本文介绍一种通过定义统一的server handler,将原有response包装为标准格式的方法,以及其中的问题。

gRPC默认的处理方法

对于正常返回,gRPC会直接输出proto文件定义好的response,如下:

{"message":"test"}

对于错误返回,gRPC会包装一层error、code与message,如下:

{"error":"error returned","code":2,"message":"error returned"}

但这种异构的返回结构不利于接口调用方进行统一的处理。

定义统一的handler

尝试使用interceptor

开始尝试使用gRPC拦截器实现此功能,但这种方法需要在拦截器中使用proto中定义好的标准response对应的message类型,接收经过jsonpb序列化后的字节流,且涉及到go的interface类型到proto any类型的处理,增加了复杂度,并且在proto定义中也需要感知到标准response的具体格式,处理方式并不优雅。

使用 WithForwardResponseOption包装response

gRPC runtime包提供了 WithForwardResponseOption方法对每次response进行重定向处理。可以在 WithForwardResponseOption中使用自定义的返回结构,将原有response内容添加到data中,如下:

// CustomizedResponse customized response
type CustomizedResponse struct {
    Code int `json:"code"`
    // Data returns response Data when status is ok
    Data interface{} `json:"data"`
    // Error returns Error message when status is not ok
    Error string `json:"error"`
}

// HTTPSuccHandler mux server http handler
func HTTPSuccHandler(ctx context.Context, w http.ResponseWriter, m proto.Message) error {
    resp := CustomizedResponse{
        Code:  200,
        Data:  m,
        Error: "",
    }
    bs, err := json.Marshal(&resp)
    if err != nil {
        return err
    }
    w.Write(bs)
    return nil
}

使用 WithForwardResponseOption处理response,会存在两个问题:

  1. 只能包装正常返回,对于错误返回无法处理:

    {"error":"error returned","code":2,"message":"error returned"}
    
  2. 正常情况下,会在包装后的响应体后追加包装前的message内容,如下:

    {"code":200,"data":{"message":"test"},"error":""}{"message":"test"}
    

使用 WithProtoErrorHandler包装response

gRPC gateway也提供了 WithProtoErrorHandler来定制化错误返回。为了解决 WithForwardResponseOption带来的问题,使用 WithForwardResponseOption来拦截正确返回,然后将其包装为错误返回,这就将正常返回与错误返回都重定向到同一个处理函数中,进行统一处理。

  1. WithForwardResponseOption部分:

    // HTTPSuccHandler mux server http handler
    func HTTPSuccHandler(ctx context.Context, w http.ResponseWriter, m proto.Message) error {
        resp := CustomizedResponse{
            Code:  200,
            Data:  m,
            Error: "",
        }
        bs, err := json.Marshal(&resp)
        if err != nil {
            return err
        }
        return errors.New(successFlag + string(bs))
    }
    
  2. WithProtoErrorHandler部分:

     // HTTPErrorHandler mux server http handler
     func HTTPErrorHandler(
         ctx context.Context, mux *runtime.ServeMux, m runtime.Marshaler, w http.ResponseWriter, r *http.Request, err error,
     ) {
         // success error
         raw := err.Error()
         if strings.HasPrefix(raw, successFlag) {
             raw = raw[len(successFlag):]
             _, _ = w.Write([]byte(raw))
             return
         }
    
         // normal error
         s, ok := status.FromError(err)
         if !ok {
             s = status.New(codes.Unknown, err.Error())
         }
         resp := CustomizedResponse{
             Code:  int(s.Code()),
             Data:  nil,
             Error: s.Message(),
         }
         bs, _ := json.Marshal(&resp)
         _, _ = w.Write(bs)
     }
    

正常接口返回:

{"code":200,"data":{"message":"test"},"error":""}

错误接口返回:

{"code":2,"data":null,"error":"error returned"}

通过在 WithForwardResponseOption函数返回的正常结果前面添加特定标识符,然后将结果整体转发给 WithProtoErrorHandler处理。这种处理方式也可避免正常情况下追加response的问题。 在 WithProtoErrorHandler函数中对每一个进来的返回查看是否具备正常结果的特定标识符,来进行不同处理。

转json时 omitempty问题

如果在proto文件中定义message时未指定额外的json tag,默认生成的pb文件中结构体字段所带的json tag为 omitempty。就会导致正常情况下返回合理零值时,response也会将对应属性忽略。如下:

{"code":200,"data":{},"error":""}

可通过两种方式解决此问题:

  1. 通过 protoc-go-inject-tag等外部插件,在定义proto时指定tag。 这种方式需要对每个待设置的属性都进行修改,开发成本较高。

  2. WithForwardResponseOption函数中进行统一处理。

    业务中只需要在正常响应中关注response,所以可以在 WithForwardResponseOption中统一处理,如下:

    // HTTPSuccHandler mux server http handler
    func HTTPSuccHandler(ctx context.Context, w http.ResponseWriter, m proto.Message) error {
        mj := jsonpb.Marshaler{EmitDefaults: true}
        mjs, err := mj.MarshalToString(m)
        if err != nil {
            return err
        }
        resp := CustomizedResponse{
            Code:  200,
            Error: "",
        }
        if err = json.Unmarshal([]byte(mjs), &resp.Data); err != nil {
            return err
        }
        bs, err := json.Marshal(&resp)
        if err != nil {
            return err
        }
        return errors.New(successFlag + string(bs))
    }
    

    返回零值响应如下:

    {"code":200,"data":{"message":""},"error":""}
    

benchmark

响应处理函数与正常流程相比,多了序列化、中间转发等额外操作,所以需要对其性能进行测试。

  1. 在proto中定义标准response性能

    goos: darwin
    goarch: amd64
    pkg: github.com/brickzzhang/grpc-helloworld/client
    cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
    BenchmarkSayHello-12             403       2692788 ns/op
    PASS
    ok      github.com/brickzzhang/grpc-helloworld/client    2.115s
    
  2. 通过handler构造统一返回性能

    goos: darwin
    goarch: amd64
    pkg: github.com/brickzzhang/grpc-helloworld/client
    cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
    BenchmarkSayHello-12             494       2311517 ns/op
    PASS
    ok      github.com/brickzzhang/grpc-helloworld/client    1.727s
    

通过benchmark结果可知,在添加统一处理函数后,本地通过http调用接口,平均每次调用时长比不加长约0.3ms。

Demo

文章中所有代码参见gRPC-Helloworld

参考

解决使用Proto生成的类转json时字段缺失的问题

protobuf导出golang,调整默认tag的方法

How to elegant rewrite/custom resp body from gRPC resp ?

gRPC 转 RESTful JSON API