首页 > 代码库 > 游戏网络编程(三)——WebSocket入门及实现自己的WebSocket协议
游戏网络编程(三)——WebSocket入门及实现自己的WebSocket协议
(一)WebSocket简介
短连接:在传统的Http协议中,客户端和服务器端的通信方式是短连接的方式,也就是服务器端并不会保持一个和客户端的连接,在消息发送后,会断开这个连接,客户端下次通信时,必须再建立和服务器的新连接,这就是短连接。在短链接的情况下,客户端必须不停的主动发起请求,而服务器始终被动的响应请求,来推送回数据。这种方式用到游戏开发中,显然是不适合的。
长连接:那么与之相对的就是长连接了。在长连接的情况下,客户端和服务器端始终保持一条有效的连接,那么客户端并不需要不停的主动发送消息,而服务器端也能主动的推送消息到客户端。很类似前面介绍的Socket的收发方式。那么显然长连接是我们游戏网络开发所需要的。
WebSocket:正是有了这样的需求,所以产生了WebSocket这一协议。注意,WebSocket只是一种协议,并不是一种Socket。WebSocket可以在客户端和服务器端建立一种全双工的通信连接。其协议是基于Tcp的方式实现的。
(二)WebSocket基础知识
1.握手
WebSocket其实就是使用Tcp建立连接,那么当终端建立连接时,怎么才能知道是一般的Tcp方式还是WebSocket协议方式呢?这里就需要靠握手,简单的说,通过握手机制,终端就能判别建立的是什么样的连接,从而决定是以WebSocket方式来处理还是Tcp方式来处理消息。
如果我们是自己实现服务器端,其实我们在收包的时候,就是一般的Tcp Socket的收包,并没有什么不同,该怎么处理还是怎么处理。但对于客户端就不一样了。因为大部分情况,客户端是使用现有的浏览器来作为客户端代码的JS运行环境的(除非你连客户端浏览器环境也是自己实现)。现有浏览器必须明确的知道协议类型,才能正确的建立长连接,并处理WebSocket包,并使用相关的JS代码,所以握手就变的及其重要了。
当实现我们自己的服务器时,建立握手的意义在于正确的通知客户端,服务器可以接收并允许建立一条基于WebSocket协议的连接
握手请求类似于下面这样的一段信息,不同的浏览器可能不一样,因为不同的浏览器遵循的WebSocket协议版本可能并不一致。
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,/;q=0.8
Accept-Language: zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Sec-WebSocket-Version: 13
Origin: http://localhost:5754
Sec-WebSocket-Extensions: permessage-deflate
Sec-WebSocket-Key: DC8b7Irs1RsyDvP2iEdsUQ==
Connection: keep-alive, Upgrade
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket
对于上面的内容,其实我们没必要知道太多,其中关键的是“Sec-WebSocket-Key”中的内容。稍后将做解释,我们先看服务器应该如何响应这样的握手。当服务器决定接收这个WebSocket连接时,服务器必须回发一段有效的Http
response消息给客户端。这个很重要,因为只有发送正确的响应,客户端浏览器才能确认WebSocket请求被接收,才能正确的建立起WebSocket连接(其实说白了就是因为浏览器不是我们自己开发,假设你有那闲工夫,自己开发整个浏览器和WebSocket环境,握手协议想怎么定是你自己的事,否则就要遵循标准)。
正确的服务器返回响应如下:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
同样,不必过于关注具体内容,前面三行照抄就行了。我们只需要关注两个地方,一个是换行,一个是Sec-WebSocket-Accept
换行:上述消息中,前三行后必须跟一个换行符,最后一行后则要跟两个换行符
Sec-WebSocket-Accept*:这个值是一个经过加密处理的字符串,客户端将验证该值来判断是否成功建立WebSocket连接,因为这个值的正确与否相当重要。对该值的计算方法是,将发来请求时的Sec-WebSocket-Key与GUID值“258EAFA5-E914-47DA-95CA-C5AB0DC85B11”连接,然后将新字符串进行SHA1加密,将加密结果进行Base64的编码转换得到。(要注意的时,连接用的GUID值就是黑体标粗的这个值,时固定的,我第一次看文档,还以为这个值只是个举例,后来才发现原来是个常量字符串)
Tips:
处理握手协议时,除了以上两点需要注意外,还有字符编码格式也会影响建立连接的成功性。所以最好换行符使用Environment.NewLine,而不要使用”\r\n”。另外生成的响应消息字符串,最好使用Encoding.UTF8编码,否则很容易因为编码问题,导致客户端无法识别,造成连接建立不成功。
附上生成加密key值和生成响应返回消息的代码
private static byte[] PackHandShakeData(string secKeyAccept)
{
var responseBuilder = new StringBuilder();
responseBuilder.Append("HTTP/1.1 101 Switching Protocols" + Environment.NewLine);
responseBuilder.Append("Upgrade: websocket" + Environment.NewLine);
responseBuilder.Append("Connection: Upgrade" + Environment.NewLine);
responseBuilder.Append("Sec-WebSocket-Accept: " + secKeyAccept + Environment.NewLine + Environment.NewLine);
return Encoding.UTF8.GetBytes(responseBuilder.ToString());
}
private static string GetSecKeyAccetp(byte[] handShakeBytes, int bytesLength)
{
string handShakeText = Encoding.UTF8.GetString(handShakeBytes, 0, bytesLength);
string key = string.Empty;
var heads = handShakeText.Split("\n".ToCharArray());
foreach (var head in heads)
{
if (head.Contains("Sec-WebSocket-Key:"))
{
key = head;
key = head.Replace("Sec-WebSocket-Key:", "").Trim();
}
}
}
sc.Send(PackHandShakeData(GetSecKeyAccetp(buffer, length)));
2.帧数据
因为是基于Tcp Socket实现的,所以WebSocket实际的数据传输也是以流的方式传输。和Tcp一样,WebSocket有自己的传输帧格式。在这个格式中,WebSocket定义了消息字节流开始部分的字节的用途及含义。下面我们可以看示意图
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
byte1
(1)Fin代表数据是否结束,WebSocket会把较大的数据分成片发送,最后一片数据的Fin为1,代表数据片完结
(2)RSV1-RSV3是保留为,一般为0
(3)最后4bit代表Opcode,OpCode用来指示数据帧的类型。WebSocket的帧分为两大类型,数据帧和控制帧。
0x0 代表连续帧,也就因为这该帧数据是分片数据中的一片,并不是完整数据
0x1 代表数据是文本内容
0x2 代表数据时二进制流
0x3-0x7 保留给日后的非控制帧使用
0x8 代表该数据时一个关闭命令通知(下面会解释关闭)
0x9 代表Ping帧(同样下面会解释Ping)
0xA 代表Pong帧
0xB-0xF 保留给日后的控制帧使用
byte2
(1)Mask代表发来的帧中的数据,是否经过掩码处理,1为true,0为false,一般在客户端发给服务器端的数据中,该值都是1,也就是经过掩码处理,服务器发往客户端的不用掩码。(注意,所谓的客户端,服务端是相对的,接收WebSocket连接的那一端,也就是上面提到的回发加密处理的那一端是服务器端。这也解释了,为什么我们要遵循WebSocket标准来进行握手,否则客户端怎么知道自己发的数据得要掩码处理呢)
(2)后面7位代表数据帧的数据长度或者是一个长度指示。我自己理解为是一个长度预判。当数据长度不超过125字节时,该值就是实际的数据长度,当长度在126~65535时,该值为固定的126,超过65535,该值固定为127
byte3~byte4
当Payload len = 126时,保存的是该帧数据的16位真实长度
byte3~byte10
当Payload len = 127时,保存的是该帧数据64位的真实长度
注意,如果长度不超过125,那么byte3~byte10就不代表数据长度了,也就是说不会预留给数据长度用,而是给后续的帧头信息使用,后续帧头的字节信息左移
byte11~byte14
这4个字节代表掩码值,用客户端指定,每个包都不一样,只有经过掩码值的解码处理,才能获得正确的数据
由此可以看到,WebSocket的消息封包,服务器端至少需要2个字节,客户端至少6个字节
后续的字节就是实际发送的数据字节流了,下面是对数据帧解析的示例代码
bool close = (buffer[0] & 0x08) == 0x08;
//暂时不处理,服务器端暂时只接收ping,不作服务器端主动发ping的考虑
bool ping = (buffer[0] & 0x09) == 0x09;
bool pong = (buffer[0] & 0x0A) == 0x0A;
bool fin = (buffer[0] & 0x80) == 0x80; // 1bit,1表示最后一帧
bool mask_flag = (buffer[1] & 0x80) == 0x80; // 是否包含掩码
...
//足够读取分隔符
string data = http://www.mamicode.com/null;"hljs-typename">int payload_len = buffer[1] & 0x7F; // 数据长度
byte[] masks = new byte[4];
byte[] payload_data;
if (payload_len == 126)
{
Array.Copy(buffer, 4, masks, 0, 4);
payload_len = (UInt16)(buffer[2] << 8 | buffer[3]);
payload_data = http://www.mamicode.com/new byte[payload_len];
Array.Copy(buffer, 8, payload_data, 0, payload_len);
}
else if (payload_len == 127)
{
Array.Copy(buffer, 10, masks, 0, 4);
byte[] uInt64Bytes = new byte[8];
for (int i = 0; i < 8; i++)
{
uInt64Bytes[i] = buffer[9 - i];
}
UInt64 len = BitConverter.ToUInt64(uInt64Bytes, 0);
payload_data = http://www.mamicode.com/new byte[len];
for (UInt64 i = 0; i < len; i++)
{
payload_data[i] = buffer[i + 14];
}
}
else
{
Array.Copy(buffer, 2, masks, 0, 4);
payload_data = http://www.mamicode.com/new byte[payload_len];
Array.Copy(buffer, 6, payload_data, 0, payload_len);
}
for (var i = 0; i < payload_len; i++)
{
payload_data[i] = (byte)(payload_data[i] ^ masks[i % 4]);
}
3.关闭连接
有握手,那么当然就讲关闭了,网上很多教程往往只说明了建立握手,但是对于关闭WebSocket连接去只字未提。WebSocket的关闭,在实际操作中经常遇到的有三种情况,一种是浏览器的关闭,一种是我们js代码主动关闭,还有一种是浏览器刷新(没错,刷新,我一开始没注意这个问题)。而无论哪种方式,对于WebSocket来说,它必须发一个关闭的控制帧数据到对端。也就是上面提到的Opcode必须为0x8。
在发送了一个关闭的控制帧后,应用就不应该继续发送数据,而对端在收到一个关闭控制帧后,也必须尽快发送一个关闭帧回应。(这里所谓尽快,其实是可控的,并不是立刻,你可以等到你的收发结束后,才立刻发送一个关闭回应)。发送关闭帧后的端,将不再处理收到的数据。
关闭帧可能会包含数据,如果其包含数据,那么前两个字节一定是一个无符号整型所代表的状态码,代表了发生关闭的原因
4.Ping/Pong
WebSocket基于Tcp,同时它也改进了Tcp的一些实现特性。比如WebSocket自带Ping/Pong,以此来实现其保持长连接的特性。使用Tcp时,我们往往要自己实现心跳,但WebSocket的Ping/Pong则完全替我们实现了心跳。不过很讽刺的是,虽然其WebSocket标准明确的实现了Ping/Pong但是现在各浏览器,或是WebSocket库,并没有提供发送Ping/Pong的API,也就是你如果不是自己实现WebSocket的协议的话,这Ping/Pong根本是没法发的。
但目前的浏览器或者JS库,虽然不同供发Ping的API,但它们可以接收Ping处理,并回发Pong数据。所以在我的项目里,由于我们自己实现WebSocket的服务器端协议,所以自己实现发Ping数据,然后处理浏览器返回的Pong数据来检测了心跳。
另外,当一端收到多次ping时,并不需要返回每一个响应,只要返回最近一次Ping的Pong响应即可
(三)WebSocket理解误区
1.分包,粘包,连包,半包
网上很多资料都说WebSocket不会粘包,半包。OK,这是正确的,因为上述将数据帧的时候我们已经看到WebSocket会将大的数据,自动分片发送。所以WebSocket会自动分包发送,因为这种分包发送,WebSocket的数据不会溢出接收缓冲区,所以也不会有半包的情况发送。
但是关于粘包,和连包,我看到一部分资料都说不会。因为WebSocket具有帧头信息,所以不会粘包?这是不完全正确的,要知道Tcp的报文也是具有包头信息的,只不过Socket已经处理了。而且经过我对我们项目服务器实际压力测试,发现WebSocket会粘包,连包。不同的是,WebSocket的数据中拥有包头信息,但Tcp没有(实际开发中,我们自己一定会加个包头来分割封包的,WebSocket只是替我们设计了一个包头而已),但对这个包头分割的处理,还是要我们自己完成,WebSocket不会代劳,如果我们自己不处理,抱歉,妥妥的粘包,连包
以上就是对WebSocket的一些简单的理解心得和解释,详细的内容,大家可以去官网下载标准的文档看,不过要注意一定要下最新的,我一开始下的是06版本,结果怎么弄都发现控制帧的数据代码不对。
个人理解观点,如有错误,欢迎讨论指正
游戏网络编程(三)——WebSocket入门及实现自己的WebSocket协议