本文由金蝶顺手记技术团队丁同舟分享。
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…)