本文由金蝶隨手記技術團隊丁同舟分享。
1、引言
跟移動端IM中追求數據傳輸效率、網絡流量消耗等需求一樣,隨手記客户端與服務端交互的過程中,對部分數據的傳輸大小和效率也有較高的要求,普通的數據格式如 JSON 或者 XML 已經不能滿足,因此決定採用 Google 推出的 Protocol Buffers 以達到數據高效傳輸。本文將基於隨手記團隊的Protobuf應用實踐,分享了Protobuf的技術原理、上手實戰等(本篇要分享的是技術原理),希望對你有用。
學習交流:
- 移動端IM開發入門文章:《新手入門一篇就夠:從零開發移動端IM》
-
開源IM框架源碼:https://github.com/JackJiang2...(備用地址點此)
(本文已同步發佈於:http://www.52im.net/thread-41...)2、系列文章
本文是系列文章中的第 8 篇,本系列總目錄如下:
《IM通訊協議專題學習(一):Protobuf從入門到精通,一篇就夠!》
《IM通訊協議專題學習(二):快速理解Protobuf的背景、原理、使用、優缺點》
《IM通訊協議專題學習(三):由淺入深,從根上理解Protobuf的編解碼原理》
《IM通訊協議專題學習(四):從Base64到Protobuf,詳解Protobuf的數據編碼原理》
《IM通訊協議專題學習(五):Protobuf到底比JSON快幾倍?全方位實測!》
《IM通訊協議專題學習(六):手把手教你如何在Android上從零使用Protobuf》(稍後發佈..)
《IM通訊協議專題學習(七):手把手教你如何在NodeJS中從零使用Protobuf》
《IM通訊協議專題學習(八):金蝶隨手記團隊的Protobuf應用實踐(原理篇)》(* 本文)
《IM通訊協議專題學習(九):金蝶隨手記團隊的Protobuf應用實踐(實戰篇) 》(稍後發佈..)3、基本介紹
Protocol buffers 為 Google 提出的一種跨平台、多語言支持且開源的序列化數據格式。相對於類似的 XML 和 JSON,Protocol buffers 更為小巧、快速和簡單。其語法目前分為proto2和proto3兩種格式。相對於傳統的 XML 和 JSON, Protocol buffers 的優勢主要在於:更加小、更加快。對於自定義的數據結構,Protobuf 可以通過生成器生成不同語言的源代碼文件,讀寫操作都非常方便。
假設現在有下面 JSON 格式的數據:{"id":1,"name":"jojo","email":"123@qq.com",}使用 JSON 進行編碼,得出byte長度為43的的二進制數據:7b226964 223a312c 226e616d 65223a22 6a6f6a6f 222c2265 6d61696c 223a2231 32334071 712e636f 6d227d如果使用 Protobuf 進行編碼,得到的二進制數據僅有20個字節:0a046a6f 6a6f1001 1a0a3132 33407171 2e636f6d
4、編碼原理
相對於基於純文本的數據結構如 JSON、XML等,Protobuf 能夠達到小巧、快速的最大原因在於其獨特的編碼方式。《Protobuf從入門到精通,一篇就夠!》對 Protobuf 的 Encoding 作了很好的解析。例如:對於int32類型的數字,如果很小的話,protubuf 因為採用了Varint方式,可以只用 1 個字節表示。
5、Varint原理
Varint 中每個字節的最高位 bit 表示此 byte 是否為最後一個 byte 。1 表示後續的 byte 也表示該數字,0 表示此 byte 為結束的 byte。
例如數字 300 用 Varint 表示為 1010 1100 0000 0010:
▲ 圖片源自《Protobuf從入門到精通,一篇就夠!》
注意:需要注意解析的時候會首先將兩個 byte 位置互換,因為字節序採用了 little-endian 方式。但 Varint 方式對於帶符號數的編碼效果比較差。因為帶符號數通常在最高位表示符號,那麼使用 Varint 表示一個帶符號數無論大小就必須要 5 個 byte(最高位的符號位無法忽略,因此對於 -1 的 Varint 表示就變成了 010001)。Protobuf 引入了 ZigZag 編碼很好地解決了這個問題。
6、ZigZag編碼
關於 ZigZag 的編碼方式,博客園上的一篇博文《整數壓縮編碼 ZigZag》做出了詳細的解釋。
ZigZag 編碼按照數字的絕對值進行升序排序,將整數通過一個 hash 函數h(n) = (n<<1)^(n>>31)(如果是 sint64 h(n) = (n<<1)^(n>>63))轉換為遞增的 32 位 bit 流。關於為什麼 64 的 ZigZag 為 80 01,《整數壓縮編碼 ZigZag》中有關於其編碼唯一可譯性的解釋。通過 ZigZag 編碼,只要絕對值小的數字,都可以用較少位的 byte 表示。解決了負數的 Varint 位數會比較長的問題。
7、T-V and T-L-V
Protobuf 的消息結構是一系列序列化後的Tag-Value對。其中 Tag 由數據的 field 和 writetype組成,Value 為源數據編碼後的二進制數據。假設有這樣一個消息:message Person {int32 id = 1;string name = 2;}其中,id字段的field為1,writetype為int32類型對應的序號。編碼後id對應的 Tag 為 (field_number << 3) | wire_type = 0000 1000,其中低位的 3 位標識 writetype,其他位標識field。每種類型的序號可以從這張表得到:
需要注意,對於string類型的數據(在上表中第三行),由於其長度是不定的,所以 T-V的消息結構是不能滿足的,需要增加一個標識長度的Length字段,即T-L-V結構。
8、反射機制
Protobuf 本身具有很強的反射機制,可以通過 type name 構造具體的 Message 對象。陳碩的文章《一種自動反射消息類型的 Google Protobuf 網絡傳輸方案》中對 GPB 的反射機制做了詳細的分析和源碼解讀。這裏通過 protobuf-objectivec 版本的源碼,分析此版本的反射機制。
陳碩對 protobuf 的類結構做出了詳細的分析 —— 其反射機制的關鍵類為Descriptor類:每個具體 Message Type 對應一個 Descriptor 對象。儘管我們沒有直接調用它的函數,但是Descriptor在“根據 type name 創建具體類型的 Message 對象”中扮演了重要的角色,起了橋樑作用。同時,陳碩根據 GPB 的 C++ 版本源代碼分析出其反射的具體機制:DescriptorPool類根據 type name 拿到一個 Descriptor的對象指針,在通過MessageFactory工廠類根據Descriptor實例構造出具體的Message對象。示例代碼如下:Message createMessage(conststd::string& typeName){ Message message = NULL; constDescriptor descriptor = DescriptorPool::generated_pool()->FindMessageTypeByName(typeName); if(descriptor) { constMessage prototype = MessageFactory::generated_factory()->GetPrototype(descriptor); if(prototype) { message = prototype->New(); } } returnmessage;}注意:1)DescriptorPool 包含了程序編譯的時候所鏈接的全部 protobuf Message types;2)MessageFactory 能創建程序編譯的時候所鏈接的全部 protobuf Message types。
9、以Protobuf-objectivec為例
在 OC 環境下,假設有一份 Message 數據結構如下:message Person { string name = 1; int32 id = 2; string email = 3;}解碼此類型消息的二進制數據:Person newP = [[Person alloc] initWithData:data error:nil];這裏調用了:- (instancetype)initWithData:(NSData)data error:(NSError)errorPtr { return[selfinitWithData:data extensionRegistry:nilerror:errorPtr];}其內部調用了另一個構造器:- (instancetype)initWithData:(NSData )data extensionRegistry:(GPBExtensionRegistry )extensionRegistry error:(NSError )errorPtr { if((self = [self init])) { @try { [self mergeFromData:data extensionRegistry:extensionRegistry]; //... } @catch (NSException exception) { //... } } return self;}去掉一些防禦代碼和錯誤處理後,可以看到最終由mergeFromData:方法實現構造:- (void)mergeFromData:(NSData)data extensionRegistry:(GPBExtensionRegistry )extensionRegistry { GPBCodedInputStream input = [[GPBCodedInputStream alloc] initWithData:data]; //根據傳入的data構造出數據流對象 [selfmergeFromCodedInputStream:input extensionRegistry:extensionRegistry]; //通過數據流對象進行merge [input checkLastTagWas:0]; //校檢 [input release];}這個方法主要做了兩件事:1)通過傳入的 data 構造GPBCodedInputStream對象實例;2)通過上面構造的數據流對象進行 merge 操作。GPBCodedInputStream負責的工作很簡單,主要是把源數據緩存起來,並同時保存一系列的狀態信息,例如size, lastTag等。其數據結構非常簡單:typedef struct GPBCodedInputStreamState {constuint8_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;NSUIntegerrecursionDepth;} GPBCodedInputStreamState; @interface GPBCodedInputStream () {@packagestruct GPBCodedInputStreamState state_;NSData buffer_;}merge 操作內部實現比較複雜,首先會拿到一個當前 Message 對象的 Descriptor 實例,這個 Descriptor 實例主要保存 Message 的源文件 Descriptor 和每個 field 的 Descriptor,然後通過循環的方式對 Message 的每個 field 進行賦值。Descriptor 簡化定義如下:@interfaceGPBDescriptor : NSObject<NSCopying>@property(nonatomic, readonly, strong, nullable) NSArray<GPBFieldDescriptor> fields;@property(nonatomic, readonly, strong, nullable) NSArray<GPBOneofDescriptor> oneofs; //用於 repeated 類型的 filed@property(nonatomic, readonly, assign) GPBFileDescriptor file;@end其中GPBFieldDescriptor定義如下:@interface GPBFieldDescriptor () {@package GPBMessageFieldDescription description_; GPB_UNSAFE_UNRETAINED GPBOneofDescriptor containingOneof_; SELgetSel_; SELsetSel_; SELhasOrCountSel_; // Count for map<>/repeated fields, has otherwise. SELsetHasSel_;}其中GPBMessageFieldDescription保存了 field 的各種信息,如數據類型、filed 類型、filed id等。除此之外,getSel和setSel為這個 field 在對應類的屬性的 setter 和 getter 方法。mergeFromCodedInputStream:方法的簡化版實現如下:- (void)mergeFromCodedInputStream:(GPBCodedInputStream )input extensionRegistry:(GPBExtensionRegistry )extensionRegistry { GPBDescriptor descriptor = [selfdescriptor]; //生成當前 Message 的Descriptor實例 GPBFileSyntax syntax = descriptor.file.syntax; //syntax 標識.proto文件的語法版本 (proto2/proto3) NSUInteger startingIndex = 0; //當前位置 NSArray fields = descriptor->fields_; //當前 Message 的所有 fileds //循環解碼 for(NSUIntegeri = 0; i < fields.count; ++i) { //拿到當前位置的FieldDescriptor GPBFieldDescriptor fieldDescriptor = fields[startingIndex]; //判斷當前field的類型 GPBFieldType fieldType = fieldDescriptor.fieldType; if(fieldType == GPBFieldTypeSingle) { //MergeSingleFieldFromCodedInputStream 函數中解碼 Single 類型的 field 的數據 MergeSingleFieldFromCodedInputStream(self, fieldDescriptor, syntax, input, extensionRegistry); //當前位置+1 startingIndex += 1; } else if(fieldType == GPBFieldTypeRepeated) { // ... // Repeated 解碼操作 } else{ // ... // 其他類型解碼操作 } } // for(i < numFields)}可以看到,descriptor在這裏是直接通過 Message 對象中的方法拿到的,而不是通過工廠構造:GPBDescriptor descriptor = [self descriptor]; //desciptor方法定義- (GPBDescriptor )descriptor { return [[selfclass] descriptor];}這裏的descriptor類方法實際上是由GPBMessage的子類具體實現的。例如在Person這個消息結構中,其descriptor方法定義如下:+ (GPBDescriptor )descriptor { static GPBDescriptor descriptor = nil; if(!descriptor) { static GPBMessageFieldDescription fields[] = { { .name = "name", .dataTypeSpecific.className = NULL, .number = Person_FieldNumber_Name, .hasIndex = 0, .offset = (uint32_t)offsetof(Person__storage_, name), .flags = GPBFieldOptional, .dataType = GPBDataTypeString, }, //... //每個field都會在這裏定義出GPBMessageFieldDescription }; GPBDescriptor localDescriptor = //這裏會根據fileds和其他一系列參數構造出一個Descriptor對象 descriptor = localDescriptor; } return descriptor;}接下來,在構造出 Message 的 Descriptor 後,會對所有的 fields 進行遍歷解碼。解碼時會根據不同的fieldType調用不同的解碼函數。例如對於fieldType == GPBFieldTypeSingle,會調用 Single 類型的解碼函數:MergeSingleFieldFromCodedInputStream(self, fieldDescriptor, syntax, input, extensionRegistry);MergeSingleFieldFromCodedInputStream內部提供了一系列宏定義,針對不同的數據類型進行數據解碼。#define CASE_SINGLE_POD(NAME, TYPE, FUNC_TYPE) \ caseGPBDataType##NAME: { \ TYPE val = GPBCodedInputStreamRead##NAME(&input->state_); \ GPBSet##FUNC_TYPE##IvarWithFieldInternal(self, field, val, syntax); \ break; \ }#define CASE_SINGLE_OBJECT(NAME) \ caseGPBDataType##NAME: { \ idval = GPBCodedInputStreamReadRetained##NAME(&input->state_); \ GPBSetRetainedObjectIvarWithFieldInternal(self, field, val, syntax); \ break; \ } CASE_SINGLE_POD(Int32, int32_t, Int32) ... #undef CASE_SINGLE_POD#undef CASE_SINGLE_OBJECT例如:對於int32類型的數據,最終會調用int32_t GPBCodedInputStreamReadInt32(GPBCodedInputStreamState state);函數讀取數據並賦值。這裏內部實現其實就是對於 Varint 編碼的解碼操作:int32_t GPBCodedInputStreamReadInt32(GPBCodedInputStreamState state) { int32_t value = ReadRawVarint32(state); return value;}在對數據解碼完成後,拿到一個int32_t,此時會調用GPBSetInt32IvarWithFieldInternal進行賦值操作。其簡化實現如下:void GPBSetInt32IvarWithFieldInternal(GPBMessage self, GPBFieldDescriptor field, int32_t value, GPBFileSyntax syntax) { //最終的賦值操作 //此處self為GPBMessage實例 uint8_t storage = (uint8_t )self->messageStorage_; int32_t typePtr = (int32_t )&storage[field->description_->offset]; typePtr = value; }其中typePtr為當前需要賦值的變量的指針。至此,單個 field 的賦值操作已經完成。總結一下,在 protobuf-objectivec 版本中,反射機制中構建 Message 對象的流程大致為:
1)通過 Message 的具體子類構造其 Descriptor,Descriptor 中包含了所有 field 的 FieldDescriptor;
2)循環通過每個 FieldDescriptor 對當前 Message 對象的指定 field 賦值。
10、參考資料
[1] Protobuf 官方開發者指南(中文譯版)
[2] Protobuf官方手冊
[3] Why do we use Base64?
[4] The Base16, Base32, and Base64 Data Encodings
[5] Protobuf從入門到精通,一篇就夠!
[5] 如何選擇即時通訊應用的數據傳輸格式
[7] 強列建議將Protobuf作為你的即時通訊應用數據傳輸格式
[8] APP與後台通信數據格式的演進:從文本協議到二進制協議
[9] 面試必考,史上最通俗大小端字節序詳解
[10] 移動端IM開發需要面對的技術問題(含通信協議選擇)
[11] 簡述移動端IM開發的那些坑:架構設計、通信協議和客户端
[12] 理論聯繫實際:一套典型的IM通信協議設計詳解
[13] 58到家實時消息系統的協議設計等技術實踐分享
(本文已同步發佈於:http://www.52im.net/thread-41...)