IM通讯协议专题学习(八):金蝶随手记团队的Protobuf应用实践(原理篇)

简介: 本文将基于随手记团队的Protobuf应用实践,分享了Protobuf的技术原理、上手实战等(本篇要分享的是技术原理),希望对你有用。

本文由金蝶随手记技术团队丁同舟分享。

1、引言

跟移动端IM中追求数据传输效率、网络流量消耗等需求一样,随手记客户端与服务端交互的过程中,对部分数据的传输大小和效率也有较高的要求,普通的数据格式如 JSON 或者 XML 已经不能满足,因此决定采用 Google 推出的 Protocol Buffers 以达到数据高效传输。

本文将基于随手记团队的Protobuf应用实践,分享了Protobuf的技术原理、上手实战等(本篇要分享的是技术原理),希望对你有用。

学习交流:

- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM

- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK备用地址点此

(本文已同步发布于:http://www.52im.net/thread-4114-1-1.html

2、系列文章

本文是系列文章中的第 8篇,本系列总目录如下:

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 () {

@package

struct 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-4114-1-1.html

目录
相关文章
|
28天前
|
人工智能 数据可视化 API
10 分钟构建 AI 客服并应用到网站、钉钉或微信中测试评
10 分钟构建 AI 客服并应用到网站、钉钉或微信中测试评
66 2
|
2月前
|
人工智能
10 分钟构建 AI 客服并应用到网站、钉钉或微信中简说
10 分钟构建 AI 客服并应用到网站、钉钉或微信
|
2月前
|
数据采集 监控 测试技术
大型IM稳定性监测实践:手Q客户端性能防劣化系统的建设之路
本文以iOS端为例,详细分享了手 Q 客户端性能防劣化系统从0到1的构建之路,相信对业界和IM开发者们都有较高的借鉴意义。
98 2
|
8天前
|
人工智能 自然语言处理 搜索推荐
AI技术在智能客服系统中的应用与挑战
【9月更文挑战第32天】本文将探讨AI技术在智能客服系统中的应用及其面临的挑战。我们将分析AI技术如何改变传统客服模式,提高服务质量和效率,并讨论在实际应用中可能遇到的问题和解决方案。
112 65
|
23天前
|
人工智能 运维 负载均衡
10 分钟构建 AI 客服并应用到网站、钉钉或微信中
《10分钟构建AI客服并应用到网站、钉钉或微信中》的解决方案通过详尽的文档和示例代码,使具有一定编程基础的用户能够快速上手,顺利完成AI客服集成。方案涵盖高可用性、负载均衡及定制化选项,满足生产环境需求。然而,若文档不清晰或存在信息缺失,则可能导致部署障碍。实际部署中可能遇到网络、权限等问题,需逐一排查。云产品的功能、性能及操作配置便捷性直接影响解决方案效果,详尽的产品手册有助于快速解决问题。总体而言,该方案在各方面表现出色,值得推荐。
|
1天前
|
机器学习/深度学习 人工智能 自然语言处理
AI技术在智能客服中的应用:重塑客户体验
AI技术在智能客服中的应用:重塑客户体验
|
16天前
|
人工智能
解决方案评测|10分钟构建AI客服并应用到聊天系统中获奖名单公布
10分钟构建AI客服并应用到聊天系统中获奖名单公布!!!
|
14天前
|
机器学习/深度学习 自然语言处理 搜索推荐
探索深度学习与自然语言处理(NLP)在智能客服系统中的创新应用
探索深度学习与自然语言处理(NLP)在智能客服系统中的创新应用
55 0
|
2月前
|
人工智能 自然语言处理 Serverless
阿里云百炼应用实践系列-让微信公众号成为智能客服
本文主要介绍如何基于百炼平台快速在10分钟让您的微信公众号(订阅号)变成 AI 智能客服。我们基于百炼平台的能力,以官方帮助文档为参考,让您的微信公众号(订阅号)成 为AI 智能客服,以便全天候(7x24)回应客户咨询,提升用户体验,介绍了相关技术方案和主要代码,供开发者参考。
阿里云百炼应用实践系列-让微信公众号成为智能客服
|
2月前
|
小程序 前端开发 Java
携程技术分享:亿级流量的办公IM及开放平台技术实践
本文总结了携程办公IM这些年的发展历程及未来的演进方向,并着重从高可用、高性能和可扩展的角度,探讨开放式平台的技术实现及发展方向。
35 0
携程技术分享:亿级流量的办公IM及开放平台技术实践

热门文章

最新文章