本文目录
前言:之前写项目的时候只是简单用了下Protobuf,以为就弄懂protobuf了,今天刚好跟朋友聊天,被朋友拷打知不知道Protobuf原理,ok,确实不是很懂, 找了一些文章看看来搞懂,于是就有了这篇文章。
Protobuf_4">1. Protobuf介绍
在网络通信和数据存储的时候,数据序列化 是非常重要的,特别是现在微服务横行,序列化更是至关重要。传统HTTP通信的时候,一般都是用Json作为消息传递的数据格式。但是谷歌一直在用Protobuf,这肯定是有原因的,所以特意学习了一下Protobuf,来研究研究。
Protobuf(Protocol Buffers)
是由 Google 开发的一种轻量级、高效的数据交换格式,它被用于结构化数据的序列化、反序列化和传输。相比于 XML 和 JSON 等文本格式,Protobuf 具有更小的数据体积、更快的解析速度和更强的可扩展性。
核心思想:使用协议(Protocol)
来定义数据的结构和编码方式
。使用 Protobuf,可以先定义数据的结构
和各字段的类型
、字段
等信息,然后使用Protobuf提供的编译器生成对应的代码,用于序列化和反序列化数据。由于 Protobuf 是基于二进制编码
的,因此可以在数据传输和存储中实现更高效的数据交换,同时也可以跨语言
使用。
比如下面这张图,就是很好的一个例子。Java语言写序列化,然后接收端用Python进行反序列化。
Protobuf_16">2. Protobuf的优势
更小的数据量:Protobuf 的二进制编码通常比 XML 和 JSON 小 3-10 倍,因此在网络传输和存储数据时可以节省带宽和存储空间。
更快的序列化和反序列化速度:由于 Protobuf 使用二进制格式,所以序列化和反序列化速度比 XML 和 JSON 快得多。
跨语言:Protobuf 支持多种编程语言,可以使用不同的编程语言来编写客户端和服务端。这种跨语言的特性使得 Protobuf 受到很多开发者的欢迎(JSON 也是跨语言的)。
易于维护可扩展:Protobuf 使用 .proto 文件定义数据模型和数据格式,这种文件比 XML 和 JSON 更容易阅读和维护,且可以在不破坏原有协议的基础上,轻松添加或删除字段,实现版本升级和兼容性。
Protobuf_26">3. 编写Protobuf
// 文件:addressbook.proto
syntax = "proto3";// 指定 Protobuf 包名,防止有相同类名的message定义
package goprotobuf;// Go 生成的包路径 可以通过 go_package 选项指定
option go_package = "/";message Person {// =1,=2 作为序列化后的二进制编码中的字段的唯一标签,也因此,1-15 比 16 会少一个字节,所以尽量使用 1-15 来指定常用字段。optional int32 id = 1;optional string name = 2;optional string email = 3;enum PhoneType {MOBILE = 0;HOME = 1;WORK = 2;}message PhoneNumber {optional string number = 1;optional PhoneType type = 2;}repeated PhoneNumber phones = 4;
}message AddressBook {repeated Person people = 1;
}
头部全局定义
syntax = "proto3"
:指定 Protobuf 版本为版本3(最新版本)
option go_package = "/"
: Go 生成的包路径,可以通过 go_package 选项指定
消息结构具体定义
message Person
定一个了一个 Person 类。
其中:
修饰符 optional
表示可选字段,可以不赋值。
修饰符 repeated
表示数据重复多个,如数组,如 List。
修饰符 required
表示必要字段,必须给值,否则会报错 RuntimeException
,但是在 Protobuf 版本 3 中被移除。
字段类型定义
修饰符后面跟着的是 字段类型,比如 string
字符串、bytes
二进制数据类型、enum
枚举类型,message
消息类型,可以嵌套其他的消息类型。bool
布尔类型,只有两个值,true和false。
标签号
字段后面的 =1
这种 是作为 序列化之后的 二进制编码 中的 字段 的对应标签。因为protobuf消息在序列化之后是不包含字段信息的,只有对应的字段序号,所以节省了对应的空间。
尽量使用1-15编号,比16少一个字节,这里我们来讲讲为什么。
Base128_88">Base128编码
Protobuf 使用一种称为 Base 128
编码(也称为 LEB128 或 Protobuf 的 Varint
编码)来表示字段标签号和字段值。这种编码方式会根据数值的大小动态分配字节数,以节省空间。具体规则如下:
字段标签号的编码:
字段标签号在序列化时会被编码为 Varint 格式。Varint 编码是一种可变长度的编码方式,小数值占用的字节数更少。
Varint 编码的规则:
如果数值小于 128,占用 1 个字节。
如果数值大于等于 128,会占用多个字节(每个字节的最高位为 1,最后一个字节的最高位为 0)。
字段标签号的计算:
在 Protobuf 中,字段标签号会与字段类型信息结合,形成一个 Key
,用于标识字段。
Key 的计算公式为:Key = (FieldNumber << 3) | WireType
。
<<3
称为 左移运算符(Left Shift Operator),它将一个二进制数的所有位向左移动指定的位数,并在右侧填充零。具体来说,<<3 表示将一个数的二进制表示向左移动 3 位。
也就是说,左移 n 位的效果相当于将原数乘以2^n
。
其中,FieldNumber
是字段的标签号,WireType
是字段类型的编码(例如,0 表示 Varint 类型,2 表示 Length-Delimited 类型等)。
| WireType
是指 加上这个数值,比如:Key=(FieldNumber×8)+WireType
。
也就是标签号为 15 的字段,Key = (15 << 3) | 0 = 120
。
这些值都小于 128,因此可以使用 1 个字节 来表示。
因为 FieldNumber 是一个整数,而 WireType 的范围是 0 到 7,所以 FieldNumber 需要左移 3 位(即乘以 8),以便为 WireType 留出低 3 位的空间(这样就刚好能够容纳0-7,从二进制的角度来说)。这样,Key 值可以同时包含字段编号和字段类型的信息。
可能有很多人很好奇,1个字节应该可以表示0-255,而不是128.这里我们继续来看看。
在计算机中,一个字节(Byte)由 8 个位(Bit)组成。每个位可以是 0 或 1,因此一个字节可以表示 2^8=256
种不同的值,范围从 0 到 255。
但是在Protobu f的 Varint 编码中,一个字节可以表示的最大数值是 127,而不是 255。这是因为 Varint 编码使用最高位(即第 8 位)作为 继续位(Continuation Bit),用于指示是否还有更多的字节跟随。
如果最高位为 0,表示该字节是最后一个字节;如果最高位为 1,表示后面还有更多的字节。
所以当表示127的时候,就是 0111 1111
,也就是120+7=127
。
在二进制中,1000 0000 表示的十进制数值是 128。但在 Protobuf 的 Varint 编码中,这个二进制数的最高位是 1,表示它不是最后一个字节,后面还有更多的字节。因此,1000 0000 在 Varint 编码中表示的数值是 128,但它是多字节序列的一部分,而不是单独的一个字节。也就是它表示一个数值为 128 的多字节序列的开始
。
那么,1000 0000 0000 0000
为128吗?并不是。
再来总结下Varint编码的规则:每个字节的低 7 位用于存储数据,每个字节的最高位(第 8 位)用于表示是否还有后续字节:如果最高位是 1,表示后面还有更多字节。如果最高位是 0,表示这是最后一个字节。
这也就是为什么说明了 因此,使用 1-15 的标签号可以减少序列化后的数据大小,尤其是在消息中包含大量字段时,这种节省会更明显。这也是为什么 Protobuf 推荐将常用字段的标签号放在 1-15 的范围内。
4. TLV
TLV 是一种编码结构,用于描述数据的组织方式。TLV 是 Tag-Length-Value
的缩写,表示数据由三部分组成。
Tag(标签)
:用于标识字段的编号和类型。在 Protobuf
中,Tag 是由字段编号(field number)和线缆类型(wire type)
组合而成的,通过公式 (field_number << 3) | wire_type
编码。
Length(长度)
表示 Value 部分的长度。对于某些数据类型(如字符串、嵌套消息等,即string、bytes、embedded messages
),Length 是必要的;而对于一些固定长度的类型(如 int32、fixed64 等),Length 可能会被省略。
Value(值)
:是实际存储的数据内容
比如
message Person {int32 id = 1;string name = 2;
}
对应的实例为
id: 123
name: "Alice"
其二进制编码可能如下:
08 7B
:08 是 Tag(字段编号 1,类型为 VARINT),7B 是 Value(123 的 Varint 编码),int类型不需要显示指定长度。
对于存储Varint编码数据,就不需要存储字节长度 Length,所以实际上Protocol Buffer的存储方式是 T - V;
12 05 41 6C 69 63 65
:12 是 Tag(字段编号 2,类型为 LEN),05 是 Length(5,表示字符串长度),41 6C 69 63 65 是 Value(字符串 “Alice” 的 UTF-8 编码)。
若Protocol Buffer采用其他编码方式(如LENGTH_DELIMITED)则采用T - L - V
ProtobufTLV_189">Protobuf的TLV编码
Protobuf 在将数据转换成二进制时,会对字段和类型重新编码,减少空间占用。它采用 TLV 格式来存储编码后的数据。TLV 也是就是 Tag-Length-Value ,是一种常见的编码方式,因为数据其实都是键值对形式,所以在 TAG 中会存储对应的字段和类型信息,Length 存储内容的长度,Value 存储具体的内容。
上面我们讲过,比如类型信息标记,比如 int32 怎么标记,因为类型个数有限,所以 Protobuf 规定了每个类型对应的二进制编码,比如 int32 对应二进制 000,string 对应二进制 010,这样就可以只用三个比特位存储类型信息。
这种编码方式可以在数据值比较小的情况下,只使用一个字节来存储数据,以此来提高编码效率。
并且Protobuf 还可以通过采用压缩算法来减少数据传输的大小。比如 GZIP 算法能够将原始数据压缩成更小的二进制格式,从而在网络传输中能够节省带宽和传输时间。Protobuf 还提供了一些可选的压缩算法,如 zlib 和 snappy,这些算法在不同的场景下能够适应不同的压缩需求
。
比如下面这张图。
根据刚刚的公式来解释下。
首先是id
的Tag:1<<3 + 2= 10
(注意id是string类型) ,也就是 1010
,。
然后是name
的Tag:2 << 3 + 2 = 18
,也就是10010
。
Length
长度就更好理解了,分别是1和2。
如何通过Varint表示300?
Protobuf_218">5. 编译Protobuf
使用 Protobuf 提供的编译器,可以将 .proto 文件编译成各种语言的代码文件(如 Java、C++、Python 等)。
比如下面两种编译代码方式。
protoc --java_out=./java ./resources/addressbook.protoprotoc --go_out=./go
6. 构造消息对象
刚刚我们定义了对应Proto消息对象如下,那么我们应该怎么使用。
syntax = "proto3";// 指定 Protobuf 包名,防止有相同类名的message定义
package goprotobuf;// Go 生成的包路径 可以通过 go_package 选项指定
option go_package = "/";message Person {// =1,=2 作为序列化后的二进制编码中的字段的唯一标签,也因此,1-15 比 16 会少一个字节,所以尽量使用 1-15 来指定常用字段。optional int32 id = 1;optional string name = 2;optional string email = 3;enum PhoneType {MOBILE = 0;HOME = 1;WORK = 2;}message PhoneNumber {optional string number = 1;optional PhoneType type = 2;}repeated PhoneNumber phones = 4;
}message AddressBook {repeated Person people = 1;
}
这里给出对应的Go版本代码方式。
package mainimport ("fmt""log""github.com/wdbyte/protobuf/addressbook" // 假设这是生成的 Go 包路径
)func main() {// 直接构建phoneNumber1 := &addressbook.PhoneNumber{Number: "18388888888",Type: addressbook.PhoneType_HOME,}person1 := &addressbook.Person{Id: 1,Name: "www.wdbyte.com",Email: "xxx@wdbyte.com",Phones: []*addressbook.PhoneNumber{phoneNumber1},}addressBook1 := &addressbook.AddressBook{People: []*addressbook.Person{person1},}fmt.Println(addressBook1)fmt.Println("------------------")// 链式构建addressBook2 := &addressbook.AddressBook{People: []*addressbook.Person{{Id: 2,Name: "www.wdbyte.com",Email: "yyy@126.com",Phones: []*addressbook.PhoneNumber{{Number: "18388888888",Type: addressbook.PhoneType_HOME,},},},},}fmt.Println(addressBook2)
}
输出如下:
people {id: 1name: "www.wdbyte.com"email: "xxx@wdbyte.com"phones {number: "18388888888"type: HOME}
}------------------
people {id: 2name: "www.wdbyte.com"email: "yyy@126.com"phones {number: "18388888888"type: HOME}
}
参考文章:
1、https://blog.csdn.net/carson_ho/article/details/70568606/?ops_request_misc=&request_id=&biz_id=102&utm_term=Varint%E7%BC%96%E7%A0%81%E5%A6%82%E4%BD%95%E8%A1%A8%E7%A4%BA128%EF%BC%9F&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduweb~default-5-70568606.142^v101^pc_search_result_base5&spm=1018.2226.3001.4187
2、https://segmentfault.com/a/1190000043775488#item-4-5