当前位置: 首页 > article >正文

Protobuf原理与序列化

本文目录

  • 1. Protobuf介绍
  • 2. Protobuf的优势
  • 3. 编写Protobuf
    • 头部全局定义
    • 消息结构具体定义
    • 字段类型定义
    • 标签号
    • Base128编码
  • 4. TLV
    • Protobuf的TLV编码
    • 如何通过Varint表示300?
  • 5. 编译Protobuf
  • 6. 构造消息对象

前言:之前写项目的时候只是简单用了下Protobuf,以为就弄懂protobuf了,今天刚好跟朋友聊天,被朋友拷打知不知道Protobuf原理,ok,确实不是很懂, 找了一些文章看看来搞懂,于是就有了这篇文章。

1. Protobuf介绍

在网络通信和数据存储的时候,数据序列化 是非常重要的,特别是现在微服务横行,序列化更是至关重要。传统HTTP通信的时候,一般都是用Json作为消息传递的数据格式。但是谷歌一直在用Protobuf,这肯定是有原因的,所以特意学习了一下Protobuf,来研究研究。

Protobuf(Protocol Buffers)是由 Google 开发的一种轻量级、高效的数据交换格式,它被用于结构化数据的序列化、反序列化和传输。相比于 XML 和 JSON 等文本格式,Protobuf 具有更小的数据体积、更快的解析速度和更强的可扩展性。

核心思想:使用协议(Protocol)来定义数据的结构和编码方式。使用 Protobuf,可以先定义数据的结构各字段的类型字段等信息,然后使用Protobuf提供的编译器生成对应的代码,用于序列化和反序列化数据。由于 Protobuf 是基于二进制编码的,因此可以在数据传输和存储中实现更高效的数据交换,同时也可以跨语言使用。

比如下面这张图,就是很好的一个例子。Java语言写序列化,然后接收端用Python进行反序列化。

在这里插入图片描述

2. Protobuf的优势

更小的数据量:Protobuf 的二进制编码通常比 XML 和 JSON 小 3-10 倍,因此在网络传输和存储数据时可以节省带宽和存储空间。

更快的序列化和反序列化速度:由于 Protobuf 使用二进制格式,所以序列化和反序列化速度比 XML 和 JSON 快得多。

跨语言:Protobuf 支持多种编程语言,可以使用不同的编程语言来编写客户端和服务端。这种跨语言的特性使得 Protobuf 受到很多开发者的欢迎(JSON 也是跨语言的)。

易于维护可扩展:Protobuf 使用 .proto 文件定义数据模型和数据格式,这种文件比 XML 和 JSON 更容易阅读和维护,且可以在不破坏原有协议的基础上,轻松添加或删除字段,实现版本升级和兼容性。

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编码

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


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?

在这里插入图片描述

5. 编译Protobuf

使用 Protobuf 提供的编译器,可以将 .proto 文件编译成各种语言的代码文件(如 Java、C++、Python 等)。

在这里插入图片描述
比如下面两种编译代码方式。

protoc --java_out=./java ./resources/addressbook.proto

protoc --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 main

import (
	"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: 1
  name: "www.wdbyte.com"
  email: "xxx@wdbyte.com"
  phones {
    number: "18388888888"
    type: HOME
  }
}

------------------
people {
  id: 2
  name: "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.kler.cn/a/568147.html

相关文章:

  • Android中的四大组件及其生命周期
  • 学习笔记-单片机蓝桥杯大模板更新-米醋
  • uniapp h5页面获取跳转传参的简单方法
  • 设置电脑一接通电源就主动开机
  • OpenEuler学习笔记(三十五):搭建代码托管服务器
  • IP-----动态路由OSPF(2)
  • Docker数据卷容器实战
  • CSS 中最常用的三种选择器的详细讲解(配合实例)
  • (视频教程)Compass代谢分析详细流程及python版-R语言版下游分析和可视化
  • 从零基础到通过考试
  • 基于大数据的家用汽车推荐系统(源码+lw+部署文档+讲解),源码可白嫖!
  • 【星云 Orbit-F4 开发板】03g. 按键玩法七:矩阵键盘单个触发
  • 网络安全员证书
  • 基于Java+Jsp+SpringMVC漫威手办商城系统设计和实现
  • 达梦:内存相关参数
  • SQL命令详解之增删改数据
  • 使用 Spring Boot 和 Keycloak 的 OAuth2 快速指南
  • MyBatis中是如何对占位符进行赋值的?
  • 【Unity】AI Navigation自动寻路(导航)功能
  • Android15 Camera HAL Android.bp中引用Android.mk编译的libB.so