神奇的Google二進位制編解碼技術:Protobuf

計算機網路程式設計中一個非常基本的問題:該怎樣表示client與server之間互動的資料,在往下看之前先想一想這個問題。

共識與協議

這個問題可不像看上去的那樣簡單,因為client程序和server程序執行在不同的機器上,這些機器可能執行在不同的處理器平臺、可能執行在不同的作業系統、可能是由不同的程式語言編寫的,server要怎樣才能識別出client傳送的是什麼資料呢?就像這樣:

神奇的Google二進位制編解碼技術:Protobuf

client給server傳送了一段資料:

server怎麼能知道該怎樣“解讀”這段資料呢?

顯然,client和server在傳送資料之前必須首先達成某種關於怎樣解讀資料的共識,這就是所謂的

協議

這裡的協議可以是這樣的:“將每8個位元為一個單位解釋為無符號數字”,如果協議是這樣的,那麼server接收到這串二進位制後就會將其解析為81(01010001)與33(00100001)。

當然,這裡的協議也可以是這樣的:“將每8個位元為一個單位解釋為ASCII字元”,那麼server接收到這串二進位制後就將其解析為“Q!”。

可見,同樣一串二進位制在不同的“上下文/協議”下有完全不一樣的解讀, 這也是為什麼計算機明明只認知0和1但是卻能處理非常複雜任務的根本原因,因為一切都可以編碼為0和1,同樣的我們也可以從0和1中解析出我們想要的資訊,這就是所謂的編解碼技術。

實際上不止0和1,我們也可以將資訊編碼為摩斯密碼(Morse code)等,只不過計算機擅長處理0和1而已。

神奇的Google二進位制編解碼技術:Protobuf

扯遠了,回到本文的主題。

遠端過程呼叫:RPC

作為程式設計師我們知道,client以及server之間不會簡單傳遞一串數字以及字元這樣簡單,尤其在網際網路大廠後端服務這種場景下。

當我們在電商App搜尋商品、打車App呼叫出租車以及刷短影片時,每一次請求的背後在後端都涉及大量服務之間的互動,就像這樣:

神奇的Google二進位制編解碼技術:Protobuf

完成一次客戶端請求gateway這個服務要呼叫N多個下游服務,所謂呼叫是說A服務向B服務傳送一段資料(請求),B服務接收到這段資料後執行相應的函式,並將結果返回給A服務。

只不過對於服務A來說並不想關心網路傳輸這樣的底層細節,如果能像呼叫本地函式一樣呼叫遠端服務就好了,這就是所謂的RPC,經典的實現方式是這樣的:

神奇的Google二進位制編解碼技術:Protobuf

RPC對上層提供和普通函式一樣的介面,只不過在實現上封裝了底層複雜的網路通訊,RPC框架是當前網際網路後端的基石之一,很多所謂網際網路後端的職位無非就是在此基礎之上堆業務邏輯。

本文我們不關心其中的細節,這裡我們只關心在網路層client是怎樣對請求引數進行編碼、server怎樣對請求引數進行解碼的,也就是本文開頭提出的問題。

資訊的編解碼

在思考怎樣進行編解碼之前我們必須意識到:

client和server可能是用不同語言編寫的,你的編解碼方案必須通用且不能和語言繫結

編解碼方法的效能問題,尤其是對時間要求苛刻的服務

首先,我們最應該能想到的就是以純文字的形式來表示。

純文字從來都是一種非常有友好的資訊載體,為什麼?很簡單,因為人類(我們)可以直接看懂,就像這段:

{ “widget”: { “window”: { “title”: “Sample Konfabulator Widget”, “name”: “main_window”, “width”: 500, “height”: 500 }, “image”: { “src”: “Images/Sun。png”, “name”: “sun1”, “hOffset”: 250, “vOffset”: 250, }, }}

是不是很清晰,一目瞭然,只要我們實現約定好文字的結構(也就是語法),那麼client和server就能利用這種文字進行資訊的編碼以及解碼,不管client和server是執行在x86還是Arm、是32位的還是64位的、執行在Linux上還是windows上、是大端還是小端,都可以無障礙交流。

因此在這裡,文字的語法就是一種協議。

神奇的Google二進位制編解碼技術:Protobuf

順便說一句, 你都規定好了文字的語法,實際上就相當於發明了一種語言 。

這裡用來舉例用的語言就是所謂的Json,只不過json這種語言不是用來表示邏輯(程式碼)而是用來儲存資料的。

Json就是這個老頭提出來的:

神奇的Google二進位制編解碼技術:Protobuf

除了Json,另一種利用文字儲存資料的表示方法是XML,來一段感受下:

ToveJaniReminderDon‘t forget me this weekend!

相對Json來說是不是就沒那麼容易看懂了,Json出現後在web領域逐漸取代了XML。

當兩段資料量很少的時候——就像瀏覽器和服務端的互動,Json可以工作的非常好,這個場景就是這裡:

神奇的Google二進位制編解碼技術:Protobuf

在這裡是json的天下。

但對於後端服務之間的互動來說就不一樣了,後端服務之間的RPC呼叫可能會傳輸大量資料,如果全部用純文字的形式來表示資料那麼不管是網路頻寬還是效能可能都會差強人意。

神奇的Google二進位制編解碼技術:Protobuf

在這種場景下,Json並不是最好的選項,主要原因之一就在於效能以及資料的體積。

我們知道,文字表示對人類是最友好的,對機器來說則不是這樣,對機器來說最好的還是01二進位制。

那麼有沒有二進位制的編碼方法嗎?答案是肯定的,這就是當前網際網路後端中流行的protobuf,Google公司開源專案。

那麼protobuf有什麼神奇之處嗎?

假設client端想給server端傳輸這樣一段資訊:“我有一個id,其值為43”,那麼在XML下是這樣表示的:

43

數一數這這段資料佔據了多少位元組,很顯然是11位元組;

而如果用json來表示呢?

{“id”:43}

數一數這段資料佔據了多少位元組,顯然是9位元組;

而如果用protobuf來表示呢? 是這樣的:

// 訊息定義message Msg { optional int32 id = 1;}// 例項化Msg msg;msg。set_id(43);

其中Msg的定義看上去比Json和XML更加複雜了,但這些只是給人看的,這些還會被protbuf進一步處理,最終被編碼為:

082b

也就是0x08與0x2b,這佔據了多少位元組呢?答案是2位元組。

從json的9位元組到protobuf的2位元組,資料大小減少了4倍多,資料量的減少意味著:

更少的網路頻寬

更快的解析速度

那麼protobuf是怎樣做到這一點的呢?

protobuf是怎樣實現的?

首先,我們來思考最簡單的情況,該怎樣表示數字。

你可能會想這還不簡單,統一用固定長度,比如用64個位元(8位元組),這種方法可行,但問題是不論一個數字有多小,比方2,那麼用這種方法表示2也需要佔據64個位元(8位元組):

明明只要一個位元組就能表示而我們卻用了8個,前面的全都是0,這也太奢侈太浪費了吧。

顯然,在這裡我們不能使用固定長度來表示數字,而需要使用變長方法來表示。

什麼叫變長?意思是說如果數字本身比較大,那麼其使用的位元位可以較多,但如果數字很小那麼就應該使用較少的位元位來表示,這就叫變長,隨機應變,不死板。

那怎樣變長呢?

我們規定:對於每一個位元組來說, 第一個位元位如果是1那麼表示接下來的一個位元依然要用來解釋為一個數字,如果第一個位元為0,那麼說明接下來的一個位元組不是用來表示該數字的。

也就是說對於每個8個位元(1位元組)來說,它的有效載荷是7個位元,第一個位元僅僅用來標記是否還應該把接下來的一個位元組解析為數字。

根據這個規定假設來了這樣一串01二進位制:

根據規定,我們首先取出第一個位元組,也就是:

此時我們發現第一個位元位是1,因此我們知道接下來的一個位元組也屬於該數字,將當前位元組的1去掉就是:

然後我們看下一個位元組:

我們發現第一個bit為0,因此我們知道下一個位元組不屬於該數字了。

接下來我們將解析到的0101100(第一個位元組去掉第一個位元位)以及第二個位元組0000010(第二個位元組去掉第一個位元位)翻轉之後拼接到一起,這裡之所以翻轉是因為我們規定數字的高位在後。

這個過程就是:

1010110000000010 -> 10101100 | 00000010 // 解析得到兩個位元組 _ _ -> 0101100 | 0000010 // 各自去掉最高位 -> 0000010 | 0101100 // 兩個位元組翻轉順序 0000010 + 0101100-> 100101100 // 拼接

最後我們得到了100101100,這一串二進位制表示數字300。

這種數字的變長表示方法在protobuf中被稱之為varint。

因此在這種表示方法下,如果數字較大,那麼使用的位元就多,如果數字較小那麼使用位元就少,聰明吧。

有的同學看到這裡可能會問題,剛才講解的方法只能表示無符號數字,那麼有符號數字該怎麼表示呢?比如-2該怎麼表示?

有符號數的表示

按照剛才變長編碼的思想,-2147483646使用的位元位應該比-2要少。

然而我們知道在計算機世界中負數使用補碼錶示的,也就是說最高位(最左側的位元位)一定是1,假設我們使用64位來表示數字,那麼如果我們依然用補碼來表示數字的話那麼無論這個負數有多大還是多小都需要佔據10個位元組的空間。

為什麼是10個位元組呢?

不要忘了varint每個位元組的有效負荷是7個位元,那麼對於需要64位表示的數字來說就需要64/7向上取整也就是10個位元組來表示。

這顯然不能滿足我們對數字變長儲存的要求。

該怎麼解決這個問題呢?

既然無符號數字可以方便的進行變長編碼,那麼我們將有符號數字對映稱為無符號數字不就可以了,這就是所謂的ZigZag編碼,是不是很聰明,就像這樣:

原始資訊 編碼後0 0 -1 1 1 2-2 32 4-3 53 6。。。 。。。2147483647 4294967294-2147483648 4294967295

這樣我們就可以將有符號數字轉為無符號數字,接收方接收到該資料後再恢復出有符號數字。

現在數字的問題徹底解決了,但這僅僅是萬里長征第一步。

欄位名稱與欄位型別

對於任何一個有用的資訊都包含這樣幾部分:

欄位名稱

欄位型別

欄位值

就像C/C++中定義變數時:

int i = 100;

在這裡,欄位名稱就是i,欄位型別是int,欄位值是100。

剛才我們用varint以及ZigZag編碼解決了欄位值表示的問題,那麼該怎樣表示欄位名稱和欄位型別呢?

首先,對於欄位型別還比較簡單,因為欄位型別就那麼多,protobuf中定義了6種欄位型別:

神奇的Google二進位制編解碼技術:Protobuf

對於6種欄位型別我們使用3個位元位來表示就足夠了。

接下來比較有趣的是欄位名稱該怎麼表示呢?假設我們需要傳遞這樣一個欄位:

int long_long_name = 100;

那麼我們真的需要把“long_long_name”這麼多字元透過網路傳遞給對端嗎?

既然通訊雙方需要協議,那麼“long_long_name”這欄位其實是client和server都知道的,它們唯一不知道的就是“

哪些值屬於哪些欄位

”。

為解決這個問題,

我們給每個欄位都進行編號

,比如通訊雙方都知道“long_long_name”這個欄位的編號是2,那麼對於:

int long_long_name = 100;

這個資訊我們只需要傳遞:

欄位名稱:2 (2對應欄位“long_long_name”)

欄位型別:0 (0表示varint型別,參見上圖)

欄位值:100

所以我們可以看到, 無論你用多麼複雜的欄位名稱也不會影響編碼後佔據的空間,欄位名稱根本就不會出現在編碼後的資訊中, so clever。

從宏觀上看

我們已經在protobuf中看到了數字以及欄位名稱以及欄位型別是怎麼表示了,現在是時候從宏觀角度來看看多個欄位該怎麼編碼了。

從本質上講,protobuf被編碼後形成一系列的key-value,每個key-value對應一個proto中的欄位。

也就是鍵值對:

其中value比較簡單,也就是欄位值;而欄位名稱和欄位型別會被拼接成key,protobuf中共有6種類型,因此只需要3個位元位即可;欄位名稱只需要儲存對應的編號,這樣可以就可以這樣編碼:

(欄位編號 << 3) | 欄位型別

假設server接收到了一個key為0x08,其二進位制的表示為:

由於key也是利用varint編碼的,因此需要將第一個位元位去掉,這樣我的得到:

根據key的編碼方式,其後三個位元位表示欄位型別,即:

也就是0,這樣我們知道該key的型別是Varint(第0號型別),而欄位編號為抹掉後3個位元位的值,即:

這樣,我們就知道了該key對應的欄位編號為1,得到編號我們就能根據編號找到對應的編號名稱。

巢狀資料

與Json和XML類似,protobuf中也支援巢狀訊息,就像這樣:

message SubMsg { optional int32 id = 1;}message Msg { optional SubMsg msg = 1;}

其實現也比較簡單,這依然遵循被編碼後形成一系列的key-value,只不過對於巢狀型別的key來說,其value是由子訊息的key-value組成。

神奇的Google二進位制編解碼技術:Protobuf

protobuf與編譯語言

與Json一樣,protobuf也是一門語言,兼具了文字的可讀性以及二進位制的高效。

protobuf之所以能做到這一點就好比C語言與機器指令。

C語言是給程式設計師看的,可讀性好,而機器指令是給硬體使用的,效能好,編譯器會將C語言程式轉為機器可執行的機器指令。

而protobuf也一樣,protobuf也是一門語言,會將可讀性較好的訊息編碼為二進位制從而可以在網路中進行傳播,而對端也可以將其解碼回來。

在這裡protobuf中定義的訊息就好比C語言,編碼後的二進位制訊息就好比機器指令。

而protobuf作為事實上語言必然有自己的語法,其語法就是這樣:

神奇的Google二進位制編解碼技術:Protobuf

怎麼樣,還覺得編譯原理沒什麼用嗎?

不理解編譯原理是不可能發明protobuf這種技術的。

總結

我在寫這篇文章時不斷感嘆,Google的這項技術節省了多少程式設計師的時間,同時我們也能看到這種基石般的技術依賴的底層原理卻非常古老:

資訊的編解碼

編譯原理

怎麼樣,這些是不是遠遠沒有IT界各種流行的技術聽上去時髦有趣,而正是這種樸素的技術支撐起了工業界,現在你也應該能明白底層技術的重要性了吧。

作者:

陸小風

碼農的荒島求生

出處:https://mp。weixin。qq。com/s?__biz=Mzg4OTYzODM4Mw==&mid=2247487472&idx=1&sn=c7c09668e48356d7116fccd6ce9a96d6