Skip to content

一文掌握 Protobuf

概述

Protocol Buffers are a language-neutral, platform-neutral extensible mechanism for serializing structured data.

Protobuf 是一种语言无关、平台无关、可扩展的 序列化 机制。

Protobuf 具有以下特点:

  • 数据结构紧凑
  • 解析速度快
  • 跨编程语言

工作机制

Protobuf 工作机制如下图所示: protobuf-workflow.png 图中存在以下几个关键概念:

  • .proto 数据格式定义文件:使用特定的语法定义消息的结构。
  • protoc 编译器:将 .proto 定义编译成特定语言的源代码。
  • 消息编码格式:我认为的 protobuf 的核心。

使用案例及建议

许多项目使用了 protobuf,例如:

另外,官方建议在如下场景应 避免 使用 protobuf:

  • protobuf 假设消息会一次性加载到内存,因此不适合大型数据。
  • 同样的数据被 protobuf 序列化后得到的字节流可能不同,因此不适合直接通过字节流比较数据是否相同的场景。
  • 对于数据压缩的需求场景,由于 protobuf 的编码格式不是专门的数据压缩算法,针对图像、视频等数据,应使用更专用的算法。
  • 对于涉及大型多维浮点数数组的数据科学场景,应使用更有效率的算法。
  • 对于非面向对象的编程语言支持不完善。
  • protobuf 消息本身不能描述消息的内容,必须结合 .proto 文件解析后才可使用。
  • protobuf 不是正式标准,在使用时需要考虑是否合法合规。

Proto3 语法

Protobuf 设计了一套专用语言来定义结构化数据,该定义需保存在 .proto 为后缀的文件中。

Protobuf 定义语言分为 proto2 和 proto3 两个版本,这里只介绍 proto3 版本。

Message 类型

Message 类型用于定义一个消息,下面是一个例子:

proto
syntax = "proto3";

/* SearchRequest represents a search query, with pagination options to
 * indicate which results to include in the response. */

message SearchRequest {
  string query = 1;
  int32 page_number = 2;  // Which page number do we want?
  int32 results_per_page = 3;  // Number of results to return per page.
}

其中:

  • 第一行指定了使用 proto3 版本,如果不写则默认为 proto2。
  • 消息体中的字段定义格式为:[type] [field_name] = [field_no];
  • /* ... */ - 行间注释;// - 行内注释。

字段编号

必须 为每个字段定义字段编号。在编码时,会使用字段编号 标识 字段,而不是字段名称。

字段编号有以下限制:

  • 每个字段的字段编号必须唯一。
  • 范围为 [1, 536,870,911],且 [19,000, 19,999] 为保留编号。
  • 不能 使用之前使用过的字段编号,包括已经弃用的编号。

更改 & 删除字段

CAUTION

在更改 & 删除字段时需要格外小心,若操作不当会引起严重问题。

由于 protobuf 使用字段编号标识字段,重用字段编号会导致数据解析错误。 因此,在删除字段时需要 声明 弃用的字段编号,以防后面定义新字段时重用编号。

此外,protobuf 还可以将消息序列化成 JSON 或 文本(Text)格式的数据,因此,弃用的字段名称也需要声明。

Protobuf 提供了 reserved 关键字声明弃用的字段编号及名称。

proto
message Foo {
  reserved 2, 15, 9 to 11;
  reserved "foo", "bar";
}

更多注意事项参考 Updating A Message Type

字段标签

我们可以为字段指定额外的标签(Label),例如:

proto
message Result {
  string url = 1;
  string title = 2;
  repeated string snippets = 3;
}

字段标签有以下值:

  • optional:若未设置该值,则返回默认值,此时该字段不会被序列化。
  • repeated:列表,列表中元素的顺序将被保留。
  • map:键值对。
  • 不指定(默认情况):若该值为默认值,则不会被序列化。

字段默认值

消息被解析后,若没有值,则返回默认值。各类型的默认值如下:

  • string:空字符串。
  • bytes:空 bytes。
  • bool:false。
  • 数值型:0。
  • enum:第一个 枚举值。
  • Message 类型:取决于变成语言,具体参考 generated code guide

对于带有 repeated 标签的类型,默认值为空(具体取值取决于编程语言,通常为空列表)。

类型组合使用

多个 message 类型可以 组合 使用,例如:

proto
message SearchResponse {
  repeated Result results = 1;
}

message Result {
  string url = 1;
  string title = 2;
  repeated string snippets = 3;
}

也可以在一个 message 类型 内部 定义其他 message 类型,例如:

proto
message SearchResponse {
  message Result {
    string url = 1;
    string title = 2;
    repeated string snippets = 3;
  }
  repeated Result results = 1;
}

Enums

下面的例子展示了如何定义一个枚举类型:

proto
enum Corpus {
  CORPUS_UNSPECIFIED = 0;
  CORPUS_UNIVERSAL = 1;
  CORPUS_WEB = 2;
  CORPUS_IMAGES = 3;
  CORPUS_LOCAL = 4;
  CORPUS_NEWS = 5;
  CORPUS_PRODUCTS = 6;
  CORPUS_VIDEO = 7;
}

IMPORTANT

  1. 枚举类型 必须 有一个编号为 0 的值,该值也是这个枚举类型的 默认 值。
  2. 枚举类型的第一个值的编号 必须 为 0。

Maps

可以使用 map 标签定义键值对:

map<key_type, value_type> map_field = N;

下面是一个例子:

proto
map<string, Project> projects = 3;

WARNING

map 不能与 repeated 叠加使用。

未知字段

当消息格式发生变化时,旧版本的解析程序可能会无法解析新的数据。

Proto3 消息 保留 未知字段,并在解析期间和序列化输出中包含它们,这与 proto2 行为相匹配。

Any

Any 类型是 protobuf 的一个内置类型,它可以包含任意序列化后的字节流。要使用 Any,需要导入 google/protobuf/any.proto 文件。

proto
import "google/protobuf/any.proto";

message ErrorStatus {
  string message = 1;
  repeated google.protobuf.Any details = 2;
}

Oneof

如果消息包含多个字段,并且同时 最多 设置一个字段,则可以使用 oneof 关键字进行定义,该特性会节省内存。

proto
message SampleMessage {
  oneof test_oneof {
    string name = 4;
    SubMessage sub_message = 9;
  }
}

Oneof 具有如下特性:

  • 在编写用户代码时,为 oneof 包裹的字段赋值会自动清除其余的值。

    下面是一个 C++ 代码的例子:

    c++
    SampleMessage message;
    message.set_name("name");
    CHECK_EQ(message.name(), "name");
    // Calling mutable_sub_message() will clear the name field and will set
    // sub_message to a new instance of SubMessage with none of its fields set.
    message.mutable_sub_message();
    CHECK(message.name().empty());
  • 反序列化时若存在多个值,则 最后 一个值有效。

  • 不能 添加 repeated 标签。

从其他 .proto 文件导入定义

可以使用 import 关键字导入其他文件的消息定义:

proto
import "myproject/other_protos.proto";

可以使用 import public 传递依赖项,下面是一个经历版本迭代后抽离公共消息定义的例子:

proto
// new.proto
// 将通用的消息定义从 old.proto 移动到这里
proto
// old.proto
// 导入通用的消息定义和其他消息定义
import public "new.proto";
import "other.proto";
proto
// client.proto
import "old.proto";
// 这里只会导入公共的消息定义,而不会导入其他的消息定义
// 因此无需做任何修改

Packages

可以使用 package 关键字定义消息所在的包名:

proto
package foo.bar;
message Open { ... }

可以使用全限定类型名指定字段的类型:

proto
message Foo {
  ...
  foo.bar.Open open = 1;
  ...
}

Services

当与 RPC 一起使用时,可以在 .proto 文件中定义 RPC 服务接口,protobuf 编译器将会根据语言生成对应的源代码。

proto
service SearchService {
  rpc Search(SearchRequest) returns (SearchResponse);
}

Wire 格式编码

下面用一个例子简单感受一下 protobuf 简洁高效的编码方式。

proto
message Test1 {
  optional int32 a = 1;
}

假设字段 a 的值为 150,上面的消息编码的结果为

08 96 01

Base 128 Varints 算法

可变宽度整数(Variable-width integers, or varints)是 wire 格式编码的核心, 它可以使用 更少的位数 对整型数字进行编码。

在前面的例子中,字段 a 的值为 32 位整数 150,其二进制表示如下:

00000000 00000000 00000000 10010110

可见前 24 位均为 0,实际有效位数只有最后 8 位。

而在实际生产实践中,越小的数字往往越经常使用。Base 128 Varints 算法也是基于这个现实提出的。

下面看一下编码过程:

# 32 位整数 150 的二进制表示
00000000 00000000 00000000 10010110

# 从低位开始,按每 7 位一组进行分组,并抛弃全部为 0 的组(无效数据)
0000001 0010110

# 按小端序排序(低位在前,高位在后)
0010110
0000001

# 为每组数据在头部添加标志位,1 代表后面还有数据,0 代表是最后一组
1 0010110
0 0000001

# 将各组拼接到一起
10010110 00000001 -> 96 01(十六进制)

解码过程与编码过程相反:

十六进制 96 01 的二进制表示为
10010110 00000001

丢弃各组的第一位标志位
0010110 0000001

由于编码时使用的是小端序(低位在前,高位在后),解码时需要恢复原来的顺序。
0000001 0010110

将各组拼接起来
00000010010110 -> 150(十进制)

通过上面的例子可知,原本 32 位整数 150 在编码过后只占 16 位,空间占用降低 50% 。

TLV 消息结构

Tag-Length-Value(TLV) 是通信协议中常用的编码格式,SSHTLS 等协议都使用了 TLV 格式的报文。

Protobuf 使用 TLV 编码格式,每个字段会被转换成字段号、字段类型和有效负载组成的记录。

proto-message-structure.png

关于字段类型的取值可以参考 Message Structure

通过查询文档可知 VARINT 类型为 0, 又因为 VARINT 通过每字节的首位确定后面的字节是否属于有效负载, 因此不需要 Length 部分。

对于前面的例子: int32 a = 1;,其 Tag 部分为

field_no   type 
00001      000   -> 08(十六进制)

最后,将有效负载拼接到一起,就得到了本节开头中的编码结果:

Tag  Value
08   96 01

其他内容

前面仅介绍了针对无符号整型数据的编码,对于其他类型,protobuf 均提供了支持,这里不再一一列举。

更多内容请参考 Encoding

代码示例

Talk is cheap, show me the code.

Code is cheap, show me the GPT-4 account 😅

下面是一个在 Rust 中使用 protobuf 的例子。

NOTE

安装 proto 编译器

Protobuf GitHub 下载 protoc,并添加到系统路径。

设置完成后,可使用以下命令查看是否安装成功。

bash
protoc --version
-----------------
libprotoc 27.0

创建项目

bash
cargo new protobuf --lib

添加依赖

toml
# Cargo.toml

# ...

[dependencies]
bytes = "1"
prost = "0.12"
hex = "0.4"

[build-dependencies]
prost-build = "0.12"

编写 .proto 消息定义文件

proto
// src/rpc.proto

syntax = "proto3";

package rpc.proto;

enum Corpus {
  CORPUS_UNSPECIFIED = 0;
  CORPUS_UNIVERSAL = 1;
  CORPUS_WEB = 2;
  CORPUS_IMAGES = 3;
  CORPUS_LOCAL = 4;
  CORPUS_NEWS = 5;
  CORPUS_PRODUCTS = 6;
  CORPUS_VIDEO = 7;
}

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 results_per_page = 3;
  Corpus corpus = 4;
}

message SearchResponse {
  message Result {
    string url = 1;
    string title = 2;
    repeated string snippets = 3;
  }
  int32 code = 1;
  string message = 2;
  repeated Result results = 3;
}

编写预编译脚本

rust
// build.rs

use std::io::Result;

// rust 构建脚本,构建 rust crate 之前的 Hook;
// 构建脚本必须在根目录,且文件名为 build.rs;
// 构建脚本需要的依赖项可以在 Cargo.toml 的 [build-dependencies] 中声明。
fn main() -> Result<()> {
    // 第一个参数指明 .proto 文件名,也可以带路径
    // 第二个参数指明 .proto 文件所在的目录
    // 生成的 rust 代码默认存放在 Cargo OUT_DIR 路径下,可以使用 include! 宏将其导入到我们的 rust 模块中,使用 env! 宏获取 OUT_DIR 的实际值。
    prost_build::compile_protos(&["rpc.proto"], &["src/"])?;
    Ok(())
}

编写用户代码

rust
// src/rpc.rs

// 使用 include! 宏导入 protoc 编译得到的 rust 代码
// "OUT_DIR" 是默认的 protobuf rust 代码输出位置。
// 注意 env! 宏获取的是编译期的环境变量,而 std::env::var() 函数获取的是运行时的环境变量
include!(concat!(env!("OUT_DIR"), "/rpc.proto.rs"));

#[cfg(test)]
mod tests {
    use bytes::BytesMut;
    use prost::Message;

    use crate::rpc;

    #[test]
    fn proto_message_test() {
        let req = rpc::SearchRequest {
            query: String::from("query string"),
            ..Default::default()
        };

     
        let mut buffer = BytesMut::new();
        
        // 序列化
        req.encode(&mut buffer).unwrap();

        println!("Serialized bytes for SearchRequest: {}", hex::encode(&buffer));

        // 反序列化
        let obj = rpc::SearchRequest::decode(&mut buffer).unwrap();
        
        println!("Deserialized result for SearchRequest: {:?}", obj);
    }
}

总结

Protobuf 是一种快速高效的序列化机制,适用于对于数据传输效率要求较高的场景。 此外,protobuf 是 gRPC 的默认序列化协议,因此适合与其一起使用。

由于序列化后的消息本身不具有自解释性,需要结合消息定义文件进行解析,且修改消息结构容易出现问题, 因此个人认为不适用于需求频繁变更的场景。 另外,在跨团队、跨公司协作的场景下,会增加系统对接的复杂性,因此对于公开的服务应谨慎使用。