初次听闻 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 需要三步:
- 安装工具链,详见 官网
- 在
.proto
文件中编写你的 protobuf 格式 - 用
protoc
把.proto
文件编译成目标语言的代码 - 在目标语言中引用编译出来的代码,引用 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 确实蛮节省内存的。