【Protobuf系列】:从序列化与反序列化到消息类与Service:深入理解Protobuf以及RPC调用机制

🔥 本文专栏: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的缺点同样比较明显,那就是序列化之后的数据体积通常较大。

例如,整数10010000都可以使用一个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_tint64_t还是uint32_t

JSON解析器通常会根据数字的文本形式进行初步判断。例如:

json 复制代码
100

通常会被解析为整数形式,而:

json 复制代码
100.5

通常会被解析为浮点数形式。

但是最终使用int32_tint64_tfloat还是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,其中34对应已经废弃的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字节。

对于fixed32fixed64等类型,字段值采用固定长度编码。解析器看到对应的Wire Type后,就可以直接读取4字节或者8字节。

而对于stringbytes以及嵌套消息等类型,则通常采用Wire Type为2的Length-delimited编码。此时,字段值前面会先保存一个通过Varint编码的长度,解析器读取出长度后,再从后续字节流中提取指定数量的字节。

需要注意的是,Wire Type只负责描述字段值在字节流中的物理编码方式,并不能单独确定字段在业务层面的具体数据类型。

例如,stringbytes和嵌套消息都可能使用Wire Type为2的编码方式。接收方需要根据字段编号,在双方约定的.proto消息结构中找到对应字段,才能进一步确定这些字节应该被解释为字符串、字节数组还是嵌套消息。

例如:

protobuf 复制代码
message User
{
    int32 id = 1;
    string name = 2;
}

Protobuf在序列化时并不会传输字段名称:

text 复制代码
id
name

而是将字段编号12编码到各自字段的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的字段编号是2id的字段编号是1

在序列化后的字节流中,Protobuf真正编码的是字段编号,而不是字段名称以及字段的声明位置。因此,一旦某个字段编号已经投入使用,就不能随意修改,也不能再交给另一个含义完全不同的字段使用。

Protobuf的通用数据类型

.proto文件中定义字段时,可以使用Protobuf提供的通用数据类型,例如:

text 复制代码
int32
int64
uint32
uint64
float
double
bool
string
bytes

这里容易产生一个误区:由于stringbool等名称和C++中的类型比较相似,看起来好像是在直接使用C++的数据类型。

但实际上,这些类型都属于Protobuf自身定义的跨语言类型系统,并不是C++类型。

例如:

protobuf 复制代码
int32 id = 1;
string name = 2;

这里的int32string表示的是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中的stringbytes通常都会映射为std::string,但是二者表达的语义不同。

string用于表示文本字符串,例如:

protobuf 复制代码
string username = 1;

bytes用于表示任意二进制字节序列,例如:

protobuf 复制代码
bytes image_data = 2;

bytes可以保存图片、文件、压缩数据、加密数据或者其他二进制内容。

之所以在C++中仍然可以使用std::string保存bytes,是因为std::string本质上也能够保存任意字节,其中可以包含\0,并不只能存储普通的可打印字符。

因此,虽然stringbytes在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::vectorpush_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用于保存错误码和错误信息。

这里需要注意,successresult.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");

分别用于设置usernamepassword字段。

对应的读取接口则是:

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::ServiceRpcChannel的服务类与Stub代码。


消息类型生成的C++类

假设在.proto文件中定义了一个登录请求消息:

protobuf 复制代码
message LoginRequest
{
    bytes username = 1;
    bytes password = 2;
}

经过protoc编译后,会生成一个对应的C++类:

cpp 复制代码
test::LoginRequest

这个类中会保存usernamepassword两个字段,并提供对应的读写接口,例如:

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生成代码的基础上,补充网络通信、服务注册、方法查找、请求调度和响应发送等功能,最终将客户端的一次本地形式调用,转换为服务端真正执行的远程业务调用。

结语

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

相关推荐
Mortalbreeze2 小时前
深入 Linux Socket 编程:端口号、网络字节序与 struct sockaddr 详解
linux·服务器·网络·c++
2zcode2 小时前
项目文档:基于MATLAB神经网络的心力衰竭预测与临床辅助决策系统研究
开发语言·神经网络·matlab·心力衰竭预测\
减瓦3 小时前
深入 Quarkus:云原生时代 Java 的重生之路
java·开发语言·云原生
杨运交3 小时前
[053][核心模块]Java枚举缓存与ORM集成实践
java·开发语言·缓存
AI产品库3 小时前
从沙盒到生产库:OpenAI GPT-5.6 Sol 在评测中自主入侵 Hugging Face
网络·gpt
繁星蓝雨3 小时前
C++设计原理——异常处理
java·c++·异常处理·noexcept·throw·try catch
Kurisu_红莉栖3 小时前
关于相关问题的自我回答
c++
hansang_IR3 小时前
【题解】[AGC020E] Encoding Subsets
c++·算法·dp
2401_894915533 小时前
Geo优化系统源码部署搭建技术——PHP程序开发部署指南
开发语言·php
wuyk5554 小时前
67.嵌入式C语言进阶:结构体指针实战指南——STM32外设、传感器数据访问的高效技巧
c语言·开发语言·stm32·单片机