protobuf快速上手
什么是protobuf
- 在了解
Protobuf之前,我们先来搞清楚什么是序列化和反序列化。 - 不管是面向对象还是面向过程,程序里的数据最终都是存储在变量中的,也就是在内存里。
如果我们想把这些数据发送到另一台主机上,直接传输原始的内存块会带来很多问题 :- 不同主机的硬件可能不一样 ,比如字节序(大小端)不同、内存对齐方式也不同;
- 更重要的是,你的内存块只在当前机器的内存上下文中有意义,换到另一台机器上就完全没用了;
- 此外,网络传输本身用的是字节流,你**发出的数据必须让对方能够"看懂"**才行。
- 那么序列化和反序列化 的作用就出来了: 序列化就是把本机的数据转换为字节序列的过程 , 反序列化就是把字节序列转换为实际数据的的过程
Protocol Buffers(简称Protobuf)是Google开发的一种语言中立、平台中立、可扩展 的序列化 结构数据机制。你可以把它想象成更小、更快、更简单 的JSON或XML,但它是二进制格式。- 核心用途
- 数据序列化 :将结构化数据转换为紧凑的二进制格式,便于存储或网络传输。
- 服务接口定义 :常与
gRPC搭配,定义RPC服务的请求和响应类型。
快速配置环境
- 参考README文档进行配置
- 本篇文章并不涉及到具体原理, 只介绍如何上手和
proto文件的各种数据类型
快速开始
- 首先创建一个
contacts.proto的文件, 向其中写入以下消息, 语法我们稍后介绍
proto
syntax = "proto3";
// 可选的
package con;
message PeopleInfo {
string name = 1;
int32 id = 2;
}
- 接着使用
protoc --cpp_output=. contacts.proto生成对应的文件, 目前支持的语言有cpp,c#,java,Rust,python等, 命令执行完成后, 当前目录下会多出来两个文件contacts.pb.h,contacts.pb.cc,contacts.pb.h内部会为PeopleInfo这个message生成一个类和各种操作字段的方法,cc文件中存储的则是实现 - 下面我们来写一个简单的程序
cpp
#include "contacts.pb.h"
#include <iostream>
#include <string>
int main()
{
int id = 10;
std::string name = "blog";
// 先把数据设置好
con::PeopleInfo pi;
pi.set_id(id);
pi.set_name(name);
// 序列化
std::string str = pi.SerializeAsString();
if (str.empty())
{
std::cerr << "序列化失败\n";
return -1;
}
else
{
std::cout << "序列化成功, 结果是: " << str << "\n";
}
con::PeopleInfo pi1;
// 反序列化
if (!pi1.ParseFromString(str))
{
std::cerr << "反序列化失败\n";
return -1;
}
else
{
std::cout << "id: " << pi1.id() << "\nname: " << name << "\n";
}
return 0;
}
- 使用
g++ main.cc contacts.pb.cc -o main $(pkg-config --libs protobuf) -std=c++17 -pthread编译, 运行结果如下图, 可以看到我们直接对序列化后的结果进行输出, 并不是完整的, 原因前面也说了,protobuf序列化采用的是二进制格式 , 读取到什么都不意外

语法介绍
- 我并不会具体介绍
protobuf生成的文件, 我只讲解一下常用的接口, 接下来我们学习一下proto文件 - 我们可以把
proto文件看作是一种协议, 我们按照规定的语法写,protoc按照规定进行代码的生成.proto文件使用双斜杠进行注释 , 首行syntax="proto3"指定语法版本 , 还有其他的版本, 这里我们以proto3讲解,package可以看做是cpp内的命名空间 , 用来防止名字冲突 ,message用来定义一个消息, 命名风格推荐使用大驼峰 , 每个消息里的字段都有其明确的类型 , 其后的序号用来唯一标识该字段 , 这个数字[19000, 19999]是保留的, 我们不可以使用, 使用了是会编译报错的, 当然你也可以使用reserved保留一些数字, , 这个数字最大是229 - 1, 实际建议不要超过228- 1 - 数据类型还分为标量类型 和自定义类型 , 自定义类型就是我们自己写的
message之类的, 还有一些是google官方提供的, 比如枚举类型 ,Any类型等. 下图包含了标量的一些信息 protobuf还支持还有数组类型 , 使用repeated修饰, 不需要像我们使用的编程语言, 需要使用[]声明

enum规则
- 定义语法
proto
enum PhoneType {
MP = 0;
TEL = 1;
}
- 使用注意: 即使在同一个文件下, 不同的
enum类型中也是不允许出现相同的名称的枚举值 的, 同一文件里的两个不同的枚举类型中不能出现相同的枚举字段 , 在不同文件中的枚举类型如果要出现同名的枚举字段, 需要保证至少有n-1个文件声明了命名空间 . 当然, 并不建议使用相同的枚举字段, 枚举类型也是支持嵌套定义在message内的 - 枚举类型的首个字段设置为
0的原因是因为可能反序列化时并没有设置这个字段, 那么就需要使用序号为0的字段作为默认填充, 保证程序的健壮性 - 枚举常量值在
32位整数范围内, 不建议使用负值
proto
// cong.proto
syntax = "proto3";
package cong;
enum PhoneType {
MP = 0;
}
// contacts.proto
syntax = "proto3";
package con;
import "cong.proto";
enum ph {
MP = 0;
}
message PeopleInfo {
string name = 1;
int32 id = 2;
cong.PhoneType pt = 3;
}
- 这是生成的枚举类型的定义, 这里不知道为什么没有命名空间+枚举类型名的前缀, 读者可以写一个程序然后验证一下

Any类型
cpp中可以使用std::any来表示任意类型, 同样,protobuf也是支持的, 这个类型是声明好的, 我们直接导入google/protobuf/any.proto即可.可以看到这个目录下有很多google提供的类型- 要使用这个类型可以使用
import "google/protobuf/any.proto";导入, 使用google.protobuf.Any可以定义一个字段 - 使用
PackFrom()可以把任意消息类型转换为Any类型 , 使用UnPackTo()可以把**Any类型转换为之前的类型**,Is()方法可以用来判断存放的消息类型是否为typename T

oneof类型
- 可以用来申明一个集合, 这个集合里只有一个字段可以使用, 类似联合体, 同一时刻只能有一个活跃成员
- 定义方式,
oneof内的成员的字段编号接着最近的那个字段继续向下编写就可以, 和枚举类型不同
proto
message exam {
int32 id = 1;
oneof other {
string name = 2;
string nick = 3;
}
}
map类型
- 类似常见的编程语言里的
map,key-val结构,key除了float和bytes都可以,val可以是任意字段这个字段不可以使用repeated修饰, 无序存储
proto
map<string, string> m = 1;
默认值
- 有时一个
message里的字段我们可能不需要设置或者忘记设置, 序列化可能并不会失败, 但可能其值是随机的, 非常危险, 为了保证程序的健壮性, 会在反序列时对未设置的字段给予默认值 , 下图是不同类型对应的默认值, 枚举类型我们前面讲了, 其默认值是编号为0的字段

proto文件更新规则
- 有时随着代码和软件规模的增加, 原有的字段的数据类型可能无法满足具体的需求, 这时就需要对
proto文件进行更新, 因为涉及到数据的变化, 我们不能随便更改一个字段的数据类型, 如果需要更新需要遵循以下规则 - 不能修改任何已有字段的字段编号
- 如果移除旧的字段, 需要保证不在使用移除字段的字段编号, 可以使用
reserved进行保留编号 int32,uint32,int64,uint64和bool是完全兼容的, 可以直接改变, 如果出现数据溢出的问题, 会截断sint32和sint64相互兼容但是不兼容其他类型string和bytes在合法utf-8字节情况下也是兼容的fixed32和sfixed32兼容,fixed64和sfixed64兼容enum和int32,uint32,int64,uint64兼容, 也存在数据截断问题
未知字段
- 如果我们更改了某些字段后, 但是只有我们自己知道, 对方并不知道, 当对方收到数据进行反序列化时, 就会出现数据错误的问题, 对方可能还按照原来的格式进行反序列化数据, 那么自然得到的数据就是错误的,
protobuf也提供了解决方案, 就是未知字段 - 生成的文件中的
PeopleInfo是继承了Message这个类

PeopleInfo内有一个GetReflection()函数,他会返回一个Reflection对象, 内部可以使用UnknownFieldSet获取未知字段的集合 , 通过其内部定义的一些工具函数进行未知字段的获取 ,Descriptor类用来对message类型定义的描述, 包括**message的名字, 所有字段的描述, 以及原始的proto文件内容等**


protobuf的兼容性
protobuf支持向前和向后兼容, 也就是当proto文件改变后, 他也能够正常反序列化 , 不存在的字段就当做是未知字段- 这样的好处是便于维护 , 一个庞大的项目, 我们不可能完全知道有哪些地方依赖了我们定义的
message, 我们也不可能同时升级所有的模块, 为了保证系统的功能不受影响, 就需要通信协议向前或向后兼容
protobuf缺点
- 说了这么多, 一个东西不可能只有优点没有缺点, 下面介绍一下它的缺点
- 序列化的数据是二进制的, 不方便人类阅读, 必须借助特定的工具查看
- 配置麻烦, 无法开箱即用, 还需要编写
proto文件和message, 如果要调用接口, 就得先查询由protoc生成的代码文件
总结
- 除了使用
protobuf外, 还可以使用json进行序列化, 我们来对比二者 JSON和Protobuf的对比,可以清晰地看到这种权衡:- 调试性 :
JSON可以直接阅读和维护,而Protobuf必须依赖.proto文件和工具。 - 数据结构 :
JSON灵活,但ProtoBuf可避免数据错配,然而强制Schema也带来了额外的编排工作。 - 传输效率 :
JSON有重复的字段名,内容大;ProtoBuf则通过省略字段名,做到了极致精简,性能出色。
- 调试性 :
- 面对两者,选择的关键在于你所面对的场景:
- 选择
Protocol Buffers:当数据大小敏感,性能是首要目标,且团队能接受严格的规范管理。 - 选择
JSON:当开发速度和调试便利性更重要 ,对外公开API,且不希望通过强制契约来增加复杂性。
- 选择
这篇文章就到这里了, 觉得写的还不错的话欢迎点赞关注, 有问题的地方还请批评指正.