【protobuf】protobuf语法及序列化原理
protobuf
- 一、背景
- 1. 序列化
- 1.1 定义
- 1.2 目的
- 2. 反序列化
- 2.1 定义
- 2.2 目的
- 3. Protocol Buffers
- 二、protobuf语法(proto3)
- 1. 定义一个Message
- 2. 字段类型
- 2.1 标量类型
- 2.2 枚举类型
- 2.3 map类型
- 3. 分配字段编号
- 4. 指定字段规则
- 三、举例分析protobuf序列化原理
- 1. 举例
- 2. 序列化结果分析
- 2.1
- 2.2
参考:https://cloud.tencent.com/developer/article/1520442
一、背景
序列化和反序列化是分布式系统、网络通信和数据存储中非常重要的概念。它们的主要目的是将数据转换为一种可以传输或存储的格式,并在需要时恢复原始数据。
1. 序列化
1.1 定义
序列化 是将数据结构或对象转换为一种可以存储或传输的格式的过程。常见的序列化格式包括 JSON、XML、Protocol Buffers、Thrift 等。
1.2 目的
- 数据传输:
- 在网络通信中,数据需要在不同的系统或服务之间传输。序列化将数据转换为字节流,使其可以通过网络传输。
- 数据存储:
- 在持久化存储中,数据需要保存到文件、数据库或其他存储介质。序列化将数据转换为一种可以存储的格式。
- 跨语言通信:
- 在多语言环境中,不同编程语言之间的数据结构可能不兼容。序列化将数据转换为一种通用格式,使得不同语言的系统可以互相通信。
2. 反序列化
2.1 定义
反序列化 是将序列化后的数据恢复为原始数据结构或对象的过程。
2.2 目的
- 数据恢复:
- 在接收到序列化的数据后,需要将其恢复为原始的数据结构,以便进行处理和操作。
- 数据读取:
- 从存储介质中读取序列化的数据后,需要将其反序列化为原始的数据结构,以便使用。
3. Protocol Buffers
Protobuf是我们在网络传输中经常会用到的协议,优点是版本间兼容性强,对数据序列化时的极致压缩使得Protobuf包体积比xml、json等格式要小很多,节约流量。数据不管在代码中是什么复杂结构体,传输时都要序列化成二进制串。
二、protobuf语法(proto3)
1. 定义一个Message
message用于定义结构数据,可以包含多种类型字段(field),每个字段声明以分号结尾。message经过protoc编译后会生成对应的class类,field则会生成对应的方法。
定义一个搜索请求(搜索请求:search request)的message,那么就需要有query的string字段、你感兴趣的结果所在的数据页的页码page_number以及每页上的结果的数量result_per_page。
syntax = "proto3"; // 表示使用的protobuf版本是proto3
package exmaple; //为.proto文件添加package声明符,可以防止不同 .proto项目间消息类型的命名发生冲突。
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
- 文件的第一行声明标识你使用proto3语法:如果你不写这句声明,pb编译器会假设你使用的proto2。它必须是.proto文件中第一个非空、非注释的语句。
- SearchRequest类型中声明了三个字段,每个字段都是键值对,都是用来在SearchRequest中储存对应信息的。每个字段都有一个名字和类型。
2. 字段类型
- string类型,默认值是空字符串。
- bytes类型,默认值是空bytes。
- bool类型,默认值是false。
- 数字类型,默认值是0。
- 枚举类型,默认值是第一个枚举值,即0。
- repeated修饰的属性,默认值是空。
2.1 标量类型
.proto类型 | Java类型 | 备注 |
---|---|---|
double | double | |
bool | boolean | |
string | String | 一个字符串必须是UTF-8 或 7-bit ASCII编码 的文本 |
bytes | ByteString | 可能包含任意顺序的字节数据 |
int32 | int | 如果你的字段可能含有负数,使用sint32 |
int64 | long | 如果你的字段可能含有负数,使用sint64 |
uint32 | int | 如果你的字段可能含有负数,使用sint64 |
uint64 | long | 如果你的字段可能含有负数,使用sint64 |
fixed32 | int | 总是4个字节 |
fixed64 | long | 总是8个字节 |
sfixed32 | int | 总是4个字节 |
sfixed64 | long | 总是8个字节 |
2.2 枚举类型
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
enum Corpus {
UNIVERSAL = 0;
WEB = 1;
IMAGES = 2;
LOCAL = 3;
NEWS = 4;
PRODUCTS = 5;
VIDEO = 6;
}
Corpus corpus = 4;
}
- 枚举类型中第一个元素的值必须从0开始,而且proto3中删除了default标记,默认值为第一个元素。
- 当枚举类型是在某一个消息内部定义,但是希望在另一个消息中使用时,需要采用MessageType.EnumType的语法格式。
2.3 map类型
protobuf中的map类似与STL中的关联型容器相似,map是key-value类型,key可以是int或者string,value可以是自定义message。
map<key_type, value_type> map_field = N;
// 与上述定义等价
message MapFieldEntry {
key_type key = 1;
value_type value = 2;
}
repeated MapFieldEntry map_field = N;
- map不能定义为repeated类型。
- 当为.proto文件产生生成文本格式的时候,map会按照key 的顺序排序,数值化的key会按照数值排序。
- 从序列化中解析时,如果有重复的key,只会使用第一个key。
3. 分配字段编号
message定义中的每个字段都有一个唯一编号。这些编号用来在message编码后的二进制数据中来区分各个字段,一旦你的message开始使用就不可以改变其字段的编号。
1-15的字段号使用一个字节进行编码,内容包括字段号和字段类型。16-2047的字段号占用两个字节进行编码。因此,你应该把1-15保留为经常使用的字段编号。记得要保留几个1-15的编号,以便为后续添加的频繁使用的字段进行编号。
4. 指定字段规则
message的字段可以是下面的一种:
- singular规则:一个message可以有零个或者一个singular字段(但是不会超过一个)。这也是proto3语法的默认字段规则。
- repeated规则:使用repeated修饰的字段,可以重复任何次(包括零次)。(其实就是一个数组)。repeated数组中元素的顺序是固定的。
三、举例分析protobuf序列化原理
1. 举例
syntax = "proto3";
package exmaple;
message Child {
sint64 fsint64 = 1;
}
enum DayOfWeek {
MONDAY = 0;
TUESDAY = 1;
WEDENESDAY = 2;
THURSDAY = 3;
FRIDAY = 4;
SATURDAY = 5;
SUNDAY = 6;
}
message AllDataType {
int32 fint32 = 1;
int64 fint64 = 2;
uint32 fuint32 = 3;
uint64 fuint64 = 4;
sint32 fsint32 = 5;
sint64 fsint64 = 6;
fixed32 ffixed32 = 7;
fixed64 ffixed64 = 8;
double fdouble = 9;
float ffloat = 10;
bool fbool = 11;
DayOfWeek fenum = 12;
Child fmessage = 13;
map<uint32, double> fmap = 14;
repeated bool frepeatbool = 15;
string fstring = 16;
bytes fbytes = 17;
sfixed32 fsfixed32 = 18;
sfixed64 fsfixed64 = 19;
}
AllDataType
这一结构体中包含了Protobuf所支持的全部数据类型。
下一步,使用protoc编译该proto文件,并在程序中声明一个AllDataType
类型的数据,将其序列化,并打印出来。
package main
import (
"fmt"
"<path-to>/example"
"github.com/golang/protobuf/proto"
)
func main() {
test := example.AllDataType{
Fint32: 257,
Fint64: -2,
Fuint32: 1,
Fuint64: 1025,
Fsint32: 0,
Fsint64: -2,
Ffixed32: 17,
Ffixed64: 2049,
Fdouble: -0.1,
Ffloat: 0.6,
Fbool: true,
Fenum: example.DayOfWeek_SUNDAY,
Fmessage: &example.Child{Fsint64: 3},
Fmap: map[uint32]float64{3: -0.1, 0: 2.12},
Frepeatbool: []bool{true, false, true},
Fstring: "Hello World",
Fbytes: []byte{129, 0, 19, 56},
Fsfixed32: 12345,
Fsfixed64: 54321,
}
data, _ := proto.Marshal(&test) // protobuf将结构体序列化为二进制串
fmt.Println(data) // 打印AllDataType类型的数据序列化后的二进制串
}
最后一行打印的结果为:
2. 序列化结果分析
接下来就是最关键的一幅图,我们逐个字节地来分析一下上面的打印结果中,每个字节所代表的含义:
2.1
图中橙色部分(如第1行第1列,第1行第4列)用于表示字段field number(简写为fn)以及wire type(简写为wt)。其中field number是proto文件中标注的该字段数字代号,而wire type表示本字段的数据类型属于哪种归类,这些归类主要用于提醒反序列化程序如何判断本字段值占据几个字节。Wire type值与数据类型的映射关系为:
Write Type | 解释 | 数据类型 |
---|---|---|
0 | varint变长整型(见下文) | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
1 | 固定8字节 | fixed64, sfixed64, double |
2 | 需显式告知长度(见下文) | string, bytes, 嵌套类型(embedded messages),repeated字段 |
3 | (已废弃) | (已废弃) |
4 | (已废弃) | (已废弃) |
5 | 固定4字节 | fixed32, sfixed32, float |
因为wire type种类很少,为了进一步节约字节,write type只用了3个bits来表示,而fn则使用更高位来表示:
2.2
参考【1】中的表格,大部分整数类型的wire type都是varint变长整型。Varint简单说就是每个字节最高位不用来表示具体数值,只用来表示“本字节是不是这个数字的最后一个字节”。 0表示最后一个字节,1表示不是最后一字节、后面还有。因此如果要把varint还原为普通的二进制表示,需要去掉最高bit,把剩下的7个bit组合起来看。
需要注意protobuf的varint采用类似小端模式,因此图中第1行第3列存的是高位,第2列是低位,转化十进制过程中需要把他们调换一下位置,其他使用varint的类型也是类似机制。