gRPC 数据包传输格式解析:从 Protobuf 到 HTTP/2

gRPC 数据包传输格式解析:从 Protobuf 到 HTTP/2

在使用 RPC 进行通信时,我们平时写的代码可能只是调用一个远程函数,例如 GetUser(),但真正传输到网络中的并不是函数名和参数对象本身,而是一段按照规则组织好的二进制数据

以 gRPC 为例,它的完整传输链路大致是:

text 复制代码
业务对象
    ↓
Protobuf 二进制编码
    ↓
gRPC Message 封装
    ↓
HTTP/2 Frame 封装
    ↓
TCP / TLS / IP 传输

也就是说,Protobuf 负责把对象压缩成二进制,gRPC 负责在 Protobuf 数据前面加上自己的消息头,HTTP/2 负责把 gRPC 消息放进不同的 Frame 中传输


一、先定义一个 proto 文件

假设有这样一个 proto 文件:

proto 复制代码
syntax = "proto3";

package demo;

service UserService {
  rpc GetUser(GetUserRequest) returns (UserReply);
}

message GetUserRequest {
  int32 id = 1;
  string name = 2;
}

message UserReply {
  int32 id = 1;
  string name = 2;
  int32 age = 3;
}

现在客户端要调用:

text 复制代码
GetUserRequest {
    id: 150
    name: "abc"
}

我们重点分析这个对象在网络中到底会变成什么样


二、Protobuf 层:对象如何变成二进制

Protobuf 编码时,不会传字段名,比如不会传 idname 这些字符串,而是传:

text 复制代码
字段编号 + 字段类型 + 字段值

准确来说,字段编号和字段类型会合并成一个 tag

计算方式是:

text 复制代码
tag = 字段编号 << 3 | wire_type

其中 wire_type 表示字段在二进制中的编码类型

常见的 wire type 如下:

wire type 名称 含义 常见字段类型
0 Varint 变长整数编码 int32int64uint32boolenum
1 64-bit 固定 8 字节 fixed64double
2 Length-delimited 长度 + 数据内容 stringbytes、嵌套 message、packed repeated
5 32-bit 固定 4 字节 fixed32float

其中 int32 使用 Varint 编码,string 使用 Length-delimited 编码


1、字段 id = 150 的编码

字段定义是:

proto 复制代码
int32 id = 1;

字段编号是 1int32 对应的 wire_type0

所以 tag 是:

text 复制代码
1 << 3 | 0 = 8

十六进制表示为:

text 复制代码
08

接下来编码字段值 150

150 使用 Varint 编码,Varint 的核心思想是:每 7 bit 存一组,最高位用来表示后面是否还有数据

150 的二进制可以理解为:

text 复制代码
1001 0110

按 7 位一组拆分后,再低位组放前面,高位组放后面,最后设置最高位标记,得到:

text 复制代码
96 01

所以 id = 150 的完整 Protobuf 编码是:

text 复制代码
08 96 01

其中:

text 复制代码
08      // tag,表示字段编号 1,类型 Varint
96 01   // 字段值 150 的 Varint 编码

2、字段 name = "abc" 的编码

字段定义是:

proto 复制代码
string name = 2;

字段编号是 2string 对应的 wire_type2,所以 tag 是:

text 复制代码
2 << 3 | 2 = 18

十六进制表示为:

text 复制代码
12

string 是 Length-delimited 类型,也就是:

text 复制代码
长度 + 内容

字符串 "abc" 长度是 3,内容对应的 ASCII 十六进制是:

text 复制代码
a = 61
b = 62
c = 63

所以 name = "abc" 的完整编码是:

text 复制代码
12 03 61 62 63

其中:

text 复制代码
12          // tag,表示字段编号 2,类型 Length-delimited
03          // 字符串长度为 3
61 62 63    // 字符串 abc 的内容

3、完整 Protobuf 数据

现在这个对象:

text 复制代码
GetUserRequest {
    id: 150
    name: "abc"
}

最终会被 Protobuf 编码成:

text 复制代码
08 96 01 12 03 61 62 63

拆开看就是:

text 复制代码
08 96 01          // id = 150
12 03 61 62 63    // name = "abc"

总长度是 8 字节


三、gRPC 层:在 Protobuf 前面加 5 字节头部

gRPC 不会直接把裸 Protobuf 数据交给 HTTP/2,而是会在 Protobuf 数据前面加上一个 5 字节的 gRPC 头部

格式是:

text 复制代码
1 字节压缩标志位 + 4 字节消息体长度 + Protobuf 数据

也就是:

text 复制代码
+----------------------+----------------------+
| compressed flag      | message length       |
| 1 byte               | 4 bytes              |
+----------------------+----------------------+
| protobuf payload                            |
+---------------------------------------------+

1、压缩标志位

第 1 字节表示后面的 Protobuf payload 是否被压缩

text 复制代码
00:没有压缩
01:已经压缩

注意,这个字节只表示"是否压缩",不表示"使用什么压缩算法",真正的压缩算法会放在 HTTP/2 的 header 里,例如:

text 复制代码
grpc-encoding: gzip

如果压缩位是 01,服务端就会根据 grpc-encoding 指定的算法先解压,再把解压后的数据交给 Protobuf 反序列化

处理流程是:

text 复制代码
收到 gRPC message
    ↓
检查 compressed flag
    ↓
如果是 1,根据 grpc-encoding 解压
    ↓
得到原始 Protobuf 二进制
    ↓
Protobuf 反序列化成业务对象

2、消息体长度

后面 4 字节表示 Protobuf payload 的长度,使用大端序,刚才的 Protobuf 数据是:

text 复制代码
08 96 01 12 03 61 62 63

长度是 8 字节,所以 gRPC 的长度字段是:

text 复制代码
00 00 00 08

3、完整 gRPC Message

由于这里不压缩,所以压缩标志位是:

text 复制代码
00

Protobuf 长度是:

text 复制代码
00 00 00 08

Protobuf 数据是:

text 复制代码
08 96 01 12 03 61 62 63

所以完整 gRPC Message 是:

text 复制代码
00 00 00 00 08 08 96 01 12 03 61 62 63

拆开看:

text 复制代码
00                // compressed flag,表示没有压缩
00 00 00 08       // Protobuf payload 长度为 8 字节
08 96 01          // Protobuf: id = 150
12 03 61 62 63    // Protobuf: name = "abc"

这就是 gRPC 层真正要交给 HTTP/2 DATA Frame 的数据


四、HTTP/2 层:gRPC 基于 HTTP/2 传输

一次 gRPC 调用,本质上是一次 HTTP/2 请求

HTTP/2 不像 HTTP/1.1 那样使用纯文本格式传输,而是使用二进制 Frame

对于一次普通的 gRPC unary 调用,通常会涉及:

text 复制代码
HEADERS Frame
DATA Frame
Trailers

HEADERS Frame 负责传请求头,比如调用哪个服务、哪个方法,DATA Frame 负责传真正的 gRPC Message,Trailers 负责传 gRPC 调用状态


1、HEADERS Frame:说明调用哪个 RPC 方法

逻辑上,gRPC 请求头大概可以理解为:

text 复制代码
:method: POST
:scheme: http
:path: /demo.UserService/GetUser
:authority: 127.0.0.1:50051
content-type: application/grpc
te: trailers
grpc-encoding: identity
grpc-accept-encoding: gzip

其中最重要的是:

text 复制代码
:path: /demo.UserService/GetUser

它表示当前 RPC 调用的是:

text 复制代码
demo 包下的 UserService 服务中的 GetUser 方法

注意,这些 header 在 HTTP/2 中并不是直接以明文字符串传输的,而是会经过 HPACK 压缩,这里只是为了方便理解


2、DATA Frame:真正装载 gRPC Message

HTTP/2 中每个 Frame 都有 9 字节头部

格式是:

text 复制代码
+-----------------------------------------------+
| Length: 24 bits                               |
+---------------+-------------------------------+
| Type: 8 bits   | Flags: 8 bits                |
+-----------------------------------------------+
| Reserved: 1 bit | Stream Identifier: 31 bits   |
+-----------------------------------------------+
| Payload ...                                   |
+-----------------------------------------------+

也就是:

text 复制代码
Length              3 字节
Type                1 字节
Flags               1 字节
Reserved + StreamID 4 字节
Payload             真实数据

对于 DATA Frame 来说,Payload 里面放的就是刚才的 gRPC Message


五、HTTP/2 DATA Frame 头部字段含义

1、Length

Length 占 24 bit,也就是 3 字节,表示当前 Frame 的 payload 长度

这里要注意,Length 表示的是 HTTP/2 Frame 的 payload 长度,不包括 HTTP/2 自己的 9 字节头部

在我们的例子中,gRPC Message 是:

text 复制代码
00 00 00 00 08 08 96 01 12 03 61 62 63

长度是:

text 复制代码
5 字节 gRPC 头部 + 8 字节 Protobuf 数据 = 13 字节

所以 HTTP/2 DATA Frame 的 Length 是:

text 复制代码
00 00 0d

0d 是十六进制,表示十进制的 13


2、Type

Type 占 1 字节,表示当前 Frame 的类型,常见类型有:

text 复制代码
0x0:DATA
0x1:HEADERS
0x4:SETTINGS
0x6:PING
0x7:GOAWAY
0x8:WINDOW_UPDATE

在我们的例子中,当前 Frame 是 DATA Frame,所以 Type 是:

text 复制代码
00

3、Flags

Flags 占 1 字节,表示当前 Frame 的附加标志,不同类型的 Frame,Flags 的含义不同

对于 DATA Frame,常见的 flag 有:

text 复制代码
0x1:END_STREAM
0x8:PADDED

END_STREAM 表示当前发送方向的数据已经结束

对于普通 unary gRPC 请求来说,客户端通常只发送一个请求消息,发送完这个 DATA Frame 后,请求体就结束了,所以可以带上 END_STREAM

因此这里 flags 是:

text 复制代码
01

表示:

text 复制代码
这个 DATA Frame 发送完后,客户端不会继续向这个 stream 发送请求体数据

4、Reserved

Reserved 是保留位,占 1 bit,位于最后 4 字节的最高位

正常情况下它是:

text 复制代码
0

它目前没有业务含义,只是 HTTP/2 协议预留出来的位


5、Stream Identifier

Stream Identifier 占 31 bit,表示当前 Frame 属于哪个 HTTP/2 stream

HTTP/2 支持在一个 TCP 连接上同时传输多个 stream,也就是多路复用

例如同一个连接上可以同时有多个 RPC 调用:

text 复制代码
stream 1:/demo.UserService/GetUser
stream 3:/demo.FileService/Upload
stream 5:/demo.ChatService/SendMessage

这些 Frame 可以交错发送,接收方根据 stream identifier 判断每个 Frame 属于哪一次 RPC 调用

客户端主动发起的 stream 通常使用奇数编号,例如:

text 复制代码
1, 3, 5, 7

服务端主动发起的 stream 通常使用偶数编号,例如:

text 复制代码
2, 4, 6, 8

在我们的例子中,stream id 假设为 1,所以最后 4 字节是:

text 复制代码
00 00 00 01

这里最高位的 Reserved 是 0,剩下的 31 bit 表示 stream id 是 1


六、完整 DATA Frame 示例

现在把前面的内容合起来

Protobuf 数据是:

text 复制代码
08 96 01 12 03 61 62 63

gRPC Message 是:

text 复制代码
00 00 00 00 08 08 96 01 12 03 61 62 63

HTTP/2 DATA Frame 头部是:

text 复制代码
00 00 0d          // Length = 13
00                // Type = DATA
01                // Flags = END_STREAM
00 00 00 01       // Reserved = 0,Stream Identifier = 1

所以完整的 HTTP/2 DATA Frame 可以表示为:

text 复制代码
00 00 0d 00 01 00 00 00 01
00 00 00 00 08 08 96 01 12 03 61 62 63

拆开看:

text 复制代码
00 00 0d          // HTTP/2 DATA payload 长度 = 13
00                // HTTP/2 frame type = DATA
01                // END_STREAM
00 00 00 01       // stream id = 1

00                // gRPC compressed flag = 0,表示没有压缩
00 00 00 08       // gRPC message length = 8

08 96 01          // Protobuf: id = 150
12 03 61 62 63    // Protobuf: name = "abc"

这一段就是一次 gRPC 请求中,DATA Frame 部分的核心数据组织方式


七、如果开启压缩会怎样

假设还是这个 Protobuf 数据:

text 复制代码
08 96 01 12 03 61 62 63

如果不开启压缩,gRPC Message 是:

text 复制代码
00 00 00 00 08 08 96 01 12 03 61 62 63

如果开启 gzip 压缩,那么流程会变成:

text 复制代码
Protobuf 原始数据
    ↓
gzip 压缩
    ↓
压缩后的 payload
    ↓
gRPC 头部 compressed flag 设置为 1

此时 gRPC Message 大概是:

text 复制代码
01 00 00 00 xx <gzip 压缩后的数据>

其中:

text 复制代码
01                // compressed flag = 1
00 00 00 xx       // 压缩后的 payload 长度
<gzip压缩后的数据> // 不是原始 Protobuf,而是压缩后的 Protobuf

同时 HTTP/2 header 里会带上类似:

text 复制代码
grpc-encoding: gzip

服务端收到后,不会直接把 payload 交给 Protobuf,而是先根据 grpc-encoding 解压,再得到原始 Protobuf 数据,最后反序列化成对象

所以 gRPC 的压缩不是对整个连接压缩,而是对每个 gRPC Message 的 payload 压缩,尤其在流式 RPC 中,一个 stream 里可能有多个 message,每个 message 前面都有自己的 5 字节 gRPC 头部,也就可以分别标记是否压缩


八、一次完整 gRPC 调用可以这样理解

一次普通的 gRPC unary 调用大致是:

text 复制代码
客户端发送 HEADERS Frame
    :method = POST
    :path = /demo.UserService/GetUser
    content-type = application/grpc

客户端发送 DATA Frame
    HTTP/2 Frame Header
        Length
        Type
        Flags
        Stream Identifier

    gRPC Message
        compressed flag
        message length
        protobuf payload

服务端返回 HEADERS Frame
    :status = 200
    content-type = application/grpc

服务端返回 DATA Frame
    gRPC Message
        compressed flag
        message length
        protobuf payload

服务端返回 Trailers
    grpc-status = 0
    grpc-message = ""

这里还要注意一点,HTTP 状态码 200 不一定代表 RPC 业务成功,gRPC 真正的调用结果主要看 trailers 里的:

text 复制代码
grpc-status

比如:

text 复制代码
grpc-status: 0

表示 RPC 调用成功

相关推荐
渡我白衣1 小时前
定时器与时间轮思想
linux·开发语言·前端·c++·人工智能·深度学习·神经网络
luyun0202021 小时前
实用小工具,吾爱出品
开发语言·c++·算法
芋只因1 小时前
HTTP & HTTPS 详解
网络协议·http·https
蜡笔小马1 小时前
06.C++设计模式-装饰模式
c++·设计模式·装饰器模式
宏笋1 小时前
C++11使用chrono获取当前时间戳
c++
问心无愧05131 小时前
ctf show web入门47
前端·笔记
网络工程小王1 小时前
【LangGraph 状态持久化(Checkpoint)详解】学习笔记
jvm·人工智能·笔记·langchain
Shadow(⊙o⊙)1 小时前
硬核手搓解析!进程-内核分析:命令行参数及环境变量,重构main()
linux·运维·服务器·开发语言·c++·后端·学习
问心无愧05131 小时前
ctf show web入门81
前端·笔记