深入Protobuf源码-编码实现

本文涉及的产品
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
简介:

基本类型编码

在前文有提到消息是一系列的基本类型以及其他消息类型的组合,因而基本类型是probobuf编码实现的基础,这些基本类型有:

.proto Type

Java Type

C++ Type

Wire Type

double

double

double

WIRETYPE_FIXED64(1)

float

float

float

WIRETYPE_FIXED32(5)

int64

long

int64

WIRETYPE_VARINT(0)

int32

int

int32

WIRETYPE_VARINT(0)

uint64

long

unit64

WIRETYPE_VARINT(0)

uint32

int

unit32

WIRETYPE_VARINT(0)

sint64

long

int64

WIRETYPE_VARINT(0)

sint32

int

int32

WIRETYPE_VARINT(0)

fixed64

long

unit64

WIRETYPE_FIXED64(1)

fixed32

int

unit32

WIRETYPE_FIXED32(5)

sfixed64

long

int64

WIRETYPE_FIXED64(1)

sfixed32

int

int32

WIRETYPE_FIXED32(5)

bool

boolean

bool

WIRETYPE_VARINT(0)

string

String

string

WIRETYPE_LENGTH_DELIMITED(2)

bytes

ByteString

string

WIRETYPE_LENGTH_DELIMITED(2)

Java种对不同类型的选择,其他的类型区别很明显,主要在与int32uint32sint32fixed32中以及对应的64位版本的选择,因为在Java中这些类型都用int(long)来表达,但是protobuf内部使用ZigZag编码方式来处理多余的符号问题,但是在编译生成的代码中并没有验证逻辑,比如uint的字段不能传入负数之类的。而从编码效率上,对fixed32类型,如果字段值大于2^28,它的编码效率比int32更加有效;而在负数编码上sint32的效率比int32要高;uint32则用于字段值永远是正整数的情况。

在实现上,protobuf使用CodedOutputStream实现序列化逻辑、CodedInputStream实现反序列化逻辑,他们都包含write/read基本类型和Message类型的方法,write方法中同时包含fieldNumbervalue参数,在写入时先写入由fieldNumberWireType组成的tag值(添加这个WireType类型信息是为了在对无法识别的字段编码时可以通过这个类型信息判断使用那种方式解析这个未知字段,所以这几种类型值即可),这个tag值是一个可变长int类型,所谓的可变长类型就是一个字节的最高位(msbmost significant bit)用1表示后一个字节属于当前字段,而最高位0表示当前字段编码结束。在写入tag值后,再写入字段值value,对不同的字段类型采用不同的编码方式:
1. int32/int64类型,如果值大于等于0,直接采用可变长编码,否则,采用64位的可变长编码,因而其编码结果永远是10个字节,所有说它int32/int64类型在编码负数效率很低(然而这里我一直木有想明白对int32类型为什么需要做64位的符号扩展,不扩展,5个字节就可以了啊,而且对64位的负数也不需要用符号扩展,或者无法符号扩展,google上也没有找到具体原因)。

2. uint32/uint64类型,也采用变长编码,不对负数做验证。

3. sint32/sint64类型,首先对该值做ZigZag编码,以保留,然后将编码后的值采用变长编码。所谓ZigZag编码即将负数转换成正数,而所有正数都乘2,如0编码成0-1编码成11编码成2-2编码成3,以此类推,因而它对负数的编码依然保持比较高的效率。

4. fixed32/sfixed32/fixed64/sfixed64类型,直接将该值以小端模式的固定长度编码。

5. double类型,先将double转换成long类型,然后以8个字节固定长度小端模式写入。

6. float类型,先将float类型转换成int类型,然后以4个字节固定长度小端模式写入。

7. bool类型,写01的一个字节。

8. string类型,使用UTF-8编码获取字节数组,然后先用变长编码写入字节数组长度,然后写入所有的字节数组。

Tag

msgByteSize

msgByte

9. bytes类型(ByteString),先用变长编码写入长度,然后写入整个字节数组。

Tag

msgByteSize

msgByte

10. 对枚举类型(类型值WIRETYPE_VARINT),用int32编码方式写入定义枚举项时给定的值(因而在给枚举类型项赋值时不推荐使用负数,因为int32编码方式对负数编码效率太低)。

11. 对内嵌Message类型(类型值WIRETYPE_LENGTH_DELIMITED),先写入整个Message序列化后字节长度,然后写入整个Message

Tag

msgByteSize

msgByte

注:ZigZag编码实现:(n << 1) ^ (n >> 31) / (n << 1) ^ (n >> 63);在CodedOutputStream中还存在一些用于计算某个字段可能占用的字节数的compute静态方法,这里不再详述。

protobuf的序列化中,所有的类型最终都会转换成一个可变长int/long类型、固定长度的int/long类型、byte类型以及byte数组。对byte类型的写只是简单的对内部buffer的赋值:

public  void writeRawByte( final  byte value)  throws IOException {
   if (position == limit) {
    refreshBuffer();
  }
  buffer[position++] = value;
}

32位可变长整形实现为:

public  void writeRawVarint32( int value)  throws IOException {
   while ( true) {
     if ((value & ~0x7F) == 0) {
      writeRawByte(value);
       return;
    }  else {
      writeRawByte((value & 0x7F) | 0x80);
      value >>>= 7;
    }
  }
}
对于定长, protobuf 采用小端模式,如对 32 位定长整形的实现:     
public  void writeRawLittleEndian32( final  int value)  throws IOExcep-tion {
    writeRawByte((value      ) & 0xFF);
    writeRawByte((value >>  8) & 0xFF);
    writeRawByte((value >> 16) & 0xFF);
    writeRawByte((value >> 24) & 0xFF);
}

byte数组,可以简单理解为依次调用writeRawByte()方法,只是CodedOutputStream在实现时做了部分性能优化。这里不详细介绍。
CodedInputStream则是根据CodedOutputStream的编码方式进行解码,因而也不详述,其中关于ZigZag的解码:(n >>> 1) ^ -(n & 1)

repeated字段编码

对于repeated字段,一般有两种编码方式:

1.     每个项都先写入tag,然后写入具体数据。如对基本类型:

Tag

Data

Tag

Data

而对message类型:

Tag

Length

Data

Tag

Length

Data

2.     先写入tag,后count,再写入count个项,每个项包含length|data数据。即:

Tag

Count

Length

Data

Length

Data

从编码效率的角度来看,个人感觉第二中情况更加有效,然而不知道处于什么原因考虑,protobuf采用了第一种方式来编码,个人能想到的一个理由是第一种情况下,每个消息项都是相对独立的,因而在传输过程中接收端每接收到一个消息项就可以进行解析,而不需要等待整个repeated字段的消息包。对于基本类型,protobuf也采用了第一种编码方式,后来发现这种编码方式效率太低,因而可以添加[packed = true]的描述将其转换成第三种编码方式(第二种方式的变种,对基本数据类型,比第二种方式更加有效):
3. 先写入tag,后写入字段的总字节数,再写入每个项数据。即:

Tag

dataByteSize

Data

Data

目前protobuf只支持基本类型的packed修饰,因而如果将packed添加到非repeated字段或非基本类型的repeated字段,编译器在编译.proto文件时会报错。

未识别字段编码

protobuf中,将所有未识别字段保存在UnknownFieldSet中,并且在每个由protobuf编译生成的Message类以及GeneratedMessage.Builder中保存了UnknownFieldSet字段unknownFields;该字段可以从CodedInputStream中初始化(调用UnknownFieldSet.BuildermergeFieldFrom()方法)或从用户自己通过Builder设置;在序列化时,调用UnknownFieldSetwriteTo()方法将自身内容序列化到CodedOutputStream中。

UnknownFieldSet顾名思义是未知字段的集合,其内部数据结构是一个FieldNumberFieldMap,而一个Field用于表达一个未知字段,它可以是任何值,因而它包含了所有5中类型的List字段,这里并没有对一个Field验证,因而允许多个相同FieldNumber的未知字段,并且他们可以是任意类型值。UnknownFieldSet采用MessageLite编程模式,因而它实现了MessageLite接口,并且定义了一个Builder类实现MessageLite.Builder接口用于手动或从CodedInputStream中构建UnknownFieldSet。虽然Field本身没有实现MessageLite接口,它依然实现了该接口的部分方法,如writeTo()getSerializedSize()用于实现向CodedOutputStream中序列化自身,并且定义了Field.Builder类用于构建Field实例。

在一个Message序列化时(writeTo()方法实现),在写完所有可识别的字段以及扩展字段,这个定义在Message中的UnknownFieldSet也会被写入CodedOutputStream中;而在从CodedInputStream中解析时,对任何未知字段也都会被写入这个UnknownFieldSet中。



扩展字段编码

在写框架代码时,经常由扩展性的需求,在Java中,只需要简单的定义一个父类或接口即可解决,如果框架本身还负责构建实例本身,可以使用反射或暴露Factory类也可以顺利实现,然而对序列化来说,就很难提供这种动态plugin机制了。然而protobuf还是提出来一个相对可以接受的机制(语法有点怪异,但是至少可以用):在一个message中定义它支持的可扩展字段值的范围,然后用户可以使用extend关键字扩展该message定义(具体参考相关章节)。在实现中,所有这些支持字段扩展的message类型继承自ExtendableMessage类(它本身继承自GeneratedMessage类)并实现ExtendableMessageOrBuilder接口,而它们的Builder类则继承自ExtendableBuilder类并且同时也实现了ExtendableMessageOrBuilder接口。

ExtendableMessage ExtendableBuilder 类都包含 FieldSet<FieldDescriptor> 类型的字段用于保存该 message 所有的扩展字段值。 FieldSet 中保存了 FieldDescriptor 到其 Object 值的 Map ,然而在 ExtendableMessage ExtendableBuilder 中则使用 GeneratedExtension 来表识一个扩展字段,这是因为 GeneratedExtension 除了包含对一个扩展字段的描述信息 FieldDescriptor 外,还存储了该扩展字段的类型、默认值等信息,在 protobuf 消息定义编译器中会为每个扩展字段生成相应的 GeneratedExtension 实例以供用户使用:
public  static  final GeneratedExtension<Foo, Integer> bar = Generated-Message.newFileScopedGeneratedExtension( Integer. classnull );

bar.internalInit(descriptor.getExtensions().get(0));

Base base = Base.newBuilder().setExtension(SearchRequestProtos.bar, 11).build();
用户使用该 bar 静态字段用于作为 key 与它对应的值关联,这种关联关系写入 extensions 字段中。从而在序列化时,对每个字段,按正常的值字段先写 Tag 在写实际值内容将它序列化到 CodedOutputStream 中( ExtensionWriter.writeUntil() 方法);在反序列化中,我们需要告诉 protobuf 哪些字段是扩展字段,从而它在解析到无法识别的字段可以判断这个字段是否是扩展字段,因而 protobuf 提供了 ExtensionRegistry 类,它用于注册所有识别的扩展字段,并且在 protobuf 编译出来的代码中也存在一个静态方法将所有已定义的扩展字段注册到用户提供的 ExtensionRegistry 实例中:     
public  static  void registerAllExtensions(ExtensionRegistry registry) {
  registry.add(SearchRequestProtos.bar);
}


相关文章
|
编解码 Java 编译器
【Protobuf】Protobuf中的Message语法规范
在Message中定义一个或者多个字段,FieldType是字段的数据类型,可以是基本类型(如int32、string、bool等)或其他定义的Message类型。fieldName是字段的名称,可以根据需求自定义。fieldNumber是字段的唯一标识号,用于在消息的二进制编码中标识字段。
409 0
|
6月前
|
开发工具 git
protobuf的复杂结构
protobuf的复杂结构
93 1
|
JSON Go 数据格式
Golang 语言中怎么解码 4 种常见JSON 格式数据?
Golang 语言中怎么解码 4 种常见JSON 格式数据?
49 0
|
JSON 编解码 安全
Golang 语言中怎么提升 JSON 编解码的性能?
Golang 语言中怎么提升 JSON 编解码的性能?
130 0
|
7月前
|
存储 XML JSON
protobuf原理以及实例(Varint编码)
protobuf原理以及实例(Varint编码)
162 0
|
C++ 容器
使用protobuf的简单流程记录、编译protobuf时遇到的坑 以及 链接protobuf的坑
使用protobuf的简单流程记录、编译protobuf时遇到的坑 以及 链接protobuf的坑
351 0
|
XML 存储 JSON
数据序列化工具 Protobuf 编码&避坑指南
我们现在所有的协议、配置、数据库的表达都是以 protobuf 来进行承载的,所以我想深入总结一下 protobuf 这个协议,以免踩坑。 先简单介绍一下 Protocol Buffers(protobuf),它是 Google 开发的一种数据序列化协议(与 XML、JSON 类似)。它具有很多优点,但也有一些需要注意的缺点: 优点: 效率高:Protobuf 以二进制格式存储数据,比如 XML 和 JSON 等文本格式更紧凑,也更快。序列化和反序列化的速度也很快。 跨语言支持:Protobuf 支持多种编程语言,包括 C++、Java、Python 等。 清晰的结构定义:使用 prot
|
JSON 数据格式 C++
Protobuf vs CBOR:新一代的二进制序列化格式
Protobuf vs CBOR:新一代的二进制序列化格式
1489 0
|
编解码 JSON 安全
IM通讯协议专题学习(四):从Base64到Protobuf,详解Protobuf的数据编码原理
本篇将从Base64再到Base128编码,带你一起从底层来理解Protobuf的数据编码原理。 本文结构总体与 Protobuf 官方文档相似,不少内容也来自官方文档,并在官方文档的基础上添加作者理解的内容(确保不那么枯燥),如有出入请以官方文档为准。
392 0
IM通讯协议专题学习(四):从Base64到Protobuf,详解Protobuf的数据编码原理
|
编解码
一文搞懂Base64编解码
一文搞懂Base64编解码
547 0