Protobuf原理与序列化

news/2025/2/28 11:59:12/

本文目录

前言:之前写项目的时候只是简单用了下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 或 ProtobufVarint 编码)来表示字段标签号和字段值。这种编码方式会根据数值的大小动态分配字节数,以节省空间。具体规则如下:

字段标签号的编码:

字段标签号在序列化时会被编码为 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


http://www.ppmy.cn/news/1575497.html

相关文章

sql时间函数

1、adddate—将时间/日期间隔添加到日期 adddate("2015-01-03",INTERVAL 1 day) #2015-01-042、date_add,date_sub—增加/减去指定的时间间隔 date_add("2025-02-27",interval 1 day) #2025-02-28 date_sub("2025-02-27",interval 1 day) #202…

DeepSeek-R1 蒸馏

蒸馏的概念 简介 蒸馏&#xff08;Distillation&#xff0c;又称模型蒸馏、数据蒸馏、知识蒸馏等&#xff09;是一种通过大模型&#xff08;教师模型&#xff09;生成或优化训练数据&#xff0c;使小模型&#xff08;学生模型&#xff09;能够高效学习的技术&#xff0c;其核…

11、ubuntu-22.04安装go的最新版本

卸载 apt 版&#xff08;防止冲突&#xff09;&#xff1a; sudo apt remove golang-go删除旧的 Go 安装目录&#xff08;假设安装在 /usr/local/go&#xff09;&#xff1a; sudo rm -rf /usr/local/go sudo apt autoremove下载最新版本 Go 1.24 压缩包&#xff1a; wget h…

解决单元测试 mock final类报错

文章目录 前言解决单元测试 mock final类报错1. 报错原因2. 解决方案3. 示例demo4. 扩展 前言 如果您觉得有用的话&#xff0c;记得给博主点个赞&#xff0c;评论&#xff0c;收藏一键三连啊&#xff0c;写作不易啊^ _ ^。   而且听说点赞的人每天的运气都不会太差&#xff0…

C++ 设计模式 十二:责任链模式 (读书 现代c++设计模式)

责任链 文章目录 责任链场景指针链代理链总结**责任链模式的核心思想****何时需要使用责任链模式&#xff1f;****责任链模式解决的核心问题****与其他设计模式的协同使用****与其他模式的对比****经典应用场景****实现步骤与关键点****注意事项****总结** 今天是第十二种设计模…

Android 布局系列(二):FrameLayout 布局的应用

引言 在安卓开发中&#xff0c;布局管理是构建用户界面的核心之一。对于简单的界面或是需要叠加多个视图的场景&#xff0c;FrameLayout 是一个非常实用的布局容器。它是安卓中最基础的布局之一&#xff0c;能够帮助我们轻松管理多个视图的叠加。尽管它没有复杂的排版功能&…

计算机毕业设计Python+DeepSeek-R1大模型考研院校推荐系统 考研分数线预测 考研推荐系统 考研(源码+文档+PPT+讲解)

温馨提示&#xff1a;文末有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;文末有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;文末有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 作者简介&#xff1a;Java领…

如何通过JS实现关闭网页时清空该页面在本地电脑的缓存存储?

要通过JavaScript实现关闭网页时清空该页面在本地电脑的缓存存储&#xff0c;可以采用以下方法&#xff1a; 使用window.onbeforeunload事件监听器&#xff1a; 在Vue.js应用中&#xff0c;可以在App.vue组件的mounted生命周期钩子中监听window.onbeforeunload事件&#xff0c…