服务器开发通信协议设计介绍

一、选择TCP还是UDP协议

由于我们的即时通讯软件的用户存在用户状态问题,即用户登录成功以后可以在他的好友列表中看到哪些好友在线,所以客户端和服务器需要保持长连接状态。另外即时通讯软件一般要求信息准确、有序、完整地到达对端,而这也是TCP协议的特点之一。综合这两个所以这里我们选择TCP协议,而不是UDP协议。

二、协议的结构

由于TCP协议是流式协议,所谓流式协议即通讯的内容是无边界的字节流:如A给B连续发送了三个数据包,每个包的大小都是100个字节,那么B可能会一次性收到300个字节;也可能先收到100个字节,再收到200个字节;也可能先收到100个字节,再收到50个字节,再收到150个字节;或者先收到50个字节,再收到50个字节,再收到50个字节,最后收到150个字节。也就是说,B可能以任何组合形式收到这300个字节。即像水流一样无明确的边界。为了能让对端知道如何给包分界,目前一般有三种做法:

以固定大小字节数目来分界,上文所说的就是属于这种类型,如每个包100个字节,对端每收齐100个字节,就当成一个包来解析;

以特定符号来分界,如每个包都以特定的字符来结尾(如\n),当在字节流中读取到该字符时,则表明上一个包到此为止。

固定包头+包体结构,这种结构中一般包头部分是一个固定字节长度的结构,并且包头中会有一个特定的字段指定包体的大小。这是目前各种网络应用用的最多的一种包格式。

上面三种分包方式各有优缺点,方法1和方法2简单易操作,但是缺点也很明显,就是很不灵活,如方法一当包数据不足指定长度,只能使用占位符如0来凑,比较浪费;方法2中包中不能有包界定符,否则就会引起歧义,也就是要求包内容中不能有某些特殊符号。而方法3虽然解决了方法1和方法2的缺点,但是操作起来就比较麻烦。我们的即时通讯协议就采用第三种分包方式。所以我们的协议包的包头看起来像这样:

一个应用中,有许多的应用数据,拿我们这里的即时通讯来说,有注册、登录、获取好友列表、好友消息等各种各样的协议数据包,而每个包因为业务内容不一样可能数据内容也不一样,所以各个包可能看起来像下面这样:

看到没有?由于每一个业务的内容不一样,定义的结构体也不一样。如果业务比较多的话,我们需要定义各种各样的这种结构体,这简直是一场噩梦。那么有没有什么方法可以避免这个问题呢?有,我受jdk中的流对象的WriteInt32、WriteByte、WriteInt64、WriteString,这样的接口的启发,也发明了一套这样的协议,而且这套协议基本上是通用协议,可用于任何场景。我们的包还是分为包头和包体两部分,包头和上文所说的一样,包体是一个不固定大小的二进制流,其长度由包头中的指定包体长度的字段决定。

接下来的核心部分就是如何操作这个二进制流,我们将流分为二进制读和二进制写两种流,下面给出接口定义:

这样如果是上文的一个登录数据包,我们只要写成如下形式就可以了:

接着我们再对端,解得正确的包体后,我们只要按写入的顺序依次读出来即可:

这里给出BinaryReadStream和BinaryWriteStream的完整实现:

这里详细解释一下上面的实现原理,即如何把各种类型的字段写入这种所谓的流中,或者怎么从这种流中读出各种类型的数据。上文的字段在流中的格式如下图:

这里最简便的方式就是每个字段的长度域都是固定字节数目,如4个字节。但是这里我们并没有这么做,而是使用了一个小小技巧去对字段长度进行了一点压缩。对于字符串类型的字段,我们将表示其字段长度域的整型值(int32类型,4字节)按照其数值的大小压缩成1~5个字节,对于每一个字节,如果我们只用其低7位。最高位为标志位,为1时,表示其左边的还有下一个字节,反之到此结束。例如,对于数字127,我们二进制表示成01111111,由于最高位是0,那么如果字段长度是127及以下,一个字节就可以存储下了。如果一个字段长度大于127,如等于256,对应二进制100000000,那么我们按照刚才的规则,先填充最低字节(从左往右依次是从低到高),由于最低的7位放不下,还有后续高位字节,所以我们在最低字节的最高位上填1,即10000000,接着次高位为00000100,由于次高位后面没有更高位的字节了,所以其最高位为0,组合起来两个字节就是10000000 0000100。对于数字50000,其二进制是1100001101010000,根据每7个一拆的原则是:11 0000110 1010000再加上标志位就是:10000011 10000110 01010000。采用这样一种策略将原来占4个字节的整型值根据数值大小压缩成了1~5个字节(由于我们对数据包最大长度有限制,所以不会出现长度需要占5个字节的情形)。反过来,解析每个字段的长度,就是先取出一个字节,看其最高位是否有标志位,如果有继续取下一个字节当字段长度的一部分继续解析,直到遇到某个字节最高位不为1为止。

对一个整形压缩和解压缩的部分从上面的代码中摘录如下:

压缩:

解压

三、关于跨系统与跨语言之间的网络通信协议解析与识别问题

由于我们的即时通讯同时涉及到Java和C++两种编程语言,且有windows、linux、安卓三个平台,而我们为了保障学习的质量和效果,所以我们不用第三跨平台库(其实我们也是在学习如何编写这些跨平台库的原理),所以我们需要学习以下如何在Java语言中去解析C++的网络数据包或者反过来。安卓端发送的数据使用Java语言编写,pc与服务器发送的数据使用C++编写,这里以在Java中解析C++网络数据包为例。 这对于很多人来说是一件很困难的事情,所以只能变着法子使用第三方的库。其实只要你掌握了一定的基础知识,利用一些现成的字节流抓包工具(如tcpdump、wireshark)很容易解决这个问题。我们这里使用tcpdump工具来尝试分析和解决这个问题。

首先,我们需要明确字节序列这样一个概念,即我们说的大端编码(big endian)和小端编码(little endian),x86和x64系列的cpu使用小端编码,而数据在网络上传输,以及Java语言中,使用的是大端编码。那么这是什么意思呢?

我们举个例子,看一个x64机器上的32位数值在内存中的存储方式:

i在内存中的地址序列是0x003CF7C4~0x003CF7C8,值为40 e2 01 00。

十六进制0001e240正好等于10进制123456,也就是说小端编码中权重高的的字节值存储在内存地址高(地址值较大)的位置,权重值低的字节值存储在内存地址低(地址值较小)的位置,也就是所谓的高高低低。

相反,大端编码的规则应该是高低低高,也就是说权值高字节存储在内存地址低的位置,权值低的字节存储在内存地址高的位置。

所以,如果我们一个C++程序的int32值123456不作转换地传给Java程序,那么Java按照大端编码的形式读出来的值是:十六进制40E20100 = 十进制1088553216。

所以,我们要么在发送方将数据转换成网络字节序(大端编码),要么在接收端再进行转换。

下面看一下如果C++端传送一个如下数据结构,Java端该如何解析(由于Java中是没有指针的,也无法操作内存地址,导致很多人无从下手),下面利用tcpdump来解决这个问题的思路。

我们客户端发送的数据包:

其结构体定义如下:

利用tcpdump抓到的包如下:

放大一点:

我们白色标识出来就是我们收到的数据包。这里我想说明两点:

如果我们知道发送端发送的字节流,再比照接收端收到的字节流,我们就能检测数据包的完整性,或者利用这个来排查一些问题;

对于Java程序只要按照这个顺序,先利用java.net.Socket的输出流java.io.DataOutputStream对象readByte、readInt32、readInt32、readBytes、readBytes方法依次读出一个char、int32、int32、16个字节的字节数组、63个字节数组即可,为了还原像int32这样的整形值,我们需要做一些小端编码向大端编码的转换。

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20180606G0UV2E00?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。

扫码关注云+社区

领取腾讯云代金券