IM通訊協議專題學習(三):由淺入深,從根上理解Protobuf的編解碼原理

JackJiang發表於2022-11-28
本文由碼農的荒島求生陸小風分享,為了提升閱讀體驗,進行了較多修訂和排版。

1、引言
搞即時通訊IM方面開發的程式設計師,在談到通訊層實現時,必然會提到網路程式設計。那麼計算機網路程式設計中的一個非常基本的問題:到底該怎樣組織Client與server之間互動的資料呢?本篇文章我們不討論IM系統中的那些高階技術話題,我們迴歸到通訊的本質——也就是資料在網路中互動時的編解碼原理,並由淺入深從底層理解Protobuf的編解碼技術實現。
圖片

學習交流:
-- 移動端IM開發入門文章:《新手入門一篇就夠:從零開發移動端IM》
--開源IM框架原始碼:https://github.com/JackJiang2...(備用地址點此)

(本文已同步釋出於:http://www.52im.net/thread-40...

2、系列文章
本文是系列文章中的第 3 篇,本系列總目錄如下:
《IM通訊協議專題學習(一):Protobuf從入門到精通,一篇就夠!》
《IM通訊協議專題學習(二):快速理解Protobuf的背景、原理、使用、優缺點》
《IM通訊協議專題學習(三):由淺入深,從根上理解Protobuf的編解碼原理》(* 本文)
《IM通訊協議專題學習(四):從Base64到Protobuf,詳解Protobuf的資料編碼原理》(稍後釋出..)
《IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?請看全方位實測!》(稍後釋出..)《IM通訊協議專題學習(六):手把手教你如何在Android上從零使用Protobuf》(稍後釋出..)《IM通訊協議專題學習(七):手把手教你如何在NodeJS中從零使用Protobuf》(稍後釋出..)《IM通訊協議專題學習(八):金蝶隨手記團隊的Protobuf應用實踐(原理篇)  》(稍後釋出..)《IM通訊協議專題學習(九):金蝶隨手記團隊的Protobuf應用實踐(實戰篇) 》(稍後釋出..)

3、共識與協議
針對引言中引出的“到底該怎樣組織Client與Server之間互動的資料呢?”。這個問題可不像看上去那樣簡單,因為Client程式和Server程式執行在不同的機器上,這些機器可能執行在不同的處理器平臺、可能執行在不同的作業系統、可能是由不同的程式語言編寫的,Server要怎樣才能識別出Client傳送的是什麼資料呢?就像這樣:
圖片
如上圖所示,Client給Server傳送了一段資料:0101000100100001Server怎麼能知道該怎樣“解讀”這段資料呢?顯然:Client和Server在傳送資料之前必須首先達成某種關於怎樣解讀資料的共識,這就是所謂的協議。這裡的協議可以是這樣的:“將每8個位元為一個單位解釋為無符號數字”。如果協議是上面這樣定義的:那麼Server接收到這串二進位制後就會將其解析為 81(01010001) 與 33(00100001)。當然,這裡的協議也可以是這樣的:“將每8個位元為一個單位解釋為ASCII字元”,那麼Server接收到這串二進位制後就將其解析為“Q!”。可見:同樣一串二進位制在不同的“上下文/協議”下有完全不一樣的解讀,這也是為什麼計算機明明只認知0和1但是卻能處理非常複雜任務的根本原因,因為一切都可以編碼為0和1,同樣的我們也可以從0和1中解析出我們想要的資訊,這就是所謂的編解碼技術。實際上不止0和1,我們也可以將資訊編碼為摩斯密碼(Morse code)等,只不過計算機擅長處理0和1而已。
圖片
扯遠了,回到本文的主題。

4、一個例子:
遠端過程呼叫(RPC)作為程式設計師我們知道,Client以及Server之間不會簡單傳遞一串數字以及字元這樣簡單,尤其在網際網路大廠後端服務這種場景下。當我們在電商App裡搜尋商品、叫車App裡呼叫計程車以及刷短影片時,每一次請求的背後在後端都涉及大量服務之間的互動。就像這樣:
圖片
完成一次客戶端請求gateway這個服務要“呼叫”N多個下游服務,所謂“呼叫”是說A服務向B服務傳送一段資料(請求),B服務接收到這段資料後執行相應的函式,並將結果返回給A服務。只不過對於服務A來說並不想關心網路傳輸這樣的底層細節,如果能像呼叫本地函式一樣呼叫遠端服務就好了,這就是所謂的RPC。經典的實現方式是這樣的:
圖片
RPC對上層提供和普通函式一樣的介面,只不過在實現上封裝了底層複雜的網路通訊(當然也包括協議的定義,協議的解解碼等)。RPC框架是當前網際網路後端的基石之一,很多所謂網際網路後端的職位無非就是在此基礎之上堆業務邏輯。本文我們不關心其中的細節,我們只關心在網路層Client是怎樣對請求引數進行編碼、Server怎樣對請求引數進行解碼的,也就是本文開頭提出的問題。
5、資訊的編解碼
5.1純文字的編解碼對人類很友好
在思考怎樣進行編解碼之前,我們必須意識到:1)Client和Server可能是用不同語言編寫的(你的編解碼方案必須通用且不能和語言繫結);2)編解碼方法的效能問題必須要考慮(尤其是對時間要求苛刻的服務)。首先,我們最應該能想到的就是以純文字的形式來表示。純文字從來都是一種非常有友好的資訊載體。為什麼?很簡單,因為人類(我們)可以直接看懂。就像這段:{ "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上、是大端還是小端,都可以無障礙交流。因此:在這裡,文字的語法就是一種協議(如下圖所示)。
圖片
順便說一句:你都規定好了文字的語法,實際上就相當於發明了一種語言。這裡用來舉例用的語言就是所謂的JSON,只不過JSON這種語言不是用來表示邏輯(程式碼)而是用來儲存資料的。
JSON就是這個老頭提出來的:
圖片
除了JSON,另一種利用文字儲存資料的表示方法是XML。來一段XML感受下:<note><to>Tove</to><from>Jani</from><heading>Reminder</heading><body>Don't forget me this weekend!</body></note>相對JSON來說是不是就沒那麼容易看懂了,自從JSON出現後在Web領域就逐漸取代了XML。當兩段資料量很少的時候——就像瀏覽器和服務端的互動,JSON可以工作的非常好(如下圖所示)。這個場景就是這樣:
圖片
在這裡是JSON的天下。
5.2純文字對計算機來說不夠友好
在上小節中我們知道,JSON這類純文字的編解碼方式對於人類非常友好。但對於後端服務之間的互動(或者具體如IM裡Client和Server之間的互動)來說就不一樣了,後端服務之間的RPC呼叫可能會傳輸大量資料,如果全部用純文字的形式來表示資料那麼不管是網路頻寬還是效能可能都會差強人意。
圖片
在這種場景下,JSON並不是最好的選項,主要原因之一就在於效能以及資料的體積。我們知道:文字表示對人類是最友好的,對機器來說則不是這樣,對機器來說最好的還是01二進位制。那麼有沒有二進位制的編碼方法嗎?答案是肯定的,這就是當前網際網路後端中流行的Protobuf,Google公司開源專案。那麼Protobuf有什麼神奇之處嗎?假設Client端想給Server端傳輸這樣一段資訊:“我有一個id,其值為43”。那麼在XML下是這樣表示的:<id>43</id>數一數這這段資料佔據了多少位元組,很顯然是11位元組。而如果用JSON來表示呢?{"id":43}數一數這段資料佔據了多少位元組,顯然是9位元組。而如果用Protobuf來表示呢? 是這樣的://訊息定義message Msg {  optional int32 id= 1;}//例項化Msg msg;msg.set_id(43);其中Msg的定義看上去比JSON和XML更加複雜了,但這些只是給人看的,這些還會被protbuf進一步處理。最終被Protobuf編碼為:1082b也就是0x08與0x2b,這佔據了多少位元組呢?答案是2位元組。從JSON的9位元組到Protobuf的2位元組,資料大小減少了4倍多。資料量的減少意味著:1)更少的網路頻寬;2)更快的解析速度。那麼,Protobuf是怎樣做到這一點的呢?
6、Protobuf是怎樣實現編解碼的?
首先,我們來思考最簡單的情況,正常情況下,我們該怎樣表示數字。你可能會想這還不簡單,統一用固定長度,比如用64個位元(8位元組)。這種方法可行,但問題是不論一個數字有多小,比方2,那麼用這種方法表示2也需要佔據64個位元(8位元組),如下所示。
圖片
明明只要一個位元組就能表示而我們卻用了8個,前面的全都是0,這也太奢侈太浪費了吧。顯然,在這裡我們不能使用固定長度來表示數字,而需要使用變長方法來表示。什麼叫變長?意思是說如果數字本身比較大,那麼其使用的位元位可以較多,但如果數字很小那麼就應該使用較少的位元位來表示,這就叫變長,隨機應變,不死板。那怎樣變長呢?我們規定:對於每一個位元組來說,第一個位元位如果是1那麼表示接下來的一個位元依然要用來解釋為一個數字,如果第一個位元為0,那麼說明接下來的一個位元組不是用來表示該數字的。也就是說對於每個8個位元(1位元組)來說,它的有效載荷是7個位元,第一個位元僅僅用來標記是否還應該把接下來的一個位元組解析為數字。根據這個規定,假設來了這樣一串01二進位制:1010110000000010根據規定,我們首先取出第一個位元組,也就是:10101100此時我們發現第一個位元位是1,因此我們知道接下來的一個位元組也屬於該數字。將當前位元組的1去掉就是:0101100然後我們看下一個位元組:00000010我們發現第一個bit為0,因此我們知道下一個位元組不屬於該數字了。接下來我們將解析到的0101100(第一個位元組去掉第一個位元位)以及第二個位元組0000010(第二個位元組去掉第一個位元位)翻轉之後拼接到一起(這裡之所以翻轉是因為我們規定數字的高位在後)。這個過程就是:     1010110000000010 ->  10101100 | 00000010 //解析得到兩個位元組    _          _->  0101100  |  0000010  //各自去掉最高位->  0000010  |  0101100  //兩個位元組翻轉順序    0000010  +  0101100->  100101100           //拼接最後我們得到了100101100,這一串二進位制表示數字300。這種數字的變長表示方法在Protobuf中被稱之為varint。因此在這種表示方法下,如果數字較大,那麼使用的位元就多,如果數字較小那麼使用位元就少,聰明吧。有的同學看到這裡可能會問題,剛才講解的方法只能表示無符號數字,那麼有符號數字該怎麼表示呢?比如-2該怎麼表示?
7、Protobuf的有符號數表示
按照剛才變長編碼的思想,-2147483646使用的位元位應該比-2要少。然而我們知道在計算機世界中負數使用補碼錶示的,也就是說最高位(最左側的位元位)一定是1,假設我們使用64位來表示數字,那麼如果我們依然用補碼來表示數字的話那麼無論這個負數有多大還是多小都需要佔據10個位元組的空間。為什麼是10個位元組呢?不要忘了varint每個位元組的有效負荷是7個位元,那麼對於需要64位表示的數字來說就需要64/7向上取整也就是10個位元組來表示。這顯然不能滿足我們對數字變長儲存的要求。該怎麼解決這個問題呢?既然無符號數字可以方便的進行變長編碼,那麼我們將有符號數字對映稱為無符號數字不就可以了,這就是所謂的ZigZag編碼,是不是很聰明。ZigZag編碼就像這樣:原始資訊      編碼後0            0-1           11            2-2           32            4-3           53            6...          ...2147483647   4294967294-2147483648  4294967295這樣我們就可以將有符號數字轉為無符號數字,接收方接收到該資料後再恢復出有符號數字。現在數字的問題徹底解決了,但這僅僅是萬里長征第一步。
8、Protobuf的欄位名稱與欄位型別
對於任何一個有用的資訊都包含這樣幾部分:1)欄位名稱;2)欄位型別;3)欄位值。就像C/C++中定義變數時:int i = 100;在這裡,欄位名稱就是i,欄位型別是int,欄位值是100。剛才我們用varint以及ZigZag編碼解決了欄位值表示的問題,那麼該怎樣表示欄位名稱和欄位型別呢?首先,對於欄位型別還比較簡單,因為欄位型別就那麼多。Protobuf中定義了6種欄位型別:
圖片
對於6種欄位型別我們使用3個位元位來表示就足夠了。接下來比較有趣的是欄位名稱該怎麼表示呢?假設我們需要傳遞這樣一個欄位:int long_long_name = 100;那麼我們真的需要把“long_long_name”這麼多字元透過網路傳遞給對端嗎?既然通訊雙方需要協議,那麼“long_long_name”這欄位其實是Client和Server都知道的,它們唯一不知道的就是“哪些值屬於哪些欄位”。為解決這個問題,我們給每個欄位都進行編號,比如通訊雙方都知道“long_long_name”這個欄位的編號是2。那麼對於“int long_long_name = 100; ”我們該怎麼表示呢。這個資訊我們只需要傳遞:1)欄位名稱:2 (2對應欄位“long_long_name”);2)欄位型別:0 (0表示varint型別,參見上圖);3)欄位值:100。所以我們可以看到,無論你用多麼複雜的欄位名稱也不會影響編碼後佔據的空間,欄位名稱根本就不會出現在編碼後的資訊中,so clever。
9、從宏觀上看Protobuf的編碼原理
我們已經在Protobuf中看到了數字以及欄位名稱以及欄位型別是怎麼表示了,現在是時候從宏觀角度來看看多個欄位該怎麼編碼了。從本質上講,Protobuf被編碼後形成一系列的key-value,每個key-value對應一個proto中的欄位。也就是鍵值對:
圖片
其中value比較簡單,也就是欄位值;而欄位名稱和欄位型別會被拼接成key。Protobuf中共有6種型別,因此只需要3個位元位即可。欄位名稱只需要儲存對應的編號。這樣就可以這樣編碼:(欄位編號 << 3) | 欄位型別假設Server接收到了一個key為0x08,其二進位制的表示為:0000 1000由於key也是利用varint編碼的,因此需要將第一個位元位去掉。這樣我的得到:000 1000根據key的編碼方式,其後三個位元位表示欄位型別,即:000也就是0,這樣我們知道該key的型別是Varint(第0號型別),而欄位編號為抹掉後3個位元位的值,即:0001這樣,我們就知道了該key對應的欄位編號為1,得到編號我們就能根據編號找到對應的編號名稱。

10、Protobuf的巢狀資料
與JSON和XML類似,Protobuf中也支援巢狀訊息.就像這樣:message SubMsg {  optional int32 id= 1;}message Msg {  optional SubMsg msg = 1;}其實現也比較簡單,這依然遵循被編碼後形成一系列的key-value,只不過對於巢狀型別的key來說,其value是由子訊息的key-value組成,如下圖所示。
圖片

11、Protobuf與編譯語言
與JSON一樣,Protobuf也是一門語言,兼具了文字的可讀性以及二進位制的高效。Protobuf之所以能做到這一點,就好比C語言與機器指令。C語言是給程式設計師看的,可讀性好。而機器指令是給硬體使用的,效能好。編譯器會將C語言程式轉為機器可執行的機器指令。而Protobuf也一樣,Protobuf也是一門語言,會將可讀性較好的訊息編碼為二進位制從而可以在網路中進行傳播,而對端也可以將其解碼回來。在這裡Protobuf中定義的訊息就好比C語言,編碼後的二進位制訊息就好比機器指令。而Protobuf作為事實上語言必然有自己的語法。其語法就是這樣:
圖片
怎麼樣,還覺得編譯原理沒什麼用嗎?不理解編譯原理是不可能發明Protobuf這種技術的。
12、本文小結
我在寫這篇文章時不斷感嘆,Google的這項技術節省了多少程式設計師的時間,同時我們也能看到這種基石般的技術依賴的底層原理卻非常古老。
比如下面這些:
1)資訊的編解碼;
2)編譯原理。
怎麼樣,這些是不是遠遠沒有IT界各種流行的技術聽上去時髦有趣,而正是這種樸素的技術支撐起了工業界,現在你也應該能明白底層技術的重要性了吧。
13、參考資料
[1]Protobuf官方網站
[2]Protobuf從入門到精通,一篇就夠!
[3]如何選擇即時通訊應用的資料傳輸格式
[4]強列建議將Protobuf作為你的即時通訊應用資料傳輸格式
[5]APP與後臺通訊資料格式的演進:從文字協議到二進位制協議
[6]面試必考,史上最通俗大小端位元組序詳解
[7]移動端IM開發需要面對的技術問題(含通訊協議選擇)
[8]簡述移動端IM開發的那些坑:架構設計、通訊協議和客戶端
[9]理論聯絡實際:一套典型的IM通訊協議設計詳解
[10]58到家實時訊息系統的協議設計等技術實踐分享
(本文已同步釋出於:http://www.52im.net/thread-40...

相關文章