Protocol Buffers (protobuf) 简介

448 阅读17分钟

Protocol Buffers(简称 Protobuf 或 Proto)是 Google 开发的一种语言中立、平台中立、可扩展的结构化数据序列化机制。它被设计用于序列化结构化数据,类似于 XML 和 JSON,但更小、更快、更简单。

核心优势

  1. 体积小
  • 二进制编码,比 JSON/XML 小 3-10 倍
  • 使用 varint 可变长度编码,节省空间
  • 字段编号而非字段名,减少冗余
  1. 速度快
  • 解析速度比 JSON 快 20-100 倍
  • 序列化速度快 5-10 倍
  • 无需解析文本,直接操作二进制数据
  1. 强类型系统
  • 编译时类型检查
  • 自动生成代码,减少错误
  • IDE 自动补全和类型提示
  1. 向后兼容性
  • 可以安全地添加新字段
  • 旧代码可以读取新格式数据
  • 新代码可以读取旧格式数据
  1. 跨语言支持
  • 支持 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名称说明适用字段类型
0Varint可变长度整数int32, int64, uint32, uint64, sint32, sint64, bool, enum
164-bit固定 8 字节fixed64, sfixed64, double
2Length-delimited长度前缀string, bytes, embedded messages, packed repeated fields
3Start group已废弃仅 Proto2 使用
4End group已废弃仅 Proto2 使用
532-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 字节数据]
}

解码过程:

  1. 读取 key 0x0A,提取字段编号 1 和 Wire Type 2
  2. 查询 proto schema,得知字段 1 对应类型是 Address
  3. 读取 length=10,知道接下来 10 字节是 Address 的数据
  4. 递归解码这 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 字节(只有编号 12

解码时,解析器通过查询 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。

使用示例

  1. 安装必要的依赖:npm install protobufjs
  2. 定义 .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;
}
  1. 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 表示验证通过
  },
};

生成代码的主要功能

  1. 类型定义
  • 定义了消息的结构
  • 包含字段类型和编号信息
  • 定义了枚举值和嵌套消息
  1. 消息创建
  • create() 方法用于创建新的消息实例
  • 自动处理默认值
  • 支持部分字段初始化
  1. 序列化功能
  • encode() 方法将消息对象转换为二进制格式
  • 处理字段编号和类型信息
  • 优化数据存储空间
  1. 反序列化功能
  • decode() 方法将二进制数据转换回消息对象
  • 处理字段验证和类型转换
  • 支持向后兼容性

参考资料