WebSocket协议实操

8/30/2022 2:02:30 PM
1013
0

我们知道WebSocket为了兼容HTTP协议,是在HTTP协议的基础之上进行升级得到的。在客户端和服务器端建立HTTP连接之后,客户端会向服务器端发送一个升级到webSocket的协议。首先,WebSocket连接必须由浏览器发起,我们来看看WebSocket连接是如何创建的。

在 JavaScript 中,WebSocket 构造函数有两种重载形式:

var socket = new WebSocket('ws://example.com');
var socket = new WebSocket('ws://example.com', 'chat');

在以上两种形式中,url 参数表示要连接的 WebSocket 服务器的 URL 地址。protocols 参数是可选的,用于指定子协议。如果指定了子协议,服务器会根据客户端请求的子协议选择是否接受连接。 需要注意的是,浏览器支持的子协议数量和类型可能有限制,具体取决于浏览器的实现。

请求协议是一个标准的HTTP请求,格式如下:

GET ws://localhost:3000/ws/chat HTTP/1.1
Host: localhost
Upgrade: websocket
Connection: Upgrade
Origin: http://localhost:3000
Sec-WebSocket-Key: client-random-string
Sec-WebSocket-Version: 13

ps:Websocket链接: 首先浏览器与服务器建立了TCP链接,在浏览器与服务器建立链接的时候是不发送数据的,只是发送三次握手所需要的协议头数据,不带有负载数据。浏览器要在第一次发送数据时候要发送上文格式的数据。

注意,这里的HTTP版本必须是1.1以上。HTTP的请求方法必须是GET

通过设置Upgrade和Connection这两个header,表示我们准备升级到webSocket了。除了这里列的属性之外,其他的HTTP自带的header属性都是可以接受的。

这里还有两个比较特别的header,他们是Sec-WebSocket-Version和Sec-WebSocket-Key。

先看一下Sec-WebSocket-Version, 它表示的是客户端请求的WebSocket的版本号。如果服务器端并不明白客户端发送的请求,则会返回一个400 ("Bad Request"),在这个返回中,服务器端会返回失败的信息。

如果是不懂客户端发送的Sec-WebSocket-Version,服务器端同样会将Sec-WebSocket-Version返回,以告知客户端。

Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13

首先, Sec-WebSocket-Key 是一个 Base64 encode 的值,这个是浏览器随机生成的

然后, Sec_WebSocket-Protocol 是一个用户定义的字符串,用来区分同URL下,不同的服务所需要的协议。简单理解:今晚我要服务A,别搞错啦~

最后, Sec-WebSocket-Version 是告诉服务器所使用的 Websocket Draft (协议版本),在最初的时候,Websocket协议还在 Draft 阶段,各种奇奇怪怪的协议都有,而且还有很多期奇奇怪怪不同的东西,什么Firefox和Chrome用的不是一个版本之类的

 

该请求和普通的HTTP请求有几点不同:

  1. GET请求的地址不是类似/path/,而是以ws://开头的地址;
  2. 请求头Upgrade: websocket和Connection: Upgrade表示这个连接将要被转换为WebSocket连接;
  3. Sec-WebSocket-Key是用于标识这个连接,并非用于加密数据;
  4. Sec-WebSocket-Version指定了WebSocket的协议版本。

随后,服务器如果接受该请求,就会返回如下响应:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: server-random-string
Sec-WebSocket-Protocol: chat

该响应代码101表示本次连接的HTTP协议即将被更改,更改后的协议就是Upgrade: websocket指定的WebSocket协议。

这里的Sec-WebSocket-Accept是根据客户端请求中的Sec-WebSocket-Key来生成的。具体而言是将客户端发送的Sec-WebSocket-Key 和 字符串"258EAFA5-E914-47DA-95CA-C5AB0DC85B11" 进行连接。然后使用SHA1算法求得其hash值。

最后将hash值进行base64编码即可。

当服务器端返回Sec-WebSocket-Accept之后,客户端可以对其进行校验,已完成整个握手过程。

webSocket的消息格式

之所以要使用webSocket是因为client和server可以随时随地发送消息。这是websocket的神奇所在。那么发送的消息是什么格式的呢?我们来详细看一下。

client和server端进行沟通的消息是以一个个的frame的形式来传输的。frame的格式如下:


      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 ...                |
     +---------------------------------------------------------------+

头部解析

针对前面的格式概览图,这里逐个字段进行讲解,如有不清楚之处,可参考协议规范,或留言交流。

FIN:1个比特。

如果是1,表示这是消息(message)的最后一个分片(fragment),如果是0,表示不是是消息(message)的最后一个分片(fragment)。

RSV1, RSV2, RSV3:各占1个比特。

一般情况下全为0。当客户端、服务端协商采用WebSocket扩展时,这三个标志位可以非0,且值的含义由扩展进行定义。如果出现非零的值,且并没有采用WebSocket扩展,连接出错。

Opcode: 4个比特。

操作代码,Opcode的值决定了应该如何解析后续的数据载荷(data payload)。如果操作代码是不认识的,那么接收端应该断开连接(fail the connection)。可选的操作代码如下:

%x0:表示一个延续帧。当Opcode为0时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片。
%x1:表示这是一个文本帧(frame)
%x2:表示这是一个二进制帧(frame)
%x3-7:保留的操作代码,用于后续定义的非控制帧。
%x8:表示连接断开。
%x9:表示这是一个ping操作。
%xA:表示这是一个pong操作。
%xB-F:保留的操作代码,用于后续定义的控制帧。

Mask: 1个比特。

表示是否要对数据载荷进行掩码操作。从客户端向服务端发送数据时,需要对数据进行掩码操作;从服务端向客户端发送数据时,不需要对数据进行掩码操作。

如果服务端接收到的数据没有进行过掩码操作,服务端需要断开连接。

如果Mask是1,那么在Masking-key中会定义一个掩码键(masking key),并用这个掩码键来对数据载荷进行反掩码。所有客户端发送到服务端的数据帧,Mask都是1。
掩码的算法、用途在下一小节讲解。

Payload length:

数据载荷的长度,单位是字节。为7位,或7+16位,或1+64位。

假设数Payload length === x,如果

x为0~126:数据的长度为x字节。
x为126:后续2个字节代表一个16位的无符号整数,该无符号整数的值为数据的长度。
x为127:后续8个字节代表一个64位的无符号整数(最高位为0),该无符号整数的值为数据的长度。
此外,如果payload length占用了多个字节的话,payload length的二进制表达采用网络序(big endian,重要的位在前)。

Masking-key:0或4字节(32位)

所有从客户端传送到服务端的数据帧,数据载荷都进行了掩码操作,Mask为1,且携带了4字节的Masking-key。如果Mask为0,则没有Masking-key。

如何掩码: 

  1. 对负载数据字节的的索引  i mode 4 = j (j 的值为0/1/2/3),然后从Masking-key中取出对应索引J的字节:掩码键
  2. 将步骤1得到 掩码键 与 负载数据索引 i 的字节进行进行异或(XOR)操作

服务器解码:基于异或操作的特性,结果与原始操作数进行XOR操作将得到另一个原始操作数。所以解码与掩码的执行过程完全相同。

下面是一个C#的解码过程,同样适用于掩码

public static byte[] DecodePayload(byte[] payload, byte[] mask)
{
    byte[] decodedPayload = new byte[payload.Length];
    for (int i = 0; i < payload.Length; i++)
    {
        decodedPayload[i] = (byte)(payload[i] ^ mask[i % 4]);
    }
    return decodedPayload;
}

 

备注:载荷数据的长度,不包括mask key的长度。

Payload data:(x+y) 字节

载荷数据:包括了扩展数据、应用数据。其中,扩展数据x字节,应用数据y字节。

扩展数据:如果没有协商使用扩展的话,扩展数据数据为0字节。所有的扩展都必须声明扩展数据的长度,或者可以如何计算出扩展数据的长度。此外,扩展如何使用必须在握手阶段就协商好。如果扩展数据存在,那么载荷数据长度必须将扩展数据的长度包含在内。

应用数据:任意的应用数据,在扩展数据之后(如果存在扩展数据),占据了数据帧剩余的位置。载荷数据长度 减去 扩展数据长度,就得到应用数据的长度。

 

服务器解码步骤

服务器在解码WebSocket数据时,需要按照WebSocket协议的规定进行解码操作。

WebSocket数据帧的解码过程如下:

  1. 首先,读取第一个字节,该字节包含了一些控制位和数据长度信息。
  2. 根据第一个字节的控制位,判断数据是否被掩码处理。如果掩码位为1,则数据被掩码处理,需要进行解码操作。
  3. 读取第二个字节,该字节包含了数据长度的一些信息。根据数据长度的不同情况,分别进行处理。
    • 如果长度值在0-125之间,则表示数据长度为长度值本身。
    • 如果长度值为126,则接下来的2个字节表示数据的真实长度。
    • 如果长度值为127,则接下来的8个字节表示数据的真实长度。
  4. 根据上一步得到的数据长度,读取相应长度的数据。
  5. 如果数据被掩码处理,需要根据掩码值对读取到的数据进行解码操作。
    • 对于每个字节,与对应位置的掩码字节进行异或操作,得到解码后的字节。
  6. 解码后的数据即为服务器解析到的真实数据。

在C#中对 payload解析案例如下


  /// <summary>
        /// 读取数据,websocket也需要处理分包和粘包
        /// </summary>
        /// <param name="buffer">socket缓冲区</param>
        /// <param name="offset">偏移量</param>
        /// <param name="count">数据包可用长度</param>
        /// <returns>处理是否成功</returns>
        public override bool ProcessReceive(byte[] buffer, int offset, int count)
        {
            this.ActiveDateTime = DateTime.Now;
            try
            {
                //第一个字节最低四位 用来表示 消息类型的,不同的语言,这四位的值可能不一样
                //&00001111,取出第一个字节后面四位
                //websocket 第一个字节后面四位决定了应该如何解析后续的数据载荷(data payload)
                /*
                    %x0:表示一个延续帧。当Opcode为0时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片。
                    %x1:表示这是一个文本帧(frame)
                    %x2:表示这是一个二进制帧(frame)
                    %x3-7:保留的操作代码,用于后续定义的非控制帧。
                    %x8:表示连接断开。
                    %x9:表示这是一个ping操作。
                    %xA:表示这是一个pong操作。
                    %xB-F:保留的操作代码,用于后续定义的控制帧。
                 */

                var pac_type = (buffer[0] & 0xF);              //得到消息类型  
                if (pac_type == 1)    //websocket文本协议
                {

                    string resqust = string.Empty;

                    bool result = AnalyticData(buffer, count, out resqust);
                    if (!result) return false;
                    //收到消息调用相关委托(通知客户端管理程序)

                    _asyncClientManager.On_ReceiveHandle(this.AsyncSocketUserToken, new ReceiveEventArgs()
                    {
                        Message = resqust,
                        EndPrint = AsyncSocketUserToken.ConnectSocket.RemoteEndPoint.AddressFamily.ToString()
                    });
                    //暂时把消息返回就好
                    {
                        _outgoingDataAssembler.Clear();
                        _outgoingDataAssembler.AddMessage(resqust);
                        DoSendResult();
                    }

                    //交给命令解析组件解析,解析完毕交给业务组件,最后业务组件应答客户端
                    //string command = AnalyzeCommon(resqust);
                    //if (!string.IsNullOrWhiteSpace(command))
                    //{
                    //    TelnetClientManager.SendData(AsyncSocketUserToken.ConnectSocket, this.Encode.GetBytes(command));
                    //}
                }
                else if (pac_type == 2)
                {
                    //消息的数据类型为二进制的
                }
                else if (pac_type == 7)//http协议通过GET访问
                {
                    string packetStr = Encode.GetString(buffer);

                    if (Regex.Match(packetStr.ToLower(), "upgrade: websocket").Value != "")//websocket握手协议
                    {
                        string endHandShake = GetSecKeyAccetp(packetStr, count);
                        byte[] data = PackHandShakeData(endHandShake);       //握手,打包握手信息

                        _asyncClientManager.SendAsyncEvent(this.AsyncSocketUserToken, data, 0, data.Length);           //应答握手包 
                        _asyncClientManager.On_ReceiveHandle(this.AsyncSocketUserToken, new ReceiveEventArgs()
                        {
                            Message = "握手消息",
                            EndPrint = AsyncSocketUserToken.ConnectSocket.RemoteEndPoint.AddressFamily.ToString()
                        });

                        return true;

                    }
                    else//其他http的GET请求
                    {
                        return false;
                    }
                }
                else if (pac_type == 8)//websocket退出协议
                {

                    _asyncClientManager.On_ReceiveHandle(this.AsyncSocketUserToken, new ReceiveEventArgs()
                    {
                        Message = "断开连接",
                        EndPrint = AsyncSocketUserToken.ConnectSocket.RemoteEndPoint.AddressFamily.ToString()
                    });
                    _asyncClientManager.CloseClientSocket(AsyncSocketUserToken);
                    return false;


                }
                else if (pac_type == 9)//firefox固有的websocket PING协议     需应答任意字符,否则浏览器会自动下发断开连接
                {
                    _asyncClientManager.SendAsyncEvent(this.AsyncSocketUserToken, this.Encode.GetBytes("1"), 0, 1);

                }
                else if (pac_type == 10)//IE10以上固有的websocket PONG协议
                {
                    return true;
                }
                else if (pac_type == 12)
                {
                    if (buffer.Length == 23)//flash请求策略文件(特有:flash建立socket前需策略文件)
                    {
                        byte[] data = this.Encode.GetBytes("<cross-domain-policy><allow-access-from domain=\"*\" to-ports=\"*\" /></cross-domain-policy>\0");
                        _asyncClientManager.SendAsyncEvent(AsyncSocketUserToken, data, 0, data.Length);
                    }
                }

                return true;
            }
            catch (Exception ex)
            {
                throw;
            }

        }





        /// <summary>
        /// 解析客户端数据包
        /// </summary>
        /// <param name="recBytes">服务器接收的数据包</param>
        /// <param name="recByteLength">有效数据长度</param>
        /// <returns></returns>
        public bool AnalyticData(byte[] recBytes, int recByteLength, out string dataMessage)
        {


            dataMessage = string.Empty;


            DynamicBufferManager receiveBuffer = AsyncSocketUserToken.ReceiveBuffer;

            if (recByteLength < 2) { return true; }  //websocket 协议 头部最少两个字节来确定消息

            //第一个字节: 最高位如果是1表示该消息,是消息的尾部,如果是0表示后续还有数据包
            //最低四位 用来表示 消息类型的,不同的语言,这四位的值可能不一样
            bool fin = (recBytes[0] & 0x80) == 0x80; // 1bit,1表示最后一帧    &10000000 ,取出第一位
            if (!fin)
            {
                return true;// 超过一帧暂不处理 
            }

            //第二个字节: 最高位 0/1表示 后面是否跟着4个字节的掩码
            //剩下的7位如果值<=125 表示 后面的数据大小 就是该七位的大小
            //剩下的7位如果值=126  表示后面跟着2个字节的,这两个字节 用来存数据的大小字节
            //剩下的7位如果值=127  表示后面跟着8个字节的,这八个字节 用来存数据的大小字节
            bool mask_flag = (recBytes[1] & 0x80) == 0x80; // 是否包含掩码  
            if (!mask_flag)
            {
                return false;// 不包含掩码的暂不处理
            }
            int payload_len = recBytes[1] & 0x7F; // 数据长度   &0111 1111,取出剩余7位的值进行计算

            byte[] masks = new byte[4];
            byte[] payload_data;

            if (payload_len == 126) //剩下的7位如果值 = 126  表示后面跟着2个字节的,这两个字节 用来存数据的大小字节
            {
                Array.Copy(recBytes, 4, masks, 0, 4);
                payload_len = (UInt16)(recBytes[2] << 8 | recBytes[3]);
                payload_data = new byte[payload_len];


                Array.Copy(recBytes, 8, payload_data, 0, payload_len);

            }
            else if (payload_len == 127)   //剩下的7位如果值=127  表示后面跟着8个字节的,这八个字节 用来存数据的大小字节
            {

                Array.Copy(recBytes, 10, masks, 0, 4);
                byte[] uInt64Bytes = new byte[8];
                for (int i = 0; i < 8; i++)
                {
                    uInt64Bytes[i] = recBytes[9 - i];
                }
                UInt64 len = BitConverter.ToUInt64(uInt64Bytes, 0);

                payload_data = new byte[len];
                for (UInt64 i = 0; i < len; i++)
                {
                    payload_data[i] = recBytes[i + 14];
                }
            }
            else //剩下的7位如果值 <= 125 表示 后面的数据大小 就是该七位的大小
            {
                Array.Copy(recBytes, 2, masks, 0, 4);
                payload_data = new byte[payload_len];
                Array.Copy(recBytes, 6, payload_data, 0, payload_len);
            }

            for (var i = 0; i < payload_len; i++)
            {
                payload_data[i] = (byte)(payload_data[i] ^ masks[i % 4]);       //进行反掩码
            }

            receiveBuffer.WriteBuffer(payload_data, 0, payload_len);   //写入如数据

            if (fin)
            {
                if ((receiveBuffer.DataCount > 10 * 1024 * 1024)) //最大Buffer异常保护
                {
                    return false;
                }
                byte[] data = new byte[receiveBuffer.DataCount];
                Array.Copy(receiveBuffer.Buffer, 0, data, 0, receiveBuffer.DataCount);
                receiveBuffer.Clear(receiveBuffer.DataCount); //从缓存中清理
                //最后一帧,
                dataMessage = Encoding.UTF8.GetString(data);
            }

            return true;
        }

FIN和Opcode 组合起来正好一个字节的长度

 FIN可以和opcode一起配合使用,用来发送长消息。

FIN=1表示,是最后一个消息。 0x1表示是text消息,0x2是0,表示是二净值消息,0x0表示消息还没有结束,所以0x0通常和FIN=0 一起使用。

上文说到,服务器发送数据给socket client时候不需要掩码,因此发送数据封包则简单的多。下面是服务器发送数据的封包处理


        /// <summary>
        /// 打包服务器数据(应答数据打包)
        /// </summary>
        /// <param name="message">数据</param>
        /// <returns>数据包</returns>
        private static byte[] PackData(string message)
        {
            /*
             *  第一个字节可以是 10000001    FIN,RSV1, RSV2, RSV3,Opcode(4bit)     
             *  0x81=10000001      websocket协议的数据头, FIN=1 表示这是消息(message)的最后一个分片, 最后1是opcode位
                    %x0:表示一个延续帧。当Opcode为0时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片。
                    %x1:表示这是一个文本帧(frame)
                    %x2:表示这是一个二进制帧(frame)
                    %x3-7:保留的操作代码,用于后续定义的非控制帧。
                    %x8:表示连接断开。
                    %x9:表示这是一个ping操作。
                    %xA:表示这是一个pong操作。
                    %xB-F:保留的操作代码,用于后续定义的控制帧。
  
            
             *第二个字节 Mask+Payload len   Mask: 0/1 表示是否包含掩码,Payload len: 7位,表示数据长度,如果值<=125 表示 后面的数据大小 就是该七位的大小  if (payload_len <= 125)  emp.Length<126
             *第三个字节第四个字节 如果第二个字节的值=126  表示后面跟着2个字节的,这两个字节 用来存数据的大小字节     if (payload_len == 126)   temp.Length<=Max(int32)
             *第如果第二个字节的值=127  表示后面跟着8个字节的,这八个字节 用来存数据的大小字节 if (payload_len == 127)     emp.Length>Max(int32)
              服务器发送的数据帧不需要掩码,所以第二个字节的值应该是 0 +  数据长度  01111111 
             */

            byte[] contentBytes = null;
            byte[] temp = Encoding.UTF8.GetBytes(message);

            if (temp.Length < 126)
            {
                contentBytes = new byte[temp.Length + 2];
                contentBytes[0] = 0x81;
                contentBytes[1] = (byte)temp.Length;
                Array.Copy(temp, 0, contentBytes, 2, temp.Length);
            }
            else if (temp.Length < 0xFFFF)
            {
                contentBytes = new byte[temp.Length + 4];
                contentBytes[0] = 0x81;
                contentBytes[1] = 126;
                contentBytes[2] = (byte)(temp.Length >> 8);
                contentBytes[3] = (byte)(temp.Length & 0xFF);

                Array.Copy(temp, 0, contentBytes, 4, temp.Length);
            }
            else
            {
                // 超长内容  
                contentBytes = new byte[temp.Length + 10];
                contentBytes[0] = 0x81;
                contentBytes[1] = 127;
                contentBytes[2] = (byte)((temp.Length >> 56) & 0xFF);
                contentBytes[3] = (byte)((temp.Length >> 48) & 0xFF);
                contentBytes[4] = (byte)((temp.Length >> 40) & 0xFF);
                contentBytes[5] = (byte)((temp.Length >> 32) & 0xFF);
                contentBytes[6] = (byte)((temp.Length >> 24) & 0xFF);
                contentBytes[7] = (byte)((temp.Length >> 16) & 0xFF);
                contentBytes[8] = (byte)((temp.Length >> 8) & 0xFF);
                contentBytes[9] = (byte)(temp.Length & 0xFF);
                Array.Copy(temp, 0, contentBytes, 10, temp.Length);

            }

            return contentBytes;
        }

Extensions和Subprotocols

在客户端和服务器端进行握手的过程中,在标准的websocket协议基础之上,客户端还可以发送Extensions或者Subprotocols。这两个有什么区别呢?

首先这两个都是通过HTTP头来设置的。但是两者还是有很大的不同。Extensions可以对WebSocket进行控制,并且修改payload,而subprotocols只是定义了payload的结构,并不会对其进行修改。

Extensions是可选的,而Subprotocols是必须的。

你可以将Extensions看做是数据压缩,它是在webSocket的基础之上,对数据进行压缩或者优化操作,可以让发送的消息更短。

而Subprotocols 表示的是消息的格式,比如使用soap或者wamp。

子协议是在WebSocket协议基础上发展出来的协议,主要用于具体的场景的处理,它是是在WebSocket协议之上,建立的更加严格的规范。

比如,客户端请求服务器时候,会将对应的协议放在Sec-WebSocket-Protocol头中:

GET /socket HTTP/1.1
...
Sec-WebSocket-Protocol: soap, wamp

服务器端会根据支持的类型,做对应的返回,如:

Sec-WebSocket-Protocol: soap

 

全部评论



提问