1.Protobuf概述

protobuf也叫protocol buffer是google 的一种数据交换的格式,它独立于语言,独立于平台。google 提供了多种语言的实现:java、c#、c++、go 和 python 等,每一种实现都包含了相应语言的编译器以及库文件。

由于它是一种二进制的格式,比使用 xml 、json进行数据交换快许多。可以把它用于分布式应用之间的数据通信或者异构环境下的数据交换。作为一种效率和兼容性都很优秀的二进制数据传输格式,可以用于诸如网络传输、配置文件、数据存储等诸多领域。

在网络通信中的定位:

在OSI七层协议模型中展现层(Presentation Layer)的主要功能是把应用层的对象转换成一段连续的二进制串,或者反过来,把二进制串转换成应用层的对象–这两个功能就是序列化和反序列化。一般而言,TCP/IP协议的应用层对应与OSI七层协议模型的应用层,展示层和会话层,所以序列化协议属于TCP/IP协议应用层的一部分。

1.1源码安装

github源代码下载地址:

https://github.com/protocolbuffers/protobuf/release
# 以 protobuf 3.21.12 为例
# 自行下载源码包, 解压缩
$ tar zxvf protobuf-cpp-3.21.12.tar.gz 
# 进入到解压目录
$ cd protobuf-3.21.12/
# 构建并安装
$ ./configure         # 检查安装环境, 生成 makefile
$ make                # 编译
$ sudo make install   # 安装

1.2测试

$ protoc --version
protoc: error while loading shared libraries: libprotoc.so.32: cannot open shared object file: No such file or directory

通过输出的信息可以看到找不到动态库,此时可以通过 find 进行搜索

$ sudo find /usr/local/ -name libprotoc.so     
/usr/local/lib/libprotoc.so

此时存储动态库的路径就找到了。

使用 find 命令进行搜索的时候,如果从 /usr/local/ 目录中搜索不到需要的文件信息,可以从根目录 / 开始搜索。

其实在通过make install 安装的时候通过终端输出的日志信息也可以找到动态库对应的存储路径:

...................................
...................................
libtool: install: (cd /usr/local/lib && { ln -s -f libprotoc.so.32.0.12 libprotoc.so.32 || { rm -f libprotoc.so.32 && ln -s libprotoc.so.32.0.12 libprotoc.so.32; }; })
libtool: install: (cd /usr/local/lib && { ln -s -f libprotoc.so.32.0.12 libprotoc.so || { rm -f libprotoc.so && ln -s libprotoc.so.32.0.12 libprotoc.so; }; })
...................................
...................................

以上是从安装日志中找到的对我们有用的信息,和搜索的结果一样,可以看到这个动态库位于/usr/local/lib目录,接下来需要需要将这目录添加到/etc/ld.so.conf这个配置文件中:

$ sudo vim /etc/ld.so.conf

打开文件后把目录添加到第二行,保存退出。

最后,需要更新配置,在终端执行如下命令:

$ sudo ldconfig

配置完成之后,再次进行测试:

$ protoc --version
libprotoc 3.21.12

现在就可以看到安装的protobuf的版本号了。

1.3如何使用

在学习protbuf之前,我们需要搞清楚使用protbuf进行数据的序列化主要有哪几个步骤:

  1. 确定数据格式,数据可简单可复杂,比如:
// 要序列化的数据
// 第一种: 单一数据类型
int number;

// 第二种: 复合数据类型
struct Person
{
    int id;
    string name;
    string sex;	
    int age;
};
  1. 创建一个新的文件, 文件名随意指定, 文件后缀为 .proto

  2. 根据protobuf的语法, 编辑.proto文件

  3. 使用 protoc 命令将 .proto 文件转化为相应的 C++ 文件

  • 源文件: xxx.pb.cc –> xxx对应的名字和 .proto文件名相同
  • 头文件: xxx.pb.h –> xxx对应的名字和 .proto文件名相同
  1. 需要将生成的c++文件添加到项目中, 通过文件中提供的类 API 实现数据的序列化/反序列化

protobuf中的数据类型 和 C++ 数据类型对照表:

Protobuf 类型C++ 类型备注
doubledouble64位浮点数
floatfloat32位浮点数
int32int32位整数
int64long64位整数
uint32unsigned int32位无符号整数
uint64unsigned long64位无符号整数
sint32signed int32位整数,处理负数效率比int32更高
sint64signed long64位整数,处理负数效率比int64更高
fixed32unsigned int(32位)总是4个字节。如果数值总是比 2 28 2^{28} 228 大的话,这个类型会比uint32高效。
fixed64unsigned long(64位)总是8个字节。如果数值总是比 2 56 2^{56} 256 大的话,这个类型会比uint64高效。
sfixed32int (32位)总是4个字节
sfixed64long (64位)总是8个字节
boolbool布尔类型
stringstring一个字符串必须是UTF-8编码或者7-bit ASCII编码的文本
bytesstring处理多字节的语言字符、如中文,建议protobuf中字符型类型使用 bytes
enumenum枚举
messageobject of class自定义的消息类型

2.Protobuf语法

2.1基本使用

假设我定义了这样一个结构体,现在要基于这个结构体完成数据的序列化,结构体原型如下:

struct Person
{
    int id;
    string name;
    string sex;	// man woman
    int age;
};

接下来,我们需要新建一个文件,给它起个名字,后缀指定为 .proto,在文件的第一行需要指定Protobuf的版本号,有两个版本Protobuf 2 和 Protobuf 3,此处我们使用的是版本3。

syntax="proto3";

接着需要定义一个消息体,其格式如下:

message 名字	
{
    // 类中的成员, 格式
    数据类型 成员名字 = 1;
    数据类型 成员名字 = 2;
    数据类型 成员名字 = 3;
	   ......     
	   ......
}
  • message后面的名字就是生成的类的名字,自己指定一个合适的名字即可
  • 等号后面的编号要从1开始,每个成员都有一个唯一的编号,不能重复,一般连续编号即可。

基于上面的语法,上面结构体对应的.proto文件的内容可以写成这样:

// Person.proto
syntax = "proto3";

// 在该文件中对要序列化的结构体进行描述
message Person
{
    int32 id = 1;
    bytes name = 2;
    bytes sex = 3;	
    int32 age = 4;
}

.proto文件编辑好之后就可以使用protoc工具将其转换为C++文件了。

$ protoc -I path .proto文件 --cpp_out=输出路径(存储生成的c++文件)

在 protoc 命令中,-I 参数后面可以跟随一个或多个路径,用于告诉编译器在哪些路径下查找导入的文件或依赖的文件,使用绝对路径或相对路径都是没问题的。

如果有多个路径,可以使用多个 -I 参数或在一个参数中使用冒号(:)分隔不同的路径。如果只有一个路径-I 参数可以省略。

例如,protoc -I path1 -I path2 或 protoc -I path1:path2 都表示告诉编译器在 path1 和 path2 路径下查找导入的文件或依赖的文件。

我们可以在.proto文件所在目录执行protoc命令,并生成到当前目录:

$ protoc ./Person.proto --cpp_out=.
# 或者使用 -I 参数
$ protoc -I ./ Person.proto --cpp_out=.

2.2repeated限定修饰符

在使用Protocol Buffers(Protobuf)中,可以使用repeated关键字作为限定修饰符来表示一个字段可以有多个值,即重复出现的字段。repeated关键字可以用于以下数据类型:基本数据类型、枚举类型和自定义消息类型。

比如要序列化的数据中有一个数组,结构如下:

// 要序列化的数据
struct Person
{
int id;
string name[10];
string sex;	
int age;
};

Protobuf 的消息体就可以写成下面的样子:

message Person
{
  int32 id = 1;
  repeated bytes name = 2;
  bytes sex = 3;	
  int32 age = 4;
}

使用repeated关键字定义的字段在Protobuf序列化和反序列化时会被当作一个集合或数组来处理。这个name可以作为一个动态数组来使用。

2.3枚举

在 Protocol Buffers 中,枚举类型用于定义一组特定的取值。下面定义一个枚举,并将其添加到Person中:

// 要序列化的数据
// 枚举
enum Color
{
    Red = 5,	// 可以不给初始值, 默认为0
    Green,
    Yellow,
    Blue
};

// 要序列化的数据
struct Person
{
    int id;
    string name[10];
    string sex;	
    int age;
    // 枚举类型
    Color color;
};

以下是如何在 Protobuf 中定义和使用枚举类型,其语法如下:

enum 名字
{
    元素名 = 0;	// 枚举中第一个原素的值必须为0
    元素名 = 数值;
}

枚举元素之间使用分号间隔 ;,并且需要注意一点proto3 中的第一个枚举值必须为 0,第一个元素以外的元素值可以随意指定。

上面例子中的数据在.proto文件中可以写成如下格式:

// 定义枚举类型
enum Color
{
    Red = 0;
    Green = 3;		// 第一个元素以外的元素值可以随意指定
    Yellow = 6;
    Blue = 9;
}
// 在该文件中对要序列化的结构体进行描述
message Person
{
    int32 id = 1;
    repeated bytes name = 2;
    bytes sex = 3;	
    int32 age = 4;
    // 枚举类型
    Color color = 5;
}

2.4 proto文件的导入

在 Protocol Buffers 中,可以使用import语句在当前.ptoto中导入其它的.proto文件。这样就可以在一个.proto文件中引用并使用其它文件中定义的消息类型和枚举类型。

语法格式如下:

import "要使用的proto文件的名字";

假设现在我有一个proto文件**Address.proto**,里边记录了地址信息:

syntax = "proto3";
// 地址信息
message Address
{
  bytes addr = 1;
  bytes number = 2;
}

我现需要再上面定义的Person.proto中添加这个人的地址信息,因此就需要用到Address.proto中定义的消息体:

syntax = "proto3";
// 使用另外一个proto文件中的数类型, 需要导入这个文件
import "Address.proto";

// 在该文件中对要序列化的结构体进行描述
// 定义枚举类型
enum Color
{
    Red = 0;
    Green = 3;		// 第一个元素以外的元素值可以随意指定
    Yellow = 6;
    Blue = 9;
}
// 在该文件中对要序列化的结构体进行描述
message Person
{
    int32 id = 1;
    repeated bytes name = 2;
    bytes sex = 3;	
    int32 age = 4;
    // 枚举类型
    Color color = 5;
    // 添加地址信息, 使用的是外部proto文件中定义的数据类型
    Address addr = 6;
}
  • import语句中指定的文件路径可以是相对路径或绝对路径。如果文件在相同的目录中,只需指定文件名即可。
  • 导入的文件将会在编译时与当前文件一起被编译。
  • 导入的文件也可以继续导入其他文件,形成一个文件依赖的层次结构。

2.5 包(package)

在 Protobuf 中,可以使用package关键字来定义一个消息所属的包(package)。包是用于组织和命名消息类型的一种机制,类似于命名空间的概念。

在一个.proto文件中,可以通过在顶层使用package关键字来定义包:

syntax = "proto3";
package mypackage;
message MyMessage 
{
  // ...
}

在这个示例中,我们使用package关键字将MyMessage消息类型定义在名为mypackage的包中。包名作为一个标识符来命名,可以使用任何有效的标识符,按惯例使用小写字母和下划线。

使用包可以避免不同.proto文件中的消息类型名称冲突,同时也可以更好地组织和管理大型项目中的消息定义。可以将消息类型的名称定义在特定的包中,并使用限定名来引用这些类型。

下面有两个proto文件,分别给他们添加一个package:

syntax = "proto3";
// 添加命名空间 Dabing
package Dabing;

// 地址信息, 这个Address类属于命名空间: Dabing
message Address
{
    bytes addr = 1;
    bytes number = 2;
}
syntax = "proto3";
// 使用另外一个proto文件中的数类型, 需要导入这个文件
import "Address.proto";
// 指定命名空间 ErBing
package ErBing;

// 以下的类 Person 和枚举 Color 都属于命名空间 ErBing
// 在该文件中对要序列化的结构体进行描述
// 定义枚举类型
enum Color
{
    Red = 0;
    Green = 3;		// 第一个元素以外的元素值可以随意指定
    Yellow = 6;
    Blue = 9;
}
// 在该文件中对要序列化的结构体进行描述
message Person
{
    int32 id = 1;
    repeated bytes name = 2;
    bytes sex = 3;	
    int32 age = 4;
    // 枚举类型
    Color color = 5;
    // 添加地址信息, 使用的是外部proto文件中定义的数据类型
    // 如果这个外边类型属于某个命名空间, 语法格式:
    // 命名空间的名字.类名 变量名=编号;
    Dabing.Address addr = 6;
}

3. 序列化和反序列化

3.1 **.pb.h 头文件

通过protoc 命令对.proto文件的转换,得到的头文件中有一个类,这个类的名字和 .proto文件中message关键字后边指定的名字相同,.proto文件中message消息体的成员就是生成的类的私有成员。

那么如何访问生成的类的私有成员呢? 可以调用生成的类提供的公共成员函数,这些函数有如下规律:

  • 清空(初始化) 私有成员的值: clear_变量名()
  • 获取类私有成员的值: 变量名()
  • 给私有成员进行值的设置: set_变量名(参数)
  • 得到类私有成员的地址, 通过这块地址读/写当前私有成员变量的值: mutable_变量名()
  • 如果这个变量是数组类型:
    • 数组中元素的个数: 变量名_size()
    • 添加一块内存, 存储新的元素数据: add_变量名() 、add_变量名(参数)

3.2 序列化

序列化是指将数据结构或对象转换为可以在储存或传输中使用的二进制格式的过程。在计算机科学中,序列化通常用于将内存中的对象持久化存储到磁盘上,或者在分布式系统中进行数据传输和通信。

Protobuf 中为我们提供了相关的用于数据序列化的 API,如下所示:

// 头文件目录: google\protobuf\message_lite.h
// --- 将序列化的数据 数据保存到内存中
// 将类对象中的数据序列化为字符串, c++ 风格的字符串, 参数是一个传出参数
bool SerializeToString(std::string* output) const;
// 将类对象中的数据序列化为字符串, c 风格的字符串, 参数 data 是一个传出参数
bool SerializeToArray(void* data, int size) const;

// ------ 写磁盘文件, 只需要调用这个函数, 数据自动被写入到磁盘文件中
// -- 需要提供流对象/文件描述符关联一个磁盘文件
// 将数据序列化写入到磁盘文件中, c++ 风格
// ostream 子类 ofstream -> 写文件
bool SerializeToOstream(std::ostream* output) const;
// 将数据序列化写入到磁盘文件中, c 风格
bool SerializeToFileDescriptor(int file_descriptor) const;

3.2 反序列化

反序列化是指将序列化后的二进制数据重新转换为原始的数据结构或对象的过程。通过反序列化,我们可以将之前序列化的数据重新还原为其原始的形式,以便进行数据的读取、操作和处理。

Protobuf 中为我们提供了相关的用于数据序列化的 API,如下所示:

bool ParseFromString(const std::string& data) ;
bool ParseFromArray(const void* data, int size);
// istream -> 子类 ifstream -> 读操作
// wo ri
// w->写 o: ofstream , r->读 i: ifstream
bool ParseFromIstream(std::istream* input);
bool ParseFromFileDescriptor(int file_descriptor);

3.3 序列化协议特性*

通用性

通用性有两个层面的意义:

  • 第一、技术层面,序列化协议是否支持跨平台、跨语言。如果不支持,在技术层面上的通用性就大大降低了。
  • 第二、流行程度,序列化和反序列化需要多方参与,很少人使用的协议往往意味着昂贵的学习成本;另一方面,流行度低的协议,往往缺乏稳定而成熟的跨语言、跨平台的公共包。

强健性/鲁棒性

以下两个方面的原因会导致协议不够强健:

  • 第一、成熟度不够,一个协议从制定到实施,到最后成熟往往是一个漫长的阶段。协议的强健性依赖于大量而全面的测试,对于致力于提供高质量服务的系统,采用处于测试阶段的序列化协议会带来很高的风险。
  • 第二、语言/平台的不公平性。为了支持跨语言、跨平台的功能,序列化协议的制定者需要做大量的工作;但是,当所支持的语言或者平台之间存在难以调和的特性的时候,协议制定者需要做一个艰难的决定–支持更多人使用的语言/平台,亦或支持更多的语言/平台而放弃某个特性。当协议的制定者决定为某种语言或平台提供更多支持的时候,对于使用者而言,协议的强健性就被牺牲了。

可调试性/可读性

序列化和反序列化的数据正确性和业务正确性的调试往往需要很长的时间,良好的调试机制会大大提高开发效率。序列化后的二进制串往往不具备人眼可读性,为了验证序列化结果的正确性,写入方不得同时撰写反序列化程序,或提供一个查询平台–这比较费时;另一方面,如果读取方未能成功实现反序列化,这将给问题查找带来了很大的挑战–难以定位是由于自身的反序列化程序的bug所导致还是由于写入方序列化后的错误数据所导致。对于跨公司间的调试,由于以下原因,问题会显得更严重:

  • 第一、支持不到位,跨公司调试在问题出现后可能得不到及时的支持,这大大延长了调试周期。
  • 第二、访问限制,调试阶段的查询平台未必对外公开,这增加了读取方的验证难度。

如果序列化后的数据人眼可读,这将大大提高调试效率, XML和JSON就具有人眼可读的优点。

性能

性能包括两个方面,时间复杂度和空间复杂度:

  • 第一、空间开销(Verbosity), 序列化需要在原有的数据上加上描述字段,以为反序列化解析之用。如果序列化过程引入的额外开销过高,可能会导致过大的网络,磁盘等各方面的压力。对于海量分布式存储系统,数据量往往以TB为单位,巨大的的额外空间开销意味着高昂的成本。
  • 第二、时间开销(Complexity),复杂的序列化协议会导致较长的解析时间,这可能会使得序列化和反序列化阶段成为整个系统的瓶颈。

可扩展性/兼容性

移动互联时代,业务系统需求的更新周期变得更快,新的需求不断涌现,而老的系统还是需要继续维护。如果序列化协议具有良好的可扩展性,支持自动增加新的业务字段,而不影响老的服务,这将大大提供系统的灵活度。

安全性/访问限制

在序列化选型的过程中,安全性的考虑往往发生在跨局域网访问的场景。当通讯发生在公司之间或者跨机房的时候,出于安全的考虑,对于跨局域网的访问往往被限制为基于HTTP/HTTPS的80和443端口。如果使用的序列化协议没有兼容而成熟的HTTP传输层框架支持,可能会导致以下三种结果之一:

  • 第一、因为访问限制而降低服务可用性。
  • 第二、被迫重新实现安全协议而导致实施成本大大提高。
  • 第三、开放更多的防火墙端口和协议访问,而牺牲安全性。

3.4 序列化和反序列化的组件-底层原理*

  • IDL (Interface Description Language) 文件:
    • 要实现分布式系统的通讯,需要参与通讯的双方对通讯的内容做一个约定。
    • 这个约定采用与具体开发语言、平台无关的语言来进行描述,即 IDL 接口描述语言(对应 .proto 文件)。
    • 它定义了要传输的消息数据结构和相关接口。
  • IDL Compiler:
    • IDL 文件中约定的内容为了在各语言和平台可见,需要一个编译器。
    • 将 IDL 文件转换为各语言对应的动态库,从而生成客户端和服务端可以使用的代码。
    • 生成的内容包括 Stub(客户端存根) 和 Skeleton(服务端骨架)。(对应 protoc 编译工具,将 .proto 文件编译为可执行文件 .pb.h 和 .pb.cc)
  • Stub/Skeleton on Lib:
    • Stub:是在分布式系统客户端的代码,对应用层暴露定义好的序列化接口。
    • 客户端会调用本地 Stub 方法接口,一方面接收应用层的参数,并将其序列化后通过底层协议栈发送到服务端。
    • 另一方面接收服务端序列化后的结果数据,反序列化后交给客户端应用层。
    • Skeleton:部署在服务端,其功能与 Stub 相反。
    • 从传输层接收序列化参数,反序列化后交给服务端应用层,调用 Server 的实际方法。
    • 将应用层的执行结果序列化后,最终传送给客户端 Stub。
    • 内部封装良好,调用者只需调用接口。
  • Client/Server:
    • 指的是应用程序代码,它们面对的是 IDL 所生成的特定语言的 class 或 struct。
    • Client:调用远程服务的一方。
    • Server:提供服务的实现方。
  • 底层协议栈和互联网:
    • 序列化之后的数据通过底层的传输层、网络层、链路层以及物理层协议,转换成数字信号在互联网中传递。

在这里插入图片描述

4. 示例程序

4.1 .proto 文件

syntax = "proto3";
package Dabing;
message Address
{
  int32 num = 1;
  bytes addr = 2;
}
syntax = "proto3";
import "Address.proto";
package Erbing;
enum Color
{
    Red = 0;
    Green = 5;
    Yellow = 6;
    Blue = 9;
}

message Person
{
    int32 id = 1;    
    repeated bytes name = 2;
    bytes sex = 3;
    int32 age = 4;
    Dabing.Address addr = 5;
    Color color = 6;
}

4.2 测试文件

#ifndef SERVER_DDZ_MYTEST_H
#define SERVER_DDZ_MYTEST_H
class MyTest
{
public:
void test();
};
#endif //SERVER_DDZ_MYTEST_H
#include "MyTest.h"
#include "Person.pb.h"

//using namespace Dabing;
//using namespace Erbing;

void MyTest::test()
{
    // 序列化
    Erbing::Person p;
    p.set_id(10);
    p.set_age(32);
    p.set_sex("man");

    p.add_name();
    p.set_name(0,"路飞");
    p.add_name("艾斯");
    p.add_name("萨博");
    p.mutable_addr()->set_addr("北京市长安区天安门");
    p.mutable_addr()->set_num(1001);
    p.set_color(Erbing::Color::Blue);

    // 序列化对象 p, 最终得到一个字符串
    std::string output;
    p.SerializeToString(&output);

    // 反序列化数据
    Erbing::Person pp;
    pp.ParseFromString(output);
    std::cout << pp.id()  << ", " << pp.sex() << ", " << pp.age() << std::endl;
    std::cout << pp.addr().addr() << ", " << pp.addr().num() << std::endl;
    int size = pp.name_size();
    for(int i=0; i<size; ++i)
    {
        std::cout << pp.name(i) << std::endl;
    }
    std::cout << pp.color() << std::endl;
}
#include "MyTest.h"
int main(int argc, char* argv[])
{
    MyTest t;
    t.test();
    return 0;
}
cmake_minimum_required(VERSION 3.10)
project(ProtoBufTest)

# 寻找 Protobuf
find_package(Protobuf REQUIRED)

# 自动处理 .proto 文件并生成源代码
# 假设你的文件叫 Person.proto 和 Address.proto
protobuf_generate_cpp(PROTO_SRCS PROTO_HDRS Person.proto Address.proto)

add_executable(test main.cpp Mytest.cpp ${PROTO_SRCS} ${PROTO_HDRS})

# 确保编译器能找到生成的 .pb.h 文件
include_directories(${CMAKE_CURRENT_BINARY_DIR})
# 必须链接库
target_link_libraries(test ${Protobuf_LIBRARIES})

注意,当这样加入 protobuf 的头文件时 #include "Person.pb.h",必须满足几点要求:

  1. cmake 配置文件加入了 pb.h 和 ph.cc 的路径(默认 build 目标)
  2. cmake 经过 build 编译后确定生成了 ph.h 和 ph.cc 文件

4.3 作为 gRPC 的默认序列化协议

5. 在 CMake 中使用

# 1. 查找 Protobuf 
find_package(Protobuf CONFIG REQUIRED)

# 2. 告诉 CMake 你的 proto 文件在哪(假设在项目根目录,叫 addressbook.proto)
set(PROTO_FILE addressbook.proto)

# 3. 关键:调用指令生成 C++ 源码和头文件变量
# 这条指令会把生成的 .pb.h 和 .pb.cc 路径分别存入 PROTO_SRCS 和 PROTO_HDRS 变量中
protobuf_generate_cpp(PROTO_SRCS PROTO_HDRS ${PROTO_FILE})

# 4. 将生成的源文件一起加入到你的可执行文件中
add_executable(main main.cpp ${PROTO_SRCS} ${PROTO_HDRS})

# 5. 链接库
target_link_libraries(main PRIVATE protobuf::libprotobuf)
find_package(Protobuf CONFIG REQUIRED)

add_executable(main main.cpp target.proto) # 直接把 proto 文件当成源码喂给 target

# 让 CMake 自动处理该 target 中所有的 proto 文件
protobuf_generate(TARGET main LANGUAGE cpp) 

target_link_libraries(main PRIVATE protobuf::libprotobuf)
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐