豆浆大叔

MoE 系列[十] - HTTP json 转 gRPC

尽管 json 解析比较费 CPU,但是,由于 HTTP + json 简单好上手,适配成本低,深受大家喜欢,尤其是对外的场景,HTTP + json 可以说是首选。

但是,有些内部的微服务场景,因为调用方也是可控的内部服务,所以,gRPC 以及类似 RPC 协议,在内部场景被广泛的使用。

把内部的 gRPC/RPC 服务,通过 HTTP + json 暴露给外部用户,加上对应的认证鉴权,限流策略,则是 API 网关的一大应用场景。

演示场景

本文将演示基于 Envoy Golang 扩展,来实现这一协议转换需求:

  1. 简单模式,转为普通的 json

  2. 服务端推流模式,转为 SSE + json

这里演示的 grpc 服务,直接使用 github 上的开源项目 golang grpc demo[1]

刚好也两个都有:

service Demo {
  // 简单模式。一个请求,一个响应。
  //客户端发送一个请求,包含两个数字,服务端是返回两个数字的和
  rpc Add (TwoNum) returns (Response) {}

  //服务端流模式,客户端发送一个请求,服务端返回多次。
  //请求一次,返回三次,分别是两数子和、两数之积、两数之差
  rpc GetStream (TwoNum) returns (stream Response) {}
}

上代码

因为主要是工程实践,也没有太多原理好介绍,咱们直接看代码就可以

这里是完整的代码仓库:envoy-http-to-grpc-demo[2]

接下来,针对两种场景的代码实现,做个简单的介绍

简单模式

gRPC 与普通的 http 请求区别也不大,从针对请求的处理,我们可以很清晰的看到这些差异

针对 header 的处理,简单两行就可以搞定:

header.Set("content-type", "application/grpc")
header.Del("content-length")

body 的处理需要多几行,因为 body 有一套 grpc message 的编码方式

不过,核心也就是把 json 转成 pb 编码,期中 demo.TwoNum 就来自 golang grpc demo 中使用 proto 生成的 Golang struct。

然后再套上 grpc message 的 header,因为没有 content-length,每个 grpc message 都有 4 byte 来申明长度

jsonBuf := buffer.Bytes()
data := &demo.TwoNum{}
if err := json.Unmarshal(jsonBuf, data); err != nil {
 return f.badRequest(err)
}

grpcHeader := make([]byte, 5, 10)
pbBuf := proto.NewBuffer(grpcHeader)
if err := pbBuf.Marshal(data); err != nil {
 return f.badRequest(err)
}

grpcBuf := pbBuf.Bytes()
size := len(grpcBuf) - 5

setGrpcHeader(grpcBuf, size)
buffer.Set(grpcBuf)

针对响应的处理,则是逆向的,这里不做表述

服务端推流模式

流式处理对于每个 grpc message 的处理逻辑是一样的,唯一不同的是,需要从数据流中切分出一个个 grpc message 出来

不过,好在 grpc message header 里有 4 byte 的长度,要实现分包也简单

核心就是先从 header 里读长度,长度够了就可以切片,解码了

func (f *filter) transCoder() ([]byte, error) {
 if len(f.remainBuf) < 5 {
  return nil, nil
 }
 size := int(f.remainBuf[1])<<24 | int(f.remainBuf[2])<<16 | int(f.remainBuf[3])<<8 | int(f.remainBuf[4])
 if len(f.remainBuf) < size+5 {
  return nil, nil
 }
 grpcBuf := f.remainBuf[5 : size+5]
 f.remainBuf = f.remainBuf[size+5:]
 data := &demo.Response{}
 err := proto.Unmarshal(grpcBuf, data)
 if err != nil {
  return nil, err
 }
 jsonBuf, err := json.Marshal(data)
 if err != nil {
  return nil, err
 }
 return jsonBuf, nil
}

然后,再将 json 封装为 SSE 流式返回

msg := fmt.Sprintf("event: message\\ndata: %s\n\n", jsonBuf)

测试体验

  1. 简单模式,实现加法
curl -X POST localhost:10000/demo.Demo/Add -d '{"x":100,"y":10}'
{"result":110}
  1. 流式 SSE 输出
curl -X POST localhost:10000/demo.Demo/GetStream -d '{"x":100,"y":10}'
event: message\ndata: {"result":110}

event: message\ndata: {"result":1000}

event: message\ndata: {"result":90}

Envoy json to grpc

Envoy 有个 json to grpc 的 C++ filter[3]

也可以实现 json 转 grpc,直接从 proto 生成 pb,然后在 Envoy 配置里指定 pb 就可以了

为啥还想着用 Golang 再撸一个呢

有两个原因:

  1. Golang 有更强的可定制能力

    比如我们这里把服务端推流转成了 HTTP SSE 协议,如果要在 C++ 里写,开发成本至少要高上一个数量级

  2. 后续可以将协议转换再抽象一层

    一来可以像 Envoy 的 json to grpc 一样,直接配置 pb 即可,简单场景代码都可以不需要写了

    另外,也可以支持 HTTP 转基于 TCP 的各种 RPC 协议,这块中国电子云的杜鑫老哥已经搞了一个 提案[4],PR 也在路上了

意外发现

原本开发实现还挺快的,差不多两个小时就搞定了,不过,到了压测的时候,发现了一个 Golang runtime panic,说是 GC 的时候发现有指针指向了 free 的内存对象。

还好,经过一番折腾,是 Envoy Golang 的一个 bug,header value 如果是空字符串,就有可能踩到这个坑,又搞了 PR[5] 来修复

另外,还有一个功能 bug:trailer 里面设置 header 没有生效,以及一个优化点

细节就不展开了,后面有空的话,单独写一篇来介绍

参考资料
[1]

golang grpc demo: https://github.com/zlingqu/go-grpc-demo

[2]

envoy-http-to-grpc-demo: https://github.com/doujiang24/envoy-http-to-grpc-demo

[3]

C++ filter: https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/grpc_json_transcoder_filter

[4]

提案: https://github.com/envoyproxy/envoy/issues/35749

[5]

PR: https://github.com/envoyproxy/envoy/pull/35930