🔥 本文专栏:Protobuf
🌸作者主页:努力努力再努力wz



💪 今日博客励志语录 :
所谓逆袭,不是突然变得耀眼,而是在很长一段黯淡的日子里,没有把自己弄丢。
思维导图

从网络字节流到Protobuf:序列化、HTTP与JSON的数据组织方式
在此前的学习中,我们已经实现过一个网络服务器,因此对于客户端---服务器架构并不陌生。
在这种架构下,客户端会向服务器发送请求报文。请求报文通过网络传递到服务器之后,服务器会对请求进行解析和处理,然后生成对应的响应报文,并通过网络重新发送给客户端。对于这一套完整的请求---响应流程,我们目前已经十分熟悉了。
但是无论是客户端发送请求,还是服务器返回响应,最终通过网络传输的都只能是一段连续的字节流。因此,在发送数据之前,通常需要先将程序内部的结构化数据转换为可以传输的字节序列;而接收方收到字节序列之后,还需要按照双方约定的格式,将其重新还原为结构化数据。这两个过程分别称为序列化和反序列化。
HTTP协议中的数据组织
在此前实现的网络服务器中,上层采用的是HTTP协议。
HTTP/1.1是一个具有特定格式的文本协议。一个HTTP请求报文主要由请求行、请求头、空行以及可选的请求正文组成,其基本格式如下:
text
请求行\r\n
请求头字段1\r\n
请求头字段2\r\n
...\r\n
\r\n
请求正文
其中,请求行包含请求方法、请求目标以及HTTP版本,例如:
text
POST /login HTTP/1.1\r\n
请求头则由一个个类似键值对的字段组成,例如:
text
Content-Type: application/json\r\n
Content-Length: 100\r\n
每一行之间使用回车换行符 \r\n 进行分隔,而请求头和请求正文之间通过一个空行进行分隔。从完整字节流的角度来看,也就是通过连续的:
text
\r\n\r\n
来表示请求头结束。
HTTP本身还定义了一套判断报文边界的规则。例如,Content-Length字段可以用于表示请求正文的字节长度,使接收方知道在请求头结束之后,还需要继续读取多少字节;而Content-Type字段则用于说明请求正文中的数据类型,使接收方知道应该按照什么格式解析正文。
因此,虽然TCP本身是面向字节流的协议,不会保留应用层消息之间的边界,但是HTTP通过自身规定的报文格式,使接收方能够从连续的TCP字节流中解析出一条完整的HTTP请求。
对于HTTP协议来说,我们通常不需要再使用JSON或者Protobuf去编码整个HTTP报文,因为HTTP本身已经规定了完整的报文格式。客户端只需要将内部的HttpRequest对象按照HTTP规定的格式组装为一段连续的字节流,然后通过网络发送出去;服务端收到数据之后,再按照相同的格式解析请求行、请求头以及请求正文,最终重新构造出一个结构化的HttpRequest对象。
从这个角度来说,将HttpRequest对象组装为HTTP报文字节流的过程,本质上同样可以看作一种编码或者序列化过程;而服务端按照HTTP格式还原出HttpRequest对象,则可以看作对应的解析或者反序列化过程。
请求正文中的数据
需要注意的是,HTTP请求正文中可以携带不同类型的数据。
如果请求正文原本就是图片、音频、压缩包或者其他文件,那么这些数据本身已经是一段二进制字节序列,可以直接放入HTTP正文中进行传输,并不需要先转换为字符串。此时,请求头中的Content-Type会告诉接收方正文中的数据类型,而Content-Length会告诉接收方正文所占的字节数。
但是,如果请求正文中需要传递的是一个结构化对象,例如用户信息、登录参数或者订单数据,那么就不能直接将这个对象在内存中的原始字节发送到网络中,而是需要先使用一种稳定的数据格式进行序列化。
例如,假设程序中存在如下结构:
cpp
struct LoginRequest
{
std::string username;
std::string password;
};
我们不能直接执行:
cpp
send(fd, &request, sizeof(request), 0);
将这个对象在内存中的原始字节发送出去。
首先,结构体或者类对象可能存在内存对齐和填充字节。不同平台、不同编译器甚至不同编译选项,都可能采用不同的对齐规则,从而导致对象中各个字段的偏移量不同。接收方即使收到相同的字节,也不一定能够按照正确的字段位置将其还原出来。
其次,多字节数据还存在大小端问题。例如,同一个整数在小端机器和大端机器中的字节排列顺序可能不同。如果直接传输对象的原始内存,就可能导致接收方解析出错误的数据。
除此之外,对象内部还可能包含指针、std::string、虚函数表指针等内容。这些数据中可能保存的是当前进程地址空间中的内存地址,而这个地址对于另一个进程来说没有任何意义。
因此,网络序列化并不是简单地将对象的原始内存复制出去,而是提取对象中真正需要传输的逻辑字段,再按照双方共同约定的稳定格式进行编码,从而屏蔽内存对齐、大小端、编译器等底层差异。
JSON文本序列化
JSON便是一种常见的文本序列化方式。
使用JSON时,会将结构化对象中的字段转换为具有固定语法的字符串。例如,一个用户对象可以被序列化为:
json
{
"id": 100,
"name": "wangzhe"
}
JSON不会传输对象在内存中的原始布局,而是将字段名和字段值按照统一的文本格式重新组织起来。接收方收到JSON字符串之后,只需要按照JSON语法解析字段名和对应的值,然后重新构造本地对象即可。
因此,接收方并不需要关心发送方对象中的字段位于哪个偏移位置,也不需要关心发送方采用大端还是小端存储。JSON通过统一的文本表示方式,屏蔽了对象底层内存布局带来的差异。
JSON作为文本序列化格式,一个非常明显的优点就是可读性强。序列化之后的数据可以直接被人阅读和调试,字段所表达的含义也非常直观。
但是,JSON的缺点同样比较明显,那就是序列化之后的数据体积通常较大。
例如,整数100和10000都可以使用一个4字节的int32_t进行保存,但是转换为文本之后,在ASCII或者UTF-8编码下:
text
100 → 3字节
10000 → 5字节
当然,单独观察数值时,较小的数字转换为字符串之后不一定比4字节整数更大。JSON真正明显的额外开销,还来自字段名以及各种格式字符。
例如:
json
{"user_id":10000}
这里不仅需要传输真正的数据10000,还需要传输字段名user_id、双引号、冒号以及大括号等内容。
因此,对于字段较多或者消息发送频率较高的场景,JSON会产生较大的空间和网络传输开销。
而接下来我们要认识的Protobuf,同样用于完成结构化对象的序列化和反序列化。不同的是,Protobuf并不是文本序列化格式,而是一种二进制序列化方式。相比于JSON,Protobuf不会在每一条消息中完整地携带字段名,并且会根据不同的数据类型采用更加紧凑的编码方式,因此通常能够显著减小序列化之后的数据体积。
接下来,我们将围绕Protobuf最核心的机制展开介绍,包括.proto文件、生成类、字段编号、Wire Type以及Varint编码等内容,而不会完整覆盖Protobuf的所有知识。
从JSON到Protobuf:Schema、Wire Format与二进制序列化原理
根据上文,我们已经认识了序列化与反序列化的基本概念。接下来,我们将正式进入Protobuf的学习。
在介绍Protobuf的过程中,我们会将其与JSON进行对比。通过这种方式,可以更加清楚地认识Protobuf的特点,以及它为什么能够被广泛应用于RPC框架和分布式系统之中。
JSON与Protobuf的基本区别
JSON是一种文本序列化格式。它会将结构化对象中的字段名和字段值,按照特定的文本格式组织成一个字符串,例如:
json
{
"id": 100,
"name": "wangzhe",
"score": 98.5
}
在这个JSON字符串中,不仅包含真正需要传输的数据,还包含字段名、双引号、冒号、逗号以及大括号等格式信息。
JSON还可以通过自身的语法表示几种通用数据类型,例如:
text
字符串
数字
布尔值
数组
对象
null
例如:
json
{
"age": 21,
"passed": true,
"skills": ["C++", "Linux"]
}
JSON解析器可以根据语法判断:
text
21 → 数字
true → 布尔值
["C++", "Linux"] → 数组
但是需要注意,JSON只能够表达通用的JSON数据类型,并不会携带编程语言中的精确类型信息。
例如,下面这些C++变量:
cpp
int32_t a = 100;
int64_t b = 100;
uint32_t c = 100;
序列化为JSON之后,都可能表示为:
json
100
因此,接收方只能判断它是一个数字,却不能仅根据JSON文本判断它在发送端原本是int32_t、int64_t还是uint32_t。
JSON解析器通常会根据数字的文本形式进行初步判断。例如:
json
100
通常会被解析为整数形式,而:
json
100.5
通常会被解析为浮点数形式。
但是最终使用int32_t、int64_t、float还是double保存数据,仍然取决于具体JSON库的内部实现,或者接收方目标对象中对应字段的类型。
JSON反序列化后的对象
JSON反序列化之后,不一定会直接生成一个具体的C++结构体或者类对象。
如果接收方没有提前定义目标类型,JSON库通常会将解析结果保存到一个通用的JSON容器对象中,例如:
cpp
json object;
随后可以通过字段名访问其中的数据:
cpp
object["id"];
object["name"];
object["score"];
这种通用JSON对象在逻辑上类似于:
cpp
map<string, variant<...>>
也就是通过键保存对应的值。至于其内部具体使用哈希表、有序映射还是其他数据结构,则由不同JSON库的实现决定。
由于JSON字符串本身携带字段名和通用类型信息,因此即使接收方没有提前生成一个具体的C++类,也可以先将其解析成通用JSON对象。
不过,这并不意味着JSON完全不需要双方约定数据格式。
虽然接收方能够解析出字段名和值,但是仍然需要知道:
text
id字段表示什么
name字段表示什么
这些字段是否必须存在
各个字段在业务上具有什么含义
因此,JSON具有较强的自描述能力,但双方依然需要对字段名称和业务语义达成约定。
JSON的空间与解析开销
JSON最大的优势之一是可读性强。序列化之后的内容可以直接被人阅读,也方便开发人员调试和排查问题。
但是,由于JSON采用文本格式,它通常会产生较大的数据体积。
例如,一个4字节的int32_t可以表示:
text
100
10000
但是转换为ASCII或者UTF-8文本之后:
text
100 → 3字节
10000 → 5字节
当然,单独观察一个数值时,较小的数字转换为文本后不一定比4字节整数更大。JSON真正明显的额外开销,还来自字段名和格式字符。
例如:
json
{"user_id":10000}
这里不仅需要传输真正的数据10000,还需要传输:
text
user_id
双引号
冒号
大括号
而且在每一条消息中,相同的字段名往往都会重复出现。因此,当字段数量较多或者消息发送频率较高时,JSON会产生明显的空间开销。
JSON在反序列化时还需要进行文本解析,例如:
text
识别大括号、双引号和逗号
读取并匹配字段名
判断数字、字符串或者布尔值
将十进制数字文本转换为整数或者浮点数
这些字符串处理和数值转换操作,也会带来一定的解析成本。
Protobuf并不是发送对象的原始内存
相比于JSON,Protobuf是一种二进制序列化方式。
这里首先需要纠正一个容易产生的误区:
Protobuf序列化后的字节流,并不是结构体或者类对象在内存中的原始字节。
Protobuf会根据自身规定的Wire Format,对对象中的各个字段重新进行编码,然后生成一段标准化的二进制字节流。
完整过程可以理解为:
text
结构化对象
↓
按照Protobuf Wire Format编码
↓
与对象原始内存布局无关的二进制字节流
因此,Protobuf序列化后的数据与对象在内存中的原始布局并不等价。
例如,一个整数在C++内存中可能固定占用4字节,但是Protobuf可能采用Varint变长编码。一个字符串字段则可能被编码为长度信息加字符串内容。
Protobuf也不会直接传输对象中的:
text
内存对齐填充字节
指针地址
虚函数表指针
std::string内部结构
它只会提取真正需要传输的逻辑字段,并按照统一的编码规则生成字节流。
Protobuf字节流的基本结构
对于一条Protobuf消息来说,其序列化结果由一个个字段的编码结果依次组成。每个字段在逻辑上都可以表示为:
text
Key + 编码后的字段值
其中,Key又由字段编号和Wire Type两部分组成:
text
字段编号 + Wire Type
二者会先通过位运算合并成一个整数:
text
key = (field_number << 3) | wire_type
其中,Wire Type占据低3位,字段编号存放在更高位。得到Key之后,Protobuf还会使用Varint对其进行编码,并将编码结果写入字节流。
因此,在反序列化时,解析器读取出Key之后,可以通过位运算分别提取字段编号和Wire Type:
text
field_number = key >> 3
wire_type = key & 0x07
这里的字段编号用于标识当前数据对应消息中的哪个字段,而Wire Type则用于告诉解析器:后面的字段值采用了哪一种二进制编码方式,应该按照什么规则读取。
Wire Type本质上是一个数值,其取值范围为0~5,其中3和4对应已经废弃的Group类型,目前常用的主要是0、1、2、5。
不同的Wire Type对应不同的读取方式:
text
Wire Type = 0
→ Varint变长编码
Wire Type = 1
→ 固定读取8字节
Wire Type = 2
→ 先读取数据长度,再读取指定数量的字节
Wire Type = 5
→ 固定读取4字节
对于Varint编码来说,每个字节的最高位用于表示后面是否还有数据:
text
最高位为1
→ 后面还有字节,需要继续读取
最高位为0
→ 当前字节已经是最后一个字节
每个字节剩余的低7位才真正用于保存数值数据。通过这种方式,较小的整数可以使用较少的字节进行存储,而不需要始终占用固定的4字节或8字节。
对于fixed32和fixed64等类型,字段值采用固定长度编码。解析器看到对应的Wire Type后,就可以直接读取4字节或者8字节。
而对于string、bytes以及嵌套消息等类型,则通常采用Wire Type为2的Length-delimited编码。此时,字段值前面会先保存一个通过Varint编码的长度,解析器读取出长度后,再从后续字节流中提取指定数量的字节。
需要注意的是,Wire Type只负责描述字段值在字节流中的物理编码方式,并不能单独确定字段在业务层面的具体数据类型。
例如,string、bytes和嵌套消息都可能使用Wire Type为2的编码方式。接收方需要根据字段编号,在双方约定的.proto消息结构中找到对应字段,才能进一步确定这些字节应该被解释为字符串、字节数组还是嵌套消息。
例如:
protobuf
message User
{
int32 id = 1;
string name = 2;
}
Protobuf在序列化时并不会传输字段名称:
text
id
name
而是将字段编号1和2编码到各自字段的Key中。
接收方读取出字段编号后,可以在User消息的结构定义中找到对应成员;再根据Wire Type确定如何从字节流中提取完整的字段值,最后按照字段本身的数据类型进行解释。
因此,Protobuf传输的并不是C++对象、Java对象等对象在内存中的原始数据,而是按照统一规则重新编码后的二进制数据。
不同语言和平台只需要遵循同一套Protobuf编码规则,就可以将本地对象转换为统一的字节流,并在接收端重新还原,从而屏蔽对象内存布局、字节对齐以及语言类型实现等底层差异。
不过,Protobuf能够确定的是一条消息内部各个字段的编码方式和字段边界,而不是整条Protobuf消息在TCP字节流中的边界。
如果连续通过TCP发送两条Protobuf消息:
text
message1的字节流 + message2的字节流
由于TCP本身是面向字节流的协议,接收方仍然无法仅依靠Protobuf判断第一条完整消息在哪里结束。
因此,在RPC框架中,通常还需要在Protobuf消息外部设计一层通信协议,例如:
text
+----------------+----------------------+
| 消息总长度 | Protobuf消息数据 |
+----------------+----------------------+
这里,外层长度字段负责确定一条完整RPC消息的边界,从而解决TCP粘包和拆包问题;Protobuf则负责一条消息内部各个字段的序列化、反序列化以及字段边界识别。
Protobuf为什么需要Schema
JSON字符串中会携带字段名,而Protobuf字节流中只携带字段编号、Wire Type以及编码后的字段值,并不会携带完整字段名。
例如,接收方在字节流中读取到:
text
字段编号1
字段编号2
它并不能仅根据这两个编号知道:
text
字段1是不是id
字段2是不是name
字段1应该使用int32还是string解析
因此,Protobuf要求发送方和接收方事先共享一份消息结构定义,这份结构定义通常称为Schema。
Protobuf使用后缀名为.proto的文件定义消息结构,例如:
protobuf
syntax = "proto3";
message User
{
int32 id = 1;
string name = 2;
}
这份.proto文件告诉发送方和接收方:
text
字段编号1对应id,类型为int32
字段编号2对应name,类型为string
因此,双方必须使用相同或者兼容的Schema,才能正确完成序列化与反序列化。
需要注意,.proto文件定义的不是某一个具体对象的数据,而是一类消息的结构规则。
例如,它规定了:
text
这类消息有哪些字段
每个字段是什么类型
每个字段对应哪个编号
而具体对象则是在程序运行时根据这份规则创建出来的。
Protobuf的代码生成过程
Protobuf支持多种编程语言,其中就包括C++、Java、Python等。
为了让不同语言都能够使用同一份消息定义,Protobuf不会直接使用C++语法定义结构,而是采用独立的.proto描述语言。
完整流程如下:
text
编写.proto文件
↓
定义消息Schema
↓
使用protoc编译器
↓
生成对应语言的源代码
↓
参与项目的编译与链接
例如,对于C++来说,protoc通常会根据.proto文件生成对应的头文件和源文件。
在项目中,我们可以直接使用生成的消息类:
cpp
User user;
user.set_id(100);
user.set_name("wangzhe");
然后调用对应的序列化接口,将其编码为二进制字节流。
接收方则使用相同Schema生成的消息类,将收到的字节流反序列化为对应对象,再读取其中的字段。
因此,Protobuf相比于JSON,多出了一个重要环节:
text
预先定义消息结构
JSON通常可以直接创建通用容器对象并设置键值对,而Protobuf需要先定义.proto文件,再通过protoc生成对应语言的消息类,之后才能创建对象并进行序列化。
Protobuf的反序列化成本
相比于JSON,Protobuf的反序列化成本通常更低。
JSON解析时需要进行大量文本处理,例如:
text
识别语法符号
解析字段名
匹配字符串
判断通用数据类型
将数字文本转换为二进制数值
而Protobuf解析时,可以先读取字段的Key,从中直接得到:
text
字段编号
Wire Type
随后根据提前定义好的Schema,将字段值写入对应的成员中。
Protobuf不需要传输完整字段名,也不需要将十进制数字字符串重新转换为二进制整数,因此通常能够减少字符串解析和字段查找的成本。
不过,Protobuf字段在字节流中不一定严格按照字段编号从小到大排列。反序列化器不能依赖字段出现的顺序,而是应该根据每个字段自身携带的Key判断它对应哪个字段。
因此,JSON与Protobuf可以总结为:
text
JSON:
字段名和字段值一起传输
能够表达通用JSON类型
可以解析为通用容器对象
可读性较强
数据体积通常较大
文本解析成本较高
Protobuf:
只传输字段编号、Wire Type和编码后的字段值
依赖双方提前共享Schema
需要.proto文件和protoc生成代码
二进制格式更加紧凑
序列化和反序列化效率通常更高
Protobuf通过提前定义消息结构,将字段名称和精确类型等信息保存在Schema中,而不是在每一条消息中重复传输。正因如此,它牺牲了一部分自描述能力和使用灵活性,换来了更小的数据体积以及更高的编解码效率。
从.proto到C++消息对象:Protobuf消息定义、代码生成与序列化实践
.proto消息定义详解:字段类型、编号与消息组合
根据前文,我们已经对Protobuf的作用、字节流结构以及底层编码规则有了较为清晰的认识。接下来,我们便进入Protobuf具体代码书写层面的学习,首先来看如何通过.proto文件定义通信过程中使用的消息结构。
前面我们已经知道,Protobuf支持C++、Java、Python等多种编程语言。为了让不同语言能够使用同一套消息结构,Protobuf并不会直接使用某一种编程语言来定义通信协议,而是提供了一套独立于具体语言的描述语言。
开发者需要在后缀名为.proto的文件中,使用这套描述语言定义通信过程中所需要的消息结构。
例如:
text
user.proto
不过,.proto文件中的内容并不是C++代码,C++编译器自然也无法直接识别。因此,在项目正式编译之前,还需要先通过Protobuf提供的protoc编译器,将.proto文件转换为目标语言对应的源代码。
对于C++来说,可以执行:
bash
protoc --cpp_out=. user.proto
随后通常会生成两个文件:
text
user.pb.h
user.pb.cc
其中,.pb.h文件中主要包含生成的消息类声明,而.pb.cc文件中则包含相关接口以及序列化、反序列化逻辑的实现。
这两个文件本质上都是普通的C++源文件,后续会和项目中其他的.cpp文件一起参与编译和链接。
因此,整个过程可以表示为:
text
.proto消息描述文件
↓
protoc编译器
↓
.pb.h头文件和.pb.cc源文件
↓
参与C++项目的编译与链接
声明Protobuf语法版本
创建好.proto文件之后,在正式定义消息结构之前,首先需要声明当前文件所采用的Protobuf语法规则:
protobuf
syntax = "proto3";
这里的proto3表示当前文件采用的是Proto3语法,而不是在声明本地安装的Protobuf库版本。
除了Proto3之外,Protobuf历史上还存在Proto2语法。二者在字段规则、默认值以及部分语法特性上存在一定区别。当前学习过程中,我们主要使用Proto3。
声明package
接下来还可以通过package关键字声明当前.proto文件所属的包:
protobuf
package user;
在生成C++代码之后,这个包名通常会映射为对应的C++命名空间。例如:
protobuf
package user;
生成的消息类通常会位于类似下面的命名空间中:
cpp
namespace user
{
// 生成的消息类
}
同样,也可以声明多级包名:
protobuf
package mprpc.user;
在生成C++代码后,通常会对应为多层命名空间。
使用package的主要目的,是对不同模块中定义的消息类型进行隔离,避免多个.proto文件生成同名消息类时发生命名冲突。
不过需要注意,.proto文件中的package属于Protobuf描述语言的一部分,并不是在直接书写C++中的namespace,只是其在生成C++代码时通常会映射为命名空间。
定义消息结构
完成前面的准备工作之后,接下来就可以正式定义消息结构了。
其实,Protobuf消息结构的定义并没有想象中复杂,其形式和C++类中的成员定义有些相似。定义一条消息时,需要使用message关键字,后面跟上消息类型的名称,然后在消息内部定义对应字段。
例如:
protobuf
message LoginRequest
{
string username = 1;
string password = 2;
}
这里定义了一个名为LoginRequest的消息结构,其中包含两个字段:
text
username
password
一个字段的基本定义形式为:
text
字段类型 字段名称 = 字段编号;
例如:
protobuf
string username = 1;
其中:
text
string
→ 字段类型
username
→ 字段名称
1
→ 字段编号
需要注意,字段编号并不是字段在.proto文件中的简单书写顺序,而是这个字段在Protobuf协议中的唯一标识。
例如:
protobuf
message User
{
string name = 2;
int32 id = 1;
}
虽然name写在id前面,但是name的字段编号是2,id的字段编号是1。
在序列化后的字节流中,Protobuf真正编码的是字段编号,而不是字段名称以及字段的声明位置。因此,一旦某个字段编号已经投入使用,就不能随意修改,也不能再交给另一个含义完全不同的字段使用。
Protobuf的通用数据类型
在.proto文件中定义字段时,可以使用Protobuf提供的通用数据类型,例如:
text
int32
int64
uint32
uint64
float
double
bool
string
bytes
这里容易产生一个误区:由于string、bool等名称和C++中的类型比较相似,看起来好像是在直接使用C++的数据类型。
但实际上,这些类型都属于Protobuf自身定义的跨语言类型系统,并不是C++类型。
例如:
protobuf
int32 id = 1;
string name = 2;
这里的int32和string表示的是Protobuf类型。只有经过protoc编译器转换为C++代码后,才会映射为对应的C++类型。
常见的映射关系大致如下:
text
Protobuf int32
→ C++ int32_t
Protobuf int64
→ C++ int64_t
Protobuf bool
→ C++ bool
Protobuf string
→ C++ std::string
Protobuf bytes
→ C++ std::string
Protobuf会根据目标语言,将自身的通用类型转换为该语言中合适的具体类型。
正是因为.proto文件中的消息定义独立于具体编程语言,所以同一份.proto文件既可以生成C++代码,也可以生成Java、Python等其他语言的代码,从而实现不同语言程序之间的数据通信。
string与bytes
在C++生成代码中,Protobuf中的string和bytes通常都会映射为std::string,但是二者表达的语义不同。
string用于表示文本字符串,例如:
protobuf
string username = 1;
而bytes用于表示任意二进制字节序列,例如:
protobuf
bytes image_data = 2;
bytes可以保存图片、文件、压缩数据、加密数据或者其他二进制内容。
之所以在C++中仍然可以使用std::string保存bytes,是因为std::string本质上也能够保存任意字节,其中可以包含\0,并不只能存储普通的可打印字符。
因此,虽然string和bytes在C++代码中的底层类型可能相同,但在消息结构的语义上不能将二者完全等同。
定义重复字段
如果某个字段中需要保存多个相同类型的元素,可以在字段类型前面添加repeated关键字:
protobuf
message Student
{
repeated int32 scores = 1;
}
这里的scores字段在逻辑上可以理解为一个动态数组,其中能够保存多个int32类型的数据。
repeated后面的元素类型既可以是Protobuf内置类型:
protobuf
repeated int32 values = 1;
repeated string names = 2;
也可以是我们自己定义的消息类型:
protobuf
message User
{
int32 id = 1;
string name = 2;
}
message UserList
{
repeated User users = 1;
}
不过,经过protoc生成C++代码之后,repeated字段通常不会直接转换为std::vector,而是使用Protobuf通过C++实现的专用容器。
对于整数、浮点数、布尔值等标量类型,通常使用:
cpp
google::protobuf::RepeatedField<T>
对于字符串以及自定义消息类型,通常使用:
cpp
google::protobuf::RepeatedPtrField<T>
这些容器本质上仍然是C++代码实现的动态容器,其功能和std::vector比较相似,只不过能够更好地配合Protobuf自身对消息字段的管理、序列化和反序列化。
在实际开发中,我们通常不需要直接操作生成类内部的容器,而是使用Protobuf生成的成员接口
例如:
protobuf
message Student
{
string name = 1;
repeated int32 scores = 2;
}
这里定义了一个Student消息类型,其中:
text
name
→ 保存学生姓名
scores
→ 保存该学生的多个成绩
scores字段前面使用了repeated关键字,因此它可以保存多个int32类型的数据。在逻辑上,可以将其理解为一个动态数组。
经过protoc编译之后,Protobuf会根据这段消息定义生成对应的C++消息类,因此我们可以在C++代码中创建Student对象,并通过生成的接口操作scores字段:
cpp
Student student;
student.add_scores(90);
student.add_scores(95);
int count = student.scores_size();
int score = student.scores(0);
从使用体验上看,add_scores()类似于std::vector的push_back(),scores_size()用于获得元素数量,而scores(index)用于读取指定位置的元素。
这里不需要深入研究Protobuf专用容器的底层实现。当前只需要知道:
text
repeated
→ 表示字段可以拥有多个元素
→ 生成C++代码后由Protobuf专用容器保存
定义map字段
除了重复字段之外,Protobuf还支持通过map定义键值映射:
protobuf
message Student
{
map<string, int32> scores = 1;
}
其定义形式为:
text
map<键类型, 值类型> 字段名称 = 字段编号;
这里在逻辑上类似于C++中的关联容器,可以通过一个键找到对应的值。
不过,生成C++代码后,Protobuf通常不会简单地将其转换为std::map或者std::unordered_map,而是使用自己实现的容器:
cpp
google::protobuf::Map<Key, Value>
至于其内部具体采用怎样的数据结构,则属于Protobuf库自身的实现细节。对于使用者来说,只需要将其理解为一个支持键值映射的容器即可。
例如:
protobuf
message User
{
int32 id = 1;
string name = 2;
}
message UserTable
{
map<int32, User> users = 1;
}
这里表示通过用户编号,可以找到对应的User消息。
需要注意,map的键通常只能使用整数、布尔值或者字符串等部分标量类型,不能直接使用自定义消息类型作为键;而值既可以是标量类型,也可以是自定义消息类型。
消息类型之间的组合
一个消息结构不仅可以包含Protobuf内置类型,还可以直接将另一个message作为字段类型:
protobuf
message Address
{
string city = 1;
string street = 2;
}
message User
{
int32 id = 1;
string name = 2;
Address address = 3;
}
这里的User消息内部包含了一个Address类型的字段。
通过这种方式,可以将多个简单消息结构组合成更加复杂的消息结构,而不需要将所有字段全部平铺在同一个message中。
在二进制编码层面,嵌套消息通常会作为一段长度限定的字节流保存;但在.proto文件中,我们只需要声明字段类型即可,具体的序列化和反序列化过程由生成的消息类负责。
消息结构定义的本质
总结来说,在.proto文件中定义一个消息类型,主要是在描述:
text
消息类型的名称
消息中包含哪些字段
每个字段的数据类型
每个字段的字段名称
每个字段的字段编号
字段是否可以重复
消息之间如何进行组合
例如:
protobuf
syntax = "proto3";
package mprpc.user;
message LoginRequest
{
string username = 1;
string password = 2;
}
message LoginResponse
{
bool success = 1;
string message = 2;
}
这里并没有描述C++对象具体应该如何进行内存布局,也没有直接书写序列化和反序列化代码。
开发者只负责定义消息结构,而protoc编译器会根据这些定义,自动生成目标语言对应的消息类以及相关操作接口。
发送方可以创建生成的消息对象,为字段设置数据,然后调用序列化接口将其转换为Protobuf字节流;接收方则可以使用对应的消息类,将接收到的字节流反序列化为本地消息对象。
因此,发送方和接收方需要遵循兼容的消息结构定义。通常情况下,双方会共享同一份.proto协议文件,然后分别生成各自所使用语言对应的代码。
例如:
text
客户端使用C++
→ 根据user.proto生成C++代码
服务端使用Java
→ 根据同一个user.proto生成Java代码
双方不需要采用相同的编程语言,也不要求程序运行时仍然携带原始的.proto文件。
真正需要保证的是:发送方和接收方使用的消息结构定义彼此兼容,特别是相同字段的字段编号以及字段含义不能发生冲突。
因此,.proto文件本质上就是一份独立于具体编程语言的通信协议描述。不同语言通过同一份协议生成各自的消息类,并按照统一的Protobuf编码规则完成数据交换,这也正是Protobuf能够实现跨语言通信的重要基础。
从.proto到C++消息类:Protobuf代码生成与序列化实践
根据前文,我们已经知道了如何书写.proto文件,以及如何在其中定义对应的消息类型。接下来,我们便可以真正进入Protobuf代码实践层面的学习,观察.proto文件经过编译之后,会生成怎样的C++代码,以及我们应该如何使用这些生成的消息类。
假设现在我们已经编写好了一份名为test.proto的文件,其中定义了登录请求、登录响应、用户信息以及好友列表等消息结构:
protobuf
syntax = "proto3";
package test;
option cc_generic_services = true;
message ResultCode
{
int32 errcode = 1;
bytes errmsg = 2;
}
message LoginRequest
{
bytes username = 1;
bytes password = 2;
}
message LoginResponse
{
ResultCode result = 1;
bool success = 2;
}
message User
{
string username = 1;
bytes password = 2;
enum Sex
{
SEX_UNSPECIFIED = 0;
MALE = 1;
FEMALE = 2;
}
Sex sex = 3;
}
message GetFriendListRequest
{
User user = 1;
}
message GetFriendListResponse
{
ResultCode result = 1;
repeated User friend_list = 2;
}
这里首先定义了一个ResultCode消息类型,用来描述一次请求的执行结果:
protobuf
message ResultCode
{
int32 errcode = 1;
bytes errmsg = 2;
}
其中,errcode表示错误码,errmsg表示对应的错误描述。
一般情况下,可以约定:
text
errcode = 0
→ 请求执行成功
errcode != 0
→ 请求执行失败
接下来定义的是登录请求消息:
protobuf
message LoginRequest
{
bytes username = 1;
bytes password = 2;
}
其中包含用户名和密码两个字段。
这里的bytes类型在生成C++代码之后,通常会映射为std::string,因此后续可以通过字符串形式对字段进行赋值。不过需要注意,bytes在语义上表示任意二进制数据,并不等同于普通文本字符串。
如果用户名和密码保存的就是普通文本,从语义上来说,也可以将其定义为:
protobuf
message LoginRequest
{
string username = 1;
string password = 2;
}
如果密码保存的是加密结果、哈希值或者其他二进制内容,那么使用bytes会更加合适。
随后定义的是登录响应消息:
protobuf
message LoginResponse
{
ResultCode result = 1;
bool success = 2;
}
其中,success用于表示登录是否成功,而result用于保存错误码和错误信息。
这里需要注意,success和result.errcode都能够表示请求是否成功,因此二者在语义上存在一定重复。
如果保留当前设计,就必须保证:
text
success = true
→ result.errcode应当为0
success = false
→ result.errcode应当为非0
否则一旦两个字段的状态不一致,接收方就无法确定应该以哪一个字段为准。
另一种更简洁的设计方式,是只保留ResultCode,通过错误码判断请求是否成功:
protobuf
message LoginResponse
{
ResultCode result = 1;
}
不过当前阶段为了练习嵌套消息和不同类型字段的定义,暂时保留success字段也没有问题。
接下来定义的是用户信息消息:
protobuf
message User
{
string username = 1;
bytes password = 2;
enum Sex
{
SEX_UNSPECIFIED = 0;
MALE = 1;
FEMALE = 2;
}
Sex sex = 3;
}
这里包含用户名、密码以及性别三个字段。
其中,Sex是定义在User消息内部的枚举类型。Proto3要求枚举中的第一个成员必须对应数值0,因此这里定义了:
protobuf
SEX_UNSPECIFIED = 0;
用来表示性别尚未设置。
如果直接定义:
protobuf
MALE = 0;
FEMALE = 1;
那么当用户没有显式设置sex字段时,其默认值也会是MALE,这样就无法区分"用户选择了男性"和"用户没有设置性别"这两种情况。
随后定义的是好友列表请求:
protobuf
message GetFriendListRequest
{
User user = 1;
}
这里直接将前面定义的User消息作为字段类型,表示当前请求对应的用户信息。
最后是好友列表响应:
protobuf
message GetFriendListResponse
{
ResultCode result = 1;
repeated User friend_list = 2;
}
其中,result表示请求的执行结果,而friend_list用于保存多个好友信息。
由于friend_list字段前面使用了repeated关键字,因此该字段可以保存多个User消息对象。在逻辑上,可以将其理解为一个动态数组。
经过protoc生成C++代码后,这个字段通常会由Protobuf自己的专用容器进行管理,而不是直接使用std::vector<User>。
当前阶段不需要深入研究这个容器的内部实现,只需要知道:
text
repeated User
→ 表示该字段可以保存多个User对象
通过protoc生成C++代码
完成test.proto文件之后,接下来需要通过protoc编译器,将其转换为C++源代码。
可以执行:
bash
protoc --cpp_out=. test.proto
执行完成之后,通常会生成两个文件:
text
test.pb.h
test.pb.cc
其中:
text
test.pb.h
→ 包含生成的消息类声明
test.pb.cc
→ 包含消息类相关接口以及序列化、反序列化逻辑的实现
这两个文件本质上都是普通的C++代码,后续会和项目中的其他源文件一起参与编译和链接。
由于.proto文件中声明了:
protobuf
package test;
因此,生成的消息类通常会位于test命名空间中。
例如:
cpp
test::LoginRequest
test::LoginResponse
test::User
test::GetFriendListRequest
test::GetFriendListResponse
如果使用:
cpp
using namespace test;
便可以直接使用消息类名称。
创建并设置消息对象
接下来,我们便可以在C++代码中引入生成的头文件,并创建对应的消息对象:
cpp
#include "test.pb.h"
#include <iostream>
#include <string>
using namespace test;
int main()
{
LoginRequest req;
req.set_username("zhang san");
req.set_password("123456");
return 0;
}
这里的LoginRequest并不是我们手动编写的C++类,而是protoc根据下面的消息定义自动生成的:
protobuf
message LoginRequest
{
bytes username = 1;
bytes password = 2;
}
同时,protoc还会根据字段名称生成对应的读写接口。
例如:
cpp
req.set_username("zhang san");
req.set_password("123456");
分别用于设置username和password字段。
对应的读取接口则是:
cpp
req.username();
req.password();
因此,.proto文件中定义的字段:
protobuf
bytes username = 1;
经过代码生成后,通常会得到类似下面的操作接口:
cpp
set_username(...)
username()
开发者不需要自己实现这些字段的存储和访问逻辑,而是直接使用Protobuf生成的接口即可。
消息对象的序列化
完成字段设置之后,便可以将消息对象序列化为Protobuf字节流:
cpp
std::string send_str;
if (req.SerializeToString(&send_str))
{
std::cout << "serialize ok, size = "
<< send_str.size()
<< std::endl;
}
这里的:
cpp
req.SerializeToString(&send_str);
表示将req对象中的字段,按照Protobuf定义的统一编码规则转换为二进制数据,并保存到send_str中。
整个过程可以表示为:
text
LoginRequest消息对象
↓
SerializeToString
↓
Protobuf二进制字节流
虽然send_str的类型是std::string,但其中保存的并不是普通的文本字符串,而是一段二进制字节流,其中可能包含\0以及其他不可打印字符。
之所以可以使用std::string保存,是因为std::string不仅能够保存普通文本,也可以保存任意长度的二进制数据。
在实际网络通信中,发送方真正发送的并不是req对象本身,而是send_str中保存的序列化结果。
也就是说,发送方不会直接发送C++对象的原始内存,而是发送:
text
按照Protobuf统一编码规则生成的二进制字节流
这样接收方即使使用不同的语言和不同的平台,也可以根据相同的.proto消息结构完成数据解析。
消息对象的反序列化
假设接收方已经获得了send_str中的二进制数据,就可以创建一个同类型的消息对象,并完成反序列化:
cpp
LoginRequest reqB;
if (reqB.ParseFromString(send_str))
{
std::cout << reqB.username()
<< " "
<< reqB.password()
<< std::endl;
}
其中:
cpp
reqB.ParseFromString(send_str);
表示按照LoginRequest对应的消息结构和Protobuf编码规则,对send_str中的字节流进行解析,并将解析得到的字段保存到reqB对象中。
整个过程可以表示为:
text
Protobuf二进制字节流
↓
ParseFromString
↓
LoginRequest消息对象
完成反序列化之后,就可以通过生成的字段读取接口,获取消息中的具体内容:
cpp
reqB.username();
reqB.password();
因此,完整的测试代码可以写成:
cpp
#include "test.pb.h"
#include <iostream>
#include <string>
using namespace test;
int main()
{
LoginRequest req;
req.set_username("zhang san");
req.set_password("123456");
// 序列化
std::string send_str;
if (req.SerializeToString(&send_str))
{
std::cout << "serialize ok, size = "
<< send_str.size()
<< std::endl;
}
// 反序列化
LoginRequest reqB;
if (reqB.ParseFromString(send_str))
{
std::cout << reqB.username()
<< " "
<< reqB.password()
<< std::endl;
}
return 0;
}
这里创建了两个不同的LoginRequest对象:
text
req
→ 模拟发送方创建的消息对象
reqB
→ 模拟接收方解析得到的消息对象
二者之间并不是通过对象拷贝传递数据,而是经过了完整的序列化和反序列化过程:
text
req消息对象
↓ 序列化
send_str二进制字节流
↓ 反序列化
reqB消息对象
通过这段代码,我们便能够清楚地看到Protobuf在实际使用过程中的基本流程:
text
1. 在.proto文件中定义消息结构
2. 使用protoc生成对应的C++消息类
3. 创建消息对象并设置字段
4. 调用SerializeToString完成序列化
5. 将序列化后的字节流进行传输
6. 接收方调用ParseFromString完成反序列化
7. 通过生成的字段接口读取消息内容
因此,Protobuf真正为我们屏蔽的,就是消息结构到二进制字节流之间的转换过程。
开发者只需要操作生成的消息对象,而字段如何编码、字段编号如何写入、Wire Type如何组织,以及字节流如何重新解析,这些过程都由Protobuf内部自动完成。
从Protobuf生成类到完整RPC调用:消息、Service、Stub与RpcChannel机制
根据前文,我们已经知道,开发者需要在.proto文件中使用Protobuf提供的描述语言,定义通信过程中需要使用的消息结构以及服务接口。
.proto文件本身并不是C++代码,因此不能直接交给C++编译器处理,而是需要先通过protoc编译器,将其转换为对应语言的源代码。
对于C++来说,整个过程可以表示为:
text
test.proto
↓ protoc
test.pb.h + test.pb.cc
↓
参与项目整体编译和链接
其中:
text
test.pb.h
→ 主要包含生成类的声明
test.pb.cc
→ 主要包含生成类相关接口以及底层实现
如果.proto文件中既定义了message,又定义了service,并且通过:
protobuf
option cc_generic_services = true;
开启C++通用服务代码生成功能,那么protoc在生成消息类的同时,还会生成对应的服务类以及客户端Stub类。
如果没有开启该选项,protoc仍然会生成message对应的消息类,但不会生成这一套基于google::protobuf::Service和RpcChannel的服务类与Stub代码。
消息类型生成的C++类
假设在.proto文件中定义了一个登录请求消息:
protobuf
message LoginRequest
{
bytes username = 1;
bytes password = 2;
}
经过protoc编译后,会生成一个对应的C++类:
cpp
test::LoginRequest
这个类中会保存username和password两个字段,并提供对应的读写接口,例如:
cpp
test::LoginRequest request;
request.set_username("zhangsan");
request.set_password("123456");
std::cout << request.username() << std::endl;
std::cout << request.password() << std::endl;
这里的:
cpp
set_username()
set_password()
用于设置字段,而:
cpp
username()
password()
用于读取字段。
这些成员函数并不是程序员自己实现的,而是protoc根据.proto中的字段定义自动生成的。
因此,可以将消息类型的代码生成过程理解为:
text
.proto中的message定义
↓
protoc
↓
对应的C++消息类
↓
提供字段读写、序列化和反序列化接口
消息类与Message基类
生成的消息类并不是完全独立的类,它通常会继承Protobuf运行库提供的消息基类。
可以近似理解为:
text
LoginRequest
↓
google::protobuf::Message
↓
google::protobuf::MessageLite
这些基类都定义在Protobuf运行库中,为不同的消息类型提供统一的操作接口,例如:
cpp
SerializeToString()
ParseFromString()
ByteSizeLong()
Clear()
因此,我们可以对任意生成的消息对象调用:
cpp
std::string data;
request.SerializeToString(&data);
完成序列化,也可以调用:
cpp
test::LoginRequest requestB;
requestB.ParseFromString(data);
完成反序列化。
不过,这里不能简单地认为所有序列化逻辑完全由基类独立完成。
更准确地说,消息的序列化和反序列化由两部分共同完成:
text
Protobuf运行库
→ 提供统一接口和通用的编码能力
test.pb.cc中的生成代码
→ 提供与具体消息字段结构有关的实现
因为Protobuf运行库本身并不知道LoginRequest内部有哪些字段,而test.pb.cc中生成的代码知道:
text
字段1是username
字段2是password
字段类型是bytes
字段应该采用怎样的编码方式
因此,项目编译时需要同时编译:
text
main.cpp
test.pb.cc
在链接阶段还需要链接Protobuf运行库,例如:
bash
g++ main.cpp test.pb.cc -lprotobuf -pthread
否则,生成代码中调用的Protobuf运行库函数将无法找到对应实现,链接阶段就会出现未定义引用错误。
所以需要区分:
text
protoc
→ 代码生成工具
test.pb.h、test.pb.cc
→ 根据.proto生成的具体C++代码
libprotobuf
→ 程序运行时依赖的Protobuf运行库
消息对象的序列化与反序列化
消息对象的基本使用流程如下:
cpp
#include "test.pb.h"
#include <iostream>
#include <string>
int main()
{
test::LoginRequest request;
request.set_username("zhangsan");
request.set_password("123456");
std::string send_data;
if (!request.SerializeToString(&send_data))
{
std::cerr << "序列化失败" << std::endl;
return -1;
}
test::LoginRequest requestB;
if (!requestB.ParseFromString(send_data))
{
std::cerr << "反序列化失败" << std::endl;
return -1;
}
std::cout << requestB.username() << std::endl;
std::cout << requestB.password() << std::endl;
return 0;
}
这里的完整过程是:
text
request消息对象
↓
SerializeToString()
↓
Protobuf二进制字节流
↓
ParseFromString()
↓
requestB消息对象
发送方真正通过网络发送的不是C++对象本身,而是序列化之后得到的二进制字节流。
接收方收到字节流之后,再根据相同或者兼容的消息结构,将其反序列化为本地消息对象。
Protobuf中的服务定义
除了使用message定义通信数据之外,Protobuf还可以通过service定义一组远程调用接口。
例如:
protobuf
service UserService
{
rpc Login(LoginRequest) returns (LoginResponse);
rpc GetFriendList(GetFriendListRequest)
returns (GetFriendListResponse);
}
这里:
text
UserService
→ 服务名称
Login
→ 远程调用方法名称
LoginRequest
→ Login方法的请求消息类型
LoginResponse
→ Login方法的响应消息类型
RPC方法的定义形式为:
protobuf
rpc 方法名(请求消息类型) returns (响应消息类型);
这里并不会书写具体的登录验证、数据库查询等业务逻辑。
.proto文件只负责描述:
text
这个服务对外提供哪些方法
每个方法接收什么请求消息
每个方法返回什么响应消息
至于方法内部具体完成什么业务,则需要程序员在生成代码的基础上自行实现。
service生成的C++服务类
当.proto文件中开启:
protobuf
option cc_generic_services = true;
并定义了service之后,protoc会生成对应的C++服务类。
例如:
protobuf
service UserService
{
rpc Login(LoginRequest) returns (LoginResponse);
}
会生成类似:
cpp
test::UserService
的服务类。
这个服务类会继承Protobuf运行库中的统一服务基类:
text
test::UserService
↓
google::protobuf::Service
google::protobuf::Service基类提供了一组统一的服务操作接口,其中包括:
cpp
CallMethod()
GetDescriptor()
GetRequestPrototype()
GetResponsePrototype()
生成的UserService类还会提供与.proto中RPC方法同名的虚函数,例如:
cpp
virtual void Login(
google::protobuf::RpcController* controller,
const test::LoginRequest* request,
test::LoginResponse* response,
google::protobuf::Closure* done);
可以看到,.proto中的:
protobuf
rpc Login(LoginRequest) returns (LoginResponse);
并不会直接生成:
cpp
LoginResponse Login(LoginRequest request);
而是生成一个返回值为void、接收四个参数的虚函数。
四个参数分别表示:
text
controller
→ 记录和控制本次RPC调用的状态
request
→ 客户端传来的请求消息
response
→ 服务端需要填写的响应消息
done
→ 业务处理完成后的回调对象
ServiceDescriptor与MethodDescriptor
服务生成类中的:
cpp
GetDescriptor()
会返回一个ServiceDescriptor对象。
这个对象描述的是整个服务,例如:
text
服务名称
服务所属的.proto文件
服务中包含多少个RPC方法
每个RPC方法的描述信息
例如:
cpp
const google::protobuf::ServiceDescriptor* service_desc =
service->GetDescriptor();
可以通过:
cpp
int count = service_desc->method_count();
获取服务中的方法数量。
也可以通过下标获得某个方法的描述对象:
cpp
const google::protobuf::MethodDescriptor* method_desc =
service_desc->method(0);
还可以通过方法名查找:
cpp
const google::protobuf::MethodDescriptor* method_desc =
service_desc->FindMethodByName("Login");
获得的MethodDescriptor不是一个真正可执行的函数对象,而是用于描述某个RPC方法的元信息,其中包含:
text
方法名称
方法在当前服务中的下标
所属服务
请求消息类型
响应消息类型
因此,可以将二者理解为:
text
ServiceDescriptor
→ 描述整个服务
MethodDescriptor
→ 描述服务中的某一个RPC方法
CallMethod统一分发RPC方法
google::protobuf::Service提供了一个统一的调用入口:
cpp
CallMethod()
其接口大致为:
cpp
virtual void CallMethod(
const google::protobuf::MethodDescriptor* method,
google::protobuf::RpcController* controller,
const google::protobuf::Message* request,
google::protobuf::Message* response,
google::protobuf::Closure* done);
这里传入一个MethodDescriptor,用于说明当前需要调用服务中的哪一个方法。
生成的服务类内部,可以近似理解为通过方法下标进行分发:
cpp
void UserService::CallMethod(
const google::protobuf::MethodDescriptor* method,
google::protobuf::RpcController* controller,
const google::protobuf::Message* request,
google::protobuf::Message* response,
google::protobuf::Closure* done)
{
switch (method->index())
{
case 0:
Login(
controller,
static_cast<const LoginRequest*>(request),
static_cast<LoginResponse*>(response),
done);
break;
case 1:
GetFriendList(
controller,
static_cast<const GetFriendListRequest*>(request),
static_cast<GetFriendListResponse*>(response),
done);
break;
}
}
所以:
text
MethodDescriptor
→ 告诉CallMethod需要调用哪一个方法
CallMethod
→ 根据方法信息统一进行分发
Login、GetFriendList
→ 真正的具体RPC方法
服务提供方实现具体业务逻辑
protoc生成的UserService类只是一套服务接口骨架,并不包含真正的业务逻辑。
因此,服务提供方还需要自己定义一个派生类,继承生成的服务类,并重写对应的RPC方法。
例如:
cpp
class UserServiceImpl : public test::UserService
{
public:
void Login(
google::protobuf::RpcController* controller,
const test::LoginRequest* request,
test::LoginResponse* response,
google::protobuf::Closure* done) override
{
const std::string username = request->username();
const std::string password = request->password();
if (username == "zhangsan" &&
password == "123456")
{
response->set_success(true);
response->mutable_result()->set_errcode(0);
response->mutable_result()->set_errmsg("");
}
else
{
response->set_success(false);
response->mutable_result()->set_errcode(1);
response->mutable_result()->set_errmsg(
"用户名或密码错误");
}
done->Run();
}
};
这里业务方法主要完成:
text
读取request请求参数
↓
执行具体业务逻辑
↓
将结果填写到response
↓
调用done->Run()
因此:
text
.proto中的service
→ 定义服务接口
protoc生成的服务类
→ 提供统一分发能力和虚函数接口
程序员定义的派生类
→ 重写RPC方法并实现真正业务
done完成回调
业务方法中的:
cpp
done->Run();
表示当前业务逻辑已经处理完成,并且response对象中的响应数据已经填写完毕。
done的类型为:
cpp
google::protobuf::Closure*
它本质上是一个回调对象。
需要注意,done本身通常不负责创建response对象。响应对象一般会在业务方法调用之前,由RPC框架提前创建。
框架还会提前创建一个done回调,并将响应发送函数绑定进去。
可以近似理解为:
cpp
google::protobuf::Message* response =
response_prototype.New();
google::protobuf::Closure* done =
google::protobuf::NewCallback(
this,
&RpcProvider::SendRpcResponse,
connection,
response);
之后,框架调用:
cpp
service->CallMethod(
method,
controller,
request,
response,
done);
业务方法执行完毕后:
cpp
done->Run();
便会触发框架绑定的响应发送函数,完成:
text
序列化response
↓
通过网络连接发送响应
所以done->Run()的含义是:
当前业务方法已经处理完成,响应对象也已经填写完毕,可以执行后续的响应发送操作了。
RpcProvider服务端调度组件
RpcProvider并不是Protobuf自动生成的类,而是RPC框架开发者自己实现的服务端组件。
它可以理解为:
将网络通信、Protobuf生成的服务类以及具体业务实现组织起来的服务端总调度器。
RpcProvider通常负责:
text
接收客户端RPC请求
↓
解析服务名和方法名
↓
查找已经注册的Service对象
↓
获得ServiceDescriptor和MethodDescriptor
↓
创建并反序列化request对象
↓
创建response对象
↓
创建done回调
↓
调用service->CallMethod()
↓
最终执行具体业务方法
↓
序列化并发送response
例如业务程序可能这样启动服务:
cpp
UserServiceImpl user_service;
RpcProvider provider;
provider.NotifyService(&user_service);
provider.Run();
这里:
text
UserServiceImpl
→ 实现真正业务逻辑
RpcProvider
→ 负责网络接收、方法查找和整体调度
RpcProvider通常只需要由RPC框架实现一套,之后不同业务服务都可以注册到同一个Provider中,而不需要每个业务服务都重新实现一套网络调度流程。
客户端Stub类
除了服务端使用的服务类之外,protoc还会生成客户端使用的Stub类,例如:
cpp
test::UserService_Stub
Stub可以理解为:
服务接口在客户端一侧的代理对象。
客户端可以这样使用:
cpp
MprpcChannel channel;
test::UserService_Stub stub(&channel);
test::LoginRequest request;
request.set_username("zhangsan");
request.set_password("123456");
test::LoginResponse response;
stub.Login(
nullptr,
&request,
&response,
nullptr);
从使用者角度来看,调用:
cpp
stub.Login(...)
非常像调用本地函数。
但Stub内部并不会真正执行登录业务,而是将本次调用转交给一个RpcChannel对象。
Stub内部逻辑可以近似理解为:
cpp
void UserService_Stub::Login(
google::protobuf::RpcController* controller,
const LoginRequest* request,
LoginResponse* response,
google::protobuf::Closure* done)
{
channel_->CallMethod(
descriptor()->method(0),
controller,
request,
response,
done);
}
所以Stub的主要作用是:
text
向客户端提供明确的Login()等接口
↓
获得对应的MethodDescriptor
↓
将调用统一转交给RpcChannel::CallMethod()
RpcChannel抽象通信通道
google::protobuf::RpcChannel是Protobuf运行库提供的一个抽象基类。
它定义了统一的远程调用接口:
cpp
class RpcChannel
{
public:
virtual void CallMethod(
const MethodDescriptor* method,
RpcController* controller,
const Message* request,
Message* response,
Closure* done) = 0;
};
不过,Protobuf只定义了这个抽象接口,并没有提供真正的网络通信实现。
因此,RPC框架还需要自己实现一个派生类,例如:
cpp
class MprpcChannel
: public google::protobuf::RpcChannel
{
public:
void CallMethod(
const google::protobuf::MethodDescriptor* method,
google::protobuf::RpcController* controller,
const google::protobuf::Message* request,
google::protobuf::Message* response,
google::protobuf::Closure* done) override
{
// 客户端远程调用逻辑
}
};
这个派生类中的CallMethod()通常需要完成:
text
从MethodDescriptor获取服务名和方法名
↓
序列化request请求对象
↓
组装RPC请求报文
↓
通过网络发送给服务端
↓
接收服务端返回的数据
↓
将响应字节流反序列化到response对象
例如,请求报文中可以包含:
text
请求总长度
服务名称
方法名称
请求参数长度
序列化后的请求参数
不过,这些字段如何排列、长度如何编码,都属于RPC框架自己设计的通信协议,并不是Protobuf固定规定的。
Protobuf主要负责:
text
request和response的序列化、反序列化
服务和方法的描述信息
服务类和Stub调用骨架
真正的网络协议和网络通信则由RPC框架负责。
Stub与RpcChannel之间的多态调用
客户端创建Stub时,会将自己实现的MprpcChannel对象交给Stub:
cpp
MprpcChannel channel;
test::UserService_Stub stub(&channel);
Stub内部通常保存的是一个基类指针:
cpp
google::protobuf::RpcChannel* channel_;
但是这个基类指针实际指向的是:
cpp
MprpcChannel
当Stub内部调用:
cpp
channel_->CallMethod(...);
时,由于CallMethod()是虚函数,因此最终会通过多态调用:
cpp
MprpcChannel::CallMethod(...)
完整关系可以表示为:
text
stub.Login()
↓
Stub调用channel_->CallMethod()
↓ 虚函数多态
MprpcChannel::CallMethod()
↓
组装并发送RPC请求
↓
接收远端响应
↓
反序列化到response
因此:
text
Stub
→ 负责让远程调用在使用方式上看起来像本地函数调用
RpcChannel
→ Protobuf定义的抽象通信接口
MprpcChannel
→ RPC框架实现的具体网络通信逻辑
一次完整RPC调用的整体流程
将客户端和服务端连接起来,一次完整的RPC调用过程可以表示为:
text
客户端创建request和response
↓
客户端调用stub.Login()
↓
Stub调用RpcChannel::CallMethod()
↓
通过虚函数多态进入MprpcChannel::CallMethod()
↓
序列化request并组装RPC请求报文
↓
通过TCP发送给服务端
↓
RpcProvider接收并解析RPC请求
↓
根据服务名找到Service对象
↓
根据方法名找到MethodDescriptor
↓
创建request和response对象
↓
创建done完成回调
↓
调用service->CallMethod()
↓
CallMethod根据方法下标进行分发
↓
通过虚函数多态进入UserServiceImpl::Login()
↓
执行具体登录业务
↓
将结果填写到response
↓
调用done->Run()
↓
序列化response并发送给客户端
↓
客户端MprpcChannel接收响应
↓
将响应反序列化到response对象
↓
stub.Login()调用结束
所以,从整体上看,Protobuf主要为RPC框架提供了三类关键能力:
text
message
→ 定义请求和响应的数据结构
→ 生成消息类
→ 提供序列化和反序列化能力
service
→ 描述服务中有哪些RPC方法
→ 生成服务类和统一方法分发接口
Stub
→ 生成客户端代理类
→ 将具体RPC方法调用转交给RpcChannel
而RPC框架本身还需要实现:
text
RpcProvider
→ 服务端网络接收和调用调度
MprpcChannel
→ 客户端请求封装和网络通信
RPC通信协议
→ 规定服务名、方法名和参数如何组织
服务注册与查找
→ 管理Service对象和MethodDescriptor
响应发送
→ 序列化response并返回客户端
因此,Protobuf本身并不是一个完整的RPC网络框架。
它主要解决的是:
text
通信数据如何描述
请求和响应如何序列化
服务和方法如何统一描述
客户端和服务端如何获得标准调用接口
RPC框架则在Protobuf生成代码的基础上,补充网络通信、服务注册、方法查找、请求调度和响应发送等功能,最终将客户端的一次本地形式调用,转换为服务端真正执行的远程业务调用。

结语
那么这就是本篇文章的全部内容,我会持续更新,希望你能够多多关注,如果本文有帮助到你的话,还请三连加关注,你的支持就是我创作的最大动力!
