理解
protobuf允许不同编程语言的程序员 以自己熟悉的方式在.proto文件里定义消息结构- 然后
protobuf的引擎把这个.proto文件里描述的消息结构进行解析,最后生成对应语言的代码,这些代码里描述了之前定义的消息结构 - 然后在项目中,需要用到这些消息结构的模块,只需引入这些代码,就可以使用生成的代码中提供的一些接口来序列化或反序列化
解决的问题
序列化方法
- 原始内存数据结构以二进制形式发送或保存。
- 缺陷是必须使用完全相同的内存布局、字节序等来编译接收/读取代码
- 发明一种特殊方式将数据项编码为单个字符串。
- 适合编码非常简单的数据
- 将数据序列化为
XML- 问题是
XML是空间密集型的,编码/解码它会给应用程序带来巨大的性能损失 - 导航
XML DOM树比导航类中的简单字段通常要复杂得多
- 问题是
protobuf
- 解决上面的问题。
- 编写
.proto要存储的数据结构的描述 - 协议缓冲区编译器创建了一个类:
- 该类以高效的二进制格式实现协议缓冲区数据的自动编码和解析
- 生成的类为构成协议缓冲区的字段提供了
getter和setter - 并将读写协议缓冲区的细节作为一个单元处理
- 重要的是,协议缓冲区格式支持随着时间的推移扩展格式的想法,这样代码仍然可以读取使用旧格式编码的数据
共同基础语法
本质上做两件事
- 用
.proto文件描述数据结构,也就是“协议格式” - 用
protoc生成C++、Java、Python等语言的序列化/反序列化代码
syntax
syntax必须是文件中第一条非空、非注释语句- 如果不写,
protoc会按proto2解释- 因此最好永远显式写出来
字段的基本结构
- 字段通常由以下部分组成
string:字段类型name:字段名称1:字段编号,也叫field number、tag numberoptional:字段存在性规则
|
1 |
字段规则 字段类型 字段名 = 字段编号; |
|
1 2 3 |
// proto2 optional string name = 1; |
|
1 2 3 |
// proto3 string name = 1; |
- 字段编号不是数组下标,而是实际参与二进制编码的协议标识
字段编号规则
- 合法范围
|
1 |
1 ~ 536870911 |
- 是
Protobuf内部保留范围,不能使用
|
1 |
19000 ~ 19999 |
- 建议:
- 常用字段使用
1~15
因为字段编号1~15的 tag 通常只占一个字节,而16~2047通常占两个字节 - 次常用字段使用
16~2047 - 字段一旦发布,编号不要改变
- 删除字段后,不要把编号分配给新字段
- 常用字段使用
常用字段类型
Protobuf 类型 |
常见 C++ 类型 |
用途 |
double |
double |
双精度浮点数 |
float |
float |
单精度浮点数 |
int32 |
int32_t |
有符号整数 |
int64 |
int64_t |
有符号整数 |
uint32 |
uint32_t |
无符号整数 |
uint64 |
uint64_t |
无符号整数 |
sint32 |
int32_t |
适合经常出现负数的整数 |
sint64 |
int64_t |
适合经常出现负数的整数 |
fixed32 |
uint32_t |
固定4字节 |
fixed64 |
uint64_t |
固定8字节 |
sfixed32 |
int32_t |
有符号固定4字节 |
sfixed64 |
int64_t |
有符号固定8字节 |
bool |
bool |
布尔值 |
string |
std::string |
UTF-8字符串 |
bytes |
std::string |
任意二进制数据 |
- 需要特别注意
int32使用varint编码,负数通常需要10字节sint32使用ZigZag编码,如果正负小数值都很常见,一般更节省空间
|
1 2 |
int32 temperature = 1; sint32 offset = 2; |
- 图片、压缩数据、加密数据等任意二进制内容应该使用
- 不要用
string
- 不要用
|
1 |
bytes data = 1; |
示例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
message Person { optional string name = 1; optional int32 id = 2; optional string email = 3; enum PhoneType { MOBILE = 0; HOME = 1; WORK = 2; } message PhoneNumber { optional string number = 1; optional PhoneType type = 2 [default = HOME]; } repeated PhoneNumber phones = 4; } message AddressBook { repeated Person people = 1; } |
语法
- 还可以定义
enum类型,让某些字段具有预定义的值(如上述PhoneType) - 每个元素后面的
= 1,= 2标记,标识该字段在二进制编码中使用的唯一“标签”。- 标签编号
1-15比更高的编号需要少一个字节来编码 - 因此作为一种优化,您可以决定将这些标签用于常用或重复的元素,而将标签
16和更高的标签用于不太常用的可选元素
- 标签编号
optional- 该字段可以设置也可以不设置
- 如果未设置可选字段值,则使用默认值
对于简单类型,您可以指定自己的默认值,就像上面的type那样
否则,使用系统默认值:数字类型为零,字符串为空字符串,布尔值为false
对于message,默认值始终是消息的“默认实例”或“原型”,没有设置任何字段
调用访问器以获取未显式设置的可选(或必需)字段的值始终返回该字段的默认值
repeated- 该字段可以重复任意次数(包括零次)
- 可以将重复字段视为动态大小的数组
required- 必须提供该字段的值,否则该消息将被视为“未初始化”
- 如果
libprotobuf在调试模式下编译,序列化未初始化的message将导致断言失败
在优化的构建中,会跳过检查并且无论如何都会写入消息
但是,解析未初始化的消息总是会失败(从Parse)
proto2语法
一个 proto2 文件
|
1 2 3 4 5 6 7 8 |
syntax = "proto2"; package easychat.v1; message LoginRequest { required string username = 1; required string password = 2; } |
required
- 含义是字段必须被设置
- 如果缺少
required字段,该消息被视为未初始化,序列化通常会失败
- 如果缺少
- 但现在一般不推荐使用
required,因为协议发布以后,很难再安全取消这个要求- 例如旧客户端永远不知道新增加的
required字段 - 官方最佳实践也明确建议不要新增
required字段
- 例如旧客户端永远不知道新增加的
|
1 2 3 4 |
message LoginRequest { required string username = 1; required string password = 2; } |
optional
- 表示字段可以存在,也可以不存在
|
1 2 3 4 |
message User { optional uint64 id = 1; optional string nickname = 2; } |
- 生成的
C++类通常提供
|
1 2 3 4 |
user.has_nickname(); user.nickname(); user.set_nickname("David"); user.clear_nickname(); |
|
1 2 3 4 5 6 7 8 9 |
// “没有设置”和“设置为空字符串”是两个不同状态 // 虽然两者读取 nickname() 都可能得到空字符串,但 presence 状态不同 User a; // a.has_nickname() == false User b; b.set_nickname(""); // b.has_nickname() == true |
自定义默认值
proto2允许定义默认值
|
1 2 3 4 5 |
message Config { optional int32 timeout = 1 [default = 30]; optional string host = 2 [default = "127.0.0.1"]; optional bool enabled = 3 [default = true]; } |
- 如果字段不存在,访问器会返回默认值
- 注意:默认值通常不会被写入序列化结果,只是读取未设置字段时返回的值
|
1 2 |
config.timeout(); // 30 config.has_timeout(); // false |
repeated
- 表示一个数组
|
1 2 3 |
message ChatRoom { repeated uint64 member_ids = 1; } |
|
1 2 3 4 5 6 |
room.add_member_ids(1001); room.add_member_ids(1002); for (auto id : room.member_ids()) { // ... } |
proto2中,数值类型的repeated字段默认不使用packed编码,但可以主动开启
|
1 |
repeated int32 values = 1 [packed = true]; |
proto3语法
一个 proto3 文件
|
1 2 3 4 5 6 7 8 |
syntax = "proto3"; package easychat.v1; message LoginRequest { string username = 1; string password = 2; } |
proto3去掉了 required
- 普通字段直接定义
|
1 2 3 4 5 6 7 |
syntax = "proto3"; message User { uint64 id = 1; string name = 2; bool online = 3; } |
optional
- 现代
proto3已经支持optional
|
1 2 3 4 5 |
message User { uint64 id = 1; optional string nickname = 2; optional int32 age = 3; } |
- 生成的
C++接口可以检查
|
1 2 |
user.has_nickname(); user.has_age(); |
- 所以
|
1 2 3 |
// 表示一般不关心“有没有设置” int32 age = 1; |
|
1 2 3 4 5 6 7 |
// 表示需要区分 optional int32 age = 1; 未设置 设置为0 设置为其他值 |
|
1 2 3 4 5 6 7 8 9 10 11 12 |
// 例如配置更新协议中,这种区别非常重要 message UpdateUserRequest { uint64 user_id = 1; optional string nickname = 2; optional int32 age = 3; } nickname 不存在:不修改昵称 nickname 存在且为 "":清空昵称 nickname 存在且非空:修改昵称 |
不允许自定义默认值
- 下面的写法在
proto3中不合法
|
1 2 |
// 错误 optional int32 timeout = 1 [default = 30]; |
- 只能由业务代码处理
|
1 2 |
int timeout = config.has_timeout() ? config.timeout() : 30; |
枚举 enum
proto3要求第一个枚举值的编号必须为0,因为0是枚举字段的默认值
|
1 2 3 4 5 6 |
enum ResultCode { RESULT_CODE_UNSPECIFIED = 0; RESULT_CODE_SUCCESS = 1; RESULT_CODE_INVALID_PASSWORD = 2; RESULT_CODE_USER_NOT_FOUND = 3; } |
|
1 2 3 4 |
message LoginResponse { ResultCode result = 1; string message = 2; } |
- 所以推荐把第一个值定义成
- 这样可以区分“没有得到有效状态”和“确实成功”
|
1 2 3 4 |
RESULT_CODE_UNSPECIFIED = 0; // 而不是 RESULT_CODE_SUCCESS = 0; |
- 枚举名称建议加枚举类型前缀,因为
Protobuf的枚举值作用域规则和 C++enum class不完全一样,同一作用域中的枚举值可能发生名称冲突
嵌套消息
|
1 2 3 4 5 6 7 8 9 10 |
message User { message Address { string country = 1; string city = 2; } uint64 id = 1; string name = 2; Address address = 3; } |
|
1 |
easychat::v1::User::Address address; |
- 也可以把消息单独定义
- 如果多个消息都需要使用
Address,一般建议单独定义
- 如果多个消息都需要使用
|
1 2 3 4 5 6 7 8 9 |
message Address { string country = 1; string city = 2; } message User { uint64 id = 1; Address address = 2; } |
消息字段
- 一个消息可以作为另一个消息的字段类型
|
1 2 3 4 5 6 7 8 9 |
message User { uint64 id = 1; string name = 2; } message LoginResponse { ResultCode result = 1; User user = 2; } |
- 在
proto3中,消息类型字段本身具有presence
repeated
proto2和proto3都支持
|
1 2 3 4 |
message UserList { repeated User users = 1; repeated uint64 user_ids = 2; } |
- 它相当于集合/数组,但不等同于直接生成一个公开的
std::vector,应通过生成的接口操作
|
1 2 3 4 5 6 7 |
auto* user = list.add_users(); user->set_id(1001); user->set_name("David"); for (const auto& item : list.users()) { std::cout << item.name() << '\n'; } |
proto3中,可打包的repeated数值字段默认使用packed编码proto2默认使用非packed编码
map
map的key只能使用整数、布尔或字符串等标量类型,不能使用- 浮点数
bytesmessageenum
|
1 2 3 4 |
message UserAttributes { map<string, string> attributes = 1; map<uint64, User> users = 2; } |
|
1 2 |
(*attrs.mutable_attributes())["language"] = "C++"; (*attrs.mutable_attributes())["level"] = "senior"; |
map字段不能同时声明为repeated
oneof
- 设置
logout_request后,之前的login_request会自动被清除
|
1 2 3 4 5 6 7 8 9 |
message Packet { uint32 request_id = 1; oneof body { LoginRequest login_request = 2; LoginResponse login_response = 3; LogoutRequest logout_request = 4; } } |
|
1 2 3 4 |
Packet packet; packet.mutable_login_request()->set_username("David"); packet.mutable_logout_request(); |
- 可以通过生成的
case接口判断当前字段
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
switch (packet.body_case()) { case Packet::kLoginRequest: break; case Packet::kLoginResponse: break; case Packet::kLogoutRequest: break; case Packet::BODY_NOT_SET: break; } |
- 示例
oneof很适合替代之前协议里的DataHeader- 它能够保证消息类型和消息体在
schema层面匹配,不容易出现 cmd说这是Login,但body实际放了Logout
|
1 2 3 4 |
struct DataHeader { short cmd; int data_len; }; |
导入其他 .proto
common.proto
|
1 2 3 4 5 6 7 8 |
syntax = "proto3"; package easychat.v1; message User { uint64 id = 1; string name = 2; } |
login.proto
|
1 2 3 4 5 6 7 8 9 |
syntax = "proto3"; package easychat.v1; import "common.proto"; message LoginResponse { User user = 1; } |
- 如果引用其他
package
|
1 2 3 |
common.User user = 1; .other_package.User user = 1; |
package
- 它的主要作用是
- 避免消息名称冲突
- 在生成代码中形成命名空间
- 给协议增加版本边界
|
1 |
package easychat.v1; |
C++中通常对应- 在实际项目里,推荐一开始就带版本
- 而不是等协议发布以后再修改
package
|
1 2 |
// C++ easychat::v1::LoginRequest |
|
1 |
package company.project.v1; |
reserved
- 假设原来有
|
1 2 3 4 5 |
message User { uint64 id = 1; string name = 2; string old_address = 3; } |
- 删除
old_address后,应当这样写
|
1 2 3 4 5 6 7 |
message User { reserved 3; reserved "old_address"; uint64 id = 1; string name = 2; } |
- 也可以保留一段编号
|
1 |
reserved 10 to 20; |
- 绝对不要重新使用已经发布过的字段编号
- 官方最佳实践要求删除字段后保留其编号和名称
|
1 2 |
// 危险:旧版本认为3是old_address string password = 3; |
定义 RPC 服务
Protobuf还可以描述服务接口
|
1 2 3 4 |
service AuthService { rpc Login(LoginRequest) returns (LoginResponse); rpc Logout(LogoutRequest) returns (LogoutResponse); } |
- 流式
RPC
|
1 2 3 4 5 6 |
service ChatService { rpc SendMessage(ChatMessage) returns (SendResponse); rpc Subscribe(SubscribeRequest) returns (stream ChatMessage); } |
- 四种
gRPC形式
|
1 2 3 4 |
rpc A(Request) returns (Response); rpc B(Request) returns (stream Response); rpc C(stream Request) returns (Response); rpc D(stream Request) returns (stream Response); |
- 需要注意
.proto中的service只是接口定义,真正通过网络调用,通常还需要gRPC和对应代码生成插件
proto3完整协议示例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
syntax = "proto3"; package easychat.v1; enum ResultCode { RESULT_CODE_UNSPECIFIED = 0; RESULT_CODE_SUCCESS = 1; RESULT_CODE_INVALID_REQUEST = 2; RESULT_CODE_INVALID_PASSWORD = 3; RESULT_CODE_USER_NOT_FOUND = 4; } message UserInfo { uint64 user_id = 1; string username = 2; optional string nickname = 3; } message LoginRequest { string username = 1; string password = 2; } message LoginResponse { ResultCode result = 1; optional UserInfo user = 2; string error_message = 3; } message LogoutRequest { uint64 user_id = 1; } message LogoutResponse { ResultCode result = 1; } message NewUserJoined { UserInfo user = 1; } message Packet { uint64 request_id = 1; uint64 timestamp_ms = 2; oneof payload { LoginRequest login_request = 10; LoginResponse login_response = 11; LogoutRequest logout_request = 12; LogoutResponse logout_response = 13; NewUserJoined new_user_joined = 14; } } |
相同协议的 proto2 写法
- 语法上可以这么写,但新协议不建议大量使用
required - 更稳妥的
proto2设计也是以optional为主,然后在业务代码中验证必填字段
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
syntax = "proto2"; package easychat.v1; enum ResultCode { RESULT_CODE_UNSPECIFIED = 0; RESULT_CODE_SUCCESS = 1; RESULT_CODE_INVALID_REQUEST = 2; RESULT_CODE_INVALID_PASSWORD = 3; RESULT_CODE_USER_NOT_FOUND = 4; } message UserInfo { required uint64 user_id = 1; required string username = 2; optional string nickname = 3; } message LoginRequest { required string username = 1; required string password = 2; } message LoginResponse { required ResultCode result = 1; optional UserInfo user = 2; optional string error_message = 3 [default = ""]; } message LogoutRequest { required uint64 user_id = 1; } message LogoutResponse { required ResultCode result = 1; } message NewUserJoined { required UserInfo user = 1; } message Packet { required uint64 request_id = 1; optional uint64 timestamp_ms = 2; oneof payload { LoginRequest login_request = 10; LoginResponse login_response = 11; LogoutRequest logout_request = 12; LogoutResponse logout_response = 13; NewUserJoined new_user_joined = 14; } } |
编译
- 下载生成工具:地址
Windows下下载Win32压缩包proto文件定义
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
syntax = "proto2"; package tutorial; message Person { optional string name = 1; optional int32 id = 2; optional string email = 3; enum PhoneType { MOBILE = 0; HOME = 1; WORK = 2; } message PhoneNumber { optional string number = 1; optional PhoneType type = 2 [default = HOME]; } repeated PhoneNumber phones = 4; } message AddressBook { repeated Person people = 1; } |
- 命令如下图:
API
解析和序列化
- 每个协议缓冲区类都有使用协议缓冲区二进制格式写入和读取您选择的类型的消息的方法
bool SerializeToString(string* output) const;- 序列化消息并将字节存储在给定的字符串中
- 需要注意的是,字节是二进制的,而不是文本;使用
string类只是作为一个方便的容器
bool ParseFromString(const string& data);- 从给定的字符串解析消息
bool SerializeToOstream(ostream* output) const;- 将消息写入给定的
C++ostream
- 将消息写入给定的
bool ParseFromIstream(istream* input);- 解析来自给定
C++的消息istream
- 解析来自给定
使用
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
#include <iostream> #include <fstream> #include <string> #include "test.pb.h" using namespace std; // This function fills in a Person message based on user input. void PromptForAddress(tutorial::Person* person) { cout << "Enter person ID number: "; int id; cin >> id; person->set_id(id); cin.ignore(256, '\n'); cout << "Enter name: "; getline(cin, *person->mutable_name()); cout << "Enter email address (blank for none): "; string email; getline(cin, email); if (!email.empty()) { person->set_email(email); } while (true) { cout << "Enter a phone number (or leave blank to finish): "; string number; getline(cin, number); if (number.empty()) { break; } tutorial::Person::PhoneNumber* phone_number = person->add_phones(); phone_number->set_number(number); cout << "Is this a mobile, home, or work phone? "; string type; getline(cin, type); if (type == "mobile") { phone_number->set_type(tutorial::Person::MOBILE); } else if (type == "home") { phone_number->set_type(tutorial::Person::HOME); } else if (type == "work") { phone_number->set_type(tutorial::Person::WORK); } else { cout << "Unknown phone type. Using default." << endl; } } } // Main function: Reads the entire address book from a file, // adds one person based on user input, then writes it back out to the same // file. int main(int argc, char* argv[]) { // Verify that the version of the library that we linked against is // compatible with the version of the headers we compiled against. GOOGLE_PROTOBUF_VERIFY_VERSION; if (argc != 2) { cerr << "Usage: " << argv[0] << " ADDRESS_BOOK_FILE" << endl; return -1; } tutorial::AddressBook address_book; { // Read the existing address book. fstream input(argv[1], ios::in | ios::binary); if (!input) { cout << argv[1] << ": File not found. Creating a new file." << endl; } else if (!address_book.ParseFromIstream(&input)) { cerr << "Failed to parse address book." << endl; return -1; } } // Add an address. PromptForAddress(address_book.add_people()); { // Write the new address book back to disk. fstream output(argv[1], ios::out | ios::trunc | ios::binary); if (!address_book.SerializeToOstream(&output)) { cerr << "Failed to write address book." << endl; return -1; } } // Optional: Delete all global objects allocated by libprotobuf. google::protobuf::ShutdownProtobufLibrary(); return 0; } |
示例
proto 定义
|
1 2 3 4 |
message Person { int32 id = 1; string name = 2; } |
数据
id = 150,name = "Bob"
字节流
08 96 01 12 03 42 6f 62- 每个字段都是
Tag + Value - 如果是
length-delimited类型则是Tag + Length + Value
- 每个字段都是
|
1 2 |
[ 08 96 01 ] → 字段 id [ 12 03 42 6f 62 ] → 字段 name |
Tag 的编码公式
|
1 |
tag = (field_number << 3) | wire_type |
解析字段1
|
1 |
[ 08 96 01 ] → 字段 id |
-
08是Tag,08的二进制是0000 1000- 低
3位000 = wire_type = 0(varint) - 剩下的位右移
3位0000 1 = 1 = field_number - 所以这个
tag告诉解析器:"字段编号1,用varint方式读取后面的数据" - 对照
proto定义,field 1正是int32 id,类型对得上(int32用varint编码)
- 低
-
96 01是Value(varint解码150)varint是变长编码,每个字节最高位(MSB)是"续位标志"- 若最高位是
1,表示后面还有字节 - 若最高位是
0,表示这是最后一个字节
-
拆解
96的最高位是1→ 还有后续字节;去掉标志位后剩001 0110</li> <li>01的最高位是0→ 这是最后一个字节;去掉标志位后剩000 0001- varint
是小端序拼接(先出现的字节是低位),所以拼接顺序要把第二个字节放在高位</li> </ol> </li> </ol> <p><br />1296 = 1001 011001 = 0000 0001</p> <h3>解析字段<code>212340000001 0010110(第二字节的有效位)(第一字节的有效位)// 拼起来二进制是 10010110 = 十进制 1501[ 12 03 42 6f 62 ] → 字段 name12是Tag,12的二进制是0001 0010- 低
3位010 = wire_type = 2(length-delimited,用于string/bytes/嵌套message) - 剩余位
0000 10 = 2 = field_number - 对照
proto,field 2正是string name,wire type 2也吻合(string属于length-delimited)
- 低
03是长度- 因为
wire type 2的字段格式是Tag + Length(varint) + 原始字节,所以下一个字节03表示:后面跟着3个字节的数据
- 因为
42 6f 62是实际内容
声明:本文为原创文章,版权归Aet所有,欢迎分享本文,转载请保留出处!
你可能也喜欢
- ♥ 51CTO:C++语言高级课程一08/07
- ♥ C++_ 模板学习六08/09
- ♥ C++_volatile10/08
- ♥ C++11_四种类型转换11/10
- ♥ Spdlog记述:三07/23
- ♥ C++17_第二篇12/22




