初次听闻 ProtoBuf,以为他是只是一种“二进制编码的 JSON 平替”。但事实与之相去甚远

基本理解

在此节中我会将 JSON 与 ProtoBuf 进行横向对比。

ProtoBuf 在设计上以“机器快速解析”为终极目标,并尽力减少不必要的数据(但不包括压缩)。为此它舍弃了 JSON 的以下优秀特性:

  • 自解释性:单个 JSON 字符串就可以解析出一个完整的以键值对为基础的对象;然而,ProtoBuf 将格式定义与数据分离,protobuf 数据必须依赖某个目标语言中预定义的类似 struct 的东西、配合 protobuf 的数据编码解码算法才能够解析。
  • 可读性:很明显,作为一种二进制编码,它是不可读的。
  • 简单性/易手搓:JSON 如此流行不可不归功于其语法的简洁明了,这也意味着对于一个新语言而言支持非常简单。而 ProtoBuf 定义了一个新的数据结构,有点点复杂,不太好实现。

当然肯定有不少的优秀特性:

  • 高速解析:由于格式定义和数据的分离,格式定义被预编译进了代码内部,且以二进制编码数据,从而减少了解析时键名解析、类型判断、数值解析等开销,实现了 3-100 倍的解析性能提升。
  • 节省空间1:ProtoBuf 通过应用编号(Field Numbers)代替了键名,节省了寻键的开销和键的内存占用;ProtoBuf 用 Varint 和 ZigZag 减少了整数这种数据类型的内存占用。最重要的是,ProtoBuf 中所有部分都是可选的,实际传输中可以只传输要传输的那一部分,从而减少传输数据量;而不必像固定 struct 一样不论数据需不需要都会占固定内存。
  • 可变长度2:ProtoBuf LEB128 表示变长数据的长度,实现了非标记(例如 JSON的 {})变长度。

个人感觉,ProtoBuf 是 Golang 开发者们为了 Golang 这样的静态语言而设计出的高效、扩展性强的结构化可选数据表示方式。如果把静态考虑在内,ProtoBuf 的确是最好的结构化可选数据表示方式。

打开方式

使用 ProtoBuf 需要三步:

  1. 安装工具链,详见 官网
  2. .proto 文件中编写你的 protobuf 格式
  3. protoc.proto 文件编译成目标语言的代码
  4. 在目标语言中引用编译出来的代码,引用 Google ProtoBuf 官方库,解析编码你的 ProtoBuf 数据

ProtoBuf 语法3

最基础的 protobuf 文件定义示例如下:

// 单行注释 或者 /*多行注释*/
// 声明使用 v3 协议;否则将按照 v2 协议解析
// 必须放在(注释后的)第一行
syntax = "proto3"; 

// 定义了一个叫 Request 的 message
// message 类似于 struct,定义了一个结构化数据提的基础结构
// 建议用 Pascal 命名法
message Request {
// 每一个变量都必须手动指定其 message 内唯一编号(Field Number)
// 处于空间考虑编号建议不要超过 16,最好不要超过 18999;
// 编号实际可取 1...18999 与 20000...536870911
// 建议变量名用蛇形/下划线命名法
// 类型    变量名  变量编号
  int32 page_number = 1;
}

// 单文件可以定义多个 message
// 但是官方建议非强相关的 message 尽量放到不同文件中
message Response {
// 不同 message 中编号可以重复
  bool ok = 1;
}

protobuf 支持很多数据类型,具体请见 Scalar Value Types

下面是更多语法特性的介绍:

syntax = "proto3"; 

// 定义包名,为了外部引用(见下方)
package websiteproto;
// 为不同语言定义包名
option csharp_namespace = "Myrepo.Website.Websiteproto";
option go_package = "github.com/mygit/myrepo/main/website/websiteproto";
option java_package = "com.myapp.app.website.websiteproto";
//如果为 true,每个 message 和 service 都会被生成为一个类。
// 如果是 false,则所有的 message 和 service 都将会是 java_outer_classname 的内部类
option java_multiple_files = true;

message Record {
// optional 标志表示这个变量可以不赋值,采用默认值,减少传输
// 其实不写 optional 也是这样操作的。这只是 v2 语法的兼容
// 这个标志已经事实上被废弃了,详见 https://stackoverflow.com/a/31814967
  optional bool support_v6 = 1;
// repeated 标志表示这是一个列表
// 例如这个例子中实际声明的是一个 uint32 ip4[]
  repeated uint32 ip4 = 2;
// repeated 不可以多层嵌套 
// repeated repeated uint32 ip6 = 3; // 错误的!
// 当然可以用声明一个新的 message 的形式间接实现
// 这里就用 bytes 这个变长类型实现了某种意义上的二维数组
  repeated bytes ip6 = 4;
// reserve 用来保留一个不能被用到的编号
// 一个常见的用途就是删去某个过去的编号后,出于防患于未然考虑把这个编号禁止
  reserved 3, 15, 9 to 11;
}

message IP {
  // enum 用来声明枚举类型
  // protoc 会自动生成 value->string 的映射,在错误输出时很方便
  enum IPtype {
  // 建议全大写
  // 必须第一个指定一个值为 0 的作为默认值
    IPTYPE_UNSPECIFIED = 0;
  // 值不能超过 2^32
    IPTYPE_V4 = 1;
    IPTYPE_V6 = 2;
  // 使用 option allow_alias = true; 选项允许重复
    option allow_alias = true;
    IPTYPE_DEFAULT = 2;
  // option 是对于一个区块的选项,可以在根上;它会改变 protobuf 的行为。
  }
  // 使用枚举类型
  IPtype iptype = 1;
  bytes ip = 2;
}

import "spider.proto" // 假设 package name 为 spider
message Website {
// map 保存了映射
// 如 https://protobuf.dev/programming-guides/proto3/#maps-features 所示
// map 有很多限制。
  map<string, Record> record = 1;
  string name = 2;
// 引入外部 protobuf 格式
  spider.Record = 3; 
// oneof 表示其内部键中只会取到一个,因而复用了内存。
// oneof 不能直接使用 map 和 repeated,但可以用 message 包装后间接使用
  oneof payload {
    Page1 page1=4;
    Page2 page2=5;
  }
}

其他语法详见 Language Guide (proto 3),这里不再赘述。

Golang 使用

首先安装工具链:在 Protocol Buffers Releases 下载protobuf 的编译器放到 PATH 中,随后通过 go install google.golang.org/protobuf/cmd/protoc-gen-go@latest 安装 protoc-gen-go;通过 go get github.com/golang/protobuf 安装代码依赖。

然后编写好 protobuf,通过 protoc --go_out=<output path> <.proto file> 生成 .pb.go 文件。注意要在项目根目录下生成,因为 protoc 会把当前目录作为根目录。相应地 go_package 决定了生成的 package 名和相对根目录的路径。这时可以运行一下 go mod tidy 安装一下依赖。

最后在 golang 代码中使用。

示例代码

syntax = "proto3"; 

package Websiteproto;

option go_package = "/myproto";

message Record {
  bool support_v6 = 1;
  repeated string ip4 = 2;
  repeated string ip6 = 4;
}

message Website {
  Record website_record = 1;
  string name = 2;
}
package main

import (
	"fmt"
	"log"
	"testproto/myproto" // 引入我们的 proto

	"google.golang.org/protobuf/proto" // 引入官方库用于解析
)

func main() {
    // 初始化一个 golang struct
	p := &myproto.Website{
		// 注意 键名会被自动转换为驼峰命名法
		WebsiteRecord: &myproto.Record{
			SupportV6: true,
			Ip4:       []string{"8.8.8.8"},
			Ip6:       []string{"2001:4860:4860::8888"},
		},
		Name: "dns.google",
	}
	// 通过 proto.Marshal 编码为 protobuf 数据
	data, err := proto.Marshal(p)
	if err != nil {
		log.Fatal(err)
	}
	
	fmt.Println("marshal data len:", len(data))
	
    // 新建一个 golang struct
	np := &myproto.Website{}
    // 通过 proto.Unmarshal(data, destination) 解析 protobuf 数据
	if err = proto.Unmarshal(data, np); err != nil {
		log.Fatal(err)
	}
	// 解析完就可以当一个普通的 golang struct 对象来赋值、读取
	fmt.Println("unmarshal domain name:", np.Name)
}

输出

marshal data len: 47
unmarshal domain name: dns.google

相应的 JSON 如下:

{
    "record": {
        "support_v6": true,
        "ip4": ["8.8.8.8"],
        "ip6": ["2001:4860:4860::8888"]
    },
    "name": "dns.google"
}

即使去掉多于字符也有 100 byte。考虑到这个例子中 string 类型数据本身就要占 37 bytes,protobuf 确实蛮节省内存的。

参考