參考文章
protobuf-encode-varint-and-zigzag
protobuf格式及實現
源碼
官方文檔
什么是Protocol Buffers
官方文檔是這么說的:
Protocol Buffers是一種以有效并可擴展的格式編碼結構化數據的方式。
可以用于結構化數據串行化,很適合做數據存儲或 RPC 數據交換格式。它可用于通訊協議、數據存儲等領域的語言無關、平臺無關、可擴展的序列化結構數據格式。
優點:
- 信息的表示非常緊湊,這意味著消息的體積減少
- 性能高。它以高效的二進制方式存儲
- “向后”兼容性
- 跨平臺,支持多種語言
Protobuf的整體格式
以二進制流的方式存儲,按照定義的字段順序緊緊相鄰。每個字段對應有key-value數據相鄰,key由field_number和wire_type計算出,value由該字段定義的值(可能包括value長度)組成。
舉個例子,我們定義了一個字段id:
syntax = "proto3";
message Test {
int64 id = 1;
}
實例化Test,并對test.id 賦值為1
key: value的格式如下:
在解釋上面的內容之前先介紹一下Protobuf 采用的非常巧妙的 Encoding 方法Varint。
Varint
Varint是一種緊湊的表示數字的方法。
它用一個或多個字節來表示一個數字,值越小的數字使用越少的字節數。這能減少用來表示數字的字節數。比如對于 int32 類型的數字,一般需要 4 個 byte 來表示。但是采用 Varint,對于很小的 int32 類型的數字,則可以用 1 個 byte 來表示。當然凡事都有好的也有不好的一面,采用 Varint 表示法,大的數字則需要 5 個 byte 來表示。從統計的角度來說,一般不會所有的消息中的數字都是大數,因此大多數情況下,采用 Varint 后,可以用更少的字節數來表示數字信息。
Varint 中的每個 byte 的最高位 bit 有特殊的含義,如果該位為 1,表示后續的 byte 也是該數字的一部分,如果該位為 0,則結束。其他的 7 個 bit 都用來表示數字。
然后我們再來理解上面的圖:
-
類型位(wire_type):用來表示id字段的類型,本例中是int64,只用3bit表示字段的類型;類型位對應的含義如下圖:
-
指定tag(field_number):本例中是1,即id = 1中的id,所以在指定tag中二進制為"0001",實例圖中只有4位(最大值15),但實際上可以更大,此時需要msb位進行配合;
key序列化的公式為varint(field_number << 3 | wire_type) -
msb(most significant bit)位:當msb位為1時,表示后續的 byte 也是該數字的一部分,如果該位為 0,則結束;舉個栗子:test.id = 1, 所以在value部分的二進制位為"00000001",表示后面沒有更多信息了;如果test.id = 128,則value部分的二進制位為"10000000" "00000001",這兩個byte都是用來表示128這個值的;
讀者可能會困惑,"10000000" "00000001" 是如何表示128呢,因為Protobuf字節序采用 little-endian 的方式,最終計算前將兩個 byte 的位置相互交換過一次,如下圖:
再舉個例子,我們將id的指定tag改為99,代碼如下:
syntax = "proto3";
message Test {
int64 id = 99;
}
key部分的值:"10011000" "00000110",含義如下圖
上面只是簡單介紹了int32這種數據的表示方法,那對其他類型的數據proto buffer是如何表示的呢?
proto buffer中的各種數據表示方法
-
無符號整數: Base 128 Varints
小于 128 的數字都可以用一個 byte 表示。大于 128 的數字,比如 128,會用2個字節表示 -
有符號整數: zigzag 編碼
一個負數一般會被表示為一個很大的整數,因為計算機定義負數的符號位為數字的最高位。如果采用 Varint 表示一個負數,那么一定需要 5 個 byte。為此 Google Protocol Buffer 定義了 sint32、sint64 這種有符號類型,采用 zigzag 編碼。下面會重點介紹。 -
其他的數據類型
比如字符串等,用一個 varint 表示長度,然后將其余部分緊跟在這個長度部分之后即可。
注意的是字符串的存儲并不會壓縮空間。
Zigzag 編碼
核心思想是用無符號數來表示有符號數字,正數和負數交錯,使用 zigzag 編碼,絕對值小的數字,無論正負都可以采用較少的 byte 來表示,充分利用了 Varint 這種技術。
對于正整數來講,如果在傳輸的時候,我們把多余的0去掉(或者是盡可能去掉無意義的0),傳輸有意義的1開始的數據,那就可以做到數據的壓縮。那怎么樣壓縮無意義的0呢?
答案也很簡單,比如:00000000_00000000_00000000_00000001這個數字,我們如果能只發送一位1或者一個字節00000001,就將壓縮很多額外的數據。
負數長什么樣呢?
(-1)10 = (11111111_11111111_11111111_11111111)補
前面全是1,這怎么解決?
zigzag給出了一個很巧的方法:我們之前講補碼講過,補碼的第一位是符號位,他阻礙了我們對于前導0的壓縮,那么,我們就把這個符號位放到補碼的最后,其他位整體前移一位:
(-1)10
= (11111111_11111111_11111111_11111111)補
= (11111111_11111111_11111111_11111111)符號后移
但是即使這樣,也是很難壓縮的,因為數字絕對值越小,他所含的前導1越多。于是,這個算法就把負數的所有數據位按位求反,符號位保持不變,得到了這樣的整數:
(-1)10
= (11111111_11111111_11111111_11111111)補
= (11111111_11111111_11111111_11111111)符號后移
= (00000000_00000000_00000000_00000001)zigzag
而對于非負整數,同樣的將符號位移動到最后,其他位往前挪一位,數據保持不變。
(1)10
= (00000000_00000000_00000000_00000001)補
= (00000000_00000000_00000000_00000010)符號后移
= (00000000_00000000_00000000_00000010)zigzag
這樣,正數、0、負數都有同樣的表示方法了。我們就可以對小整數進行壓縮了。這兩種case,合并到一起,就可以寫成如下的算法:
//整型值轉換成zigzag值
int int_to_zigzag(int n)
{
return (n <<1) ^ (n >>31);
}
(1)10
= (00000000_00000000_00000000_00000001)補
左移一位 => (00000000_00000000_00000000_00000010)補
右移31位 => (00000000_00000000_00000000_00000000)補
按位異或 = (00000000_00000000_00000000_00000010)補
(-1)10
= (11111111_11111111_11111111_11111111)補
左移一位 => (11111111_11111111_11111111_11111110)補
右移31位 => (11111111_11111111_11111111_11111111)補
按位異或 = (00000000_00000000_00000000_00000001)補
- n << 1 :將整個值左移一位,不管正數、0、負數他們的最后一位就變成了0;
- n >> 31: 將符號位放到最后一位。如果是非負數,則為全0;如果是負數,就是全1。
- 最后這一步很巧妙,將兩者進行按位異或操作。
可以看到,數據位全部反轉了,而符號位保持不變,且移動到了最后一位。
我們將1轉換成(00000000_00000000_00000000_00000010)zigzag這個以后,我們最好只需要發送2bits(10),或者發送8bits(00000010),把前面的0全部省掉。因為數據傳輸是以字節為單位,所以,我們最好保持8bits這樣的單位。實際傳輸中我們怎么編碼呢?
zigzag引入了一個方法,就是用字節自己表示自己。
字節自表示方法
壓縮過程
int write_to_buffer(int zz,byte* buf,int size)
{
int ret =0;
for (int i =0; i < size; i++)
{
if ((zz & (~0x7f)) ==0)
{
buf[i] = (byte)zz;
ret = i +1;
break;
}
else
{
buf[i] = (byte)((zz &0x7f) |0x80);
zz = ((unsignedint)zz)>>7;
}
}
return ret;
}
將這個值從低位到高位切分成每7bits一組,如果高位還有有效信息,則給這7bits補上1個bit的1(0x80)。如此反復 直到全是前導0,便結束算法。
舉個例來講:
(-1000)10
= (11111111_11111111_11111100_00011000)補
= (00000000_00000000_00000111_11001111)zigzag
我們先按照七位一組的方式將上面的數字劃開:
(0000-0000000-0000000-0001111-1001111)zigzag
A、他跟(~0x7f)做與操作的結果,高位還有信息,所以,我們把低7位取出來,并在倒數第八位上補一個1(0x80):11001111
B、將這個數右移七位:(0000-0000000-0000000-0000000-0001111)zigzag
C、再取出最后的七位,跟(~0x7f)做與操作,發現高位已經沒有信息了(全是0),那么我們就將最后8位完整的取出來:00001111,并且跳出循環,終止算法;
D、最終,我們就得到了兩個字節的數據[11001111, 00001111]
通過上面幾步,我們就將一個4字節的zigzag變換后的數字變成了一個2字節的數據。
還原過程
算法如下
int read_from_buffer(byte* buf,intmax_size)
{
int ret =0;
int offset =0;
for (int i =0; i < max_size; i++, offset +=7)
{
byte n = buf[i];
if ((n &0x80) !=0x80)
{
ret |= (n <<offset);
break;
}
else
{
ret |= ((n &0x7f) << offset);
}
}
return ret;
}
整個過程就和壓縮的時候是逆向的:對于每一個字節,先看最高一位是否有1(0x80)。如果有,就說明不是最后一個數據字節包,那取這個字節的最后七位進行拼裝。否則,說明就是已經到了最后一個字節了,那直接拼裝后,跳出循環,算法結束。最終得到4字節的整數。
總結
這個算法使用的基礎就是認為在大多數情況下,我們使用的數字都是不大的數字。那我們也能通過計算,得到每超過一個7位的信息以后,傳輸的字節數就會增加1。以至于,如果數字比較大的時候,原來4字節的數字還會變成5字節:
其他
1. string
message Test2 {
optional string b = 2;
}
實例化
test2=new Test2();
test2.b="testing";
則編碼后的字節表示為:
12 07 74 65 73 74 69 6e 67
前兩個字節為key:
第一個字節0x12 → field number = 2, type = 2.
第二個字節0x07表示后面數7位是該類型的value值。
第三個字節開始是testing
2. repeated
message Test4 {
repeated int32 d = 4 [packed=true];
}
實例化并賦值
test4 = new Test4();
test4.addd=3;
test4.addd=270;
test4.addd=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)