29

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

 3 years ago
source link: https://mp.weixin.qq.com/s/Du27A0gwtNcDLQpP20W1ew
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

z2EfMv7.jpg!mobile

一、选择TCP还是UDP协议

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

二、协议的结构

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

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

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

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

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

1struct package_header
2{

3 int32_t bodysize;
4};

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

 1struct package_header
2{

3 int32_t bodysize;
4};
5
6//登录数据包
7struct register_package
8{

9 package_header header;
10 //命令号
11 int32_t cmd;
12 //注册用户名
13 char username[16];
14 //注册密码
15 char password[16];
16 //注册昵称
17 char nickname[16];
18 //注册手机号
19 char mobileno[16];
20};
21
22//登录数据包
23struct login_package
24{

25 package_header header;
26 //命令号
27 int32_t cmd;
28 //登录用户名
29 char username[16];
30 //密码
31 char password[16];
32 //客户端类型
33 int32_t clienttype;
34 //上线类型,如在线、隐身、忙碌、离开等
35 int32_t onlinetype;
36};
37
38//获取好友列表
39struct getfriend_package
40{

41 package_header header;
42 //命令号
43 int32_t cmd;
44};
45
46//聊天内容
47struct chat_package
48{

49 package_header header;
50 //命令号
51 int32_t cmd;
52 //发送人userid
53 int32_t senderid;
54 //接收人userid
55 int32_t targetid;
56 //消息内容
57 char chatcontent[8192];
58};

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

1struct package_protocol
2{

3 int32_t bodysize;
4 //注意:C/C++语法不能这么定义结构体,
5 //这里只是为了说明含义的伪代码
6 //bodycontent即为一个不固定大小的二进制流
7 char binarystream[bodysize];
8};

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

 1    //写
2 class BinaryWriteStream
3 {

4 public:
5 BinaryWriteStream(string* data);
6 const char* GetData() const;
7 size_t GetSize() const;
8 bool WriteCString(const char* str, size_t len);
9 bool WriteString(const string& str);
10 bool WriteDouble(double value, bool isNULL = false);
11 bool WriteInt64(int64_t value, bool isNULL = false);
12 bool WriteInt32(int32_t i, bool isNULL = false);
13 bool WriteShort(short i, bool isNULL = false);
14 bool WriteChar(char c, bool isNULL = false);
15 size_t GetCurrentPos() const{ return m_data->length(); }
16 void Flush();
17 void Clear();
18 private:
19 string* m_data;
20 };
 1    //读
2 class BinaryReadStream : public IReadStream
3 {
4 private:
5 const char* const ptr;
6 const size_t len;
7 const char* cur;
8 BinaryReadStream(const BinaryReadStream&);
9 BinaryReadStream& operator=(const BinaryReadStream&);
10 public:
11 BinaryReadStream(const char* ptr, size_t len);
12 const char* GetData() const;
13 size_t GetSize() const;
14 bool IsEmpty() const;
15 bool ReadString(string* str, size_t maxlen, size_t& outlen);
16 bool ReadCString(char* str, size_t strlen, size_t& len);
17 bool ReadCCString(const char** str, size_t maxlen, size_t& outlen);
18 bool ReadInt32(int32_t& i);
19 bool ReadInt64(int64_t& i);
20 bool ReadShort(short& i);
21 bool ReadChar(char& c);
22 size_t ReadAll(char* szBuffer, size_t iLen) const;
23 bool IsEnd() const;
24 const char* GetCurrent() const{ return cur; }
25 public:
26 bool ReadLength(size_t & len);
27 bool ReadLengthWithoutOffset(size_t &headlen, size_t & outlen);
28 };

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

1std::string outbuf;
2BinaryWriteStream stream(&outbuf);
3stream.WriteInt32(cmd);
4stream.WriteCString(username, 16);
5stream.WriteCString(password, 16);
6stream.WriteInt32(clienttype);
7stream.WriteInt32(onlinetype);
8//最终数据就存储到outbuf中去了
9stream.Flush();

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

 1BinaryWriteStream stream(outbuf.c_str(), outbuf.length());
2int32_t cmd;
3stream.WriteInt32(cmd);
4char username[16];
5stream.ReadCString(username, 16, NULL);
6char password[16];
7stream.WriteCString(password, 16, NULL);
8int32_t clienttype;
9stream.WriteInt32(clienttype);
10int32_t onlinetype;
11stream.WriteInt32(onlinetype);

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

  1    //计算校验和
2 unsigned short checksum(const unsigned short *buffer, int size)
3
{
4 unsigned int cksum = 0;
5 while (size > 1)
6 {
7 cksum += *buffer++;
8 size -= sizeof(unsigned short);
9 }
10 if (size)
11 {
12 cksum += *(unsigned char*)buffer;
13 }
14 //将32位数转换成16
15 while (cksum >> 16)
16 cksum = (cksum >> 16) + (cksum & 0xffff);
17 return (unsigned short)(~cksum);
18 }
19
20 bool compress_(unsigned int i, char *buf, size_t &len)
21
{
22 len = 0;
23 for (int a = 4; a >= 0; a--)
24 {
25 char c;
26 c = i >> (a * 7) & 0x7f;
27 if (c == 0x00 && len == 0)
28 continue;
29 if (a == 0)
30 c &= 0x7f;
31 else
32 c |= 0x80;
33 buf[len] = c;
34 len++;
35 }
36 if (len == 0)
37 {
38 len++;
39 buf[0] = 0;
40 }
41 //cout << "compress:" << i << endl;
42 //cout << "compress len:" << len << endl;
43 return true;
44 }
45
46 bool uncompress_(char *buf, size_t len, unsigned int &i)
47
{
48 i = 0;
49 for (int index = 0; index < (int)len; index++)
50 {
51 char c = *(buf + index);
52 i = i << 7;
53 c &= 0x7f;
54 i |= c;
55 }
56 //cout << "uncompress:" << i << endl;
57 return true;
58 }
59
60 BinaryReadStream::BinaryReadStream(const char* ptr_, size_t len_)
61 : ptr(ptr_), len(len_), cur(ptr_)
62 {
63 cur += BINARY_PACKLEN_LEN_2 + CHECKSUM_LEN;
64 }
65
66 bool BinaryReadStream::IsEmpty() const
67 {
68 return len <= BINARY_PACKLEN_LEN_2;
69 }
70
71 size_t BinaryReadStream::GetSize() const
72 {
73 return len;
74 }
75
76 bool BinaryReadStream::ReadCString(char* str, size_t strlen, /* out */ size_t& outlen)
77 {
78 size_t fieldlen;
79 size_t headlen;
80 if (!ReadLengthWithoutOffset(headlen, fieldlen)) {
81 return false;
82 }
83 // user buffer is not enough
84 if (fieldlen > strlen) {
85 return false;
86 }
87 // 偏移到数据的位置
88 //cur += BINARY_PACKLEN_LEN_2;
89 cur += headlen;
90 if (cur + fieldlen > ptr + len)
91 {
92 outlen = 0;
93 return false;
94 }
95 memcpy(str, cur, fieldlen);
96 outlen = fieldlen;
97 cur += outlen;
98 return true;
99 }
100
101 bool BinaryReadStream::ReadString(string* str, size_t maxlen, size_t& outlen)
102 {
103 size_t headlen;
104 size_t fieldlen;
105 if (!ReadLengthWithoutOffset(headlen, fieldlen)) {
106 return false;
107 }
108 // user buffer is not enough
109 if (maxlen != 0 && fieldlen > maxlen) {
110 return false;
111 }
112 // 偏移到数据的位置
113 //cur += BINARY_PACKLEN_LEN_2;
114 cur += headlen;
115 if (cur + fieldlen > ptr + len)
116 {
117 outlen = 0;
118 return false;
119 }
120 str->assign(cur, fieldlen);
121 outlen = fieldlen;
122 cur += outlen;
123 return true;
124 }
125
126 bool BinaryReadStream::ReadCCString(const char** str, size_t maxlen, size_t& outlen)
127 {
128 size_t headlen;
129 size_t fieldlen;
130 if (!ReadLengthWithoutOffset(headlen, fieldlen)) {
131 return false;
132 }
133 // user buffer is not enough
134 if (maxlen != 0 && fieldlen > maxlen) {
135 return false;
136 }
137 // 偏移到数据的位置
138 //cur += BINARY_PACKLEN_LEN_2;
139 cur += headlen;
140 //memcpy(str, cur, fieldlen);
141 if (cur + fieldlen > ptr + len)
142 {
143 outlen = 0;
144 return false;
145 }
146 *str = cur;
147 outlen = fieldlen;
148 cur += outlen;
149 return true;
150 }
151
152 bool BinaryReadStream::ReadInt32(int32_t& i)
153 {
154 const int VALUE_SIZE = sizeof(int32_t);
155 if (cur + VALUE_SIZE > ptr + len)
156 return false;
157 memcpy(&i, cur, VALUE_SIZE);
158 i = ntohl(i);
159 cur += VALUE_SIZE;
160 return true;
161 }
162
163 bool BinaryReadStream::ReadInt64(int64_t& i)
164 {
165 char int64str[128];
166 size_t length;
167 if (!ReadCString(int64str, 128, length))
168 return false;
169 i = atoll(int64str);
170 return true;
171 }
172
173 bool BinaryReadStream::ReadShort(short& i)
174 {
175 const int VALUE_SIZE = sizeof(short);
176 if (cur + VALUE_SIZE > ptr + len) {
177 return false;
178 }
179 memcpy(&i, cur, VALUE_SIZE);
180 i = ntohs(i);
181 cur += VALUE_SIZE;
182 return true;
183 }
184
185 bool BinaryReadStream::ReadChar(char& c)
186 {
187 const int VALUE_SIZE = sizeof(char);
188 if (cur + VALUE_SIZE > ptr + len) {
189 return false;
190 }
191 memcpy(&c, cur, VALUE_SIZE);
192 cur += VALUE_SIZE;
193 return true;
194 }
195
196 bool BinaryReadStream::ReadLength(size_t & outlen)
197 {
198 size_t headlen;
199 if (!ReadLengthWithoutOffset(headlen, outlen)) {
200 return false;
201 }
202 //cur += BINARY_PACKLEN_LEN_2;
203 cur += headlen;
204 return true;
205 }
206
207 bool BinaryReadStream::ReadLengthWithoutOffset(size_t& headlen, size_t & outlen)
208 {
209 headlen = 0;
210 const char *temp = cur;
211 char buf[5];
212 for (size_t i = 0; i<sizeof(buf); i++)
213 {
214 memcpy(buf + i, temp, sizeof(char));
215 temp++;
216 headlen++;
217 //if ((buf[i] >> 7 | 0x0) == 0x0)
218 if ((buf[i] & 0x80) == 0x00)
219 break;
220 }
221 if (cur + headlen > ptr + len)
222 return false;
223 unsigned int value;
224 uncompress_(buf, headlen, value);
225 outlen = value;
226 /*if ( cur + BINARY_PACKLEN_LEN_2 > ptr + len ) {
227 return false;
228 }
229 unsigned int tmp;
230 memcpy(&tmp, cur, sizeof(tmp));
231 outlen = ntohl(tmp);*/

232 return true;
233 }
234
235 bool BinaryReadStream::IsEnd() const
236 {
237 assert(cur <= ptr + len);
238 return cur == ptr + len;
239 }
240
241 const char* BinaryReadStream::GetData() const
242 {
243 return ptr;
244 }
245
246 size_t BinaryReadStream::ReadAll(char * szBuffer, size_t iLen) const
247 {
248 size_t iRealLen = min(iLen, len);
249 memcpy(szBuffer, ptr, iRealLen);
250 return iRealLen;
251 }
252
253 //=================class BinaryWriteStream implementation============//
254 BinaryWriteStream::BinaryWriteStream(string *data) :
255 m_data(data)
256 {
257 m_data->clear();
258 char str[BINARY_PACKLEN_LEN_2 + CHECKSUM_LEN];
259 m_data->append(str, sizeof(str));
260 }
261
262 bool BinaryWriteStream::WriteCString(const char* str, size_t len)
263 {
264 char buf[5];
265 size_t buflen;
266 compress_(len, buf, buflen);
267 m_data->append(buf, sizeof(char)*buflen);
268 m_data->append(str, len);
269 //unsigned int ulen = htonl(len);
270 //m_data->append((char*)&ulen,sizeof(ulen));
271 //m_data->append(str,len);
272 return true;
273 }
274
275 bool BinaryWriteStream::WriteString(const string& str)
276 {
277 return WriteCString(str.c_str(), str.length());
278 }
279
280 const char* BinaryWriteStream::GetData() const
281 {
282 return m_data->data();
283 }
284
285 size_t BinaryWriteStream::GetSize() const
286 {
287 return m_data->length();
288 }
289
290 bool BinaryWriteStream::WriteInt32(int32_t i, bool isNULL)
291 {
292 int32_t i2 = 999999999;
293 if (isNULL == false)
294 i2 = htonl(i);
295 m_data->append((char*)&i2, sizeof(i2));
296 return true;
297 }
298
299 bool BinaryWriteStream::WriteInt64(int64_t value, bool isNULL)
300 {
301 char int64str[128];
302 if (isNULL == false)
303 {
304 #ifndef _WIN32
305 sprintf(int64str, "%ld", value);
306 #else
307 sprintf(int64str, "%lld", value);
308 #endif
309 WriteCString(int64str, strlen(int64str));
310 }
311 else
312 WriteCString(int64str, 0);
313 return true;
314 }
315
316 bool BinaryWriteStream::WriteShort(short i, bool isNULL)
317 {
318 short i2 = 0;
319 if (isNULL == false)
320 i2 = htons(i);
321 m_data->append((char*)&i2, sizeof(i2));
322 return true;
323 }
324
325 bool BinaryWriteStream::WriteChar(char c, bool isNULL)
326 {
327 char c2 = 0;
328 if (isNULL == false)
329 c2 = c;
330 (*m_data) += c2;
331 return true;
332 }
333
334 bool BinaryWriteStream::WriteDouble(double value, bool isNULL)
335 {
336 char doublestr[128];
337 if (isNULL == false)
338 {
339 sprintf(doublestr, "%f", value);
340 WriteCString(doublestr, strlen(doublestr));
341 }
342 else
343 WriteCString(doublestr, 0);
344 return true;
345 }
346
347 void BinaryWriteStream::Flush()
348 {
349 char *ptr = &(*m_data)[0];
350 unsigned int ulen = htonl(m_data->length());
351 memcpy(ptr, &ulen, sizeof(ulen));
352 }
353
354 void BinaryWriteStream::Clear()
355 {
356 m_data->clear();
357 char str[BINARY_PACKLEN_LEN_2 + CHECKSUM_LEN];
358 m_data->append(str, sizeof(str));
359 }

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

jIvUVfr.jpg!mobile

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

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

压缩:

 1    //将一个四字节的整形数值压缩成1~5个字节
2 bool compress_(unsigned int i, char *buf, size_t &len)
3 {
4 len = 0;
5 for (int a = 4; a >= 0; a--)
6 {
7 char c;
8 c = i >> (a * 7) & 0x7f;
9 if (c == 0x00 && len == 0)
10 continue;
11 if (a == 0)
12 c &= 0x7f;
13 else
14 c |= 0x80;
15 buf[len] = c;
16 len++;
17 }
18 if (len == 0)
19 {
20 len++;
21 buf[0] = 0;
22 }
23 //cout << "compress:" << i << endl;
24 //cout << "compress len:" << len << endl;
25 return true;
26 }

解压

 1    //将一个1~5个字节的值还原成四字节的整形值
2 bool uncompress_(char *buf, size_t len, unsigned int &i)
3
{
4 i = 0;
5 for (int index = 0; index < (int)len; index++)
6 {
7 char c = *(buf + index);
8 i = i << 7;
9 c &= 0x7f;
10 i |= c;
11 }
12 //cout << "uncompress:" << i << endl;
13 return true;
14 }

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

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

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

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

iqUJFbM.png!mobile

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

iAJrQfa.png!mobile

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

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

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

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

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

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

yiuQF3V.jpg!mobile

其结构体定义如下:

e2q6nef.jpg!mobile

利用tcpdump抓到的包如下:

UnEBFvB.jpg!mobile

放大一点:

77zuQrV.jpg!mobile

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

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK