1. 简介
本文会介绍protocol buffers的二进制wire format(binary wire format)。你并不是需要理解这些后才能在应用里使用protocol buffers,但是当你想知道不同的protocol buffers格式是如何影响编码后的消息体的体积时,这些知识会非常有用。
一个简单的消息
假设有一个非常简单的消息定义:
syntax = "proto3";
message Test1 {
int32 a = 1;
}
在应用中,你创建了一个Test1消息并把a设置为150。然后你把消息序列化到输出流中,如果你能查看编码后的消息,你会看到三个字节:
08 96 01
到目前为止,如此小而且都是数字-但是这是什么意思呢?继续往下看
2. 消息的组成
protobuf每条消息都是由一系列的key-value键值对组成的,key和value分别采用不同的编码方式。消息的二进制格式只使用消息字段的字段编号作为key-value和声明的类型只能在解析端通过引用参考消息类型定义(即.proto文件)才能确定。
当一个消息被编码时,键和值会被连接放入字节流中。当消息被解码时,分析器需要能够跳过未识别的字段。这样,新加入消息的字段就不会破坏不知道他们存在的那些老程序。
2.1. key
key的具体值为(field_number << 3) | wire_type
,其中field_number
是.proto文件中定义的index,也就是说,Byte第一位作为标志位MSB(Most Significant Bit),最后三位用于存储wire type
(编码数据类型),其他位用于存储field_number值。
可用的wire-type如下:
Type | Meaning | Used For |
---|---|---|
0 | Varint | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
1 | 64-bit | fixed64, sfixed64, double |
2 | Length-delimited | string, bytes, embedded messages, packed repeated fields |
3 | Start group | groups (deprecated) |
4 | End group | groups (deprecated) |
5 | 32-bit | fixed32, sfixed32, float |
在消息流中的每个键都是varint
,使用(filed_number << 3) | wire_type 获得–也就是说字节的后三位存储的是wire-type
。
2.2. value
现在让我们再回到上面的消息示例。你现在知道字节流中的首个字节永远都是一个varint
键,在我们的例子中它是08或者下面的二进制(去掉了msb
)。
000 1000
通过后三位得出wire-type(0),然后右移三位得到字段编号(1)。现在你知道字段的编号是1对应的值是一个varint
,它的值存储在十六进制表示的96 01
中。
从前面我们知道,我们保存的值是150,为什么是150呢,在阅读下面的编解码的内容,你就可以解码出150。
解码步骤如下:
96 01 = 1001 0110 0000 0001
-> 000 0001 ++ 001 0110 (去掉最高有效位MSB,并反转7位组)
-> 10010110
-> 128 + 16 + 4 + 2 = 150
3. protocol buffers 编解码
3.1. Varint编码
3.1.1. 什么是varint编码
var int是一种使用一个或多个字节序列化整数的方法,会把整数编码为变长字节。对于32位整型数据经过Varint编码后需要1~5
字节,小的数字使用1个byte,大的数字使用5个bytes。64位整型数据编码后占用1~10个字节。在实际场景中小数字的使用率远远多于大数字,因此通过Varint编码对于大部分场景都可以起到很好的压缩效果。
要理解上面protocol buffers编码的数据,你需要先理解varint
,varint
是一种使用一个或多个字节编码整数的方法。较小的数字使用较少的字节。
除了最后一个字节外,varint编码中的每个字节都设置了最高有效位(most significant bit - msb)–msb为1则表明后面的字节还是属于当前数据的,如果是0那么这是当前数据的最后一个字节数据。每个字节的低7位用于以7位为一组存储数字的二进制补码表示,最低有效组在前,或者叫最低有效字节在前。这表明varint编码后数据的字节是按照小端序排列的。
3.1.2. 编码原理
除了最后一个字节外,varint编码中的每个字节都设置了最高有效位(most significant bit - msb)–msb为1则表明后面的字节还是属于当前数据的,如果是0那么这是当前数据的最后一个字节数据。每个字节的低7位用于以7位为一组存储数字的二进制补码表示,最低有效组在前,或者叫最低有效字节在前。这表明varint编码后数据的字节是按照小端序排列的。
关于字节排列的方式引用一下维基百科上的词条
字节的排列方式有两个通用规则。例如,一个多位的整数,按照存储地址从低到高排序的字节中,如果该整数的最低有效字节(类似于最低有效位)在最高有效字节的前面,则称小端序;反之则称大端序。在网络应用中,字节序是一个必须被考虑的因素,因为不同机器类型可能采用不同标准的字节序,所以均按照网络标准转化。
通俗一点说就是:大端序是按照数字的书写顺序排列的,而小端序是颠倒书写顺序进行排列的。
3.1.3. 编解码示例
3.1.3.1. 示例1
举例来说,对于数字1它占用单个字节,所以字节的最高位上是0
0000 0001
3.1.3.2. 示例2
下面以数字123456
为例,进行说明
看下面的图示会更好理解一些
图中对数字123456进行varint编码,123456用二进制表示为1 11100010 01000000
,每次从低向高取7位再加上最高有效位变成1100 0000 1100 0100 0000 0111
所以经过varint编码后123456占用三个字节分别为192 196 7(16进制为: 0xc0 0xc4 0x07)。
解码的过程就是将字节按从高位到低位的顺序,依次取出,去掉最高有效位MSB,因为是小端排序所以先解码的字节要放在低位,之后解码出来的二进制位继续放在之前已经解码出来的二进制的高位最后转换为10进制数完成varint编码的解码过程。
还是以123456为例进行说明,123456使用varint编码之后的16进制值为: c0 c4 07
,进行解码时的运算步骤如下:
c0 c4 07
-> 11000000 ++ 11000100 ++ 00000111 # 二进制表示
-> 1000000 ++ 1000100 ++ 0000111 # 去掉msb
-> 0000111 ++ 1000100 ++ 1000000 # 进行翻转
-> 11110001001000000 = 123456 # 进行相加
到这里varint的编解码过程就都搞懂了,理解了varint编码原理后再看protocol buffers的编码原理就会容易很多。
3.1.3.3. 示例3
对于数字300会有一点复杂,它占用俩个字节,编码之后的值为
1010 1100 0000 0010
编码步骤:
第一步: 转换成2进制(256 + 32 + 8 + 4)
1 0010 1100
第二步: 取最后7位,并进行小端反转7位,添加msb位,不足8位的用0填充:
反转7位:
010 1100 10
添加msb位,不足一字节8位的用0填充,则得出编码的值:
1010 1100 0000 0010
那么是怎么计算出来是300的呢?首先你需要把每个字节的msb去掉,因为它只用来告诉我们是否已经到达数字的最后一个字节(本例的varint占用俩个字节所以第一个字节的msb为1)
将两组按7位反转,因为你记得,varint存储的数字最低有效组在前。然后,将它们连接起来以获得最终值。
300编码的值为1010 1100 0000 0010
,用16进制表示,则为0xac 0x02
第一步,去除msb,并反转7位
1010 1100 000 0010
-> 010 1100 ++ 00 0010 # 去除msb
-> 00 0010 ++ 010 1100 # 反转7位
-> 256 + 32 + 8 + 4 = 300
3.1.4. 编码实现(Encoding)
由于protocol buffers中大量使用了varint编码,我从protobuf库中找到了对数据进行varint编解码的Go语言实现方法,实现代码中用位运算完成了上面说的varint编码过程。
Objective-C实现:
#pragma mark - 编码
typedef struct GPBOutputBufferState {
uint8_t *bytes; //字节数组
size_t size; //大小
size_t position; //
NSOutputStream *output; //output对象
} GPBOutputBufferState;
/// 二进制右移,返回值是32位数
/// @param value 待移位操作的值
/// @param spaces 移动的位数
static __inline__ int32_t GPBLogicalRightShift32(int32_t value, int32_t spaces) {
return (int32_t)((uint32_t)(value) >> spaces);
}
/// 二进制右移,返回值是64位数
/// @param value 待移位操作的值
/// @param spaces 移动的位数
static __inline__ int64_t GPBLogicalRightShift64(int64_t value, int32_t spaces) {
return (int64_t)((uint64_t)(value) >> spaces);
}
/// 刷新GPBOutputBufferState的缓冲区output数据
/// @param state GPBOutputBufferState对象
static void GPBRefreshBuffer(GPBOutputBufferState *state) {
if (state->output == nil) {
// We're writing to a single buffer.
[NSException raise:@"OutOfSpace" format:@""];
}
if (state->position != 0) {
NSInteger written =
[state->output write:state->bytes maxLength:state->position];
if (written != (NSInteger)state->position) {
[NSException raise:@"WriteFailed" format:@""];
}
state->position = 0;
}
}
/// 往GPBOutputBufferState的缓冲区output中写值
/// @param state GPBOutputBufferState对象
/// @param value 待写入的值
static void GPBWriteRawByte(GPBOutputBufferState *state, uint8_t value) {
if (state->position == state->size) {
GPBRefreshBuffer(state);//之前所做的操作都是在内存中进行逻辑操作,假如长度等于size的时候,则一次性把未写入的字节写入到到GPBOutputBufferState对象中
}
state->bytes[state->position++] = value;
}
static void GPBWriteRawVarint32(GPBOutputBufferState *state, int32_t value) {
while (YES) {
if ((value & ~0x7F) == 0) {//判断MSB是否为0,假如为0,则return停止写入
uint8_t val = (uint8_t)value;
GPBWriteRawByte(state, val);
return;
} else {
/**
写入二进制表示的最后7个bit位,`0x7F`的二进制表示是`0111 1111 `,
所以`value & 0x7F` 与操作时,得到value二进制表示的最后7个bit位(前面的bit位通过与0做位与运算都被舍弃了)
*/
GPBWriteRawByte(state, (value & 0x7F) | 0x80);
//右移7bit位,从高位往低位进行取值
value = GPBLogicalRightShift32(value, 7);
}
}
}
//和GPBWriteRawVarint32方法类似,只是写入的值的类型为int64_t
static void GPBWriteRawVarint64(GPBOutputBufferState *state, int64_t value) {
while (YES) {
if ((value & ~0x7FL) == 0) {
uint8_t val = (uint8_t)value;
GPBWriteRawByte(state, val);
return;
} else {
GPBWriteRawByte(state, ((int32_t)value & 0x7F) | 0x80);
value = GPBLogicalRightShift64(value, 7);
}
}
}
0x7F
的二进制表示是0111 1111
,所以x & 0x7F
与操作时,得到x二进制表示的最后7个bit位(前面的bit位通过与0做位与运算都被舍弃了)
0x80
的二进制表示是 1000 0000
,所以 0x80 | uint8(x&0x7F)
是在取出的x的后7个bit位前在最高位加上1(msb)
3.1.5. 解码实现(Decoding)
解码就是编码的逆过程,同样是用位运算就能快速有效的完成解码,结合下面的代码注释再在纸上推演一遍理解起来就不难了。
Objective-C实现:
#pragma mark - 解码
typedef struct GPBCodedInputStreamState {
const uint8_t *bytes;
size_t bufferSize;
size_t bufferPos;
// For parsing subsections of an input stream you can put a hard limit on
// how much should be read. Normally the limit is the end of the stream,
// but you can adjust it to anywhere, and if you hit it you will be at the
// end of the stream, until you adjust the limit.
size_t currentLimit;
int32_t lastTag;
NSUInteger recursionDepth;
} GPBCodedInputStreamState;
static void CheckSize(GPBCodedInputStreamState *state, size_t size) {
size_t newSize = state->bufferPos + size;
if (newSize > state->bufferSize) {
printf("error: GPBCodedInputStreamErrorInvalidSize");
}
if (newSize > state->currentLimit) {
// Fast forward to end of currentLimit;
state->bufferPos = state->currentLimit;
printf("error: GPBCodedInputStreamErrorSubsectionLimitReached");
}
}
//读取一个字节的数据
static int8_t ReadRawByte(GPBCodedInputStreamState *state) {
CheckSize(state, sizeof(int8_t));
return ((int8_t *)state->bytes)[state->bufferPos++];
}
//读取varint的值
static int64_t ReadRawVarint64(GPBCodedInputStreamState *state) {
int32_t shift = 0;//偏移位数值
int64_t result = 0;//用于保存读取的varint值
while (shift < 64) { //最大偏移长度,为2^64-1,即varint允许存储的最大值为2^64-1
int8_t b = ReadRawByte(state);
result |= (int64_t)((uint64_t)(b & 0x7F) << shift); //计算值
if ((b & 0x80) == 0) { //假如MSB位为0,则表示varint取值到达最后一位,返回值
return result;
}
shift += 7;
}
printf("Invalid VarInt64: GPBCodedInputStreamErrorInvalidVarInt");
return 0;
}
还是以之前的数字123456
为例进行说明:
c0 c4 07
读取0xc0:
result |= (0xc0 & 0x7F) << shift
result = 64
shift = 7
取0xc4:
result |= (0xc4 & 0x7F) << shift
result = 8768
shift = 14
取0x07:
result |= (0x07 & 0x7F) << shift
result = 123456 //此时取值结束
3.2. 有符号整数
就像你在上一部分看到的那样,protocol buffers中所有与wire-type0关联的类型都会被编码为varint。但是,在编码负数时,带符号的int类型(sint32和sint64)与“标准” int类型(int32和int64)之间存在着巨大区别。如果将int32或int64用作负数的类型,则结果varint总是十个字节长––实际上,它被视为一个非常大的无符号整数。如果使用带符号类型(sint32和sint64)之一,则生成的varint使用ZigZag编码,效率更高。
ZigZag编码将有符号数映射到无符号数以便具有较小绝对值的数字(比如-1)也具有较小的varint编码值。这样做的方式是通过正整数和负整数来回“曲折”,将-1编码为1,将1编码为2,将-2编码为3,依此类推,可以在下表中看到:
Signed Original | Encoded As |
---|---|
0 | 0 |
-1 | 1 |
1 | 2 |
-2 | 3 |
2147483647 | 4294967294 |
-2147483648 | 4294967295 |
3.3. 非varint数字
对与非可varint编码的数字来说比较简单–double和fixed64使用wire-type1,这会告诉解析器期望固定的64-bit的数据块。相似地float和fixed32使用wire-type5,这会告诉解析器期望固定的32-bit数据块。这两种情况都是使用小端序排列字节存储数据的。
3.4. 字符串
wire-type
= 2(长度分隔)时,表示该值是varint编码的长度值,后跟长度值指定数量的数据字节。
message Test2 {
string b = 2;
}
设置b的值为"testing"后消息对应的内容为:
12 07 74 65 73 74 69 6e 67
红色的字节是UTF-8编码后的"testing"
这里的键是0x12→0001 0010→字段号= 2,类型=2(2字段index值 << 3 | 2类数据类型, 第一个字节的后三位表示wire-type的编号,然后右移三位变成0001 0010)。值中的varint表示的数据字节长度是7,如你所见我们在它后面找到的七个字节–就是解析器要找的字符串。
3.5. 内嵌消息
下面是一个拥有内嵌消息的消息定义Test3,内嵌的消息类型是我们上面示例中定义的Test1
message Test3 {
Test1 c = 3;
}
下面则是内嵌的Test1中的a设置为150,Test3被编码后的版本
1a 03 08 96 01
如你所见,最后三个字节和我们第一个例子编码后的结果一样(08 96 01),在他们之前是数字3,–内嵌消息会像字符串一样被对对待(有线格式=2)。
3.6. 可选和可重复元素
如果proto2消息定义具有重复的元素(不带[packed = true]选项),则编码消息具有零个或多个具有相同字段编号的键值对。这些重复的值不必连续出现。它们可能与其他字段交错。解析时,元素之间的顺序会保留下来,尽管其他字段的顺序会丢失。在proto3中,重复字段使用packed编码,可以在下面看到相关编码。
通常,编码消息永远不会有一个以上非重复字段的实例。但是,解析器能处理这种实际情况,对于数字类型和字符串,如果同一字段多次出现,则解析器将接受它看到的最后一个值。对于嵌入式消息字段,解析器将合并同一字段的多个实例,就像使用Message :: MergeFrom方法一样-也就是说,后一个实例中的所有单个标量字段将替换前一个实例中的单个标量字段,可重复字段会被串联到一块。这些规则的作用是,解析两个编码的消息的连接所产生的结果与您分别解析两个消息并合并结果对象的结果完全相同。也就是说:
MyMessage message;
message.ParseFromString(str1 + str2);
等同于
MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);
这个特性有时很有用,因为即使您不知道它们的类型,也允许你合并两个消息。
3.7. 压缩重复字段
proto版本2.1.0引入了压缩重复字段,在proto2中声明为重复字段,并使用特殊的[packed = true]选项。在proto3中,默认情况下压缩标量数字类型的重复字段。这些功能类似于重复的字段,但编码方式不同。包含零元素的压缩重复字段不会出现在编码的消息中。否则,该字段的所有元素都将打包为wire-type为2(定界)的单个键值对。每个元素的编码方式与通常相同,不同之处在于元素之前没有键。
举例来说,你有以下消息类型:
message Test4 {
repeated int32 d = 4 [packed=true];
}
现在假设您构造一个Test4,为重复的字段d提供值3、270和86942。然后,消息编码后的形式为:
22 // key (field number 4, wire-type 2)
06 // payload size (6 bytes)
03 // first element (varint 3)
8E 02 // second element (varint 270)
9E A7 05 // third element (varint 86942)
只能将原始数字类型(使用varint,32位或64位线型的类型)的重复字段声明为“packed”。
3.8. 字段顺序
字段编号可以在.proto文件中以任何顺序使用。选择使用的顺序对消息的序列化方式没有影响。
序列化消息时,对于如何写入其已知字段或未知字段没有保证的顺序。序列化顺序是一个实现细节,将来任何特定实现的细节都可能更改。因此,protocol buffers解析器必须能够以任何顺序解析字段。