本文由码农的荒岛求生陆小风分享,为了晋升浏览体验,进行了较多订正和排版。
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…)