协议缓冲区(Protobufs),像 XML 和 JSON 一样,允许以不同语言编写并在不同平台上运行的应用程序交换数据。例如,用 Go 编写的发送应用程序可以将 Go 特定的销售订单编码为 Protobuf,然后用 Java 编写的接收器可以解码它,以获得接收订单的 Java 特定表示。这是一个通过网络连接的架构草图
Go sales order--->Pbuf-encode--->network--->Pbuf-decode--->Java sales order
与 XML 和 JSON 对应物相比,Protobuf 编码是二进制的而不是文本的,这可能会使调试变得复杂。但是,正如本文中的代码示例所证实的那样,Protobuf 编码在大小上比 XML 或 JSON 编码效率更高。
Protobuf 在另一方面也很高效。在实现层面,Protobuf 和其他编码系统序列化和反序列化结构化数据。序列化将特定于语言的数据结构转换为字节流,反序列化是逆操作,将字节流转换回特定于语言的数据结构。序列化和反序列化可能成为数据交换的瓶颈,因为这些操作是 CPU 密集型的。高效的序列化和反序列化是 Protobuf 的另一个设计目标。
最近的编码技术,例如 Protobuf 和 FlatBuffers,源自 1990 年代早期的 DCE/RPC(分布式计算环境/远程过程调用)倡议。与 DCE/RPC 一样,Protobuf 为数据交换中的 IDL(接口定义语言)和编码层都做出了贡献。
本文将着眼于这两个层,然后提供 Go 和 Java 代码示例,以充实 Protobuf 的细节,并表明 Protobuf 易于使用。
作为 IDL 和编码层的 Protobuf
DCE/RPC,像 Protobuf 一样,被设计为语言和平台中立的。适当的库和实用程序允许任何语言和平台在 DCE/RPC 领域中发挥作用。此外,DCE/RPC 架构非常优雅。IDL 文档是远程过程在一侧和调用者在另一侧之间的契约。Protobuf 也以 IDL 文档为中心。
IDL 文档是文本,在 DCE/RPC 中,使用基本的 C 语法以及元数据的语法扩展(方括号)和一些新的关键字,例如 interface。这是一个例子
[uuid (2d6ead46-05e3-11ca-7dd1-426909beabcd), version(1.0)]
interface echo {
const long int ECHO_SIZE = 512;
void echo(
[in] handle_t h,
[in, string] idl_char from_client[ ],
[out, string] idl_char from_service[ECHO_SIZE]
);
}
此 IDL 文档声明了一个名为 echo 的过程,它接受三个参数:类型为 handle_t(实现指针)和 idl_char(ASCII 字符数组)的 [in] 参数传递给远程过程,而 [out] 参数(也是一个字符串)从过程传递回来。在此示例中,echo 过程未显式返回值(echo 左侧的 void),但可以这样做。返回值以及一个或多个 [out] 参数允许远程过程返回任意数量的值。下一节介绍 Protobuf IDL,它在语法上有所不同,但同样充当数据交换中的契约。
IDL 文档在 DCE/RPC 和 Protobuf 中都是实用程序的输入,这些实用程序创建用于交换数据的基础设施代码
IDL document--->DCE/PRC or Protobuf utilities--->support code for data interchange
作为相对简单的文本,IDL 同样是关于数据交换细节的人工可读文档——特别是,交换的数据项的数量和每个数据项的数据类型。
Protobuf 可以用于现代 RPC 系统,例如 gRPC;但是 Protobuf 本身仅提供 IDL 层和用于在发送者和接收者之间传递消息的编码层。Protobuf 编码,像最初的 DCE/RPC 一样,是二进制的,但效率更高。
目前,XML 和 JSON 编码仍然在通过 Web 服务等技术进行的数据交换中占据主导地位,这些技术利用了现有的基础设施,例如 Web 服务器、传输协议(例如,TCP、HTTP)以及用于处理 XML 和 JSON 文档的标准库和实用程序。此外,各种风格的数据库系统都可以存储 XML 和 JSON 文档,甚至传统的关联系统也可以轻松生成查询结果的 XML 编码。现在每种通用编程语言都有支持 XML 和 JSON 的库。那么,是什么推荐回归到像 Protobuf 这样的二进制编码系统呢?
考虑负十进制值 -128。在 2 的补码二进制表示中,它在系统和语言中占主导地位,此值可以存储在单个 8 位字节中:10000000。此整数值在 XML 或 JSON 中的文本编码需要多个字节。例如,UTF-8 编码对于字符串需要四个字节,字面上是 -128,每个字符一个字节(以十六进制表示,这些值是 0x2d、0x31、0x32 和 0x38)。XML 和 JSON 还向组合中添加了标记字符,例如尖括号和花括号。关于 Protobuf 编码的详细信息即将到来,但现在感兴趣的点是通用的:文本编码往往比二进制编码的紧凑程度要低得多。
在 Go 中使用 Protobuf 的代码示例
我的代码示例侧重于 Protobuf 而不是 RPC。这是第一个示例的概述
- 名为 dataitem.proto 的 IDL 文件定义了一个 Protobuf message,其中包含六个不同类型的字段:不同范围的整数值、固定大小的浮点值和两个不同长度的字符串。
- Protobuf 编译器使用 IDL 文件生成 Protobuf message 的 Go 特定版本(以及稍后的 Java 特定版本)以及支持函数。
- Go 应用程序用随机生成的值填充本机 Go 数据结构,然后将结果序列化到本地文件。为了进行比较,XML 和 JSON 编码也序列化到本地文件。
- 作为测试,Go 应用程序通过反序列化 Protobuf 文件的内容来重建其本机数据结构的实例。
- 作为语言中立性测试,Java 应用程序也反序列化 Protobuf 文件的内容,以获得本机数据结构的实例。
此 IDL 文件以及两个 Go 和一个 Java 源文件可以 ZIP 文件形式在 我的网站上找到。
所有重要的 Protobuf IDL 文档如下所示。该文档存储在文件 dataitem.proto 中,带有通常的 .proto 扩展名。
示例 1. Protobuf IDL 文档
syntax = "proto3";
package main;
message DataItem {
int64 oddA = 1;
int64 evenA = 2;
int32 oddB = 3;
int32 evenB = 4;
float small = 5;
float big = 6;
string short = 7;
string long = 8;
}
IDL 使用当前的 proto3 而不是早期的 proto2 语法。包名称(在本例中为 main)是可选的但通常是习惯;它用于避免名称冲突。结构化的 message 包含八个字段,每个字段都具有 Protobuf 数据类型(例如,int64、string)、名称(例如,oddA、short)和等号 = 后的数字标签(又名键)。标签(在本例中为 1 到 8)是唯一的整数标识符,用于确定字段序列化的顺序。
Protobuf 消息可以嵌套到任意级别,并且一个消息可以是另一个消息中的字段类型。这是一个使用 DataItem 消息作为字段类型的示例
message DataItems {
repeated DataItem item = 1;
}
单个 DataItems 消息由重复的(零个或多个)DataItem 消息组成。
Protobuf 还支持枚举类型以提高清晰度
enum PartnershipStatus {
reserved "FREE", "CONSTRAINED", "OTHER";
}
reserved 限定符确保用于实现三个符号名称的数值不能被重用。
要生成一个或多个声明的 Protobuf message 结构的特定于语言的版本,包含这些结构的 IDL 文件将传递给 protoc 编译器(在 Protobuf GitHub 存储库中可用)。对于 Go 代码,可以按通常的方式安装支持 Protobuf 的库(以 % 作为命令行提示符)
% go get github.com/golang/protobuf/proto
将 Protobuf IDL 文件 dataitem.proto 编译为 Go 源代码的命令是
% protoc --go_out=. dataitem.proto
标志 --go_out 指示编译器生成 Go 源代码;其他语言也有类似的标志。在这种情况下,结果是一个名为 dataitem.pb.go 的文件,该文件足够小,可以将要点复制到 Go 应用程序中。以下是生成代码中的要点
var _ = proto.Marshal
type DataItem struct {
OddA int64 `protobuf:"varint,1,opt,name=oddA" json:"oddA,omitempty"`
EvenA int64 `protobuf:"varint,2,opt,name=evenA" json:"evenA,omitempty"`
OddB int32 `protobuf:"varint,3,opt,name=oddB" json:"oddB,omitempty"`
EvenB int32 `protobuf:"varint,4,opt,name=evenB" json:"evenB,omitempty"`
Small float32 `protobuf:"fixed32,5,opt,name=small" json:"small,omitempty"`
Big float32 `protobuf:"fixed32,6,opt,name=big" json:"big,omitempty"`
Short string `protobuf:"bytes,7,opt,name=short" json:"short,omitempty"`
Long string `protobuf:"bytes,8,opt,name=long" json:"long,omitempty"`
}
func (m *DataItem) Reset() { *m = DataItem{} }
func (m *DataItem) String() string { return proto.CompactTextString(m) }
func (*DataItem) ProtoMessage() {}
func init() {}
编译器生成的代码具有 Go 结构 DataItem,它导出 Go 字段(名称现在大写),这些字段与 Protobuf IDL 中声明的名称匹配。结构字段具有标准的 Go 数据类型:int32、int64、float32 和 string。在每个字段行的末尾,作为字符串,是描述 Protobuf 类型的元数据,给出 Protobuf IDL 文档中的数字标签,并提供有关 JSON 的信息,这将在后面讨论。
还有一些函数;最重要的是 proto.Marshal,用于将 DataItem 结构的实例序列化为 Protobuf 格式。辅助函数包括 Reset,用于清除 DataItem 结构,以及 String,用于生成 DataItem 的单行字符串表示形式。
在更详细地分析 Go 程序之前,值得仔细查看描述 Protobuf 编码的元数据。
Protobuf 编码
Protobuf 消息被构造为键/值对的集合,其中数字标签作为键,相应的字段作为值。字段名称(例如 oddA 和 small)是为了人类可读性,但 protoc 编译器确实在生成特定于语言的对应项时使用了字段名称。例如,Protobuf IDL 中的 oddA 和 small 名称分别变为 Go 结构中的字段 OddA 和 Small。
键及其值都将被编码,但有一个重要的区别:某些数值具有 32 位或 64 位的固定大小编码,而其他数值(包括 message 标签)是 varint 编码的——位数取决于整数的绝对值。例如,整数值 1 到 15 需要 8 位才能在 varint 中编码,而值 16 到 2047 需要 16 位。varint 编码在精神上(但不是在细节上)类似于 UTF-8 编码,它偏爱小整数值而不是大整数值。(有关详细分析,请参阅 Protobuf 编码指南。)结果是 Protobuf message 应该在字段中具有小整数值(如果可能),并且键应尽可能少,但每个字段一个键是不可避免的。
下表 1 给出了 Protobuf 编码的要点
表 1. Protobuf 数据类型
编码 | 示例类型 | 长度 |
---|---|---|
varint |
int32, uint32, int64 |
可变长度 |
fixed |
fixed32, float, double |
固定 32 位或 64 位长度 |
字节序列 |
string, bytes |
序列长度 |
未显式声明为 fixed 的整数类型是 varint 编码的;因此,在 varint 类型(例如 uint32(u 表示无符号))中,数字 32 描述的是整数的范围(在本例中为 0 到 232 - 1),而不是其位大小,位大小会因值而异。相比之下,对于固定类型(例如 fixed32 或 double),Protobuf 编码分别需要 32 位和 64 位。Protobuf 中的字符串是字节序列;因此,字段编码的大小是字节序列的长度。
另一个效率值得一提。回想一下早期的示例,其中 DataItems 消息由重复的 DataItem 实例组成
message DataItems {
repeated DataItem item = 1;
}
repeated 表示 DataItem 实例是打包的:集合具有单个标签,在本例中为 1。因此,具有重复 DataItem 实例的 DataItems 消息比具有多个但独立的 DataItem 字段的消息更有效,后者每个字段都需要自己的标签。
考虑到这些背景知识,让我们回到 Go 程序。
dataItem 程序的详细信息
dataItem 程序创建一个 DataItem 实例,并使用适当类型的随机生成值填充字段。Go 有一个 rand 包,其中包含用于生成伪随机整数和浮点值的函数,我的 randString 函数从字符集中生成指定长度的伪随机字符串。设计目标是拥有一个 DataItem 实例,其中包含不同类型和位大小的字段值。例如,OddA 和 EvenA 值分别是 64 位非负整数值,分别具有奇偶校验;但是 OddB 和 EvenB 变体的大小为 32 位,并且包含 0 到 2047 之间的小整数值。随机浮点值的大小为 32 位,字符串的长度分别为 16 (Short) 和 32 (Long) 个字符。以下是用随机值填充 DataItem 结构的代码段
// variable-length integers
n1 := rand.Int63() // bigger integer
if (n1 & 1) == 0 { n1++ } // ensure it's odd
...
n3 := rand.Int31() % UpperBound // smaller integer
if (n3 & 1) == 0 { n3++ } // ensure it's odd
// fixed-length floats
...
t1 := rand.Float32()
t2 := rand.Float32()
...
// strings
str1 := randString(StrShort)
str2 := randString(StrLong)
// the message
dataItem := &DataItem {
OddA: n1,
EvenA: n2,
OddB: n3,
EvenB: n4,
Big: f1,
Small: f2,
Short: str1,
Long: str2,
}
一旦创建并填充了值,DataItem 实例将以 XML、JSON 和 Protobuf 编码,每种编码都写入本地文件
func encodeAndserialize(dataItem *DataItem) {
bytes, _ := xml.MarshalIndent(dataItem, "", " ") // Xml to dataitem.xml
ioutil.WriteFile(XmlFile, bytes, 0644) // 0644 is file access permissions
bytes, _ = json.MarshalIndent(dataItem, "", " ") // Json to dataitem.json
ioutil.WriteFile(JsonFile, bytes, 0644)
bytes, _ = proto.Marshal(dataItem) // Protobuf to dataitem.pbuf
ioutil.WriteFile(PbufFile, bytes, 0644)
}
这三个序列化函数使用术语 marshal,它大致与 serialize 同义。正如代码所示,这三个 Marshal 函数中的每一个都返回一个字节数组,然后将其写入文件。(为了简单起见,忽略了可能的错误。)在示例运行中,文件大小为
dataitem.xml: 262 bytes
dataitem.json: 212 bytes
dataitem.pbuf: 88 bytes
Protobuf 编码明显小于其他两种。通过消除缩进字符(在本例中为空格和换行符),可以稍微减小 XML 和 JSON 序列化的大小。
下面是最终从 json.MarshalIndent 调用生成的 dataitem.json 文件,并添加了以 ## 开头的注释
{
"oddA": 4744002665212642479, ## 64-bit >= 0
"evenA": 2395006495604861128, ## ditto
"oddB": 57, ## 32-bit >= 0 but < 2048
"evenB": 468, ## ditto
"small": 0.7562016, ## 32-bit floating-point
"big": 0.85202795, ## ditto
"short": "ClH1oDaTtoX$HBN5", ## 16 random chars
"long": "xId0rD3Cri%3Wt%^QjcFLJgyXBu9^DZI" ## 32 random chars
}
尽管序列化数据进入本地文件,但相同的方法将用于将数据写入网络连接的输出流。
测试序列化/反序列化
Go 程序接下来运行一个基本测试,通过将先前写入 dataitem.pbuf 文件的字节反序列化为 DataItem 实例。这是代码段,其中删除了错误检查部分
filebytes, err := ioutil.ReadFile(PbufFile) // get the bytes from the file
...
testItem.Reset() // clear the DataItem structure
err = proto.Unmarshal(filebytes, testItem) // deserialize into a DataItem instance
用于反序列化 Protbuf 的 proto.Unmarshal 函数是 proto.Marshal 函数的逆函数。打印原始 DataItem 和反序列化克隆以确认完全匹配
Original:
2041519981506242154 3041486079683013705 1192 1879
0.572123 0.326855
boPb#T0O8Xd&Ps5EnSZqDg4Qztvo7IIs 9vH66AiGSQgCDxk&
Deserialized:
2041519981506242154 3041486079683013705 1192 1879
0.572123 0.326855
boPb#T0O8Xd&Ps5EnSZqDg4Qztvo7IIs 9vH66AiGSQgCDxk&
Java 中的 Protobuf 客户端
Java 中的示例是为了确认 Protobuf 的语言中立性。原始 IDL 文件可用于生成 Java 支持代码,其中涉及嵌套类。但是,为了抑制警告,可以进行一些小的添加。以下是修订版,它将 DataMsg 指定为外部类的名称,内部类在 Protobuf 消息之后自动命名为 DataItem
syntax = "proto3";
package main;
option java_outer_classname = "DataMsg";
message DataItem {
...
通过此更改,protoc 编译与以前相同,只是所需的输出现在是 Java 而不是 Go
% protoc --java_out=. dataitem.proto
生成的源文件(在名为 main 的子目录中)是 DataMsg.java,长度约为 1,120 行:Java 并不简洁。编译然后运行 Java 代码需要一个 JAR 文件,其中包含对 Protobuf 的库支持。此文件可在 Maven 存储库中找到。
有了这些组件,我的测试代码相对较短(并且在 ZIP 文件中作为 Main.java 提供)
package main;
import java.io.FileInputStream;
public class Main {
public static void main(String[] args) {
String path = "dataitem.pbuf"; // from the Go program's serialization
try {
DataMsg.DataItem deserial =
DataMsg.DataItem.newBuilder().mergeFrom(new FileInputStream(path)).build();
System.out.println(deserial.getOddA()); // 64-bit odd
System.out.println(deserial.getLong()); // 32-character string
}
catch(Exception e) { System.err.println(e); }
}
}
当然,生产级测试将更加彻底,但即使是此初步测试也证实了 Protobuf 的语言中立性:dataitem.pbuf 文件是 Go 程序序列化 Go DataItem 的结果,并且此文件中的字节被反序列化以在 Java 中生成 DataItem 实例。来自 Java 测试的输出与来自 Go 测试的输出相同。
用 numPairs 程序总结
让我们以一个突出显示 Protobuf 效率的示例结束,但也强调了任何编码技术所涉及的成本。考虑以下 Protobuf IDL 文件
syntax = "proto3";
package main;
message NumPairs {
repeated NumPair pair = 1;
}
message NumPair {
int32 odd = 1;
int32 even = 2;
}
NumPair 消息由两个 int32 值以及每个字段的整数标签组成。NumPairs 消息是嵌入式 NumPair 消息的序列。
Go 中的 numPairs 程序(如下)创建了 200 万个 NumPair 实例,每个实例都附加到 NumPairs 消息。此消息可以像往常一样序列化和反序列化。
示例 2. numPairs 程序
package main
import (
"math/rand"
"time"
"encoding/xml"
"encoding/json"
"io/ioutil"
"github.com/golang/protobuf/proto"
)
// protoc-generated code: start
var _ = proto.Marshal
type NumPairs struct {
Pair []*NumPair `protobuf:"bytes,1,rep,name=pair" json:"pair,omitempty"`
}
func (m *NumPairs) Reset() { *m = NumPairs{} }
func (m *NumPairs) String() string { return proto.CompactTextString(m) }
func (*NumPairs) ProtoMessage() {}
func (m *NumPairs) GetPair() []*NumPair {
if m != nil { return m.Pair }
return nil
}
type NumPair struct {
Odd int32 `protobuf:"varint,1,opt,name=odd" json:"odd,omitempty"`
Even int32 `protobuf:"varint,2,opt,name=even" json:"even,omitempty"`
}
func (m *NumPair) Reset() { *m = NumPair{} }
func (m *NumPair) String() string { return proto.CompactTextString(m) }
func (*NumPair) ProtoMessage() {}
func init() {}
// protoc-generated code: finish
var numPairsStruct NumPairs
var numPairs = &numPairsStruct
func encodeAndserialize() {
// XML encoding
filename := "./pairs.xml"
bytes, _ := xml.MarshalIndent(numPairs, "", " ")
ioutil.WriteFile(filename, bytes, 0644)
// JSON encoding
filename = "./pairs.json"
bytes, _ = json.MarshalIndent(numPairs, "", " ")
ioutil.WriteFile(filename, bytes, 0644)
// ProtoBuf encoding
filename = "./pairs.pbuf"
bytes, _ = proto.Marshal(numPairs)
ioutil.WriteFile(filename, bytes, 0644)
}
const HowMany = 200 * 100 * 100 // two million
func main() {
rand.Seed(time.Now().UnixNano())
// uncomment the modulus operations to get the more efficient version
for i := 0; i < HowMany; i++ {
n1 := rand.Int31() // % 2047
if (n1 & 1) == 0 { n1++ } // ensure it's odd
n2 := rand.Int31() // % 2047
if (n2 & 1) == 1 { n2++ } // ensure it's even
next := &NumPair {
Odd: n1,
Even: n2,
}
numPairs.Pair = append(numPairs.Pair, next)
}
encodeAndserialize()
}
每个 NumPair 中的随机生成的奇数值和偶数值范围从零到 20 亿,并且会发生变化。就原始数据而不是编码数据而言,Go 程序中生成的整数总计为 16MB:每个 NumPair 两个整数,总共 400 万个整数,每个值的大小为 4 个字节。
为了进行比较,下表包含示例 NumsPairs 消息中 200 万个 NumPair 实例的 XML、JSON 和 Protobuf 编码的条目。原始数据也包括在内。由于 numPairs 程序生成随机值,因此输出在样本运行之间有所不同,但接近表中显示的大小。
表 2. 16MB 整数的编码开销
编码 | 文件 | 字节大小 | Pbuf/其他比率 |
---|---|---|---|
无 |
pairs.raw |
16MB |
169% |
Protobuf |
pairs.pbuf |
27MB |
— |
JSON |
pairs.json |
100MB |
27% |
XML |
pairs.xml |
126MB |
21% |
正如预期的那样,Protobuf 在 XML 和 JSON 旁边大放异彩。Protobuf 编码大约是 JSON 编码的四分之一,是 XML 编码的五分之一。但是原始数据清楚地表明 Protobuf 会产生编码开销:序列化的 Protobuf 消息比原始数据大 11MB。任何编码(包括 Protobuf)都涉及结构化数据,这不可避免地会增加字节数。
序列化的 200 万个 NumPair 实例中的每个实例都涉及四个整数值:Go 结构中 Even 和 Odd 字段各一个,以及 Protobuf 编码中每个字段一个标签。作为原始数据而不是编码数据,每个实例将达到 16 个字节,并且在示例 NumPairs 消息中有 200 万个实例。但是 Protobuf 标签,像 NumPair 字段中的 int32 值一样,使用 varint 编码,因此字节长度有所不同;特别是,小整数值(在本例中包括标签)需要少于四个字节才能编码。
如果修改 numPairs 程序,使两个 NumPair 字段的值小于 2048,这将具有一个或两个字节的编码,那么 Protobuf 编码将从 27MB 降至 16MB——与原始数据的大小完全相同。下表总结了示例运行中的新编码大小。
表 3. 使用 16MB 整数 < 2048 的编码
编码 | 文件 | 字节大小 | Pbuf/其他比率 |
---|---|---|---|
无 |
pairs.raw |
16MB |
100% |
Protobuf |
pairs.pbuf |
16MB |
— |
JSON |
pairs.json |
77MB |
21% |
XML |
pairs.xml |
103MB |
15% |
总而言之,修改后的 numPairs 程序,字段值小于 2048,减少了原始数据中每个整数值的四字节大小。但是 Protobuf 编码仍然需要标签,这会向 Protobuf 消息添加字节。Protobuf 编码确实在消息大小上具有成本,但是如果编码相对较小的整数值(无论是在字段还是键中),则可以通过 varint 因子来降低此成本。
对于由混合类型的结构化数据和相对较小的整数值组成的中等大小的消息,Protobuf 比 XML 和 JSON 等选项具有明显的优势。在其他情况下,数据可能不适合 Protobuf 编码。例如,如果两个应用程序需要共享大量的文本记录或大型整数值,那么压缩而不是编码技术可能是可行的方法。
评论已关闭。