Protobuf 是一个 跨平台的协议,具有语言无关的特性。
定义:序列化是将 数据结构或对象 转换成 二进制字节流 的过程。
特点:Protobuf 针对 不同的字段类型 采用 不同的编码方式 和数据存储方式,以确保得到高效紧凑的数据压缩。
序列化过程
定义:反序列化是将在序列化过程中生成的 二进制字节流 转换成 数据结构 或者对象的过程。
反序列化过程
Protobuf 编解码流程图如下:
Protocol Buffers(简称 ProtoBuf)定义了丰富的数据类型 ,用于在 .proto
文件中定义结构化的数据模型。这些类型分为标量类型(Scalar Types) 和 复合类型(Composite Types)
标量类型是基本的数据类型,用于定义字段的值。以下是 ProtoBuf 支持的标量类型:
.PROTO 类型 | 说明 | 对应不同语言类型 |
---|---|---|
double | 双精度浮点数 | Python:float/ Java:double/ C++:double |
float | 单精度浮点数 | Python:float/ Java:float/ C++:float |
int32 | 32位整数,使用变长编码(Varint) | Python:int/ Java:int/ C++:int32 |
int64 | 64位整数,使用变长编码(Varint) | Python:int/ Java:long/ C++:int64 |
uint32 | 无符号32位整数 | Python:int/ Java:int/ C++:uint32 |
uint64 | 无符号64位整数 | Python:int/ Java:long/ C++:uint64 |
sint32 | 有符号32位整数(更高效编码负数) | Python:int/ Java:int/ C++:int32 |
sint64 | 有符号64位整数(更高效编码负数) | Python:int/ Java:long/ C++:int64 |
fixed32 | 32位无符号整数(固定4字节) | Python:int/ Java:int/ C++:uint32 |
fixed64 | 64位无符号整数(固定8字节) | Python:int/ Java:long/ C++:uint64 |
sfixed32 | 有符号32位整数(固定4字节) | Python:int/ Java:int/ C++:int32 |
sfixed64 | 有符号64位整数(固定8字节) | Python:int/ Java:long/ C++:int64 |
bool | 布尔值 | Python:bool/ Java:boolean/ C++:bool |
string | UTF-8 编码字符串 | Python:str/ Java:String/ C++:string |
bytes | 任意字节序列(类似 byte[]) | Python:bytes/ Java:ByteString/ C++:string |
String 和 bytes 区别:
string
存储的是编码后的字符串,如 "hello world"
或 "你好世界"
。bytes
存储的是字节序列(存储的是原始字节而非字符串内容),解析时无法直接得到中文字符。复合类型是由用户定义的结构化类型,包括:
① message:定义结构化的数据类型
message Address {
string city = 1;
string street = 2;
int32 zip_code = 3;
}
② enum:枚举类型
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
③ 嵌套 message
:一个 message
中可以包含另一个 message
message Person {
string name = 1;
int32 age = 2;
Address address = 3; // 使用上面定义的 Address 消息
}
字段前可以加上以下关键字,表示该字段的行为:
关键字 | 说明 |
---|---|
optional | 可选字段(proto2) |
required | 必填字段(proto2) |
repeated | 可重复字段(列表),相当于数组 |
(无关键字) | proto3 中默认所有字段都是可选的 |
示例:
message Person {
string name = 1; // 必须字段(proto3 默认可选)
repeated string hobbies = 2; // 可重复字段,表示一个字符串列表
PhoneType phone_type = 3; // 枚举字段
}
① map<key_type, value_type>
:键值对映射(proto3)
message Person {
map<string, string> metadata = 4; // 例如:{"nickname": "Bob", "role": "admin"}
}
key_type
必须是整数或字符串类型。② oneof
:多个字段中只能设置一个(节省空间)
message SampleMessage {
oneof test_oneof {
string name = 1;
int32 id = 2;
}
}
oneof
后,name
和 id
不能同时设置。③ Any
:可以包含任意类型的 message(需要导入 google/protobuf/any.proto
)
import "google/protobuf/any.proto";
message Container {
repeated google.protobuf.Any items = 1;
}
④ Timestamp
, Duration
, Struct
等(来自 well-known types
)
这些是 Google 提供的标准类型,用于处理常见数据结构:
import "google/protobuf/timestamp.proto";
message LogEntry {
string message = 1;
google.protobuf.Timestamp timestamp = 2;
}
Protobuf 的整型分为两类:**定长编码 **和 变长编码。这两类的区别在于 底层存储方式 的不同,分别适用于不同的场景。
特点:序列化后占用固定大小的空间。
类型如下:
Protobuf 类型 | 解释 | C++ 类型 |
---|---|---|
fixed32 | 定长 4 字节无符号整型 | uint32_t |
fixed64 | 定长 8 字节无符号整型 | uint64_t |
sfixed32 | 定长 4 字节有符号整型 | int32_t |
sfixed64 | 定长 8 字节有符号整型 | int64_t |
示例定义
message base {
fixed32 a = 1;
fixed64 b = 2;
sfixed32 c = 3;
sfixed64 d = 4;
}
编译后的 C++ 实现:在生成的 C++ 文件中,这些字段会被统一管理在一个内部类 Impl_
中,如下
struct Impl_ {
inline explicit constexpr Impl_(...);
::uint64_t b_;
::uint32_t a_;
::int32_t c_;
::int64_t d_;
mutable ::google::protobuf::internal::CachedSize _cached_size_;
PROTOBUF_TSAN_DECLARE_MEMBER
};
a_
、b_
,分别对应之前表格的类型特点:尽可能 压缩字节数 来提高效率,适合小数值的场景。
Protobuf 类型 | 解释 | C++ 类型 |
---|---|---|
uint32 | 变长无符号整型 | uint32_t |
uint64 | 变长无符号整型 | uint64_t |
int32 | 变长有符号整型,正数编码效率更高 | int32_t |
int64 | 变长有符号整型,正数编码效率更高 | int64_t |
sint32 | 变长有符号整型,负数编码效率更高 | int32_t |
sint64 | 变长有符号整型,负数编码效率更高 | int64_t |
Protobuf 在处理 整型字段 时,采用了非常高效的编码机制:VarInt 编码 和 ZigZag 编码
Varint 是一种变长整数编码方式 ,它的核心思想是:小的整数用更少的字节表示,大的整数用更多的字节表示。 因此,可以通过数值大小 动态调整编码后的字节数 从而实现 空间压缩 ,非常适合用于网络传输和数据存储。
这样做的好处是:
编码原理
每个字节的 最高位(MSB) 是标志位 ,表示是否还有后续字节:
当使用 Varint 解码时,只要读取到最高位为 0 的字节时,表示本字节是一个值经 Varint 编码后得到的字节流的最后一个字节。
示例:编码数字 300
300 的二进制表示为:100101100
按照 7 位一组从低位到高位分组:
101100 (后7位)
10 (高位部分)
补齐 7 位:00000010 11001100
加上标志位(除了最后一个字节外,其他都设为 1),两字节分别为:
1 1001100 (MSB=1)
0 0000010 (MSB=0)
转换为十六进制:0xAC 0x02
因此 300 被编码为两个字节:AC 02
(1010 1100 0000 0010)
代码实现
#include <iostream>
#include <vector>
#include <cstdint>
#include <stdexcept>
// Varint 编码函数
std::vector<uint8_t> EncodeVarint(uint64_t value) {
std::vector<uint8_t> buffer;
while (true) {
if ((value & ~0x7FUL) == 0) {
// 最后一个字节,最高位为 0
buffer.push_back(static_cast<uint8_t>(value));
break;
} else {
// 还有后续字节,最高位为 1,其余 7 位存储值
buffer.push_back(static_cast<uint8_t>((value & 0x7F) | 0x80));
value >>= 7;
}
}
return buffer;
}
// Varint 解码函数
uint64_t DecodeVarint(const uint8_t*& data, size_t& size) {
uint64_t result = 0;
int shift = 0;
while (true) {
if (size == 0) {
throw std::runtime_error("Varint decode error: truncated data");
}
uint8_t byte = *data++;
size--;
result |= (static_cast<uint64_t>(byte & 0x7F) << shift);
if (!(byte & 0x80)) {
// 最后一个字节,退出循环
break;
}
shift += 7;
if (shift >= 63) {
// 超过 64 位整数范围,抛出异常
throw std::runtime_error("Varint decode error: integer out of range");
}
}
return result;
}
int main() {
// 测试 Varint 编码和解码
uint64_t test_value = 300;
std::vector<uint8_t> encoded = EncodeVarint(test_value);
std::cout << "Encoded Varint: ";
for (uint8_t byte : encoded) {
std::cout << std::hex << " " << static_cast<int>(byte) << " ";
}
std::cout << std::endl;
// 准备解码
const uint8_t* data = encoded.data();
size_t size = encoded.size();
uint64_t decoded_value = DecodeVarint(data, size);
std::cout << "Decoded Varint: " << decoded_value << std::endl;
return 0;
}
此代码完整实现了Varint的核心编解码逻辑,输出结果验证了算法正确性。对于负数处理,需结合Zigzag编码(如(n << 1) ^ (n >> 31))实现更优压缩
这两个是无符号整型,因此无需考虑负数的情况,直接存储原码;只使用VarInt编码,不使用ZigZag编码。
例如数字1023,其原码为11 1111 1111(即10位都是1),如果使用VarInt编码,则只占用2 byte:
这两个类型可以用于有符号整型,但此处仅使用VarInt编码,而不使用ZigZag编码。
int32
的-1补码表示为 11111111 11111111 11111111 11111111。
因为 VarInt
每个字节的最高位表示是否还有更多字节,所以-1会编码成5个字节(因为每个字节的最高位都要设为1,除了最后一个):
因此,-1作为 int32
通过 VarInt编码后会是 11111111 11111111 11111111 11111111 00000001
因此,虽然int32和int64能够存储负数,但是效率很低。
varint 应用场景
定义:Zigzag 编码是一种变长的编码方式,其编码原理是使用无符号数来表示有符号数字,使得 绝对值小的数字都可以采用较少字节来表示\,特别适合表示负数。
原理
实现
数值映射表举例
原始值 | ZIGZAG 编码 |
---|---|
0 | 0 |
-1 | 1 |
1 | 2 |
-2 | 3 |
2 | 4 |
… | … |
n
变成 n * 2
;负数 n
变成 (-n - 1) * 2 + 1
示例:
// 编码公式(伪代码):
uint32_t zigzag_encode_32(int32_t val) {
return (uint32_t)((val << 1) ^ (val >> 31));
}
// 解码公式
int32_t zigzag_decode_32(uint32_t val) {
return (int32_t)((val >> 1) ^ -(val & 1));
}
sint32
与 sint64
这两个类型也可以用于有符号整型,结合了 VarInt
编码 和 ZigZag
编码。
-1
,也只需要一个字节,而不像 int32
那样需要5个字节类型 | 有符号 | 编码方式 | 说明 |
---|---|---|---|
int32 | 是 | Varint | 可能需要 1~5 个字节 |
int64 | 是 | Varint | 可能需要 1~10 个字节 |
uint32 | 否 | Varint | 无符号整数,适合非负数 |
uint64 | 否 | Varint | 同上,适合大整数 |
sint32 | 是 | ZigZag + Varint | 更高效编码负数 |
sint64 | 是 | ZigZag + Varint | 同上 |
fixed32 | 是/否 | 固定 4 字节 | 适用于需要固定大小的场景 |
fixed64 | 是/否 | 固定 8 字节 | 同上 |
sfixed32 | 是 | 固定 4 字节 | 带符号固定大小 |
sfixed64 | 是 | 固定 8 字节 | 同上 |
选择策略:定长 vs 变长
时,选择变长编码。
(32 位)或
(64 位)时,选择定长编码。
int vs sint:
所谓数据压缩,说白了就是将不重要的数据忽略或舍弃;由于计算机中只有0和1,本质上就是将无意义的0丢弃,而起到数据压缩的目的。数据压缩 可以借助 标记位 实现
JSON 的数据表示方式:键值对 的形式来标识数据与变量之间的映射关系。
例如,以下 JSON 数据:
{
"id": 1,
"name": "张三",
"address": "北京"
}
"id"
、"name"
、"address"
)是字符串形式。"address"
至少需要 7 字节存储字符本身,再加上额外的格式字符。ProtoBuf 的数据表示方式:其是高效的 二进制序列化协议,它通过 字段编号 来标识数据与变量之间的映射关系
字段编号的作用:
Protobuf 的序列化过程,假设有如下 message 定义:
message People {
int32 id = 1;
string name = 2;
string address = 3;
};
序列化后,数据格式大致如下:
0000 0001 "id 的值的二进制编码"
0000 0002 "name 的值的二进制编码"
0000 0003 "address 的值的二进制编码"
流程:例如,读取第一个字节 0000 0001,解析出字段编号为 1,回到 message 定义发现其为 id 变量,类型为 int32,最后用 int32 的解析规则解析后面的值。
对比如下:
JSON
:由于键是字符串形式,存储和传输效率较低。例如,“address” 至少需要 7 字节 存储字符本身,加上其他格式字符可能需要更多字节。
Protobuf
:字段编号采用 VarInt 编码,仅需 1 字节即可完成数值与变量的对应关系(字段编号在 [1, 15] 范围内时)。这使得 Protobuf 的存储和传输效率远高于 JSON。
JSON
:数据以文本形式存储,人类可直接阅读和理解。
Protobuf
:数据以二进制形式存储,人类无法直接阅读,需要借助工具或解析代码查看内容。
JSON
:新增字段时无需修改已有结构,兼容性强。Protobuf
:新增字段需要为其分配一个新的字段编号,且字段编号不可重复使用。此外,字段编号 [19000, 19999] 是保留编号,不能使用。JSON
:由于包含冗长的键名,压缩效果较差。Protobuf
:字段编号占用空间小,数据紧凑,适合高效压缩。字段编号的设计建议
小结:
效率对比
数据类型 | 原始大小 | Protobuf(MSB 标记位) | 压缩率 |
---|---|---|---|
int32(300) | 4字节 | 2字节 | 50% |
int32(-150) | 4字节 | 3字节 | 25% |
int64(1<<40) | 8字节 | 6字节 | 25% |
注意:
] 范围
repeated
字段替代数组嵌套