0%

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
2
3
4
5
6
7
8
9
service Demo {
// 简单模式。一个请求,一个响应。
//客户端发送一个请求,包含两个数字,服务端是返回两个数字的和
rpc Add (TwoNum) returns (Response) {}

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

上代码

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

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

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

简单模式

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

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

1
2
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 来申明长度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 里读长度,长度够了就可以切片,解码了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 流式返回

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

测试体验

  1. 简单模式,实现加法
1
2
curl -X POST localhost:10000/demo.Demo/Add -d '{"x":100,"y":10}'
{"result":110}
  1. 流式 SSE 输出
1
2
3
4
5
6
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

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

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

有两个原因:

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

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

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

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

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

意外发现

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

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

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

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