Go猜想录
大道至简,悟者天成
手搓 RPC 框架 -- #2 协议设计

摘要

- 头部设计
- 协议体设计
- 编解码

RPC 协议

在实际应用中,大多数 RPC 框架支持多种序列化协议。除此之外,我们还需要考虑以下几个因素:

  1. 数据压缩:提高传输效率,节省带宽。
  2. 协议版本升级:确保向后兼容,便于系统迭代。
  3. 加密:保护数据安全,防止数据泄露。

这些考虑引发了一个重要问题:如何设计一个合适的 RPC 协议?

不同协议:gRPC 协议

gRPC 协议分成头部和 body 两个部分。这是因为 gRPC 是直接基于 HTTP 来实现的,所以 gRPC 的头部就放在 HTTP 协议头,body 就放在 HTTP 协议体里面。

rpc-9.png

rpc-10.png

不同协议:Dubbo 协议

Dubbo 协议按照它自己的说法,是分成定长部分和非定长的部分。其中定长部分一般叫协议头,变长部分一般叫做协议体。

rpc-11.png

Dubbo 在 3.0 里面推出了 Triple 协议,就很接近 gRPC 的设计了。原生的 Dubbo 协议反而和 gRPC 很不一样。

不同协议:TCP 协议

gRPC 和 Dubbo 协议都可以看做是应用层协议,其它协议设计也是类似的,比如 TCP 协议。

rpc-12.png

协议设计总结

  • 协议一般都是分成两个部分:协议头和协议体。
  • 协议头包含接收方“如何处理这个消息”的必要信息,具体来说包含描述协议本身的数据,和描述这次请求的数据。
  • 协议体则大多数情况下存放请求数据。

从形态上来说,协议体必然是变长的;而协议头可以是定长的,也可以是变长的。在变长协议头的情况下,需要有两个字段来描述长度,一个描述协议头有多长,一个描述协议体有多长。

协议设计:请求

现在设计一下我们自己的协议,请求头部:

  • 设计为不定长
  • 固定字段:
    • 长度字段:用于分割消息
    • 版本字段:描述协议版本,用于后续协议升级
    • 序列化协议:用于标记采用的序列化协议
    • 压缩算法:用于标记协议体是如何被压缩的
    • 消息 ID:用于后续支持多路复用
    • 服务名
    • 方法名
  • 不固定字段:这部分主要是链路元数据,例如 trace id、a/b 测试、全链路压测的标记位等

最后的协议体里面就只存放请求参数。

rpc-13.png

协议设计:请求可变通的点

服务名、方法名和元数据都可以考虑放请求到参数里,之所以放在头部是因为:

如果我们的微服务请求要经过网关、sidecar(service mesh),那么放在头部,这些中间件就可以考虑只解析头部字段,而不必解析整个请求。

例如在 sidecar 上做负载均衡,那么只需要解析到服务名和方法名就可以,根据服务名和方法名找到可用节点,然后做负载均衡。

这些东西经常需要使用到橘色方框里面的内容。

rpc-15.png

协议设计:响应

现在我们设计一下我们自己的协议,响应头部:

  • 设计为不定长
  • 固定字段:
    • 长度字段:用于分割消息
    • 版本字段:描述协议版本,用于后续协议升级
    • 序列化协议:用于标记采用的序列化协议
    • 压缩算法:用于标记协议体是如何被压缩的
    • 消息 ID:用于后续支持多路复用
    • 错误:为了解决第二个返回值的问题

最后的协议体里面就只存放请求响应数据。

rpc-14.png

协议设计:响应错误为什么放头部

主要是实在没地方放。从理论上来说,你有两个选择:

  1. 放头部,做成一个类似于 serviceName 那种,认为是服务调用本身必需的一种数据。
  2. 协议体,也就是认为是响应体的一部分。这种做法会更加符合直觉,但是实现起来难度要更高,因为你需要区别响应数据里面,哪部分是用户返回的数据,哪部分是用户返回的错误。

接口设计

首先改造我们的 Request 和 Response,把刚才决定的协议字段加进去。

type Request struct {
	HeaderLength uint32 // 协议头长度
	BodyLength   uint32 // 协议体长度
	MessageID    uint32 // 消息ID
	Version      uint8  // 版本
	Compressor   uint8  // 压缩算法
	Serializer   uint8  // 序列化协议

	ServiceName string // 服务名
	MethodName  string // 方法名

	Meta map[string]string // 扩展字段,用于传递自定义元数据

	Data []byte // 协议体
}

type Response struct {
	HeaderLength uint32 // 协议头长度
	BodyLength   uint32 // 协议体长度
	MessageID    uint32 // 消息ID
	Version      uint8  // 版本
	Compressor   uint8  // 压缩算法
	Serializer   uint8  // 序列化协议

	Error []byte // 错误信息

	Data []byte // 响应数据
}

总结

  • RPC 协议主要包含什么?消息头和消息体。
  • 头部包含什么?有什么用?回答几个主要的头部字段,也就是我们刚才讨论的。
  • RPC 里面为什么包含长度字段?主要是为了切割消息,也就所谓的粘包问题。
  • 头部可以是变长的吗?当然可以。
  • 大概描述一下 gRPC 协议?关键点是 gRPC 利用了 HTTP 协议,然后再描述一下 gRPC 的几个常见头部。问到 Dubbo 协议也是类似。
  • 为什么尽量把元数据之类的东西放在消息头?主要是考虑到 sidecar 网关之类的东西,这样可以做到解析部分数据,提高性能。
  • 如何在 RPC 协议里面支持不同序列化协议/压缩算法……?但凡问到如何在 RPC 协议上支持 XXX 功能,核心都是要在协议本身加上对应的字段,要考虑是放到协议体还是协议头,然后客户端和服务端都要做相应的修改。

知识共享许可协议

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。