MoE 系列[十] - HTTP json 转 gRPC
尽管 json 解析比较费 CPU,但是,由于 HTTP + json 简单好上手,适配成本低,深受大家喜欢,尤其是对外的场景,HTTP + json 可以说是首选。
但是,有些内部的微服务场景,因为调用方也是可控的内部服务,所以,gRPC 以及类似 RPC 协议,在内部场景被广泛的使用。
把内部的 gRPC/RPC 服务,通过 HTTP + json 暴露给外部用户,加上对应的认证鉴权,限流策略,则是 API 网关的一大应用场景。
演示场景
本文将演示基于 Envoy Golang 扩展,来实现这一协议转换需求:
简单模式,转为普通的 json
服务端推流模式,转为 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)
测试体验
简单模式,实现加法
curl -X POST localhost:10000/demo.Demo/Add -d '{"x":100,"y":10}'
{"result":110}
流式 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 再撸一个呢
有两个原因:
Golang 有更强的可定制能力
比如我们这里把服务端推流转成了 HTTP SSE 协议,如果要在 C++ 里写,开发成本至少要高上一个数量级
后续可以将协议转换再抽象一层
一来可以像 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 没有生效,以及一个优化点
细节就不展开了,后面有空的话,单独写一篇来介绍
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