還在用JSON? Google Protocol Buffers 更快更小 (原理篇)

隨手記技術團隊發表於2018-03-20

歡迎關注微信公眾號「隨手記技術團隊」,檢視更多隨手記團隊的技術文章。轉載請註明出處
本文作者:丁同舟
原文連結:mp.weixin.qq.com/s/cyOHe1LS-…

背景

隨手記客戶端與服務端互動的過程中,對部分資料的傳輸大小和效率有較高的要求,普通的資料格式如 JSON 或者 XML 已經不能滿足,因此決定採用 Google 推出的 Protocol Buffers 以達到資料高效傳輸。

介紹

Protocol buffers 為 Google 提出的一種跨平臺、多語言支援且開源的序列化資料格式。相對於類似的 XML 和 JSON,Protocol buffers 更為小巧、快速和簡單。其語法目前分為proto2proto3兩種格式。

相對於傳統的 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
複製程式碼

編碼

相對於基於純文字的資料結構如 JSON、XML等,Protobuf 能夠達到小巧、快速的最大原因在於其獨特的編碼方式。IBM 的 developerWorks 上面有一篇Google Protocol Buffer 的使用和原理對 Protobuf 的 Encoding 作了很好的解析

例如,對於int32型別的數字,如果很小的話,protubuf 因為採用了Varint方式,可以只用 1 個位元組表示。

Varint

Varint 中每個位元組的最高位 bit 表示此 byte 是否為最後一個 byte 。1 表示後續的 byte 也表示該數字,0 表示此 byte 為結束的 byte。

例如數字 300 用 Varint 表示為 1010 1100 0000 0010

Varint 表示 300
圖片源自《Google Protocol Buffer 的使用和原理》

Note

需要注意解析的時候會首先將兩個 byte 位置互換,因為位元組序採用了 little-endian 方式。

但 Varint 方式對於帶符號數的編碼效果比較差。因為帶符號數通常在最高位表示符號,那麼使用 Varint 表示一個帶符號數無論大小就必須要 5 個 byte(最高位的符號位無法忽略,因此對於 -1 的 Varint 表示就變成了 010001)。

Protobuf 引入了 ZigZag 編碼很好地解決了這個問題。

ZigZag

ZigZag
圖片源自《整數壓縮編碼 ZigZag》

關於 ZigZag 的編碼方式,部落格園上的一篇博文整數壓縮編碼 ZigZag做出了詳細的解釋。

ZigZag 編碼按照數字的絕對值進行升序排序,將整數通過一個 hash 函式h(n) = (n<<1)^(n>>31)(如果是 sint64 h(n) = (n<<1)^(n>>63))轉換為遞增的 32 位 bit 流。

n 補碼 h(n) ZigZag (hex)
0 00 00 00 00 00 00 00 00 00
-1 ff ff ff ff 00 00 00 01 01
1 00 00 00 01 00 00 00 02 02
... ... ... ...
-64 ff ff ff c0 00 00 00 7f 7f
64 00 00 00 40 00 00 00 80 80 01
... ... ... ...

關於為什麼 64 的 ZigZag 為 80 01上面的文章中有關於其編碼唯一可譯性的解釋。

通過 ZigZag 編碼,只要絕對值小的數字,都可以用較少位的 byte 表示。解決了負數的 Varint 位數會比較長的問題。

T-V and T-L-V

Protobuf 的訊息結構是一系列序列化後的Tag-Value對。其中 Tag 由資料的 fieldwritetype組成,Value 為源資料編碼後的二進位制資料。

假設有這樣一個訊息:

message Person {
  int32 id = 1;
  string name = 2;
}
複製程式碼

其中,id欄位的field1writetypeint32型別對應的序號。編碼後id對應的 Tag 為 (field_number << 3) | wire_type = 0000 1000,其中低位的 3 位標識 writetype,其他位標識field

每種型別的序號可以從這張表得到:

Type Meaning Used For
0 Varint int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 64 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

需要注意,對於string型別的資料(在上表中第三行),由於其長度是不定的,所以 T-V的訊息結構是不能滿足的,需要增加一個標識長度的Length欄位,即T-L-V結構。

反射機制

Protobuf 本身具有很強的反射機制,可以通過 type name 構造具體的 Message 物件。陳碩的文章中對 GPB 的反射機制做了詳細的分析和原始碼解讀。這裡通過 protobuf-objectivec 版本的原始碼,分析此版本的反射機制。

protobuf_classdiagram
圖片源自《一種自動反射訊息型別的 Google Protobuf 網路傳輸方案》

陳碩對 protobuf 的類結構做出了詳細的分析 —— 其反射機制的關鍵類為Descriptor類。

每個具體 Message Type 對應一個 Descriptor 物件。儘管我們沒有直接呼叫它的函式,但是Descriptor在“根據 type name 建立具體型別的 Message 物件”中扮演了重要的角色,起了橋樑作用

同時,陳碩根據 GPB 的 C++ 版本原始碼分析出其反射的具體機制:DescriptorPool類根據 type name 拿到一個 Descriptor的物件指標,在通過MessageFactory工廠類根據Descriptor例項構造出具體的Message物件。示例程式碼如下:

Message* createMessage(const std::string& typeName)
{
  Message* message = NULL;
  const Descriptor* descriptor = DescriptorPool::generated_pool()->FindMessageTypeByName(typeName);
  if (descriptor)
  {
    const Message* prototype = MessageFactory::generated_factory()->GetPrototype(descriptor);
    if (prototype)
    {
      message = prototype->New();
    }
  }
  return message;
}
複製程式碼

Note

DescriptorPool 包含了程式編譯的時候所連結的全部 protobuf Message types MessageFactory 能建立程式編譯的時候所連結的全部 protobuf Message types

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 [self initWithData:data extensionRegistry:nil error: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`構造出資料流物件
  [self mergeFromCodedInputStream:input extensionRegistry:extensionRegistry]; //通過資料流物件進行merge
  [input checkLastTagWas:0]; //校檢
  [input release];
}
複製程式碼

這個方法主要做了兩件事:

  • 通過傳入的 data 構造GPBCodedInputStream物件例項
  • 通過上面構造的資料流物件進行 merge 操作

GPBCodedInputStream負責的工作很簡單,主要是把源資料快取起來,並同時儲存一系列的狀態資訊,例如size, lastTag等。其資料結構非常簡單:

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;

@interface GPBCodedInputStream () {
@package
struct GPBCodedInputStreamState state_;
NSData *buffer_;
}
複製程式碼

merge 操作內部實現比較複雜,首先會拿到一個當前 Message 物件的 Descriptor 例項,這個 Descriptor 例項主要儲存 Message 的原始檔 Descriptor 和每個 field 的 Descriptor,然後通過迴圈的方式對 Message 的每個 field 進行賦值。

Descriptor 簡化定義如下:

@interface GPBDescriptor : 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_;

 SEL getSel_;
 SEL setSel_;
 SEL hasOrCountSel_;  // *Count for map<>/repeated fields, has* otherwise.
 SEL setHasSel_;
}
複製程式碼

其中GPBMessageFieldDescription儲存了 field 的各種資訊,如資料型別、filed 型別、filed id等。除此之外,getSelsetSel為這個 field 在對應類的屬性的 setter 和 getter 方法。

mergeFromCodedInputStream:方法的簡化版實現如下:

- (void)mergeFromCodedInputStream:(GPBCodedInputStream *)input
               extensionRegistry:(GPBExtensionRegistry *)extensionRegistry {
 GPBDescriptor *descriptor = [self descriptor]; //生成當前 Message 的`Descriptor`例項
 GPBFileSyntax syntax = descriptor.file.syntax; //syntax 標識.proto檔案的語法版本 (proto2/proto3)
 NSUInteger startingIndex = 0; //當前位置
 NSArray *fields = descriptor->fields_; //當前 Message 的所有 fileds
 
 //迴圈解碼
 for (NSUInteger i = 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 [[self class] 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)                             \
   case GPBDataType##NAME: {                                              \
     TYPE val = GPBCodedInputStreamRead##NAME(&input->state_);            \
     GPBSet##FUNC_TYPE##IvarWithFieldInternal(self, field, val, syntax);  \
     break;                                                               \
           }
#define CASE_SINGLE_OBJECT(NAME)                                           \
   case GPBDataType##NAME: {                                              \
     id val = 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 物件的流程大致為:

  • 通過 Message 的具體子類構造其 Descriptor,Descriptor 中包含了所有 field 的 FieldDescriptor
  • 迴圈通過每個 FieldDescriptor 對當前 Message 物件的指定 field 賦值

相關連結

相關文章