序列化结构数据方法ProtoBuf使用(PHP和Go)

zkbhj 发表了文章 • 0 个评论 • 2207 次浏览 • 2020-04-13 17:24 • 来自相关话题

一、在PHP下使用ProtoBuf
 
PHP如果要使用protobuf 需要安装 protoc+ 安装protobuf composer包
 
protoc用于根据protobuf数据结构文件(.proto)生成对应的类,用于生成protobuf文件安装 google/protobuf composer 包composer require google/protobuf
 
怎么用呢?开搞!
 
1、首先,需要定一个消息类型。创建一个关于Person的定义文件(以.proto为后缀),如示例为person.proto,文件内容如下:
syntax="proto3";
package test;
message Person{
string name=1;//姓名
int32 age=2;//年龄
bool sex=3;//性别

syntax="proto3":表明使用的是proto3格式,如果不指定则为proto2package test:定义包名为test,生成类时,会产生一个目录为testmessage Person:消息主体内容,里面为各个字段的定义
 
2、生成对应的PHP类
 
定义好Person的格式后,该格式如果不生成我们所需要的类库,其实是无任何意义的,还google提供一个工具protoc生成我们要的类库。也就是最开始说的 protoc!
 
proto 最新版的下载地址是:最新3.11.3。可以通过 官方包Release 进行有选择的下载。
tar -zxvf protobuf-php-3.11.3.tar.gz
cd protobuf-3.11.3
./configure --prefix=/usr/local/protobuf
make
make install解压安装完后,就可以通过下面的命令,生成对应的类库了:
/usr/
local/protobuf/bin/protoc --php_out=./ person.proto生成后将在当前目录产生如下文件:
 
GPBMetadata/Person.phpTest/Person.php
 
3、类库生成完了,就可以在PHP项目中使用ProtoBuf了:

在PHP中使用ProtoBuf依赖一个protobuf的扩展,目前提供两种方式进行使用: 
php的c扩展,php的lib扩展包,
 
这两者均可在刚才下载包里可以找到。
 
另外,也可以使用composer进行安装该依赖扩展:
composer require google/protobuf
这里我主要是使用composer安装,应该它可以帮我产生autoload。安装好依赖后,我们就可以开始在php环境下使用protobuf了。

序列化
<?php
include 'vendor/autoload.php';
include 'GPBMetadata/Person.php';
include 'Test/Person.php';
$bindata = file_get_contents('./data.bin');
$person = new Test\Person();
$person->mergeFromString($bindata);
echo $person->getName();运行后,产生的data.bin,只有14Byte
 
反序列化
<?php
include 'vendor/autoload.php';
include 'GPBMetadata/Person.php';
include 'Test/Person.php';
$bindata = file_get_contents('./data.bin');
$person = new Test\Person();
$person->mergeFromString($bindata);
echo $person->getName();PHP常用的使用方法:

序列化:
1、serializeToString:序列化成二进制字符串
2、serializeToJsonString:序列化成JSON字符串

反序列化:
1、mergeFromString:二进制字符串反序列化
2、mergeFromJsonString:Json字符串反序列化

二、在Golang下使用ProtoBuf
 
安装protoc的方法和PHP中类型,编译安装即可。
 
安装好之后,需要安装ProtoBuf的编译器插件:protoc-gen-go:
 
进入GOPATH目录,运行:
go get -u github.com/golang/protobuf/protoc-gen-go  如果成功,会在GOPATH/bin下生成protoc-gen-go.exe文件(Windows上)。
 
1、准备好之后,就可以开始写proto文件了。假设文件目录为:
$GOPATH/src/test/protobuf/pb/user.proto代码如下:
//指定版本
//注意proto3与proto2的写法有些不同
syntax = "proto3";

//包名,通过protoc生成时go文件时
package test;

//手机类型
//枚举类型第一个字段必须为0
enum PhoneType {
HOME = 0;
WORK = 1;
}

//手机
message Phone {
PhoneType type = 1;
string number = 2;
}

//人
message Person {
//后面的数字表示标识号
int32 id = 1;
string name = 2;
//repeated表示可重复
//可以有多个手机
repeated Phone phones = 3;
}

//联系簿
message ContactBook {
repeated Person persons = 1;
}然后运行如下命令:
protoc --go_out=. *.proto
会生成一个user.pb.go的文件。
 
2、使用:
package main;

import (
"github.com/golang/protobuf/proto"
"go_dev/kongji/proto/test"
"io/ioutil"
"os"
"fmt"
)

func write() {
p1 := &test.Person{
Id: 1,
Name: "小张",
Phones: []*test.Phone{
{test.PhoneType_HOME, "111111111"},
{test.PhoneType_WORK, "222222222"},
},
};
p2 := &test.Person{
Id: 2,
Name: "小王",
Phones: []*test.Phone{
{test.PhoneType_HOME, "333333333"},
{test.PhoneType_WORK, "444444444"},
},
};

//创建地址簿
book := &test.ContactBook{};
book.Persons = append(book.Persons, p1);
book.Persons = append(book.Persons, p2);

//编码数据
data, _ := proto.Marshal(book);
//把数据写入文件
ioutil.WriteFile("./test.txt", data, os.ModePerm);
}

func read() {
//读取文件数据
data, _ := ioutil.ReadFile("./test.txt");
book := &test.ContactBook{};
//解码数据
proto.Unmarshal(data, book);
for _, v := range book.Persons {
fmt.Println(v.Id, v.Name);
for _, vv := range v.Phones {
fmt.Println(vv.Type, vv.Number);
}
}
}

func main() {
write();
read();
}
 

内容整理自下面链接:
https://www.jianshu.com/p/ce098058edf0
https://developers.google.cn/protocol-buffers/docs/gotutorial
https://developers.google.cn/protocol-buffers/docs/reference/go-generated
  查看全部
一、在PHP下使用ProtoBuf
 
PHP如果要使用protobuf 需要安装 protoc+ 安装protobuf composer包
 
  • protoc用于根据protobuf数据结构文件(.proto)生成对应的类,用于生成protobuf文件
  • 安装 google/protobuf composer 包composer require google/protobuf

 
怎么用呢?开搞!
 
1、首先,需要定一个消息类型。创建一个关于Person的定义文件(以.proto为后缀),如示例为person.proto,文件内容如下:
syntax="proto3";
package test;
message Person{
string name=1;//姓名
int32 age=2;//年龄
bool sex=3;//性别
}
 
  1. syntax="proto3":表明使用的是proto3格式,如果不指定则为proto2
  2. package test:定义包名为test,生成类时,会产生一个目录为test
  3. message Person:消息主体内容,里面为各个字段的定义

 
2、生成对应的PHP类
 
定义好Person的格式后,该格式如果不生成我们所需要的类库,其实是无任何意义的,还google提供一个工具protoc生成我们要的类库。也就是最开始说的 protoc!
 
proto 最新版的下载地址是:最新3.11.3。可以通过 官方包Release 进行有选择的下载。
tar -zxvf protobuf-php-3.11.3.tar.gz
cd protobuf-3.11.3
./configure --prefix=/usr/local/protobuf
make
make install
解压安装完后,就可以通过下面的命令,生成对应的类库了:
/usr/
local/protobuf/bin/protoc --php_out=./ person.proto
生成后将在当前目录产生如下文件:
 
  • GPBMetadata/Person.php
  • Test/Person.php

 
3、类库生成完了,就可以在PHP项目中使用ProtoBuf了:

在PHP中使用ProtoBuf依赖一个protobuf的扩展,目前提供两种方式进行使用: 
  1. php的c扩展,
  2. php的lib扩展包,

 
这两者均可在刚才下载包里可以找到。
 
另外,也可以使用composer进行安装该依赖扩展:
composer require google/protobuf

这里我主要是使用composer安装,应该它可以帮我产生autoload。安装好依赖后,我们就可以开始在php环境下使用protobuf了。

序列化
<?php
include 'vendor/autoload.php';
include 'GPBMetadata/Person.php';
include 'Test/Person.php';
$bindata = file_get_contents('./data.bin');
$person = new Test\Person();
$person->mergeFromString($bindata);
echo $person->getName();
运行后,产生的data.bin,只有14Byte
 
反序列化
<?php
include 'vendor/autoload.php';
include 'GPBMetadata/Person.php';
include 'Test/Person.php';
$bindata = file_get_contents('./data.bin');
$person = new Test\Person();
$person->mergeFromString($bindata);
echo $person->getName();
PHP常用的使用方法:

序列化:
1、serializeToString:序列化成二进制字符串
2、serializeToJsonString:序列化成JSON字符串

反序列化:
1、mergeFromString:二进制字符串反序列化
2、mergeFromJsonString:Json字符串反序列化

二、在Golang下使用ProtoBuf
 
安装protoc的方法和PHP中类型,编译安装即可。
 
安装好之后,需要安装ProtoBuf的编译器插件:protoc-gen-go:
 
进入GOPATH目录,运行:
go get -u github.com/golang/protobuf/protoc-gen-go
  如果成功,会在GOPATH/bin下生成protoc-gen-go.exe文件(Windows上)。
 
1、准备好之后,就可以开始写proto文件了。假设文件目录为:
$GOPATH/src/test/protobuf/pb/user.proto
代码如下:
//指定版本
//注意proto3与proto2的写法有些不同
syntax = "proto3";

//包名,通过protoc生成时go文件时
package test;

//手机类型
//枚举类型第一个字段必须为0
enum PhoneType {
HOME = 0;
WORK = 1;
}

//手机
message Phone {
PhoneType type = 1;
string number = 2;
}

//人
message Person {
//后面的数字表示标识号
int32 id = 1;
string name = 2;
//repeated表示可重复
//可以有多个手机
repeated Phone phones = 3;
}

//联系簿
message ContactBook {
repeated Person persons = 1;
}
然后运行如下命令:
 protoc --go_out=. *.proto
会生成一个user.pb.go的文件。
 
2、使用:
package main;

import (
"github.com/golang/protobuf/proto"
"go_dev/kongji/proto/test"
"io/ioutil"
"os"
"fmt"
)

func write() {
p1 := &test.Person{
Id: 1,
Name: "小张",
Phones: []*test.Phone{
{test.PhoneType_HOME, "111111111"},
{test.PhoneType_WORK, "222222222"},
},
};
p2 := &test.Person{
Id: 2,
Name: "小王",
Phones: []*test.Phone{
{test.PhoneType_HOME, "333333333"},
{test.PhoneType_WORK, "444444444"},
},
};

//创建地址簿
book := &test.ContactBook{};
book.Persons = append(book.Persons, p1);
book.Persons = append(book.Persons, p2);

//编码数据
data, _ := proto.Marshal(book);
//把数据写入文件
ioutil.WriteFile("./test.txt", data, os.ModePerm);
}

func read() {
//读取文件数据
data, _ := ioutil.ReadFile("./test.txt");
book := &test.ContactBook{};
//解码数据
proto.Unmarshal(data, book);
for _, v := range book.Persons {
fmt.Println(v.Id, v.Name);
for _, vv := range v.Phones {
fmt.Println(vv.Type, vv.Number);
}
}
}

func main() {
write();
read();
}

 

内容整理自下面链接:
https://www.jianshu.com/p/ce098058edf0
https://developers.google.cn/protocol-buffers/docs/gotutorial
https://developers.google.cn/protocol-buffers/docs/reference/go-generated
 

序列化结构数据方法ProtoBuf源码解读

zkbhj 发表了文章 • 0 个评论 • 2127 次浏览 • 2020-04-13 16:53 • 来自相关话题

在上一篇 《序列化结构数据方法ProtoBuf编码》 中,我们详细解析了 ProtoBuf 的编码原理。

有了这个知识储备,我们就可以深入 ProtoBuf 序列化、反序列化的源码,从代码的层面理解 ProtoBuf 具体是如何实现对数据的编码(序列化)和解码(反序列化)的。

我们重新复习一下, ProtoBuf 的序列化使用过程:
 
定义 .proto 文件protoc 编译器编译 .proto 文件生成一系列接口代码调用生成的接口实现对 .proto 定义的字段的读取以及 message 对象的序列化、反序列化方法

具体调用代码如下:
Example1 example1;
example1.set_int32val(val);
example1.set_stringval("hello,world");
example1.SerializeToString(&output);
调用 SerializeToString 函数将 example1 对象序列化(编码)成字符串。我们的目的就是了解 SerializeToString 函数里到底发生了什么,是怎么一步一步得到最终的序列化结果的。
 

注意:并非编码成字符串数据,string 只是作为编码结果的容器


我们在 .proto 文件中定义的 message 在最终生成的对应语言的代码中,例如在 C++ (xxxx.pb.h、xxxx.pb.cpp) 中每一个在 .proto 文件中定义的 message 字段都会在代码中构造成一个类,且这些 message 消息类继承于 ::google::protobuf::Message,而 ::google::protobuf::Message 继承于一个更为轻量的 MessageLite 类。其相关的类图如下所示:





 
而我们经常调用的序列化函数 SerializeToString 并定义在基类 MessageLite 中。
 
编码

当某个 Message 调用 SerializeToString 时,经过一层层调用最终会调用底层的关键编码函数 WriteVarint32ToArray 或 WriteVarint64ToArray,整个过程如下图所示:






WriteVarint32ToArray 函数可在源码目录下的 google.protobuf.io 包下的 coded_stream.h 中找到。在上一篇 深入 ProtoBuf - 编码 中我们解析了 Varint 编码原理和详细过程,WriteVarint32ToArray(以及 WriteVarint64ToArray)并是 Varint 编码的核心。

可以对照上一篇指出的 Varints 编码的几个关键点来阅读以下代码,可以看出编码实现确实优雅,代码如下:inline uint8* CodedOutputStream::WriteVarint32ToArray(uint32 value, uint8* target) {
// 0x80 -> 1000 0000
// 大于 1000 0000 意味这进行 Varints 编码时至少需要两个字节
// 如果 value < 0x80,则只需要一个字节,编码结果和原值一样,则没有循环直接返回
// 如果至少需要两个字节
while (value >= 0x80) {
// 如果还有后续字节,则 value | 0x80 将 value 的最后字节的最高 bit 位设置为 1,并取后七位
*target = static_cast<uint8>(value | 0x80);
// 处理完七位,后移,继续处理下一个七位
value >>= 7;
// 指针加一,(数组后移一位)
++target;
}
// 跳出循环,则表示已无后续字节,但还有最后一个字节

// 把最后一个字节放入数组
*target = static_cast<uint8>(value);
// 结束地址指向数组最后一个元素的末尾
return target + 1;
}

// Varint64 同理
inline uint8* CodedOutputStream::WriteVarint64ToArray(uint64 value,
uint8* target) {
while (value >= 0x80) {
*target = static_cast<uint8>(value | 0x80);
value >>= 7;
++target;
}
*target = static_cast<uint8>(value);
return target + 1;
}在上面已添加详细注释,这里再强调几个关键点。
 
value | 0x80:xxx ... xxxx xxxx | 000 ... 1000 0000 的结果其实就是将最后一个字节的第一个 bit(最高位) 置 1,其他位不变,即 xxx ... 1xxx xxxx。注意 target 是 uint8 类型的指针,这意味它只会截断获取最后一个字节,即 1xxx xxxx,这里的 1 意味着什么?这个 1 就是所谓的 msb 了,意味着后续还有字节。之后就是右移 7 位(去掉最后 7 位),处理下一个 7位。通过这里的代码应该可以体会到为什么 Varints 编码结果是低位排在前面了。

了解了最底层 IO 包中的编码函数,再结合上篇文章介绍的编码原理,对 ProtoBuf 的编码应该有了更深入的认识。

Varints 类型序列化实现

int32、int64、uint32、uint64

int32 类型编码函数对应为 WriteInt32ToArray,源码如下:// WriteTagToArray 函数将 Tag 部分写入
// WriteInt32NoTagToArray 函数将 Value 部分写入
// WriteTagToArray 和 WriteInt32NoTagToArray 底层
// 均调用 coded_stream.h 中的 WriteVarint32ToArray
//因为 ProtoBuf 中的 Tag 均采用 Varint 编码
// int32 的 Value 部分也采用 Varint 编码
inline uint8* WireFormatLite::WriteInt32ToArray(int field_number, int32 value,
uint8* target) {
target = WriteTagToArray(field_number, WIRETYPE_VARINT, target);
return WriteInt32NoTagToArray(value, target);
}int64、uint32、uint64 类型与 int32 类型同理,只是处理位数有所不同。

uint32 和 uint64 也是采用 Varint 编码,所以底层编码实现与 int32、int64 一致。

 sint32、sint64

这两种类型编码函数对应为 WriteSInt32ToArray 和 WriteSInt64ToArray 。

在上一篇文章 深入 ProtoBuf - 编码 中我们已经介绍过 Varint 编码在负数的情况下编码效率很低,固对于 sint32、sint64 类型我们会采用 ZigZag 编码将负数映射成正数然后再进行 Varint 编码,而这种映射并非采用存储的 Map,而是使用移位实现。sint32 的 ZigZag 源码实现如下:inline uint32 WireFormatLite::ZigZagEncode32(int32 n) {
// 右移为算数右移
// 左移时需要先将 n 转成 uint32 类型,防止溢出
// 当 n 为正数时 result = 2 * n
// 当 n 为负数时 result = - (2 * n + 1)
return (static_cast<uint32>(n) << 1) ^ static_cast<uint32>(n >> 31);
}经过 ZigZagEncode32 编码之后,数字成为一个正数,之后等同于 int32 或 int64 进行完全相同的编码处理。

bool 与 enum

bool 和 enum 本质就是整型,编码处理与 int32、int64 相同。

32-bit、64-bit
fixed32/fixed64

fixed32 类型对应 WriteFixed32ToArray 函数,32-bit、64-bit类型的字段比起上述 Varint 类型则要简单的多,因为每个数字均是固定字节,源码如下:inline uint8* WireFormatLite::WriteSFixed32ToArray(int field_number,
int32 value, uint8* target) {
target = WriteTagToArray(field_number, WIRETYPE_FIXED32, target);
return WriteSFixed32NoTagToArray(value, target);
}其中 WriteSFixed32NoTagToArray 源码如下:
inline uint8* WireFormatLite::WriteSFixed32NoTagToArray(int32 value,
uint8* target) {
return io::CodedOutputStream::WriteLittleEndian32ToArray(
static_cast<uint32>(value), target);
}由此可知,对于位数固定的 sfixed32 是将其转成 uint32 类型,然后使用与 fixed32 相同的函数写入。

sfixed64 与 sfixed32 同理,不赘述。
 
Length delimited 字段序列化

因为其编码结构为 Tag - Length - Value,所以其字段完整的序列化会稍微多出一些过程,其中有一些需要我们进一步整理。现在以一个 string 类型字段的序列化为例,来看看其序列化的完整过程,画出其程序时序图(上文出现过)如下:






可对照上述时序图来阅读源码,其序列化实现的几个关键函数为:
 
ByteSizeLong:计算对象序列化所需要的空间大小,在内存中开辟相应大小的空间WriteTagToArray:将 Tag 值写入到之前开辟的内存中WriteStringWithSizeToArray:将 Length + Value 值写入到之前开辟的内存中

其序列化代码的重点过程在上图的右下角,先是调用 WriteTagToArray 函数将 Tag 值写入到内存,返回指向下一个字节的指针以便继续写入。调用 WriteStringWithSizeToArray 函数,这个函数主要又执行了两个函数,先是执行 WriteVarint32ToArray 函数(注意 WriteTagToArray 内部调用的也是这个函数,因为 Tag 和 Length 都采用 Varints 编码),此函数的作用是将 Length 写入。执行的第二个函数为 WriteStringToArray,此函数的作用是将 Value(一个 UTF-8 string 值) 写入到内存,其中底层调用了 memcpy() 函数。

综上,对于 Varint 类型的字段自然采用 Varint 编码。

而对于 Length delimited 类型的字段,Tag-Length-Value 中的 Tag 和 Length 依然采用 Varint 编码,Value 若为 String 等类型,则直接进行 memcpy。

另外对于 embedded message 或 packed repeated ,则套用上述规则。底层编码实现实际并是遍历字段下所有内嵌字段,然后递归调用编码函数即可。

作者:404_89_117_101
链接:https://www.jianshu.com/p/62f0238beec8
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 查看全部
在上一篇 《序列化结构数据方法ProtoBuf编码》 中,我们详细解析了 ProtoBuf 的编码原理。

有了这个知识储备,我们就可以深入 ProtoBuf 序列化、反序列化的源码,从代码的层面理解 ProtoBuf 具体是如何实现对数据的编码(序列化)和解码(反序列化)的。

我们重新复习一下, ProtoBuf 的序列化使用过程:
 
  • 定义 .proto 文件
  • protoc 编译器编译 .proto 文件生成一系列接口代码
  • 调用生成的接口实现对 .proto 定义的字段的读取以及 message 对象的序列化、反序列化方法


具体调用代码如下:
Example1 example1;
example1.set_int32val(val);
example1.set_stringval("hello,world");
example1.SerializeToString(&output);

调用 SerializeToString 函数将 example1 对象序列化(编码)成字符串。我们的目的就是了解 SerializeToString 函数里到底发生了什么,是怎么一步一步得到最终的序列化结果的。
 


注意:并非编码成字符串数据,string 只是作为编码结果的容器



我们在 .proto 文件中定义的 message 在最终生成的对应语言的代码中,例如在 C++ (xxxx.pb.h、xxxx.pb.cpp) 中每一个在 .proto 文件中定义的 message 字段都会在代码中构造成一个类,且这些 message 消息类继承于 ::google::protobuf::Message,而 ::google::protobuf::Message 继承于一个更为轻量的 MessageLite 类。其相关的类图如下所示:

QQ截图20200413164703.jpg

 
而我们经常调用的序列化函数 SerializeToString 并定义在基类 MessageLite 中。
 
编码

当某个 Message 调用 SerializeToString 时,经过一层层调用最终会调用底层的关键编码函数 WriteVarint32ToArray 或 WriteVarint64ToArray,整个过程如下图所示:

6009978-d3f8eaef64fe78e9.png


WriteVarint32ToArray 函数可在源码目录下的 google.protobuf.io 包下的 coded_stream.h 中找到。在上一篇 深入 ProtoBuf - 编码 中我们解析了 Varint 编码原理和详细过程,WriteVarint32ToArray(以及 WriteVarint64ToArray)并是 Varint 编码的核心。

可以对照上一篇指出的 Varints 编码的几个关键点来阅读以下代码,可以看出编码实现确实优雅,代码如下:
inline uint8* CodedOutputStream::WriteVarint32ToArray(uint32 value, uint8* target) {
// 0x80 -> 1000 0000
// 大于 1000 0000 意味这进行 Varints 编码时至少需要两个字节
// 如果 value < 0x80,则只需要一个字节,编码结果和原值一样,则没有循环直接返回
// 如果至少需要两个字节
while (value >= 0x80) {
// 如果还有后续字节,则 value | 0x80 将 value 的最后字节的最高 bit 位设置为 1,并取后七位
*target = static_cast<uint8>(value | 0x80);
// 处理完七位,后移,继续处理下一个七位
value >>= 7;
// 指针加一,(数组后移一位)
++target;
}
// 跳出循环,则表示已无后续字节,但还有最后一个字节

// 把最后一个字节放入数组
*target = static_cast<uint8>(value);
// 结束地址指向数组最后一个元素的末尾
return target + 1;
}

// Varint64 同理
inline uint8* CodedOutputStream::WriteVarint64ToArray(uint64 value,
uint8* target) {
while (value >= 0x80) {
*target = static_cast<uint8>(value | 0x80);
value >>= 7;
++target;
}
*target = static_cast<uint8>(value);
return target + 1;
}
在上面已添加详细注释,这里再强调几个关键点。
 
  • value | 0x80:xxx ... xxxx xxxx | 000 ... 1000 0000 的结果其实就是将最后一个字节的第一个 bit(最高位) 置 1,其他位不变,即 xxx ... 1xxx xxxx。注意 target 是 uint8 类型的指针,这意味它只会截断获取最后一个字节,即 1xxx xxxx,这里的 1 意味着什么?这个 1 就是所谓的 msb 了,意味着后续还有字节。之后就是右移 7 位(去掉最后 7 位),处理下一个 7位。
  • 通过这里的代码应该可以体会到为什么 Varints 编码结果是低位排在前面了。


了解了最底层 IO 包中的编码函数,再结合上篇文章介绍的编码原理,对 ProtoBuf 的编码应该有了更深入的认识。

Varints 类型序列化实现

int32、int64、uint32、uint64

int32 类型编码函数对应为 WriteInt32ToArray,源码如下:
// WriteTagToArray 函数将 Tag 部分写入
// WriteInt32NoTagToArray 函数将 Value 部分写入
// WriteTagToArray 和 WriteInt32NoTagToArray 底层
// 均调用 coded_stream.h 中的 WriteVarint32ToArray
//因为 ProtoBuf 中的 Tag 均采用 Varint 编码
// int32 的 Value 部分也采用 Varint 编码
inline uint8* WireFormatLite::WriteInt32ToArray(int field_number, int32 value,
uint8* target) {
target = WriteTagToArray(field_number, WIRETYPE_VARINT, target);
return WriteInt32NoTagToArray(value, target);
}
int64、uint32、uint64 类型与 int32 类型同理,只是处理位数有所不同。


uint32 和 uint64 也是采用 Varint 编码,所以底层编码实现与 int32、int64 一致。


 sint32、sint64

这两种类型编码函数对应为 WriteSInt32ToArray 和 WriteSInt64ToArray 。

在上一篇文章 深入 ProtoBuf - 编码 中我们已经介绍过 Varint 编码在负数的情况下编码效率很低,固对于 sint32、sint64 类型我们会采用 ZigZag 编码将负数映射成正数然后再进行 Varint 编码,而这种映射并非采用存储的 Map,而是使用移位实现。sint32 的 ZigZag 源码实现如下:
inline uint32 WireFormatLite::ZigZagEncode32(int32 n) {
// 右移为算数右移
// 左移时需要先将 n 转成 uint32 类型,防止溢出
// 当 n 为正数时 result = 2 * n
// 当 n 为负数时 result = - (2 * n + 1)
return (static_cast<uint32>(n) << 1) ^ static_cast<uint32>(n >> 31);
}
经过 ZigZagEncode32 编码之后,数字成为一个正数,之后等同于 int32 或 int64 进行完全相同的编码处理。

bool 与 enum

bool 和 enum 本质就是整型,编码处理与 int32、int64 相同。

32-bit、64-bit
fixed32/fixed64

fixed32 类型对应 WriteFixed32ToArray 函数,32-bit、64-bit类型的字段比起上述 Varint 类型则要简单的多,因为每个数字均是固定字节,源码如下:
inline uint8* WireFormatLite::WriteSFixed32ToArray(int field_number,
int32 value, uint8* target) {
target = WriteTagToArray(field_number, WIRETYPE_FIXED32, target);
return WriteSFixed32NoTagToArray(value, target);
}
其中 WriteSFixed32NoTagToArray 源码如下:
inline uint8* WireFormatLite::WriteSFixed32NoTagToArray(int32 value,
uint8* target) {
return io::CodedOutputStream::WriteLittleEndian32ToArray(
static_cast<uint32>(value), target);
}
由此可知,对于位数固定的 sfixed32 是将其转成 uint32 类型,然后使用与 fixed32 相同的函数写入。

sfixed64 与 sfixed32 同理,不赘述。
 
Length delimited 字段序列化

因为其编码结构为 Tag - Length - Value,所以其字段完整的序列化会稍微多出一些过程,其中有一些需要我们进一步整理。现在以一个 string 类型字段的序列化为例,来看看其序列化的完整过程,画出其程序时序图(上文出现过)如下:

6009978-d3f8eaef64fe78e9.png


可对照上述时序图来阅读源码,其序列化实现的几个关键函数为:
 
  • ByteSizeLong:计算对象序列化所需要的空间大小,在内存中开辟相应大小的空间
  • WriteTagToArray:将 Tag 值写入到之前开辟的内存中
  • WriteStringWithSizeToArray:将 Length + Value 值写入到之前开辟的内存中


其序列化代码的重点过程在上图的右下角,先是调用 WriteTagToArray 函数将 Tag 值写入到内存,返回指向下一个字节的指针以便继续写入。调用 WriteStringWithSizeToArray 函数,这个函数主要又执行了两个函数,先是执行 WriteVarint32ToArray 函数(注意 WriteTagToArray 内部调用的也是这个函数,因为 Tag 和 Length 都采用 Varints 编码),此函数的作用是将 Length 写入。执行的第二个函数为 WriteStringToArray,此函数的作用是将 Value(一个 UTF-8 string 值) 写入到内存,其中底层调用了 memcpy() 函数。

综上,对于 Varint 类型的字段自然采用 Varint 编码。

而对于 Length delimited 类型的字段,Tag-Length-Value 中的 Tag 和 Length 依然采用 Varint 编码,Value 若为 String 等类型,则直接进行 memcpy。

另外对于 embedded message 或 packed repeated ,则套用上述规则。底层编码实现实际并是遍历字段下所有内嵌字段,然后递归调用编码函数即可。

作者:404_89_117_101
链接:https://www.jianshu.com/p/62f0238beec8
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

序列化结构数据方法ProtoBuf编码

zkbhj 发表了文章 • 0 个评论 • 1776 次浏览 • 2020-04-13 16:26 • 来自相关话题

上一篇《序列化结构数据方法ProtoBuf初探》是对ProtoBuf的简单介绍和特点对比。接下来这篇主要总结的是ProtoBuf的编码方式。
 
编码结构

TLV 格式是我们比较熟悉的编码格式。
 

所谓的 TLV 即 Tag - Length - Value。Tag 作为该字段的唯一标识,Length 代表 Value 数据域的长度,最后的 Value 并是数据本身。


ProtoBuf 编码采用类似的结构,但是实际上又有较大区别,其编码结构可见下图:






我们来一步步解析上图所表达的编码结构。

首先,每一个 message 进行编码,其结果由一个个字段组成,每个字段可划分为 Tag - [Length] - Value,如下图所示:





 

特别注意这里的 [Length] 是可选的,含义是针对不同类型的数据编码结构可能会变成 Tag - Value 的形式,如果变成这样的形式,没有了 Length 我们该如何确定 Value 的边界?答案就是 Varint 编码,在后面将详细介绍。


继续深入 Tag ,Tag 由 field_number 和 wire_type 两个部分组成: 
field_number: message 定义字段时指定的字段编号wire_type: ProtoBuf 编码类型,根据这个类型选择不同的 Value 编码方案。

整个 Tag 采用 Varints 编码方案进行编码,Varints 编码会在后面详细介绍。

Tag 结构如下图所示:






3 bit 的 wire_type 最多可以表达 8 种编码类型,目前 ProtoBuf 已经定义了 6 种,如下图所示:






第一列即是对应的类型编号,第二列为面向最终编码的编码类型,第三列是面向开发者的 message 字段的类型。
 

注意其中的 Start group 和 End group 两种类型已被遗弃。

 
 

另外要特别注意一点,虽然 wire_type 代表编码类型,但是 Varint 这个编码类型里针对 sint32、sint64 又会有一些特别编码(ZigTag 编码)处理,相当于 Varint 这个编码类型里又存在两种不同编码。


重新来看完整的编码结构图,现在我们可以理解一个 message 编码将由一个个的 field 组成,每个 field 根据类型将有如下两种格式:
 
Tag - Length - Value:编码类型表中 Type = 2 即 Length-delimited 编码类型将使用这种结构,Tag - Value:编码类型表中 Varint、64-bit、32-bit 使用这种结构。


其中 Tag 由字段编号 field_number 和 编码类型 wire_type 组成, Tag 整体采用 Varints 编码。

现在来模拟一下,我们接收到了一串序列化的二进制数据,我们先读一个 Varints 编码块,进行 Varints 解码,读取最后 3 bit 得到 wire_type(由此可知是后面的 Value 采用的哪种编码),随后获取到 field_number (由此可知是哪一个字段)。依据 wire_type 来正确读取后面的 Value。接着继续读取下一个字段 field...

Varints 编码

上一节中多次提到 Varints 编码,现在我们来正式介绍这种编码方案。

总结的讲,Varints 编码的规则主要为以下三点: 
在每个字节开头的 bit 设置了 msb(most significant bit ),标识是否需要继续读取下一个字节存储数字对应的二进制补码补码的低位排在前面

先来看一个最为简单的例子:
int32 val = 1; // 设置一个 int32 的字段的值 val = 1; 这时编码的结果如下
原码:0000 ... 0000 0001 // 1 的原码表示
补码:0000 ... 0000 0001 // 1 的补码表示
Varints 编码:0#000 0001(0x01) // 1 的 Varints 编码,其中第一个字节的 msb = 0
编码过程:
数字 1 对应补码 0000 ... 0000 0001(规则 2),从末端开始取每 7 位一组并且反转排序(规则 3),因为 0000 ... 0000 0001 除了第一个取出的 7 位组(即原数列的后 7 位),剩下的均为 0。所以只需取第一个 7 位组,无需再取下一个 7 bit,那么第一个 7 位组的 msb = 0。最终得到0 | 000 0001(0x01) 解码过程:

我们再做一遍解码过程,加深理解。

编码结果为 0#000 0001(0x01)。首先,每个字节的第一个 bit 为 msb 位,msb = 1 表示需要再读一个字节(还未结束),msb = 0 表示无需再读字节(读取到此为止)。

在上面的例子中,数字 1 的 Varints 编码中 msb = 0,所以只需要读完第一个字节无需再读。去掉 msb 之后,剩下的 000 0001 就是补码的逆序,但是这里只有一个字节,所以无需反转,直接解释补码 000 0001,还原即为数字 1。

注意:这里编码数字 1,Varints 只使用了 1 个字节。而正常情况下 int32 将使用 4 个字节存储数字 1。


再看一个需要两个字节的数字 666 的编码:nt32 val = 666; // 设置一个 int32 的字段的值 val = 666; 这时编码的结果如下
原码:000 ... 101 0011010 // 666 的源码
补码:000 ... 101 0011010 // 666 的补码
Varints 编码:1#0011010 0#000 0101 (9a 05) // 666 的 Varints 编码编码过程:
666 的补码为 000 ... 101 0011010,从后依次向前取 7 位组并反转排序,则得到:
0011010 | 0000101加上 msb,则1 0011010 | 0 0000101 (0x9a 0x05)
解码过程:
编码结果为 1#0011010 0#000 0101 (9a 05),与第一个例子类似,但是这里的第一个字节 msb = 1,所以需要再读一个字节,第二个字节的 msb = 0,则读取两个字节后停止。读到两个字节后先去掉两个 msb,剩下:0011010 000 0101将这两个 7-bit 组反转得到补码:
000 0101 0011010然后还原其原码为 666。

注意:这里编码数字 6,Varints 只使用了 2 个字节。而正常情况下 int32 将使用 4 个字节存储数字 666。

 
仔细品味上述的 Varints 编码,我们可以发现 Varints 的本质实际上是每个字节都牺牲一个 bit 位(msb),来表示是否已经结束(是否还需要读取下一个字节),msb 实际上就起到了 Length 的作用,正因为有了 msb(Length),所以我们可以摆脱原来那种无论数字大小都必须分配四个字节的窘境。通过 Varints 我们可以让小的数字用更少的字节表示。从而提高了空间利用和效率。

这里为什么强调牺牲?因为每个字节都拿出一个 bit 做 msb,而原先这个 bit 是可直接用来表示 Value 的,现在每个字节都少了一个 bit 位即只有 7 位能真正用来表达 Value。那就意味这 4 个字节能表达的最大数字为 2的28次 ,而不再是  2的32次  了。
这意味着什么?意味着当数字大于  2的28次  时,采用 Varints 编码将导致分配 5 个字节,而原先明明只需要 4 个字节,此时 Varints 编码的效率不仅不是提高反而是下降。
但这并不影响 Varints 在实际应用时的高效,因为事实证明,在大多数情况下,小于 2的28次 的数字比大于 2的28次 的数字出现的更为频繁。


到目前为止,好像一切都很完美。但是当前的 Varints 编码却存在着明显缺陷。我们的例子好像只给出了正数,我们来看一下负数的 Varints 编码情况。int32 val = -1
原码:1000 ... 0001 // 注意这里是 8 个字节
补码:1111 ... 1111 // 注意这里是 8 个字节
再次复习 Varints 编码:对补码取 7 bit 一组,低位放在前面。
上述补码 8 个字节共 64 bit,可分 9 组且这 9 组均为 1,这 9 组的 msb 均为 1(因为还有最后一组)
最后剩下一个 bit 的 1,用 0 补齐作为最后一组放在最后,最后得到 Varints 编码
Varints 编码:1#1111111 ... 0#000 0001 (FF FF FF FF FF FF FF FF FF 01)
注意,因为负数必须在最高位(符号位)置 1,这一点意味着无论如何,负数都必须占用所有字节,所以它的补码总是占满 8 个字节。你没法像正数那样去掉多余的高位(都是 0)。再加上 msb,最终 Varints 编码的结果将固定在 10 个字节。

为什么是十个字节? int32 不应该是 4 个字节吗?这里是 ProtoBuf 基于兼容性的考虑(比如开发者将 int64 的字段改成 int32 后应当不影响旧程序),而将 int32 扩展成 int64 的八个字节。
为什么之前讲正数的时候没有这种扩展?。请仔细品味 Varints 编码,正数的前提下 int32 和 int64 天然兼容!


所以目前的情况是我们定义了一个 int32 类型的变量,如果将变量值设置为负数,那么直接采用 Varints 编码的话,其编码结果将总是占用十个字节,这显然不是我们希望得到的结果。如何解决?

ZigZag 编码

在上一节中我们提到了 Varints 编码对负数编码效率低的问题。

为解决这个问题,ProtoBuf 为我们提供了 sint32、sint64 两种类型,当你在使用这两种类型定义字段时,ProtoBuf 将使用 ZigZag 编码,而 ZigZag 编码将解决负数编码效率低的问题。

ZigZag 的原理和概念比我们想象的简单易懂,一句话就可概括介绍 ZigZag 编码:
 

ZigZag 编码:有符号整数映射到无符号整数,然后再使用 Varints 编码


如下图所示:






对于 ZigZag 编码的思维不难理解,既然负数的 Varints 编码效率很低,那么就将负数映射到正数,然后对映射后的正数进行 Varints 编码。解码时,解出正数之后再按映射关系映射回原来的负数。

例如我们设置 int32 val = -2。映射得到 3,那么对数字 3 进行 Varints 编码,将结果存储或发送出去。接收方接到数据后进行 Varints 解码,得到数字 3,再将 3 映射回 -2。
 

这里的“映射”是以移位实现的,并非存储映射表。

 Varint 类型

介绍了 Varints 编码和 ZigZag 编码之后,我们就可以继续深入分析每个类型的编码。

在第一节中我们提到了 wire_type 目前已定义 6 种,其中两种已被遗弃(Start group 和 End group),只剩下四种类型: Varint、64-bit、Length-delimited、32-bit。

接下来我们就来一个个详细分析,彻底搞明白 ProtoBuf 针对每种类型的编码策略。

注意,我们在之前已经强调过,与其它三种类型不同,Varint 类型里不止一种编码策略。 除了 int32、int64 等类型的 Varints 编码,还有 sint32、sint64 类型的 ZigZag 编码。

int32、int64、uint32、uint64、bool、enum

当我们使用 int32、int64、uint32、uint64、bool、enum 声明字段类型时,其字段值将使用之前介绍的 Varints 编码。
 

其中 bool 的本质为 0 和 1,enum 本质为整数常量。


在结合本文开头介绍的编码结构: Tag - [Length] - Value,这里的 Value 采用 Varints 编码,因此不需要 Length,则编码结构为 Tag - Value,其中 Tag 和 Value 均采用 Vartins 编码。

int32、int64、uint32、uint64

来看一个最简单的 int32 的小例子:
 
syntax = "proto3";

// message 定义
message Example1 {
int32 int32Val = 1;
}

// 设置字段值 为 1
Example1 example1;
example1.set_int32val(1);
// 编码结果
tag-(Varints)0#0001 000 + value-(Varints)0#000 0001 = 0x08 0x01

// 设置字段值 为 666
Example1 example1;
example1.set_int32val(666);
// 编码结果
tag-(Varints)00001 000 + value-(Varints)1#0011010 0#000 0101 = 0x08 0x9a 0x05

// 设置字段值 为 1
Example1 example1;
example1.set_int32val(-1);
// 编码结果
tag-(Varints)00001 000 + value-(Varints)1#1111111 ... 0#000 0001 = 0x08 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0x01int64、uint32、uint64 与 int32 同理。
 
bool、enum

bool 的例子:
syntax = "proto3";

// message 定义
message Example1 {
bool boolVal = 1;
}

// 设置字段值 为 true
Example1 example1;
example1.set_boolval(true);

// 编码结果
tag-(Varints)00001 000 + value-(Varints)0#000 0001 = 08 01

// 设置字段值 为 false
Example1 example1;
example1.set_boolval(false);

// 编码结果

 

这里有个有意思的现象,当 boolVal = false 时,其编码结果为空,为什么?
这里是 ProtoBuf 为了提高效率做的又一个小技巧:规定一个默认值机制,当读出来的字段为空的时候就设置字段的值为默认值。而 bool 类型的默认值为 false。也就是说将 false 编码然后传递(消耗一个字节),不如直接不输出任何编码结果(空),终端解析时发现该字段为空,它会按照规定设置其值为默认值(也就是 false)。如此,可进一步节省空间提高效率。


enum 的例子:syntax = "proto3";

// message 定义
message Example1 {
enum COLOR {
YELLOW = 0;
RED = 1;
BLACK = 2;
WHITE = 3;
BLUE = 4;
}
// 枚举常量必须在 32 位整型值的范围
// 使用 Varints 编码,对负数不够高效,因此不推荐在枚举中使用负数
COLOR colorVal = 1;
}

// 设置字段值 为 Example1_COLOR_BLUE
Example1 example1;
example1.set_colorval(Example1_COLOR_BLUE);

// 编码结果
tag-(Varints)00001 000 + value-(Varints)0#000 0100 = 08 04sint32、sint64

sint32、sint64 将采用 ZigZag 编码。编码结构依然为 Tag - Value,只不过在编码和解码的过程中多出一个映射的过程,映射后依然采用 Varints 编码。
来看 sint32 的例子:syntax = "proto3";

// message 定义
message Example1 {
sint32 sint32Val = 1;
}

// 设置字段值 为 -1
Example1 example1;
example1.set_colorval(-1);

// 编码结果,1 映射回 -1
tag-(Varints)00001 000 + value-(Varints)0#000 0001 = 08 01

// 设置字段值 为 -2
Example1 example1;
example1.set_colorval(-2);

// 编码结果,3 映射回 -2
编码结果:tag-(Varints)00001 000 + value-(Varints)0#000 0011 = 08 03
sint64 与 sint32 同理。

int、uint 和 sint: 之所以同时出现了这三种类型,是因为历史和代码迭代的结果。ProtoBuf 最初只有 int 类型,由于 int 类型不适合负数(负数编码效率低),所以提供了 sint。因为 sint 的一部分正数其实是表达的负数,所以其正数范围有所减小,所以在一些全是正数场景下需要提供 uint 类型。

64-bit 和 32-bit 类型

64-bit 和 32-bit 比较简单,与 Varints 一样其编码结构为 Tag-Value,不同的是不管数字大小,64-bit 存储 8 字节,32-bit 存储 4 字节。读取时同理,64-bit 直接读取 8 字节,32-bit 直接读取 4 字节。

为什么需要 64-bit 和 32-bit?之前已经分析过了 Varints 编码在一定范围内是有高效的,超过某一个数字占用字节反而更多,效率更低。如果现在有场景是存在大量的大数字,那么使用 Varints 就不太合适了,此时使用 64-bit 和 32-bit 更为合适。具体的,如果数值比 256 大的话,64-bit 这个类型比 uint64 高效,如果数值比 228 大的话,32-bit 这个类型比 uint32 高效。

fixed64、sfixed64、double

来看例子:// message 定义
syntax = "proto3";

message Example1 {
fixed64 fixed64Val = 1;
sfixed64 sfixed64Val = 2;
double doubleVal = 3;
}

// 设置字段值 为 -2
example1.set_fixed64val(1)
example1.set_sfixed64val(-1)
example1.set_doubleval(1.2)

// 编码结果,总是 8 个字节
09 # 01 00 00 00 00 00 00 00
11 # FF FF FF FF FF FF FF FF (没有 ZigZag 编码)
19 # 33 33 33 33 33 33 F3 3Ffixed32、sfixed32、float

与 64-bit 同理。

Length-delimited 类型
string、bytes、EmbeddedMessage、repeated

终于遇到了体现编码结构图中 [Length] 意义的类型了。Length-delimited 类型的编码结构为 Tag - Length - Value

这种编码方式很好理解,来看例子:syntax = "proto3";

// message 定义
message Example1 {
string stringVal = 1;
bytes bytesVal = 2;
message EmbeddedMessage {
int32 int32Val = 1;
string stringVal = 2;
}
EmbeddedMessage embeddedExample1 = 3;
repeated int32 repeatedInt32Val = 4;
repeated string repeatedStringVal = 5;
}

//设置相应值
Example1 example1;
example1.set_stringval("hello,world");
example1.set_bytesval("are you ok?");

Example1_EmbeddedMessage *embeddedExample2 = new Example1_EmbeddedMessage();

embeddedExample2->set_int32val(1);
embeddedExample2->set_stringval("embeddedInfo");
example1.set_allocated_embeddedexample1(embeddedExample2);

example1.add_repeatedint32val(2);
example1.add_repeatedint32val(3);
example1.add_repeatedstringval("repeated1");
example1.add_repeatedstringval("repeated2");

//编码结果
0A 0B 68 65 6C 6C 6F 2C 77 6F 72 6C 64
12 0B 61 72 65 20 79 6F 75 20 6F 6B 3F
1A 10 08 01 12 0C 65 6D 62 65 64 64 65 64 49 6E 66 6F
22 02 02 03[ proto3 默认 packed = true](编码结果打包处理,见下一小节的介绍)
2A 09 72 65 70 65 61 74 65 64 31 2A 09 72 65 70 65 61 74 65 64 32(repeated string 为啥不进行默认 packed ?)读者可对照上面介绍过的编码来理解这段相对复杂的编码结果。(为降低难度,已按字段分行,即第一个字段的编码结果对应第一行,第二个字段对应第二行...)

补充 packed 编码

在 proto2 中为我们提供了可选的设置 [packed = true],而这一可选项在 proto3 中已成默认设置。
 

packed 目前只能用于 primitive 类型。


packed = true 主要使让 ProtoBuf 为我们把 repeated primitive 的编码结果打包,从而进一步压缩空间,进一步提高效率、速度。这里打包的含义其实就是:原先的 repeated 字段的编码结构为 Tag-Length-Value-Tag-Length-Value-Tag-Length-Value...,因为这些 Tag 都是相同的(同一字段),因此可以将这些字段的 Value 打包,即将编码结构变为 Tag-Length-Value-Value-Value...

上一节例子中 repeatedInt32Val 字段的编码结果为:22 | 02 02 03
22 即 00100010 -> wire_type = 2(Length-delimited), field_number = 4(repeatedInt32Val 字段),02 字节长度为 2,则读取两个字节,之后按照 Varints 解码出数字 2 和 3。

原文出处:
作者:404_89_117_101
链接:https://www.jianshu.com/p/73c9ed3a4877
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 查看全部
上一篇《序列化结构数据方法ProtoBuf初探》是对ProtoBuf的简单介绍和特点对比。接下来这篇主要总结的是ProtoBuf的编码方式。
 
编码结构

TLV 格式是我们比较熟悉的编码格式。
 


所谓的 TLV 即 Tag - Length - Value。Tag 作为该字段的唯一标识,Length 代表 Value 数据域的长度,最后的 Value 并是数据本身。



ProtoBuf 编码采用类似的结构,但是实际上又有较大区别,其编码结构可见下图:

QQ截图20200413160739.jpg


我们来一步步解析上图所表达的编码结构。

首先,每一个 message 进行编码,其结果由一个个字段组成,每个字段可划分为 Tag - [Length] - Value,如下图所示:

QQ截图20200413160948.jpg

 


特别注意这里的 [Length] 是可选的,含义是针对不同类型的数据编码结构可能会变成 Tag - Value 的形式,如果变成这样的形式,没有了 Length 我们该如何确定 Value 的边界?答案就是 Varint 编码,在后面将详细介绍。



继续深入 Tag ,Tag 由 field_number 和 wire_type 两个部分组成: 
  • field_number: message 定义字段时指定的字段编号
  • wire_type: ProtoBuf 编码类型,根据这个类型选择不同的 Value 编码方案。


整个 Tag 采用 Varints 编码方案进行编码,Varints 编码会在后面详细介绍。

Tag 结构如下图所示:

QQ截图20200413161046.jpg


3 bit 的 wire_type 最多可以表达 8 种编码类型,目前 ProtoBuf 已经定义了 6 种,如下图所示:

QQ截图20200413161115.jpg


第一列即是对应的类型编号,第二列为面向最终编码的编码类型,第三列是面向开发者的 message 字段的类型。
 


注意其中的 Start group 和 End group 两种类型已被遗弃。


 
 


另外要特别注意一点,虽然 wire_type 代表编码类型,但是 Varint 这个编码类型里针对 sint32、sint64 又会有一些特别编码(ZigTag 编码)处理,相当于 Varint 这个编码类型里又存在两种不同编码。



重新来看完整的编码结构图,现在我们可以理解一个 message 编码将由一个个的 field 组成,每个 field 根据类型将有如下两种格式:
 
  • Tag - Length - Value:编码类型表中 Type = 2 即 Length-delimited 编码类型将使用这种结构,
  • Tag - Value:编码类型表中 Varint、64-bit、32-bit 使用这种结构。



其中 Tag 由字段编号 field_number 和 编码类型 wire_type 组成, Tag 整体采用 Varints 编码。

现在来模拟一下,我们接收到了一串序列化的二进制数据,我们先读一个 Varints 编码块,进行 Varints 解码,读取最后 3 bit 得到 wire_type(由此可知是后面的 Value 采用的哪种编码),随后获取到 field_number (由此可知是哪一个字段)。依据 wire_type 来正确读取后面的 Value。接着继续读取下一个字段 field...

Varints 编码

上一节中多次提到 Varints 编码,现在我们来正式介绍这种编码方案。

总结的讲,Varints 编码的规则主要为以下三点: 
  1. 在每个字节开头的 bit 设置了 msb(most significant bit ),标识是否需要继续读取下一个字节
  2. 存储数字对应的二进制补码
  3. 补码的低位排在前面


先来看一个最为简单的例子:
int32 val =  1;  // 设置一个 int32 的字段的值 val = 1; 这时编码的结果如下
原码:0000 ... 0000 0001 // 1 的原码表示
补码:0000 ... 0000 0001 // 1 的补码表示
Varints 编码:0#000 0001(0x01) // 1 的 Varints 编码,其中第一个字节的 msb = 0

编码过程:
数字 1 对应补码 0000 ... 0000 0001(规则 2),从末端开始取每 7 位一组并且反转排序(规则 3),因为 0000 ... 0000 0001 除了第一个取出的 7 位组(即原数列的后 7 位),剩下的均为 0。所以只需取第一个 7 位组,无需再取下一个 7 bit,那么第一个 7 位组的 msb = 0。最终得到
0 | 000 0001(0x01) 
解码过程:

我们再做一遍解码过程,加深理解。

编码结果为 0#000 0001(0x01)。首先,每个字节的第一个 bit 为 msb 位,msb = 1 表示需要再读一个字节(还未结束),msb = 0 表示无需再读字节(读取到此为止)。

在上面的例子中,数字 1 的 Varints 编码中 msb = 0,所以只需要读完第一个字节无需再读。去掉 msb 之后,剩下的 000 0001 就是补码的逆序,但是这里只有一个字节,所以无需反转,直接解释补码 000 0001,还原即为数字 1。


注意:这里编码数字 1,Varints 只使用了 1 个字节。而正常情况下 int32 将使用 4 个字节存储数字 1。



再看一个需要两个字节的数字 666 的编码:
nt32 val = 666; // 设置一个 int32 的字段的值 val = 666; 这时编码的结果如下
原码:000 ... 101 0011010 // 666 的源码
补码:000 ... 101 0011010 // 666 的补码
Varints 编码:1#0011010 0#000 0101 (9a 05) // 666 的 Varints 编码
编码过程:
666 的补码为 000 ... 101 0011010,从后依次向前取 7 位组并反转排序,则得到:
0011010 | 0000101
加上 msb,则
1 0011010 | 0 0000101 (0x9a 0x05)

解码过程:
编码结果为 1#0011010 0#000 0101 (9a 05),与第一个例子类似,但是这里的第一个字节 msb = 1,所以需要再读一个字节,第二个字节的 msb = 0,则读取两个字节后停止。读到两个字节后先去掉两个 msb,剩下:
0011010  000 0101
将这两个 7-bit 组反转得到补码:
000 0101 0011010
然后还原其原码为 666。


注意:这里编码数字 6,Varints 只使用了 2 个字节。而正常情况下 int32 将使用 4 个字节存储数字 666。


 
仔细品味上述的 Varints 编码,我们可以发现 Varints 的本质实际上是每个字节都牺牲一个 bit 位(msb),来表示是否已经结束(是否还需要读取下一个字节),msb 实际上就起到了 Length 的作用,正因为有了 msb(Length),所以我们可以摆脱原来那种无论数字大小都必须分配四个字节的窘境。通过 Varints 我们可以让小的数字用更少的字节表示。从而提高了空间利用和效率


这里为什么强调牺牲?因为每个字节都拿出一个 bit 做 msb,而原先这个 bit 是可直接用来表示 Value 的,现在每个字节都少了一个 bit 位即只有 7 位能真正用来表达 Value。那就意味这 4 个字节能表达的最大数字为 2的28次 ,而不再是  2的32次  了。
这意味着什么?意味着当数字大于  2的28次  时,采用 Varints 编码将导致分配 5 个字节,而原先明明只需要 4 个字节,此时 Varints 编码的效率不仅不是提高反而是下降。
但这并不影响 Varints 在实际应用时的高效,因为事实证明,在大多数情况下,小于 2的28次 的数字比大于 2的28次 的数字出现的更为频繁。



到目前为止,好像一切都很完美。但是当前的 Varints 编码却存在着明显缺陷。我们的例子好像只给出了正数,我们来看一下负数的 Varints 编码情况。
int32 val = -1
原码:1000 ... 0001 // 注意这里是 8 个字节
补码:1111 ... 1111 // 注意这里是 8 个字节
再次复习 Varints 编码:对补码取 7 bit 一组,低位放在前面。
上述补码 8 个字节共 64 bit,可分 9 组且这 9 组均为 1,这 9 组的 msb 均为 1(因为还有最后一组)
最后剩下一个 bit 的 1,用 0 补齐作为最后一组放在最后,最后得到 Varints 编码
Varints 编码:1#1111111 ... 0#000 0001 (FF FF FF FF FF FF FF FF FF 01)

注意,因为负数必须在最高位(符号位)置 1,这一点意味着无论如何,负数都必须占用所有字节,所以它的补码总是占满 8 个字节。你没法像正数那样去掉多余的高位(都是 0)。再加上 msb,最终 Varints 编码的结果将固定在 10 个字节。


为什么是十个字节? int32 不应该是 4 个字节吗?这里是 ProtoBuf 基于兼容性的考虑(比如开发者将 int64 的字段改成 int32 后应当不影响旧程序),而将 int32 扩展成 int64 的八个字节。
为什么之前讲正数的时候没有这种扩展?。请仔细品味 Varints 编码,正数的前提下 int32 和 int64 天然兼容!



所以目前的情况是我们定义了一个 int32 类型的变量,如果将变量值设置为负数,那么直接采用 Varints 编码的话,其编码结果将总是占用十个字节,这显然不是我们希望得到的结果。如何解决?

ZigZag 编码

在上一节中我们提到了 Varints 编码对负数编码效率低的问题。

为解决这个问题,ProtoBuf 为我们提供了 sint32、sint64 两种类型,当你在使用这两种类型定义字段时,ProtoBuf 将使用 ZigZag 编码,而 ZigZag 编码将解决负数编码效率低的问题。

ZigZag 的原理和概念比我们想象的简单易懂,一句话就可概括介绍 ZigZag 编码:
 


ZigZag 编码:有符号整数映射到无符号整数,然后再使用 Varints 编码



如下图所示:

QQ截图20200413161656.jpg


对于 ZigZag 编码的思维不难理解,既然负数的 Varints 编码效率很低,那么就将负数映射到正数,然后对映射后的正数进行 Varints 编码。解码时,解出正数之后再按映射关系映射回原来的负数。

例如我们设置 int32 val = -2。映射得到 3,那么对数字 3 进行 Varints 编码,将结果存储或发送出去。接收方接到数据后进行 Varints 解码,得到数字 3,再将 3 映射回 -2。
 


这里的“映射”是以移位实现的,并非存储映射表。


 Varint 类型

介绍了 Varints 编码和 ZigZag 编码之后,我们就可以继续深入分析每个类型的编码。

在第一节中我们提到了 wire_type 目前已定义 6 种,其中两种已被遗弃(Start group 和 End group),只剩下四种类型: Varint、64-bit、Length-delimited、32-bit

接下来我们就来一个个详细分析,彻底搞明白 ProtoBuf 针对每种类型的编码策略。

注意,我们在之前已经强调过,与其它三种类型不同,Varint 类型里不止一种编码策略。 除了 int32、int64 等类型的 Varints 编码,还有 sint32、sint64 类型的 ZigZag 编码。

int32、int64、uint32、uint64、bool、enum

当我们使用 int32、int64、uint32、uint64、bool、enum 声明字段类型时,其字段值将使用之前介绍的 Varints 编码。
 


其中 bool 的本质为 0 和 1,enum 本质为整数常量。



在结合本文开头介绍的编码结构: Tag - [Length] - Value,这里的 Value 采用 Varints 编码,因此不需要 Length,则编码结构为 Tag - Value,其中 Tag 和 Value 均采用 Vartins 编码。

int32、int64、uint32、uint64

来看一个最简单的 int32 的小例子:
 
syntax = "proto3";

// message 定义
message Example1 {
int32 int32Val = 1;
}

// 设置字段值 为 1
Example1 example1;
example1.set_int32val(1);
// 编码结果
tag-(Varints)0#0001 000 + value-(Varints)0#000 0001 = 0x08 0x01

// 设置字段值 为 666
Example1 example1;
example1.set_int32val(666);
// 编码结果
tag-(Varints)00001 000 + value-(Varints)1#0011010 0#000 0101 = 0x08 0x9a 0x05

// 设置字段值 为 1
Example1 example1;
example1.set_int32val(-1);
// 编码结果
tag-(Varints)00001 000 + value-(Varints)1#1111111 ... 0#000 0001 = 0x08 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0x01
int64、uint32、uint64 与 int32 同理。
 
bool、enum

bool 的例子:
syntax = "proto3";

// message 定义
message Example1 {
bool boolVal = 1;
}

// 设置字段值 为 true
Example1 example1;
example1.set_boolval(true);

// 编码结果
tag-(Varints)00001 000 + value-(Varints)0#000 0001 = 08 01

// 设置字段值 为 false
Example1 example1;
example1.set_boolval(false);

// 编码结果

 


这里有个有意思的现象,当 boolVal = false 时,其编码结果为空,为什么?
这里是 ProtoBuf 为了提高效率做的又一个小技巧:规定一个默认值机制,当读出来的字段为空的时候就设置字段的值为默认值。而 bool 类型的默认值为 false。也就是说将 false 编码然后传递(消耗一个字节),不如直接不输出任何编码结果(空),终端解析时发现该字段为空,它会按照规定设置其值为默认值(也就是 false)。如此,可进一步节省空间提高效率。



enum 的例子:
syntax = "proto3";

// message 定义
message Example1 {
enum COLOR {
YELLOW = 0;
RED = 1;
BLACK = 2;
WHITE = 3;
BLUE = 4;
}
// 枚举常量必须在 32 位整型值的范围
// 使用 Varints 编码,对负数不够高效,因此不推荐在枚举中使用负数
COLOR colorVal = 1;
}

// 设置字段值 为 Example1_COLOR_BLUE
Example1 example1;
example1.set_colorval(Example1_COLOR_BLUE);

// 编码结果
tag-(Varints)00001 000 + value-(Varints)0#000 0100 = 08 04
sint32、sint64

sint32、sint64 将采用 ZigZag 编码。编码结构依然为 Tag - Value,只不过在编码和解码的过程中多出一个映射的过程,映射后依然采用 Varints 编码。
来看 sint32 的例子:
syntax = "proto3";

// message 定义
message Example1 {
sint32 sint32Val = 1;
}

// 设置字段值 为 -1
Example1 example1;
example1.set_colorval(-1);

// 编码结果,1 映射回 -1
tag-(Varints)00001 000 + value-(Varints)0#000 0001 = 08 01

// 设置字段值 为 -2
Example1 example1;
example1.set_colorval(-2);

// 编码结果,3 映射回 -2
编码结果:tag-(Varints)00001 000 + value-(Varints)0#000 0011 = 08 03
sint64 与 sint32 同理。


int、uint 和 sint: 之所以同时出现了这三种类型,是因为历史和代码迭代的结果。ProtoBuf 最初只有 int 类型,由于 int 类型不适合负数(负数编码效率低),所以提供了 sint。因为 sint 的一部分正数其实是表达的负数,所以其正数范围有所减小,所以在一些全是正数场景下需要提供 uint 类型。


64-bit 和 32-bit 类型

64-bit 和 32-bit 比较简单,与 Varints 一样其编码结构为 Tag-Value,不同的是不管数字大小,64-bit 存储 8 字节,32-bit 存储 4 字节。读取时同理,64-bit 直接读取 8 字节,32-bit 直接读取 4 字节。

为什么需要 64-bit 和 32-bit?之前已经分析过了 Varints 编码在一定范围内是有高效的,超过某一个数字占用字节反而更多,效率更低。如果现在有场景是存在大量的大数字,那么使用 Varints 就不太合适了,此时使用 64-bit 和 32-bit 更为合适。具体的,如果数值比 256 大的话,64-bit 这个类型比 uint64 高效,如果数值比 228 大的话,32-bit 这个类型比 uint32 高效。

fixed64、sfixed64、double

来看例子:
// message 定义
syntax = "proto3";

message Example1 {
fixed64 fixed64Val = 1;
sfixed64 sfixed64Val = 2;
double doubleVal = 3;
}

// 设置字段值 为 -2
example1.set_fixed64val(1)
example1.set_sfixed64val(-1)
example1.set_doubleval(1.2)

// 编码结果,总是 8 个字节
09 # 01 00 00 00 00 00 00 00
11 # FF FF FF FF FF FF FF FF (没有 ZigZag 编码)
19 # 33 33 33 33 33 33 F3 3F
fixed32、sfixed32、float

与 64-bit 同理。

Length-delimited 类型
string、bytes、EmbeddedMessage、repeated

终于遇到了体现编码结构图中 [Length] 意义的类型了。Length-delimited 类型的编码结构为 Tag - Length - Value

这种编码方式很好理解,来看例子:
syntax = "proto3";

// message 定义
message Example1 {
string stringVal = 1;
bytes bytesVal = 2;
message EmbeddedMessage {
int32 int32Val = 1;
string stringVal = 2;
}
EmbeddedMessage embeddedExample1 = 3;
repeated int32 repeatedInt32Val = 4;
repeated string repeatedStringVal = 5;
}

//设置相应值
Example1 example1;
example1.set_stringval("hello,world");
example1.set_bytesval("are you ok?");

Example1_EmbeddedMessage *embeddedExample2 = new Example1_EmbeddedMessage();

embeddedExample2->set_int32val(1);
embeddedExample2->set_stringval("embeddedInfo");
example1.set_allocated_embeddedexample1(embeddedExample2);

example1.add_repeatedint32val(2);
example1.add_repeatedint32val(3);
example1.add_repeatedstringval("repeated1");
example1.add_repeatedstringval("repeated2");

//编码结果
0A 0B 68 65 6C 6C 6F 2C 77 6F 72 6C 64
12 0B 61 72 65 20 79 6F 75 20 6F 6B 3F
1A 10 08 01 12 0C 65 6D 62 65 64 64 65 64 49 6E 66 6F
22 02 02 03[ proto3 默认 packed = true](编码结果打包处理,见下一小节的介绍)
2A 09 72 65 70 65 61 74 65 64 31 2A 09 72 65 70 65 61 74 65 64 32(repeated string 为啥不进行默认 packed ?)
读者可对照上面介绍过的编码来理解这段相对复杂的编码结果。(为降低难度,已按字段分行,即第一个字段的编码结果对应第一行,第二个字段对应第二行...)

补充 packed 编码

在 proto2 中为我们提供了可选的设置 [packed = true],而这一可选项在 proto3 中已成默认设置。
 


packed 目前只能用于 primitive 类型。



packed = true 主要使让 ProtoBuf 为我们把 repeated primitive 的编码结果打包,从而进一步压缩空间,进一步提高效率、速度。这里打包的含义其实就是:原先的 repeated 字段的编码结构为 Tag-Length-Value-Tag-Length-Value-Tag-Length-Value...,因为这些 Tag 都是相同的(同一字段),因此可以将这些字段的 Value 打包,即将编码结构变为 Tag-Length-Value-Value-Value...

上一节例子中 repeatedInt32Val 字段的编码结果为:
22 | 02 02 03

22 即 00100010 -> wire_type = 2(Length-delimited), field_number = 4(repeatedInt32Val 字段),02 字节长度为 2,则读取两个字节,之后按照 Varints 解码出数字 2 和 3。

原文出处:
作者:404_89_117_101
链接:https://www.jianshu.com/p/73c9ed3a4877
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

序列化结构数据方法ProtoBuf初探

zkbhj 发表了文章 • 0 个评论 • 1546 次浏览 • 2020-04-13 15:19 • 来自相关话题

我们经常在网络通信和通用数据交换等应用场景中经常使用的技术是 JSON 或 XML,比如一般情况下,我们都是选择JSON来作为API接口的返回结果的数据序列化方法。因为JSON可读性和跨平台性很好,处理起来也方便,当然,去年在做搜索数据重构项目时,为了达到更高的性能,也接触了另外一个数据序列化方案——Msgpack,并且当时也总结学习了它和JSON两种方案的优劣,更多可以异步《两种数据序列化方案性能对比:Msgpack和Json》。
 
那么今天要学习讨论的ProtoBuf又是个何方神圣呢?我们为什么“放弃”JSON而选择 ProtoBuf呢?因为性能更好?还是有其他原因,我们这篇文章就是总结这些疑问的。首先,学习一个东西,从它的定义开始!到底 什么是 ProtoBuf?
 
一、什么是 ProtoBuf?

protocol buffers 是一种语言无关、平台无关、可扩展的序列化结构数据的方法,它可用于(数据)通信协议、数据存储等。

Protocol Buffers 是一种灵活,高效,自动化机制的结构数据序列化方法-可类比 XML,但是比 XML 更小(3 ~ 10倍)、更快(20 ~ 100倍)、更为简单。

你可以定义数据的结构,然后使用特殊生成的源代码轻松的在各种数据流中使用各种语言进行编写和读取结构数据。你甚至可以更新数据结构,而不破坏由旧数据结构编译的已部署程序。

 
简单来讲, ProtoBuf 是结构数据序列化①方法,可简单类比于 XML②,其具有以下特点:
语言无关、平台无关。即 ProtoBuf 支持 Java、C++、Python 等多种语言,支持多个平台高效。即比 XML 更小(3 ~ 10倍)、更快(20 ~ 100倍)、更为简单扩展性、兼容性好。你可以更新数据结构,而不影响和破坏原有的旧程序
 

序列化①:将结构数据或对象转换成能够被存储和传输(例如网络传输)的格式,同时应当要保证这个序列化结果在之后(可能在另一个计算环境中)能够被重建回原来的结构数据或对象。
更为详尽的介绍可参阅 维基百科(https://en.wikipedia.org/wiki/Serialization)。
类比于 XML②:这里主要指在数据通信和数据存储应用场景中序列化方面的类比,但个人认为 XML 作为一种扩展标记语言和 ProtoBuf 还是有着本质区别的。

 
二、关于 ProtoBuf 的一些思考

官方文档以及网上很多文章提到 ProtoBuf 可类比 XML 或 JSON。

那么 ProtoBuf 是否就等同于 XML 和 JSON 呢,它们是否具有完全相同的应用场景呢?

个人认为如果要将 ProtoBuf、XML、JSON 三者放到一起去比较,应该区分两个维度。一个是数据结构化,一个是数据序列化。这里的数据结构化主要面向开发或业务层面,数据序列化面向通信或存储层面,当然数据序列化也需要“结构”和“格式”,所以这两者之间的区别主要在于面向领域和场景不同,一般要求和侧重点也会有所不同。数据结构化侧重人类可读性甚至有时会强调语义表达能力,而数据序列化侧重效率和压缩。

从这两个维度,我们可以做出下面的一些思考。

XML 作为一种扩展标记语言,JSON 作为源于 JS 的数据格式,都具有数据结构化的能力。

例如 XML 可以衍生出 HTML (虽然 HTML 早于 XML,但从概念上讲,HTML 只是预定义标签的 XML),HTML 的作用是标记和表达万维网中资源的结构,以便浏览器更好的展示万维网资源,同时也要尽可能保证其人类可读以便开发人员进行编辑,这就是面向业务或开发层面的数据结构化。

再如 XML 还可衍生出 RDF/RDFS,进一步表达语义网中资源的关系和语义,同样它强调数据结构化的能力和人类可读。

关于 RDF/RDFS 和语义网的概念可查询相关资料了解,或参阅 2-Answer 系列-本体构建模块(一) 和 3-Answer 系列-本体构建模块(二) ,文中有一些简单介绍。

JSON 也是同理,在很多场合更多的是体现了数据结构化的能力,例如作为交互接口的数据结构的表达。在 MongoDB 中采用 JSON 作为查询语句,也是在发挥其数据结构化的能力。

当然,JSON、XML 同样也可以直接被用来数据序列化,实际上很多时候它们也是这么被使用的,例如直接采用 JSON、XML 进行网络通信传输,此时 JSON、XML 就成了一种序列化格式,它发挥了数据序列化的能力。但是经常这么被使用,不代表这么做就是合理。实际将 JSON、XML 直接作用数据序列化通常并不是最优选择,因为它们在速度、效率、空间上并不是最优。换句话说它们更适合数据结构化而非数据序列化。

扯完 XML 和 JSON,我们来看看 ProtoBuf,同样的 ProtoBuf 也具有数据结构化的能力,其实也就是上面介绍的 message 定义。我们能够在 .proto 文件中,通过 message、import、内嵌 message 等语法来实现数据结构化,但是很容易能够看出,ProtoBuf 在数据结构化方面和 XML、JSON 相差较大,人类可读性较差,不适合上面提到的 XML、JSON 的一些应用场景。

但是如果从数据序列化的角度你会发现 ProtoBuf 有着明显的优势,效率、速度、空间几乎全面占优,看完后面的 ProtoBuf 编码的文章,你更会了解 ProtoBuf 是如何极尽所能的压榨每一寸空间和性能,而其中的编码原理正是 ProtoBuf 的关键所在,message 的表达能力并不是 ProtoBuf 最关键的重点。所以可以看出 ProtoBuf 重点侧重于数据序列化 而非 数据结构化。

最终对这些个人思考做一些小小的总结:
XML、JSON、ProtoBuf 都具有数据结构化和数据序列化的能力XML、JSON 更注重数据结构化,关注人类可读性和语义表达能力。ProtoBuf 更注重数据序列化,关注效率、空间、速度,人类可读性差,语义表达能力不足(为保证极致的效率,会舍弃一部分元信息)ProtoBuf 的应用场景更为明确,XML、JSON 的应用场景更为丰富。
 
大部分内容源自于简书上的下面文章~

作者:404_89_117_101
链接:https://www.jianshu.com/p/a24c88c0526a
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
官方文档:https://developers.google.cn/protocol-buffers 查看全部
我们经常在网络通信和通用数据交换等应用场景中经常使用的技术是 JSON 或 XML,比如一般情况下,我们都是选择JSON来作为API接口的返回结果的数据序列化方法。因为JSON可读性和跨平台性很好,处理起来也方便,当然,去年在做搜索数据重构项目时,为了达到更高的性能,也接触了另外一个数据序列化方案——Msgpack,并且当时也总结学习了它和JSON两种方案的优劣,更多可以异步《两种数据序列化方案性能对比:Msgpack和Json》。
 
那么今天要学习讨论的ProtoBuf又是个何方神圣呢?我们为什么“放弃”JSON而选择 ProtoBuf呢?因为性能更好?还是有其他原因,我们这篇文章就是总结这些疑问的。首先,学习一个东西,从它的定义开始!到底 什么是 ProtoBuf?
 
一、什么是 ProtoBuf?


protocol buffers 是一种语言无关、平台无关、可扩展的序列化结构数据的方法,它可用于(数据)通信协议、数据存储等。

Protocol Buffers 是一种灵活,高效,自动化机制的结构数据序列化方法-可类比 XML,但是比 XML 更小(3 ~ 10倍)、更快(20 ~ 100倍)、更为简单。

你可以定义数据的结构,然后使用特殊生成的源代码轻松的在各种数据流中使用各种语言进行编写和读取结构数据。你甚至可以更新数据结构,而不破坏由旧数据结构编译的已部署程序。


 
简单来讲, ProtoBuf 是结构数据序列化①方法,可简单类比于 XML②,其具有以下特点:
  1. 语言无关、平台无关。即 ProtoBuf 支持 Java、C++、Python 等多种语言,支持多个平台
  2. 高效。即比 XML 更小(3 ~ 10倍)、更快(20 ~ 100倍)、更为简单
  3. 扩展性、兼容性好。你可以更新数据结构,而不影响和破坏原有的旧程序

 


序列化①:将结构数据或对象转换成能够被存储和传输(例如网络传输)的格式,同时应当要保证这个序列化结果在之后(可能在另一个计算环境中)能够被重建回原来的结构数据或对象。
更为详尽的介绍可参阅 维基百科(https://en.wikipedia.org/wiki/Serialization)。
类比于 XML②:这里主要指在数据通信和数据存储应用场景中序列化方面的类比,但个人认为 XML 作为一种扩展标记语言和 ProtoBuf 还是有着本质区别的。


 
二、关于 ProtoBuf 的一些思考

官方文档以及网上很多文章提到 ProtoBuf 可类比 XML 或 JSON。

那么 ProtoBuf 是否就等同于 XML 和 JSON 呢,它们是否具有完全相同的应用场景呢?

个人认为如果要将 ProtoBuf、XML、JSON 三者放到一起去比较,应该区分两个维度。一个是数据结构化,一个是数据序列化。这里的数据结构化主要面向开发或业务层面,数据序列化面向通信或存储层面,当然数据序列化也需要“结构”和“格式”,所以这两者之间的区别主要在于面向领域和场景不同,一般要求和侧重点也会有所不同。数据结构化侧重人类可读性甚至有时会强调语义表达能力,而数据序列化侧重效率和压缩。

从这两个维度,我们可以做出下面的一些思考。

XML 作为一种扩展标记语言,JSON 作为源于 JS 的数据格式,都具有数据结构化的能力。

例如 XML 可以衍生出 HTML (虽然 HTML 早于 XML,但从概念上讲,HTML 只是预定义标签的 XML),HTML 的作用是标记和表达万维网中资源的结构,以便浏览器更好的展示万维网资源,同时也要尽可能保证其人类可读以便开发人员进行编辑,这就是面向业务或开发层面的数据结构化。

再如 XML 还可衍生出 RDF/RDFS,进一步表达语义网中资源的关系和语义,同样它强调数据结构化的能力和人类可读。

关于 RDF/RDFS 和语义网的概念可查询相关资料了解,或参阅 2-Answer 系列-本体构建模块(一) 和 3-Answer 系列-本体构建模块(二) ,文中有一些简单介绍。

JSON 也是同理,在很多场合更多的是体现了数据结构化的能力,例如作为交互接口的数据结构的表达。在 MongoDB 中采用 JSON 作为查询语句,也是在发挥其数据结构化的能力。

当然,JSON、XML 同样也可以直接被用来数据序列化,实际上很多时候它们也是这么被使用的,例如直接采用 JSON、XML 进行网络通信传输,此时 JSON、XML 就成了一种序列化格式,它发挥了数据序列化的能力。但是经常这么被使用,不代表这么做就是合理。实际将 JSON、XML 直接作用数据序列化通常并不是最优选择,因为它们在速度、效率、空间上并不是最优。换句话说它们更适合数据结构化而非数据序列化。

扯完 XML 和 JSON,我们来看看 ProtoBuf,同样的 ProtoBuf 也具有数据结构化的能力,其实也就是上面介绍的 message 定义。我们能够在 .proto 文件中,通过 message、import、内嵌 message 等语法来实现数据结构化,但是很容易能够看出,ProtoBuf 在数据结构化方面和 XML、JSON 相差较大,人类可读性较差,不适合上面提到的 XML、JSON 的一些应用场景。

但是如果从数据序列化的角度你会发现 ProtoBuf 有着明显的优势,效率、速度、空间几乎全面占优,看完后面的 ProtoBuf 编码的文章,你更会了解 ProtoBuf 是如何极尽所能的压榨每一寸空间和性能,而其中的编码原理正是 ProtoBuf 的关键所在,message 的表达能力并不是 ProtoBuf 最关键的重点。所以可以看出 ProtoBuf 重点侧重于数据序列化 而非 数据结构化。

最终对这些个人思考做一些小小的总结:
  1. XML、JSON、ProtoBuf 都具有数据结构化和数据序列化的能力
  2. XML、JSON 更注重数据结构化,关注人类可读性和语义表达能力。ProtoBuf 更注重数据序列化,关注效率、空间、速度,人类可读性差,语义表达能力不足(为保证极致的效率,会舍弃一部分元信息)
  3. ProtoBuf 的应用场景更为明确,XML、JSON 的应用场景更为丰富。

 
大部分内容源自于简书上的下面文章~

作者:404_89_117_101
链接:https://www.jianshu.com/p/a24c88c0526a
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
官方文档:https://developers.google.cn/protocol-buffers

视频格式中AVC和HEVC有什么区别?

回复

zkbhj 回复了问题 • 1 人关注 • 1 个回复 • 8922 次浏览 • 2020-02-16 10:30 • 来自相关话题

理解HTTP请求头中的参数:If-Modified-Since与Last-Modified

zkbhj 发表了文章 • 0 个评论 • 2181 次浏览 • 2020-01-17 10:25 • 来自相关话题

1.基本定义

Last-Modified 与If-Modified-Since 都是标准的HTTP请求头标签,用于记录页面的最后修改时间。

2.发送方向

Last-Modified 是由服务器发送给客户端的HTTP请求头标签

If-Modified-Since 则是由客户端发送给服务器的HTTP请求头标签

3.应用场景

(1)Last-Modified

在浏览器第一次请求某一个URL时,服务器端的返回状态会是200,内容是你请求的资源,同时有一个Last-Modified的属性标记此文件在服务期端最后被修改的时间,格式类似这样:

Last-Modified: Fri, 12 May 2006 18:53:33 GMT

后面跟的时间是服务器存储的文件修改时间

(2)If-Modified-Since

客户端第二次请求此URL时,根据 HTTP 协议的规定,浏览器会向服务器传送 If-Modified-Since 报头,询问该时间之后文件是否有被修改过:

If-Modified-Since: Fri, 12 May 2006 18:53:33 GMT

后面跟的时间是本地浏览器存储的文件修改时间

如果服务器端的资源没有变化,则时间一致,自动返回HTTP状态码304(Not Changed.)状态码,内容为空,客户端接到之后,就直接把本地缓存文件显示到浏览器中,这样就节省了传输数据量。

如果服务器端资源发生改变或者重启服务器时,时间不一致,就返回HTTP状态码200和新的文件内容,客户端接到之后,会丢弃旧文件,把新文件缓存起来,并显示到浏览器中。

以上操作可以保证不向客户端重复发出资源,也保证当服务器有变化时,客户端能够得到最新的资源。
————————————————
版权声明:本文为CSDN博主「上善若海」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/lhl11242 ... 67764 查看全部
1.基本定义

Last-Modified 与If-Modified-Since 都是标准的HTTP请求头标签,用于记录页面的最后修改时间。

2.发送方向

Last-Modified 是由服务器发送给客户端的HTTP请求头标签

If-Modified-Since 则是由客户端发送给服务器的HTTP请求头标签

3.应用场景

(1)Last-Modified

在浏览器第一次请求某一个URL时,服务器端的返回状态会是200,内容是你请求的资源,同时有一个Last-Modified的属性标记此文件在服务期端最后被修改的时间,格式类似这样:

Last-Modified: Fri, 12 May 2006 18:53:33 GMT

后面跟的时间是服务器存储的文件修改时间

(2)If-Modified-Since

客户端第二次请求此URL时,根据 HTTP 协议的规定,浏览器会向服务器传送 If-Modified-Since 报头,询问该时间之后文件是否有被修改过:

If-Modified-Since: Fri, 12 May 2006 18:53:33 GMT

后面跟的时间是本地浏览器存储的文件修改时间

如果服务器端的资源没有变化,则时间一致,自动返回HTTP状态码304(Not Changed.)状态码,内容为空,客户端接到之后,就直接把本地缓存文件显示到浏览器中,这样就节省了传输数据量。

如果服务器端资源发生改变或者重启服务器时,时间不一致,就返回HTTP状态码200和新的文件内容,客户端接到之后,会丢弃旧文件,把新文件缓存起来,并显示到浏览器中。

以上操作可以保证不向客户端重复发出资源,也保证当服务器有变化时,客户端能够得到最新的资源。
————————————————
版权声明:本文为CSDN博主「上善若海」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/lhl11242 ... 67764

什么是红海市场?什么是蓝海市场?

回复

zkbhj 回复了问题 • 1 人关注 • 1 个回复 • 5531 次浏览 • 2020-01-09 21:11 • 来自相关话题

带你了解什么是缓存穿透、缓存雪崩和缓存击穿!

zkbhj 发表了文章 • 0 个评论 • 1277 次浏览 • 2019-10-30 18:31 • 来自相关话题

缓存穿透

缓存穿透,是指查询一个数据库一定不存在的数据。正常的使用缓存流程大致是,数据查询先进行缓存查询,如果key不存在或者key已经过期,再对数据库进行查询,并把查询到的对象,放进缓存。如果数据库查询对象为空,则不放进缓存。
 
代码流程

参数传入对象主键ID根据key从缓存中获取对象如果对象不为空,直接返回如果对象为空,进行数据库查询如果从数据库查询出的对象不为空,则放入缓存(设定过期时间)想象一下这个情况,如果传入的参数为-1,会是怎么样?这个-1,就是一定不存在的对象。就会每次都去查询数据库,而每次查询都是空,每次又都不会进行缓存。假如有恶意攻击,就可以利用这个漏洞,对数据库造成压力,甚至压垮数据库。即便是采用UUID,也是很容易找到一个不存在的KEY,进行攻击。

小编在工作中,会采用缓存空值的方式,也就是【代码流程】中第5步,如果从数据库查询的对象为空,也放入缓存,只是设定的缓存过期时间较短,比如设置为60秒。解决方案

在缓存使用的场景中,缓存KEY值失效的风暴(单个KEY值失效,PUT时间较长,导致穿透缓存落到DB上,对DB造成压力)。可以采用 布隆过滤器 、单独设置个缓存区域存储空值,对要查询的key进行预先校验 、缓存降级等方法。
缓存穿透业内的解决方案已经比较成熟,主要常用的有以下几种:

bloom filter:类似于哈希表的一种算法,用所有可能的查询条件生成一个bitmap,在进行数据库查询之前会使用这个bitmap进行过滤,如果不在其中则直接过滤,从而减轻数据库层面的压力。guava中有实现BloomFilter算法。
空值缓存:一种比较简单的解决办法,在第一次查询完不存在的数据后,将该key与对应的空值也放入缓存中,只不过设定为较短的失效时间,例如几分钟,这样则可以应对短时间的大量的该key攻击,设置为较短的失效时间是因为该值可能业务无关,存在意义不大,且该次的查询也未必是攻击者发起,无过久存储的必要,故可以早点失效。

缓存雪崩

缓存雪崩,是指在某一个时间段,缓存集中过期失效。

产生雪崩的原因之一,比如在写本文的时候,马上就要到双十二零点,很快就会迎来一波抢购,这波商品时间比较集中的放入了缓存,假设缓存一个小时。那么到了凌晨一点钟的时候,这批商品的缓存就都过期了。而对这批商品的访问查询,都落到了数据库上,对于数据库而言,就会产生周期性的压力波峰。

小编在做电商项目的时候,一般是采取不同分类商品,缓存不同周期。在同一分类中的商品,加上一个随机因子。这样能尽可能分散缓存过期时间,而且,热门类目的商品缓存时间长一些,冷门类目的商品缓存时间短一些,也能节省缓存服务的资源。
 
其实集中过期,倒不是非常致命,比较致命的缓存雪崩,是缓存服务器某个节点宕机或断网。因为自然形成的缓存雪崩,一定是在某个时间段集中创建缓存,那么那个时候数据库能顶住压力,这个时候,数据库也是可以顶住压力的。无非就是对数据库产生周期性的压力而已。而缓存服务节点的宕机,对数据库服务器造成的压力是不可预知的,很有可能瞬间就把数据库压垮。
 
解决方案

加锁排队、 设置过期标志更新缓存 、 设置过期标志更新缓存 、二级缓存(引入一致性问题)、 预热、 缓存与服务降级

线程互斥:只让一个线程构建缓存,其他线程等待构建缓存的线程执行完,重新从缓存获取数据才可以,每个时刻只有一个线程在执行请求,减轻了db的压力,但缺点也很明显,降低了系统的qps。
交错失效时间:这种方法时间比较简单粗暴,既然在同一时间失效会造成请求过多雪崩,那我们错开不同的失效时间即可从一定长度上避免这种问题,在缓存进行失效时间设置的时候,从某个适当的值域中随机一个时间作为失效时间即可。

 
缓存击穿

缓存击穿,是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。

小编在做电商项目的时候,把这货就成为“爆款”。

其实,大多数情况下这种爆款很难对数据库服务器造成压垮性的压力。达到这个级别的公司没有几家的。所以,务实主义的小编,对主打商品都是早早的做好了准备,让缓存永不过期。即便某些商品自己发酵成了爆款,也是直接设为永不过期就好了。

解决方案

-双重校验(Dubbo Check)类似线程安全的懒汉单例模式实现,保证只会有一个线程去访问数据库

针对业务系统,永远都是具体情况具体分析,没有最好,只有最合适。 最后,对于缓存系统常见的缓存满了和数据丢失问题,需要根据具体业务分析,通常我们采用LRU策略处理溢出,Redis的RDB和AOF持久化策略来保证一定情况下的数据安全。





  查看全部
缓存穿透

缓存穿透,是指查询一个数据库一定不存在的数据。正常的使用缓存流程大致是,数据查询先进行缓存查询,如果key不存在或者key已经过期,再对数据库进行查询,并把查询到的对象,放进缓存。如果数据库查询对象为空,则不放进缓存。
 
代码流程

参数传入对象主键ID根据key从缓存中获取对象如果对象不为空,直接返回如果对象为空,进行数据库查询如果从数据库查询出的对象不为空,则放入缓存(设定过期时间)想象一下这个情况,如果传入的参数为-1,会是怎么样?这个-1,就是一定不存在的对象。就会每次都去查询数据库,而每次查询都是空,每次又都不会进行缓存。假如有恶意攻击,就可以利用这个漏洞,对数据库造成压力,甚至压垮数据库。即便是采用UUID,也是很容易找到一个不存在的KEY,进行攻击。

小编在工作中,会采用缓存空值的方式,也就是【代码流程】中第5步,如果从数据库查询的对象为空,也放入缓存,只是设定的缓存过期时间较短,比如设置为60秒。解决方案

在缓存使用的场景中,缓存KEY值失效的风暴(单个KEY值失效,PUT时间较长,导致穿透缓存落到DB上,对DB造成压力)。可以采用 布隆过滤器 、单独设置个缓存区域存储空值,对要查询的key进行预先校验 、缓存降级等方法。
缓存穿透业内的解决方案已经比较成熟,主要常用的有以下几种:


bloom filter:类似于哈希表的一种算法,用所有可能的查询条件生成一个bitmap,在进行数据库查询之前会使用这个bitmap进行过滤,如果不在其中则直接过滤,从而减轻数据库层面的压力。guava中有实现BloomFilter算法。
空值缓存:一种比较简单的解决办法,在第一次查询完不存在的数据后,将该key与对应的空值也放入缓存中,只不过设定为较短的失效时间,例如几分钟,这样则可以应对短时间的大量的该key攻击,设置为较短的失效时间是因为该值可能业务无关,存在意义不大,且该次的查询也未必是攻击者发起,无过久存储的必要,故可以早点失效。


缓存雪崩

缓存雪崩,是指在某一个时间段,缓存集中过期失效。

产生雪崩的原因之一,比如在写本文的时候,马上就要到双十二零点,很快就会迎来一波抢购,这波商品时间比较集中的放入了缓存,假设缓存一个小时。那么到了凌晨一点钟的时候,这批商品的缓存就都过期了。而对这批商品的访问查询,都落到了数据库上,对于数据库而言,就会产生周期性的压力波峰。

小编在做电商项目的时候,一般是采取不同分类商品,缓存不同周期。在同一分类中的商品,加上一个随机因子。这样能尽可能分散缓存过期时间,而且,热门类目的商品缓存时间长一些,冷门类目的商品缓存时间短一些,也能节省缓存服务的资源。
 
其实集中过期,倒不是非常致命,比较致命的缓存雪崩,是缓存服务器某个节点宕机或断网。因为自然形成的缓存雪崩,一定是在某个时间段集中创建缓存,那么那个时候数据库能顶住压力,这个时候,数据库也是可以顶住压力的。无非就是对数据库产生周期性的压力而已。而缓存服务节点的宕机,对数据库服务器造成的压力是不可预知的,很有可能瞬间就把数据库压垮。
 
解决方案

加锁排队、 设置过期标志更新缓存 、 设置过期标志更新缓存 、二级缓存(引入一致性问题)、 预热、 缓存与服务降级


线程互斥:只让一个线程构建缓存,其他线程等待构建缓存的线程执行完,重新从缓存获取数据才可以,每个时刻只有一个线程在执行请求,减轻了db的压力,但缺点也很明显,降低了系统的qps。
交错失效时间:这种方法时间比较简单粗暴,既然在同一时间失效会造成请求过多雪崩,那我们错开不同的失效时间即可从一定长度上避免这种问题,在缓存进行失效时间设置的时候,从某个适当的值域中随机一个时间作为失效时间即可。


 
缓存击穿

缓存击穿,是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。

小编在做电商项目的时候,把这货就成为“爆款”。

其实,大多数情况下这种爆款很难对数据库服务器造成压垮性的压力。达到这个级别的公司没有几家的。所以,务实主义的小编,对主打商品都是早早的做好了准备,让缓存永不过期。即便某些商品自己发酵成了爆款,也是直接设为永不过期就好了。

解决方案


-双重校验(Dubbo Check)类似线程安全的懒汉单例模式实现,保证只会有一个线程去访问数据库


针对业务系统,永远都是具体情况具体分析,没有最好,只有最合适。 最后,对于缓存系统常见的缓存满了和数据丢失问题,需要根据具体业务分析,通常我们采用LRU策略处理溢出,Redis的RDB和AOF持久化策略来保证一定情况下的数据安全。

QQ截图20191030182914.jpg

 

什么是HashTime33?

回复

zkbhj 回复了问题 • 1 人关注 • 1 个回复 • 4146 次浏览 • 2019-10-27 11:18 • 来自相关话题

写代码中遇到的“i18n”是什么意思?有什么出处来源?

回复

zkbhj 回复了问题 • 1 人关注 • 1 个回复 • 4652 次浏览 • 2019-10-17 15:15 • 来自相关话题