Protocol Buffers(简称 Protobuf 或 Proto)是 Google 开发的一种语言中立、平台中立、可扩展的结构化数据序列化机制。它被设计用于序列化结构化数据,类似于 XML 和 JSON,但更小、更快、更简单。
核心优势
- 体积小
- 二进制编码,比 JSON/XML 小 3-10 倍
- 使用 varint 可变长度编码,节省空间
- 字段编号而非字段名,减少冗余
- 速度快
- 解析速度比 JSON 快 20-100 倍
- 序列化速度快 5-10 倍
- 无需解析文本,直接操作二进制数据
- 强类型系统
- 编译时类型检查
- 自动生成代码,减少错误
- IDE 自动补全和类型提示
- 向后兼容性
- 可以安全地添加新字段
- 旧代码可以读取新格式数据
- 新代码可以读取旧格式数据
- 跨语言支持
- 支持 C++, Java, Python, Go, C#, JavaScript, Ruby, PHP 等
- 一次定义,多语言使用
- 统一的数据格式标准
Protobuf Wire Format 编码
Protocol Buffers 的高效性很大程度上归功于其紧凑的二进制编码格式(Wire Format)。理解 Wire Format 对于优化性能、调试问题和设计高效的 Schema 至关重要。
编码类型(Wire Types)
每个字段在序列化时都会被编码为 key-value 对,其中 key 包含了字段编号和编码类型(Wire Type):
Key = (field_number << 3) | wire_type
Protocol Buffers 定义了以下 6 种 Wire Type:
| Wire Type | 名称 | 说明 | 适用字段类型 |
|---|---|---|---|
| 0 | Varint | 可变长度整数 | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
| 1 | 64-bit | 固定 8 字节 | fixed64, sfixed64, double |
| 2 | Length-delimited | 长度前缀 | string, bytes, embedded messages, packed repeated fields |
| 3 | Start group | 已废弃 | 仅 Proto2 使用 |
| 4 | End group | 已废弃 | 仅 Proto2 使用 |
| 5 | 32-bit | 固定 4 字节 | fixed32, sfixed32, float |
为什么用 key 使用 (field_number << 3) | wire_type 编码
Wire Type 只有 6 种(0-5),用 3 位就足够表示,剩余位用来存储字段编号。通过位运算将两者打包到一起:
字段 1,类型 2(string):
(1 << 3) | 2 = 00001 010 (0x0A)
^^^^^ ^^^
字段1 类型2
解码时也很简单:
const wireType = key & 0x07; // 取低 3 位获得类型
const fieldNumber = key >> 3; // 右移 3 位获得字段编号
好处:
- 极致压缩:字段 1-15 的 key 只需 1 字节(这就是为什么建议常用字段分配到 1-15)
- 解析快速:位运算比字符串解析快数百倍
- 无需分隔符:二进制格式天然紧凑
为什么废弃 Start group / End group
Wire Type 3 和 4(Start group / End group)是 Proto2 中用于表示嵌套消息的方式:
[Start group tag][嵌套字段...][End group tag]
这种方式通过成对的起止标记来界定消息边界,类似 XML 标签。
Proto2 为什么最初使用 group?
当时的设计考虑:
- 流式解析:不需要提前知道消息长度,边读边解析
- 避免两次遍历:Length-delimited 需要先序列化计算长度,再写入数据
为什么后来废弃?
实践发现 group 的问题更严重:
- 复杂度高:需要维护嵌套层级的配对关系,解析器实现复杂
- 容易出错:Start/End 不匹配会导致解析失败,调试困难
- 无法跳过:必须解析到 End group,无法像 length-delimited 那样直接跳过 N 字节
- 计算长度的开销可接受:现代硬件下,提前计算长度的代价远小于 group 的维护成本
Proto3 中所有嵌套消息都使用 Wire Type 2:
message Person {
Address address = 1; // 编码为 [key=0x0A][length=10][address 的 10 字节数据]
}
解码过程:
- 读取 key
0x0A,提取字段编号1和 Wire Type2 - 查询 proto schema,得知字段 1 对应类型是
Address - 读取
length=10,知道接下来 10 字节是 Address 的数据 - 递归解码这 10 字节为 Address 消息
关键点:解码器必须配合 proto schema 才能知道字段类型,wire format 本身只包含字段编号,不包含具体类型名。
好处是可以直接根据 length 跳过整个消息(不关心时)或精确读取(需要解析时),无需扫描到结束标记。
Wire Type 0: Varint 编码详解
Varint 是 Protobuf 最重要的编码方式,使用变长编码来节省空间。
编码规则
- 每个字节的**最高位(MSB)**是延续位:
1表示后面还有字节0表示这是最后一个字节
- 剩余的 7 位存储实际数据
- 使用小端序(Little-Endian)
sint32/sint64:跟 int32 和 int64 一样,都是有符号整数。但是编码时,会先 ZigZag 编码再 Varint
编码示例
示例 1:数字 1
原始值: 0000 0001
编码后: 0000 0001 (1 字节)
↑
MSB=0,表示结束
示例 2:数字 300
原始值: 0000 0001 0010 1100 (二进制 300)
第一步:每 7 位分组
0000010 0101100
↓
第二步:反转顺序(小端序)
0101100 0000010
第三步:添加延续位
1010 1100 0000 0010
↑ ↑
MSB=1 MSB=0(结束)
编码后: [0xAC, 0x02] (2 字节)
示例 3:数字 -1(使用 sint32)
使用 ZigZag 编码:
原始值: -1
ZigZag: (n << 1) ^ (n >> 31) = 1
编码后: 0000 0001 (1 字节)
Wire Type 1 & 5: 固定长度编码
固定长度编码直接存储原始字节,无需额外处理。
message Example {
fixed32 value32 = 1; // 总是 4 字节
fixed64 value64 = 2; // 总是 8 字节
double pi = 3; // 8 字节(IEEE 754)
float ratio = 4; // 4 字节(IEEE 754)
}
何时使用固定长度编码:
- 数值通常大于 2^28(对于 fixed32)或 2^56(对于 fixed64)
- 需要确定的内存布局和解析性能
- 浮点数(必须使用固定长度)
Wire Type 2: Length-Delimited 编码
格式:[key][length][data]
+-------+--------+------------------+
| Key | Length | Data |
+-------+--------+------------------+
Varint Varint Length 指定的字节
String 编码示例
message Person {
string name = 1; // 字段编号 1
}
// name = "Alice"
编码结果:
Key: 0x0A = (1 << 3) | 2 // 字段 1,Wire Type 2
Length: 0x05 = 5 字节
Data: 41 6C 69 63 65 // "Alice" 的 UTF-8 编码
完整编码: [0x0A, 0x05, 0x41, 0x6C, 0x69, 0x63, 0x65]
嵌套消息编码示例
message Person {
string name = 1;
Address address = 2;
}
message Address {
string city = 1;
}
编码结构:
Person {
[Key=0x0A][Length][name data]
[Key=0x12][Length][ Address { [Key=0x0A][Length][city data]
}
]
}
Packed Repeated 编码
对于数值类型的 repeated 字段,Proto3 默认使用 packed encoding:
message Data {
repeated int32 values = 1; // [3, 270, 86942]
}
Packed 编码(默认):
[Key=0x0A][Length=6][3][270 的 Varint][86942 的 Varint]
↑
Wire Type 2
优势:
- 减少 key 的重复存储
- 整体字节数更少
- 解析速度更快
字段编号编码
在 Proto 中,针对**字段编号(如 1、2、3)**编码,而不是字段名(如 "name"、"age")。
这是 Protobuf 比 JSON/XML 小得多的关键原因:
JSON: {"name":"Bob","age":30} → 25 字节(包含字段名)
Proto: [0x0A 0x03 "Bob" 0x10 0x1E] → 8 字节(只有编号 1 和 2)
解码时,解析器通过查询 proto schema 将字段编号映射回字段名。
默认值不序列化原则
Proto3 中,默认值不会被序列化:
message Config {
int32 timeout = 1; // 默认 0
bool enabled = 2; // 默认 false
string name = 3; // 默认 ""
}
// 实例 1:{ timeout: 0, enabled: false, name: "" }
编码结果: [] (0 字节!)
// 实例 2:{ timeout: 30, enabled: true, name: "prod" }
编码结果: [0x08, 0x1E, 0x10, 0x01, 0x1A, 0x04, 0x70, 0x72, 0x6F, 0x64]
优势: 大幅减少数据量
注意: 无法区分 "未设置" 和 "设为默认值",需要时使用显式 optional 关键字(Proto3.15+)
optional:字段存在性检测(Field Presence)
Proto3 中所有字段都是"隐式可选"的(无 required 关键字),但默认无法区分"未设置"和"设为默认值"。
Proto3.15+ 引入了显式 optional 关键字,用于支持"字段存在性检测"(field presence):
message Config {
int32 timeout = 1; // 普通字段:无存在性检测
optional int32 retry = 2; // 显式 optional:有存在性检测
}
编码行为对比:
| 场景 | 普通字段 | 显式 optional 字段 |
|---|---|---|
| 未设置 | 不序列化,解码为默认值 | 不序列化,解码为 null/None |
| 设为默认值 | 不序列化(无法区分) | 会序列化(能区分) |
| 设为非默认值 | 会序列化 | 会序列化 |
示例:
// 普通字段 timeout = 0(默认值)
Config { timeout: 0 }
编码: [] (0 字节,因为 0 是默认值)
解码: timeout = 0(无法知道是未设置还是设为 0)
// 显式 optional 字段 retry = 0(默认值)
Config { retry: 0 }
编码: [0x10, 0x00] (2 字节,即使是默认值也序列化)
╰──────╯
字段2 值0
解码: retry = 0(明确知道是设为 0,有存在性信息)
关键点: wire format 本身没有区别,区别在于生成代码的逻辑和何时触发序列化。
Proto3 语法详解
3.1 基本结构
// 声明语法版本
syntax = "proto3";
// 包名(避免命名冲突)
package com.example;
// 导入其他 proto 文件
import "google/protobuf/timestamp.proto";
// 消息定义
message MyMessage {
// 字段定义
}
3.2 标量类型(Scalar Types)
message ScalarTypes {
// 整数类型
int32 normal_int = 1; // 可变长度编码
int64 long_int = 2; // 可变长度编码
uint32 unsigned_int = 3; // 无符号整数
uint64 unsigned_long = 4; // 无符号长整数
sint32 signed_int = 5; // 带符号整数(更好的负数编码)
sint64 signed_long = 6; // 带符号长整数
fixed32 fixed_int = 7; // 固定 4 字节
fixed64 fixed_long = 8; // 固定 8 字节
sfixed32 sfixed_int = 9; // 固定 4 字节带符号
sfixed64 sfixed_long = 10; // 固定 8 字节带符号
// 浮点类型
float float_num = 11; // 32 位浮点数
double double_num = 12; // 64 位浮点数
// 其他类型
bool enabled = 13; // 布尔值
string text = 14; // UTF-8 字符串
bytes binary = 15; // 任意字节序列
}
3.3 枚举类型(Enums)
enum Status {
// 第一个值必须是 0(默认值)
STATUS_UNSPECIFIED = 0;
STATUS_ACTIVE = 1;
STATUS_INACTIVE = 2;
STATUS_DELETED = 3;
}
message User {
string name = 1;
Status status = 2;
}
3.4 消息(Message)
message Person {
string name = 1;
// 嵌套消息定义
message Address {
string street = 1;
string city = 2;
string country = 3;
}
Address address = 2;
// 嵌套枚举
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
string number = 1;
PhoneType type = 2;
}
repeated PhoneNumber phones = 3;
}
3.5 重复字段(Repeated)
message Blog {
string title = 1;
string content = 2;
// 数组/列表
repeated string tags = 3;
repeated int32 related_ids = 4;
// 对象数组
message Comment {
string author = 1;
string content = 2;
}
repeated Comment comments = 5;
}
3.6 映射类型(Maps)
message Project {
string name = 1;
// Map 类型
map<string, string> labels = 2;
map<int32, string> error_codes = 3;
map<string, User> members = 4;
}
3.7 字段存在性检测(Optional)
Proto3 中所有字段默认都是"隐式可选"的(无需 required 关键字)。但如果需要区分"未设置"和"设为默认值",可以使用显式 optional 关键字(proto3.15+):
message Config {
string name = 1; // 普通字段:隐式可选,无存在性检测
// 显式 optional:支持字段存在性检测
optional string description = 2;
optional int32 timeout = 3;
}
使用场景:配置项、API 更新(PATCH)、需要区分 null 和默认值的情况。
3.8 保留字段(Reserved)
message Example {
// 保留已删除的字段编号(防止重用)
reserved 2, 15, 9 to 11;
// 保留字段名
reserved "old_field", "deprecated_field";
string name = 1;
int32 age = 3;
}
3.9 Oneof(只能设置一个字段)
message Payment {
oneof payment_method {
string credit_card = 1;
string paypal = 2;
string bitcoin_address = 3;
}
double amount = 4;
}
3.10 Any 类型(动态类型)
import "google/protobuf/any.proto";
message ErrorInfo {
string message = 1;
google.protobuf.Any details = 2;
}
Schema 设计原则
字段编号分配策略
message User {
// 1-15: 常用字段(占用 1 字节)
string id = 1;
string name = 2;
string email = 3;
int32 age = 4;
// 16-2047: 次要字段(占用 2 字节)
string bio = 16;
string website = 17;
string avatar_url = 18;
// 保留字段编号(避免未来冲突)
reserved 5, 6, 7;
reserved "old_field_name", "deprecated_field";
}
最佳实践:
- 将最常用的字段分配到 1-15 范围(key 只需 1 字节)
- 预留一些编号(如 5-10)用于未来可能的高频字段
- 使用 reserved 防止意外重用已删除的字段
命名规范
// ✅ 推荐的命名规范
message UserProfile {
string user_id = 1; // 字段名:snake_case
string display_name = 2;
PhoneType phone_type = 3; // 类型名:PascalCase
enum PhoneType {
PHONE_TYPE_UNSPECIFIED = 0; // 枚举:SCREAMING_SNAKE_CASE
PHONE_TYPE_MOBILE = 1;
PHONE_TYPE_HOME = 2;
}
}
// ❌ 避免的命名方式
message userprofile { // 应该使用 PascalCase
string userId = 1; // 应该使用 snake_case
string DisplayName = 2;
}
版本兼容性设计
// 版本 1.0
message Product {
string id = 1;
string name = 2;
double price = 3;
}
// 版本 2.0 - 安全演化
message Product {
string id = 1;
string name = 2;
double price = 3;
// ✅ 添加新字段(向后兼容)
string description = 4;
repeated string images = 5;
// ✅ 使用 optional 标记可选字段
optional string discount_code = 6;
// ❌ 永远不要更改已有字段的类型或编号
// int32 price = 3; // 错误!不能改变字段类型
}
避免深层嵌套
// ❌ 过度嵌套(难以维护和解析)
message DeepNesting {
message Level1 {
message Level2 {
message Level3 {
message Level4 {
string data = 1;
}
Level4 level4 = 1;
}
Level3 level3 = 1;
}
Level2 level2 = 1;
}
Level1 level1 = 1;
}
// ✅ 扁平化设计
message FlatDesign {
string level1_id = 1;
string level2_id = 2;
string level3_id = 3;
string data = 4;
}
跨语言类型映射原则
// ⚠️ 不同语言的类型映射存在差异
message DataTypes {
int64 timestamp = 1; // JavaScript: string | Long
uint64 user_id = 2; // JavaScript: string | Long
bytes file_data = 3; // JavaScript: Uint8Array
map<string, int32> stats = 4; // JavaScript: Object | Map
}
各语言类型映射注意事项:
JavaScript / TypeScript
message JSExample {
// ❌ 精度问题:JavaScript Number 只支持 53 位整数
int64 big_number = 1; // 映射为 string 或 Long 对象
// ✅ 推荐:使用字符串类型
string big_number_str = 2;
// ✅ 或者在 JavaScript 中显式处理
// protobufjs 配置:{ longs: String }
}
Go
message GoExample {
bytes data = 1; // 映射为 []byte(零拷贝)
map<string, int32> counts = 2; // 映射为 map[string]int32
}
Python
message PythonExample {
int64 value = 1; // 映射为 int(无精度限制)
bytes data = 2; // 映射为 bytes
repeated string tags = 3; // 映射为 list[str]
}
最佳实践:
- 避免使用
int64/uint64在 JavaScript 中:改用string或配置 protobufjs - 统一 bytes 的处理逻辑:在各语言中使用一致的编码格式(如 Base64)
- 测试跨语言兼容性:确保序列化/反序列化在不同语言间正常工作
- 文档化类型映射:在 proto 文件中注释特殊类型的处理方式
类型字段(Type Field)区分不同消息类型
Protocol Buffers 提供了多种实现类型字段的方案,每种方案都有其适用场景:
方案 1:使用 oneof(类型联合)
message Event {
oneof event_type {
UserCreatedEvent user_created = 1;
UserUpdatedEvent user_updated = 2;
UserDeletedEvent user_deleted = 3;
}
int64 timestamp = 4;
}
message UserCreatedEvent {
string user_id = 1;
string user_name = 2;
string email = 3;
}
message UserUpdatedEvent {
string user_id = 1;
string user_name = 2;
optional string email = 3; // 可选更新
}
message UserDeletedEvent {
string user_id = 1;
string reason = 2;
}
优点:
- 类型安全:编译时检查,避免拼写错误
- 字段分离:每种事件有独立的字段定义
- 自动识别:protobuf 自动设置和检测 oneof 字段
- 代码生成:生成的代码有更好的类型提示
方案 2:使用枚举类型字段
message Event {
EventType type = 1; // 使用枚举而非字符串
enum EventType {
EVENT_TYPE_UNSPECIFIED = 0;
EVENT_TYPE_USER_CREATED = 1;
EVENT_TYPE_USER_UPDATED = 2;
EVENT_TYPE_USER_DELETED = 3;
}
string user_id = 2;
optional string user_name = 3;
optional string email = 4;
optional string reason = 5;
int64 timestamp = 6;
}
优点:
- 类型安全:枚举提供编译时检查
- 序列化效率:枚举编码为整数,比字符串更小
- 向后兼容:可以安全添加新的枚举值
缺点:
- 字段冗余:所有事件共享相同的字段集合
- 语义不清晰:需要文档说明哪些字段在哪种类型下有效
区分"未设置"和"设为默认值"
Proto3 中,普通字段的默认值不会被序列化,导致无法区分"未设置"和"设为默认值":
message Config {
bool enabled = 1; // 默认 false
int32 timeout = 2; // 默认 0
}
// 问题:enabled=false 的消息和空消息在二进制上完全相同
// 无法区分用户主动设置为 false 还是未设置
当需要区分这两种状态时,有两种解决方案:
方案 1:使用显式 optional(简单、标准),Proto3.15+ 原生支持
message Config {
optional bool enabled = 1; // 有存在性检测:可以区分 null、true、false
optional int32 timeout = 2; // 有存在性检测:可以区分 null、0、其他值
}
方案 2:使用 zeroFields/nullFields 数组(复杂、高效):zero_fields:存储被明确设置为零值的字段编号;null_fields:存储应该视为未设置的字段编号
message Properties {
float opacity = 105; // 普通字段:默认值不序列化
int32 z_index = 106;
string color = 107;
// ... 100 个属性
// 特殊字段:记录状态
repeated uint32 zero_fields = 9998 [packed = false];
repeated uint32 null_fields = 9999 [packed = false];
}
三种状态的表示:
// 1. 用户设置 opacity = 0(明确的零值)
{
opacity: 0,
zero_fields: [105] // 字段编号 105
}
// 2. 用户未设置 opacity(null/继承)
{
// opacity 字段不出现在消息中
null_fields: [105]
}
// 3. 用户设置 opacity = 0.5(正常值)
{
opacity: 0.5
// 无需特殊标记
}
在 JavaScript 中使用 Protocol Buffers
Protocol Buffers 在 JavaScript 中的使用主要依赖于 protobufjs 库。下面我们将详细介绍如何在 JavaScript 项目中使用 Protocol Buffers。
使用示例
- 安装必要的依赖:
npm install protobufjs - 定义 .proto 文件
首先创建一个 person.proto 文件:
syntax = "proto3";
message Person {
string name = 1;
int32 id = 2;
string email = 3;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
string number = 1;
PhoneType type = 2;
}
repeated PhoneNumber phones = 4;
}
- JavaScript 中使用
const protobuf = require("protobufjs");
// 加载 .proto 文件
protobuf.load("person.proto", function (err, root) {
if (err) throw err;
// 获取 Person 消息类型
const Person = root.lookupType("Person");
// 创建消息对象
const payload = {
name: "张三",
id: 1234,
email: "zhangsan@example.com",
phones: [
{
number: "1234567890",
type: "MOBILE",
},
{
number: "0987654321",
type: "HOME",
},
],
};
// 验证消息对象
const errMsg = Person.verify(payload);
if (errMsg) throw Error(errMsg);
// 创建消息实例
const message = Person.create(payload);
// 编码消息
const buffer = Person.encode(message).finish();
console.log("编码后的数据:", buffer);
// 解码消息
const decoded = Person.decode(buffer);
console.log("解码后的数据:", Person.toObject(decoded));
});
代码生成
对于大型应用,可以使用 protobufjs 的静态代码生成功能。编译器会根据 .proto 文件生成对应语言的代码。这些生成的代码包含了序列化和反序列化所需的所有功能。
# 安装 protobufjs-cli
npm install -g protobufjs-cli
# 生成静态代码
pbjs -t static-module -w commonjs -o person.js person.proto
pbts -o person.d.ts person.js
以之前的 Person 消息为例,生成的 JavaScript 代码大致如下:
// 生成的代码结构示意
const Person = {
// 消息类型定义
type: {
name: "Person",
fields: {
name: { type: "string", id: 1 },
id: { type: "int32", id: 2 },
email: { type: "string", id: 3 },
phones: {
type: "repeated",
id: 4,
message: PhoneNumber,
},
},
},
// 创建消息实例
create: function (properties) {
return Object.create(this.type, properties);
},
// 编码消息
encode: function (message) {
return {
finish: function () {
// 返回二进制缓冲区
return Buffer.from(/* 编码后的数据 */);
},
};
},
// 解码消息
decode: function (buffer) {
// 从二进制缓冲区解析数据
return /* 解析后的消息对象 */;
},
// 验证消息
verify: function (message) {
// 验证消息是否符合定义
return null; // 返回 null 表示验证通过
},
};
生成代码的主要功能
- 类型定义
- 定义了消息的结构
- 包含字段类型和编号信息
- 定义了枚举值和嵌套消息
- 消息创建
create()方法用于创建新的消息实例- 自动处理默认值
- 支持部分字段初始化
- 序列化功能
encode()方法将消息对象转换为二进制格式- 处理字段编号和类型信息
- 优化数据存储空间
- 反序列化功能
decode()方法将二进制数据转换回消息对象- 处理字段验证和类型转换
- 支持向后兼容性