Grpc Proxy

对于 http proxy 大家非常熟悉, 各式各样的中间件都能简单支持. 但是在反向代理这个场景下, 同样基于 http2 协议的 grpc 却没法像 http 那样随意使用中间件做代理, 很重要的一点原因是: grpc 基于长连接, 普通的反代没法做到调用级别的负载均衡. 今天就来简单探讨下应用层代理的实现.
场景分析⌗
通过上一篇文章 Grpc 泛化调用 我们可以得出结论: grpc 四种调用方式最终都是双向流的某种特殊形式, 也就是全部可以由双向流表示. 所以我们代理只需要实现双向流的转发方式就足够了. 所以反向代理的时序图基本是这样:

根据上图可以看出代理层逻辑很简单, 代理层既作为 client 端的 server, 由作为业务 server 端的 client, 然后将 client 端的请求转发到业务 server, 将业务 server 的响应转发到 client 端.
分析下来很简单, 那么我们是否忽略了什么问题呢? 还记得上篇文章中我们看到应用层拿到的 pb 消息类型已经是 interface{}
了, 也就是反序列化后的. 但是我们代理层对消息只需要转发, 完全没必要反序列化它, 反序列化反而增加了开销, 并且最致命的一点是: pb 的序列化和反序列化都必须拿到消息定义(stub 文件/protoset/proto 文件). 一个中心化的网关一般都是服务于非常多的服务的, 如果反过来耦合所有服务的 proto 定义, 是很难接受的.
grpc codec 扩展⌗
grpc 允许我们注册扩展自己的序列化反序列化方式, 通过 encoding.RegisterCodec(&JSONCodec{})
来扩展, 例如我们下面的代码实现了 json 序列化扩展:
在使用方面, grpc 允许客户端在建立连接时通过 grpc.CallContentSubtype(codec.Name)
指定 sub content-type. 客户端只能使用服务端支持的 codec.
在代理场景下, 代理层想要做的是: 将 client 发过来的序列化后的 []byte 消息原封不动转发给业务 server, 反之同理.
那么怎么拿到原始消息呢? grpc 默认的 codec 为 proto
, 并且支持我们覆盖它. 因此我们通过这种 hack 的方式拿到原始消息:
这个 codec 会在代理层使用, 这样收到 pb 消息时, 我们用 *Frame
来调用 RecvMsg
和 SendMsg
时, 完全相当于没有序列化穿透过去了. 这样我们的代理也就不需要感知 proto 定义了.
实现⌗
上面两点已经解决了所有难点了, 剩下的只有实现细节了. 总结下来主要分为这几个模块:
- 代理 upstream 连接管理
- Codec
- 双向流转发
连接管理⌗
代理不能每来一个请求就和真正的业务服务建立一个连接, 所以我们需要对于同一个上游地址只维护一个连接. 因为 grpc client 自身连接管理已经做得很好了, 所以我们只需要做到单例就够了.
上游是多服务时, 需要额外的信息知道 client 端请求的上游到底是哪个, 有两种实现方式:
- 代理服务维护路由表, 方法 -> 服务的映射
- 客户端通过 metadata 传递信息
Codec⌗
序列化和反序列化上面已经讲的很明白了, 代理层需要引用它, 并且在和业务服务建立连接时设置 CallContentSubtype 为 proto
.
双向流转发⌗
双向流转发在 go 语言里面非常常见, 不过需要注意上节我们说的 grpc 规则.
上面的 handler 逻辑就是代理服务的核心, 作为 grpc.StreamHandler
代理泛化请求就行了.
完整代码可查看 https://github.com/zcong1993/grpc-go-beyond/tree/master/internal/proxy.
使用场景⌗
一个应用层的代理能做什么大家肯定心里有数, 理论上不需要依赖消息体的都能做, 列出来基本就这几大类:
- 可观测 - metric, trace
- 服务弹性 - 超时, 限流, 熔断, 重试
- 安全 - 消息签名, 加解密, 路由权限控制
对于 grpc 来说更关键的一点是: 让客户端使用上能够透明. 这一点可以参考 dapr 项目引入 grpc proxy 的新路历程.