分享

WebSocket原理与实践

 waston 2019-05-10

(一) 基本原理

1. 了解现有的HTTP的架构模式:
Http是客户端/服务器模式中请求-响应所用的协议,在这种模式中,客户端(一般是web浏览器)向服务器提交HTTP请求,服务器响应请求的资源
(例如HTML页面)和关于页面的附加信息。

1-1. HTTP的特点是:
HTTP是半双工协议,也就是说,在同一时刻流量只能单向流动,客户端向服务器发送请求(单向的),然后服务器响应请求(单向的)。服务器不能主动推送数据给浏览器。

当初这么设计也是有原因的,假如服务器能主动推送数据给浏览器的话,那么浏览器很容易受到攻击,比如一些广告商会主动把一些广告信息强行的传输给客户端。
半双工的缺点是:效率非常低下。
比如想实现股票的实时行情,火车票的剩余票数等这些,半双工协议是无法做到的。

2. 了解HTTP轮询,长轮询和流化。
一般情况下,浏览器访问网页时,会向页面所在的服务器发送一个HTTP请求,Web服务器确认请求并向浏览器返回响应。但是像股价,实时新闻等到达浏览器
显示页面时已经过时了,如果用户想要得到最新的实时信息,他们就要不断的刷新页面,这种显然是不切实际的。

轮询: 轮询是通过浏览器定时的向web服务器发送http的Get请求,服务器收到请求后,就把最新的数据发回给客户端,客户端得到数据后,将其显示出来,然后再定期的重复这一过程,虽然可以满足需求,但是存在一些缺点,比如某一段时间内web服务器没有更新的数据,但是浏览器仍然需要定时的发送Get请求过来询问,那么即浪费了带宽,又浪费了cpu的利用率。
如下图:

长轮询: 客户端向服务器请求信息,并在设定的时间段内打开一个连接,服务器如果没有任何信息,会保持请求打开,直到有客户端可用的信息,或者直到
指定的超时时间用完为止。这个时候,客户端会重新向服务器请求信息。长轮询也可以叫做Comet或反向ajax。它会延长HTTP响应的完成,直到服务器有
需要发送给客户端的内容,这种技术通常可以叫 "挂起GET" 或 "搁置POST".

缺点是:
当信息量非常大的时候,长轮询相对于http轮询并没有明显的性能优势,因为客户端必须频繁地重连到服务器以读取新信息。造成网络的表现和常规轮询相同。

如下图:

流化: 在流化技术中,客户端发送一个请求,服务器发送并维护一个持续更新和保持打开的开放响应。每当服务器有需要交付给客户端信息时,它就更新响应
。但是服务器从不发出完成HTTP响应的请求,从而使链接一直保持打开。在这种情况下,代理和防火墙可能缓存响应,导致信息交付的延迟增加。
因此许多流化对于存在防火墙和代理的网络是不友好的。

3. 了解WebSocket
WebSocket是一种全双工,双向,单套接字连接,使用websocket,http请求变成打开websocket的链接的单一请求,并且重用从客户端到服务器以及
服务器到客户端的同一连接。

WebSocket减少了延迟,因为一旦建立起Websocket连接,服务器可以在消息可用时发送他们。和轮询不同的是:WebSocket只发出一个请求,服务器
不需要等待来自客户端的请求,且客户端可以在任何时候向服务器端发送消息,和轮询相比的话,不管是否有可用消息,每隔一段时间都发送一个请求,
单一请求大大减少了延迟。

优点有如下:
1. WebSocket使实时通信更加有效。能节约带宽,CPU资源并减少延迟。
2. WebSocket使Web上客户端和服务器之间的通信变得更加的简单。
3. Websocket是一个底层网络协议,我们可以在它的基础之上构建其他的标准协议。

WebSocket的应用场景?
比如聊天,大型多人在线游戏,股票交易应用或实时新闻等。

(二) WebSocket协议

    WebSocket协议是为了解决web即时应用中服务器与客户端浏览器全双工通信问题而设计的。协议定义ws和wss协议,分别为普通请求和基于SSL的安全传输, ws端口是80,wss的端口为443.

WebSocket协议由两部分组成,握手和数据传输。

2-1 握手
WS的握手使用HTTP来实现的。客户端的握手消息是一个普通的,带有Upgrade头的,HTTP Request的消息。
先来看看如下代码:

<!DOCTYPE html><html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title>websocket</title>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>
  </head>
  <body>
    <script type="text/javascript">
      var wsUrl = "wss://echo.websocket.org";      var ws = new WebSocket(wsUrl);
      ws.onopen = function() {
        console.log('open');
      };
      ws.onmessage = function(msg) {
        console.log(msg.data);
      }
      ws.onclose = function() {
        console.log('已经被关闭了');
      }    </script>
  </body></html>

页面运行后,我们可以看到链接到 wss://echo.websocket.org 期间记录的一个握手协议。先来看看客户端发送http的请求头:

GET /chat HTTP/1.1Host:echo.websocket.org
Upgrade:websocket
Connection:Upgrade
Sec-WebSocket-Key:ALS2AoBJtUup67heKDgzFg==Origin:file://Sec-WebSocket-Version:13

服务器响应的头字段

Connection:Upgrade
Sec-WebSocket-Accept:qyzx/EgbRK15QNmr5PhpMQrPZMM=Server: Kaazing Gateway
Upgrade:websocket

下面是请求和响应头字段的含义:
Upgrade: websocket, 告诉服务器这个HTTP链接是升级的WebSocket协议。
Connection:Upgrade 告知服务器当前请求链接是升级的。
Origin: 该字段是用来防止客户端浏览器使用脚本进行未授权的跨源攻击,服务器要根据这个字段是否接受客户端的socket链接。
可以返回一个HTTP错误状态码来拒绝连接。

Sec-WebSocket-Key: ALS2AoBJtUup67heKDgzFg==
Sec-WebSocket-Accept: qyzx/EgbRK15QNmr5PhpMQrPZMM=

Sec-WebSocket-Key 的值是一串长度为24的字符串是客户端随机生成的base64编码的字符串,它发送给服务器,服务器需要使用它经过一定的运算规则生成服务器的key,然后把服务器的key发到客户端去,客户端验证正确后,握手成功。

握手的具体原理:当我们客户端执行 new WebSocket(''wss://echo.websocket.org')的时候,客户端就会发起请求报文进行握手申请,报文中有一个key就是
Sec-WebSocket-Key,服务器获取到key,会将这个key与字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11相连,对新的字符串通过sha1安全散列算法计算出结果后,再进行Base64编码,并且将结果放在请求头的"Sec-WebSocket-Accept",最后返回给客户端,
客户端进行验证后,握手成功。握手成功后就可以开始数据传输了。

下面是实现一个简单的握手协议的demo,代码如下:

### 目录结构如下:

demo
  |--- hands.html
  |--- hands.js

hands.html 代码如下:

<html><head>
  <title>WebSocket Demo</title></head><body>
  <script type="text/javascript">
    var ws = new WebSocket("ws://127.0.0.1:8000");
    ws.onerror = function(e) {
      console.log(e);
    };
    ws.onopen = function() {
      console.log('握手成功');
    }  </script></body></html>

hands.js 代码如下:

var crypto = require('crypto');var WS = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';

require('net').createServer(function(o) {  var key;
  o.on('data', function(e) {    if (!key) {
      console.log(e);

      key = e.toString().match(/Sec-WebSocket-Key: (.+)/)[1];
      console.log(key);      
      // WS的字符串 加上 key, 变成新的字符串后做一次sha1运算,最后转换成Base64
      key = crypto.createHash('sha1').update(key+WS).digest('base64');
      console.log(key);      // 输出字段数据,返回到客户端,
      o.write('HTTP/1.1 101 Switching Protocol\r\n');
      o.write('Upgrade: websocket\r\n');
      o.write('Connection: Upgrade\r\n');
      o.write('Sec-WebSocket-Accept:' +key+'\r\n');      // 输出空行,使HTTP头结束
      o.write('\r\n');
    } else {      // 数据处理    }
  })
}).listen(8000);

首先在命令行中 进入相对应项目目录后,运行 node hands.js, 然后打开 hands.html 运行一下即可看到 命令行中打印出来如下信息:

$ node hands.js<Buffer 47 45 54 20 2f 20 48 54 54 50 2f 31 2e 31 0d 0a 48 6f 73 74 3a 20 31 32 37 2e 30 2e 30 2e 31 3a 38 30 30 30 0d 0a 43 6f 6e 6e 65 63 74 69 6f 6e 3a 20 ... >
+iHlfGTolBaWYpnyTIw22g==W7IEsdQtwv8EP2204kssK/6pg+c=

然后在浏览器中查看请求头如下信息:

Request Headers:

Connection:Upgrade
Host:127.0.0.1:8000Origin:file://Sec-WebSocket-Extensions:permessage-deflate; client_max_window_bits
Sec-WebSocket-Key:+iHlfGTolBaWYpnyTIw22g==Sec-WebSocket-Version:13Upgrade:websocket

响应头如下信息:

Response Headers:

Connection:Upgrade
Sec-WebSocket-Accept:W7IEsdQtwv8EP2204kssK/6pg+c=
Upgrade:websocket

如上信息可以看到,获取报文中的key代码:
key = e.toString().match(/Sec-WebSocket-Key: (.+)/)[1];
console.log(key); // 打印 +iHlfGTolBaWYpnyTIw22g==

和 Request Headers:中的 Sec-WebSocket-Key 值是一样的,该值是浏览器自动生成的,然后获取该值后,与 '258EAFA5-E914-47DA-95CA-C5AB0DC85B11',相连,对新的字符串通过sha1安全散列算法计算出结果后,再进行Base64编码,
并且将结果放在请求头的"Sec-WebSocket-Accept",最后返回给客户端,客户端进行验证后,握手成功。在浏览器中可以看到打印出 握手成功了。

(三) 解析数据帧

1-1 理解数据帧的含义:
   在WebSocket协议中,数据是通过帧序列来传输的。为了数据安全原因,客户端必须掩码(mask)它发送到服务器的所有帧,当它收到一个
没有掩码的帧时,服务器必须关闭连接。不过服务器端给客户端发送的所有帧都不是掩码的,如果客户端检测到掩码的帧时,也一样必须关闭连接。
当帧被关闭的时候,可能发送状态码1002(协议错误)。

基本帧协议如下:

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

如上是基本帧协议,它带有操作码(opcode)的帧类型,负载长度,和用于 "扩展数据" 与 "应用数据" 及 它们一起定义的 "负载数据"的指定位置,
某些字节和操作码保留用于未来协议的扩展。

FIN(1位): 是否为消息的最后一个数据帧。
RSV1,RSV2,Rsv3(每个占1位),必须是0,除非一个扩展协商为非零值定义的。
Opcode表示帧的类型(4位),例如这个传输的帧是文本类型还是二进制类型,二进制类型传输的数据可以是图片或者语音之类的。(这4位转换成16进制值表示的意思如下):

0x0 表示附加数据帧0x1 表示文本数据帧0x2 表示二进制数据帧0x3-7 暂时无定义,为以后的非控制帧保留0x8 表示连接关闭0x9 表示ping0xA 表示pong0xB-F 暂时无定义,为以后的控制帧保留

Mask(占1位): 表示是否经过掩码处理, 1 是经过掩码的,0是没有经过掩码的。

payload length (7位+16位,或者 7位+64位),定义负载数据的长度。
   1. 如果数据长度小于等于125的话,那么该7位用来表示实际数据长度。
   2. 如果数据长度为126到65535(2的16次方)之间,该7位值固定为126,也就是 1111110,往后扩展2个字节(16为,第三个区块表示),用于存储数据的实际长度。
   3. 如果数据长度大于65535, 该7位的值固定为127,也就是 1111111 ,往后扩展8个字节(64位),用于存储数据实际长度。

Masking-key(0或者4个字节),该区块用于存储掩码密钥,只有在第二个子节中的mask为1,也就是消息进行了掩码处理时才有,否则没有,
所以服务器端向客户端发送消息就没有这一块。

Payload data 扩展数据,是0字节,除非已经协商了一个扩展。

1-2 客户端到服务器掩码
WebSocket协议要求客户端所发送的帧必须掩码,掩码的密钥是一个32位的随机值。所有数据都需要与掩码做一次异或运算。帧头在第二个字节的第一位表示该帧是否使用了掩码。
WebSocket服务器接收的每个载荷在处理之前首先需要处理掩码,解除掩码之后,服务器将得到原始消息内容。二进制消息可以直接交付。文本消息将进行UTF-8解码
并输出到字符串中。

二进制位运算符知识扩展:

>> 含义是右移运算符,
   右移运算符是将一个二进制位的操作数按指定移动的位数向右移动,移出位被丢弃,左边移出的空位一律补0.
比如 11 >> 2, 意思是说将数字11右移2位。
首先将11转换为二进制数为 0000 0000 0000 0000 0000 0000 0000 1011 , 然后把低位的最后2个数字移出,因为该数字是正数,
所以在高位补零,则得到的最终结果为:0000 0000 0000 0000 0000 0000 0000 0010,转换为10进制是2.

<< 含义是左移运算符
    左移运算符是将一个二进制位的操作数按指定移动的位数向左移位,移出位被丢弃,右边的空位一律补0.
比如 3 << 2, 意思是说将数字3左移2位,
首先将3转换为二进制数为 0000 0000 0000 0000 0000 0000 0000 0011 , 然后把该数字高位(左侧)的两个零移出,其他的数字都朝左平移2位,
最后在右侧的两个空位补0,因此最后的结果是 0000 0000 0000 0000 0000 0000 0000 1100,则转换为十进制是12(1100 = 1*2的3次方 + 1*2的2字方)

注意1: 在使用补码作为机器数的机器中,正数的符号位为0,负数的符号位为1(一般情况下).
           比如:十进制数13在计算机中表示为00001101,其中第一位0表示的是符号

注意2:负数的二进制位如何计算?
          比如二进制的原码为 10010101,它的补码怎么计算呢?
          首先计算它的反码是 01101010; 那么补码 = 反码 + 1 = 01101011

再来看一个列子:
-7 >> 2 意思是将数字 -7 右移2位。
负数先用它的绝对值正数取它的二进制代码,7的二进制位为: 0000 0000 0000 0000 0000 0000 0000 0111 ,那么 -7的二进制位就是 取反,
取反后再加1,就变成补码。
因此-7的二进制位: 1111 1111 1111 1111 1111 1111 1111 1001,
因此 -7右移2位就成 1111 1111 1111 1111 1111 1111 1111 1110 因此转换成十进制的话 -7 >> 2 ,值就变成 -2了。

数据帧解析的程序如下代码:(decodeDataFrame.js 代码如下:)

var crypto = require('crypto');var WS = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';

require('net').createServer(function(o) {  var key;
  o.on('data', function(e) {    if (!key) {

      key = e.toString().match(/Sec-WebSocket-Key: (.+)/)[1];      
      // WS的字符串 加上 key, 变成新的字符串后做一次sha1运算,最后转换成Base64
      key = crypto.createHash('sha1').update(key+WS).digest('base64');      // 输出字段数据,返回到客户端,
      o.write('HTTP/1.1 101 Switching Protocol\r\n');
      o.write('Upgrade: websocket\r\n');
      o.write('Connection: Upgrade\r\n');
      o.write('Sec-WebSocket-Accept:' +key+'\r\n');      // 输出空行,使HTTP头结束
      o.write('\r\n');
    } else {      // 数据处理      onmessage(e);
    }
  })
}).listen(8000);/* 
 >> 含义是右移运算符,
   右移运算符是将一个二进制位的操作数按指定移动的位数向右移动,移出位被丢弃,左边移出的空位一律补0.
 比如 11 >> 2, 意思是说将数字11右移2位。
 首先将11转换为二进制数为 0000 0000 0000 0000 0000 0000 0000 1011 , 然后把低位的最后2个数字移出,因为该数字是正数,
 所以在高位补零,则得到的最终结果为:0000 0000 0000 0000 0000 0000 0000 0010,转换为10进制是2.
  

 << 含义是左移运算符
   左移运算符是将一个二进制位的操作数按指定移动的位数向左移位,移出位被丢弃,右边的空位一律补0.
 比如 3 << 2, 意思是说将数字3左移2位,
 首先将3转换为二进制数为 0000 0000 0000 0000 0000 0000 0000 0011 , 然后把该数字高位(左侧)的两个零移出,其他的数字都朝左平移2位,
 最后在右侧的两个空位补0,因此最后的结果是 0000 0000 0000 0000 0000 0000 0000 1100,则转换为十进制是12(1100 = 1*2的3次方 + 1*2的2字方)

 注意1: 在使用补码作为机器数的机器中,正数的符号位为0,负数的符号位为1(一般情况下). 
       比如:十进制数13在计算机中表示为00001101,其中第一位0表示的是符号

 注意2:负数的二进制位如何计算?
       比如二进制的原码为 10010101,它的补码怎么计算呢?
       首先计算它的反码是 01101010; 那么补码 = 反码 + 1 = 01101011

 再来看一个列子:
 -7 >> 2 意思是将数字 -7 右移2位。
 负数先用它的绝对值正数取它的二进制代码,7的二进制位为: 0000 0000 0000 0000 0000 0000 0000 0111 ,那么 -7的二进制位就是 取反,
 取反后再加1,就变成补码。
 因此-7的二进制位: 1111 1111 1111 1111 1111 1111 1111 1001,
 因此 -7右移2位就成 1111 1111 1111 1111 1111 1111 1111 1110 因此转换成十进制的话 -7 >> 2 ,值就变成 -2了。*/function decodeDataFrame(e) {  var i = 0, j, s, arrs = [],
    frame = {      // 解析前两个字节的基本数据
      FIN: e[i] >> 7,
      Opcode: e[i++] & 15,
      Mask: e[i] >> 7,
      PayloadLength: e[i++] & 0x7F
    };    // 处理特殊长度126和127
    if (frame.PayloadLength === 126) {
      frame.PayloadLength = (e[i++] << 8) + e[i++];
    }    if (frame.PayloadLength === 127) {
      i += 4; // 长度一般用4个字节的整型,前四个字节一般为长整型留空的。
      frame.PayloadLength = (e[i++] << 24)+(e[i++] << 16)+(e[i++] << 8) + e[i++];
    }    // 判断是否使用掩码
    if (frame.Mask) {      // 获取掩码实体
      frame.MaskingKey = [e[i++], e[i++], e[i++], e[i++]];      // 对数据和掩码做异或运算
      for(j = 0, arrs = []; j < frame.PayloadLength; j++) {
        arrs.push(e[i+j] ^ frame.MaskingKey[j%4]);
      }
    } else {      // 否则的话 直接使用数据
      arrs = e.slice(i, i + frame.PayloadLength);
    }    // 数组转换成缓冲区来使用
    arrs = new Buffer(arrs);    // 如果有必要则把缓冲区转换成字符串来使用
    if (frame.Opcode === 1) {
      arrs = arrs.toString();
    }    // 设置上数据部分
    frame.PayloadLength = arrs;    // 返回数据帧
    return frame;
}function onmessage(e) {
  console.log(e)
  e = decodeDataFrame(e);  // 解析数据帧
  console.log(e);  // 把数据帧输出到控制台}

index.html代码如下:

<html><head>
  <title>WebSocket Demo</title></head><body>
  <script type="text/javascript">
    var ws = new WebSocket("ws://127.0.0.1:8000");
    ws.onerror = function(e) {
      console.log(e);
    };
    ws.onopen = function(e) {
      console.log('握手成功');
      ws.send('次碳酸钴');
    }  </script></body></html>

查看github上的源码

demo还是一样,decodeDataFrame.js 和 index.html, 先进入项目中对应的目录后,使用node decodeDataFrame.js,  然后打开index.html后查看效果

如下:

这样服务器接收客户端穿过了的数据就没问题了。

(四) 生成数据帧

    从服务器发往客户端的数据也是同样的数据帧,但是从服务器发送到客户端的数据帧不需要掩码的。我们自己需要去生成数据帧,解析数据帧的时候我们需要分片。

消息分片:
   有时候数据需要分成多个数据包发送,需要使用到分片,也就是说多个数据帧来传输一个数据。比如将大数据分成多个数据包传输,分片的目的是允许发送未知长度的消息。
这样做的好处是:
  1. 大数据的传输可以分片传输,不用考虑到数据大小导致的长度标志位不够的情况。
  2. 和http的chunk一样,可以边生成数据边传递消息,可以提高传输效率。

如果大数据不能被碎片化,那么一端就必须将消息整个载入内存缓冲之中,然后需要计算长度等操作并发送,但是有了碎片化机制,服务器端或者中间件就可以选取适用的内存缓冲长度,然后当缓冲满了之后就发送一个消息碎片。

分片规则:
1. 如果一个消息不分片的话,那么该消息只有一帧(FIN为1,opcode非0);
2. 如果一个消息分片的话,它的构成是由起始帧(FIN为0,opcode非0),然后若干(0个或多个)帧(FIN为0,opcode为0),然后结束帧(FIN为1,opcode为0)。

注意:
   1. 当前已经定义了控制帧包括 0x8(close), 0x9(Ping), 0xA(Pong). 控制帧可以出现在分片消息中间,但是控制帧不允许分片,控制帧是通过它的opcode
的最高有效位是1去确定的。
   2. 组成消息的所有帧都是相同的数据类型,在第一帧中的opcode中指明。组成消息的碎片类型必须是文本,二进制,或者其他的保留类型。

下面我们来理解下上面分片规则2中的话的含义:
  1. 开始帧(1个)---消息分片起始帧的构成是 (FIN为0,opcode非0);即:FIN=0, Opcode > 0;
  2. 传输帧(0个或多个)---是由若干个(0个或多个)帧组成; 即 FIN = 0, Opcode = 0;
  3. 终止帧(1个)--- FIN = 1, Opcode = 0;

还是看基本帧协议如下:

1                   2                   3
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 ...                |
 +---------------------------------------------------------------+

demo解析:
比如我们现在第三节我们讲到的 "解析数据帧" 里面的代码,我们发送的消息123456789后,返回的数据部分是:

<Buffer 81 89 b0 23 52 5a 81 11 61 6e 85 15 65 62 89>{ FIN: 1,
  Opcode: 1,
  Mask: 1,
  PayloadLength: '123456789',
  MaskingKey: [ 176, 35, 82, 90 ] 
}

上面返回的数据部分是16进制,因此我们需要他们转换成二进制,有关16进制,10进制,2进制的转换表如下:
16进制-->10进制-->2进制转换查看

我们现在需要把 81 89 b0 23 52 5a 81 11 61 6e 85 15 65 62 89 这些16进制先转换成10进制,然后转换成二进制,分析代码如下:
16进制(a=10, b=11, ... 依次类推)

16进制          10进制                           2进制81          8*16的1次方 + 1*16的0次方 = 129      10000001
89          8*16的1次方 + 9*16的0次方 = 137      10001001b0          11*16的1次方 + 0*16的0次方 = 176     10110000 
23          2*16的1次方 + 3*16的0次方 = 35       00100011
52          5*16的1次方 + 2*16的0次方 = 82       010100105a          5*16的1次方 + 10*16的0次方 = 90      01011010
81          8*16的1次方 + 1*16的0次方 = 129      10000001
11          1*16的1次方 + 1*16的0次方 = 17       00010001
61          6*16的1次方 + 1*16的0次方 = 97       001111016e          6*16的1次方 + 14*16的0次方 = 110     01101110
85          8*16的1次方 + 5*16的0次方 = 133      10000101
15          1*16的1次方 + 5*16的0次方 = 21       00010101
65          6*16的1次方 + 5*16的0次方 = 101      01100101
62          6*16的1次方 + 2*16的0次方 = 98       01100010
89          8*16的1次方 + 9*16的0次方 = 137      10001001

我们把上面的转换后的二进制 对照上面的 基本帧协议表看下:
1. 先看 FIN 的含义是: 第一位是否为消息的最后一个数据帧,如果为1的话,说明是,否则为0的话就不是,那说明是最后一个数据帧。
2. 第2~4位都为0,对应的RSV(1~3), 5~8为 0001,是属于opcode的部分了,opcode是代表是帧的类型;它有如下类型:

   0x0 表示附加数据帧
   0x1 表示文本数据帧
   0x2 表示二进制数据帧
   0x3-7 暂时无定义,为以后的非控制帧保留
   0x8 表示连接关闭
   0x9 表示ping
   0xA 表示pong
   0xB-F 暂时无定义,为以后的控制帧保留

注意:其中8进制是以0开头的,16进制是以0x开头的。

0001,是文本数据帧了。

3.  第九位是1,那么对应的帧协议表就是MASK部分了,Mask(占1位): 表示是否经过掩码处理, 1 是经过掩码的,0是没有经过掩码的。说明是经过掩码处理的,
也就是说可以理解为是客户端向服务器端发送数据的。(因为服务器端给客户端是不需要掩码的,否则连接中断)。

4. 第10~16位是 0001001 = 9 < 125, 对应帧协议中的 payload length的部分了,数据长度为9,因此小于125位,因此使用7位来表示实际数据长度。

5. b0, 23, 52, 5a 对应的部分是 属于Masking-key(0或者4个字节),该区块用于存储掩码密钥,只有在第二个子节中的mask为1,也就是消息进行了掩码处理时才有。

6. 81 11 61 6e 85 15 65 62 89 这些就是对应表中的数据部分了。

下面我们再来理解下 消息 123456789 怎么通过掩码加密成 81 11 61 6e 85 15 65 62 89 这些数据了。

数字字符1的ASCLL码的16进制为31,转换成10进制就是49了。其他的数字依次类推+1;

数字           10进制          二进制1             49              00110001
2             50              00110010
3             51              00110011
4             52              00110100
5             53              00110101
6             54              00110110
7             55              00110111
8             56              00111000
9             57              00111001

6-1: 其中字符1的二进制位 00110001,掩码b0的二进制位 10110000, 因此:

00110001
10110000

进行交配的话,二进制就变成:10000001,转换成10进制为 129了,那么转换成16进制就是 81了。

6-2:字符2的二进制位 00110010,掩码23的二进制位 00100011,因此:

00110010
00100011

进行交配的话,二进制就变成 00010001,转换10进制为17,那么转换成16进制就是 11了。

6-3: 字符3的二进制位 00110011,掩码52的二进制位 01010010,因此:

00110011
01010010

进行交配的话,二进制就变成:01100001,转换成10进制为 97,那么转换成16进制就是 61了。

6-4: 字符4的二进制位 00110100,掩码 5a 的二进制位 01011010,因此:

00110100
01011010

进行交配的话,二进制就变成 01101110,转换成10进制为 110,那么转换成16进制为 6e.

6-5: 字符5的二进制位 00110101,掩码b0的二进制位 10110000, 因此:

00110101
10110000

进行交配的话,二进制就变成:10000101,转换成10进制为 133,那么转换成16进制就是 85了。

6-6: 字符6的二进制位 00110110,掩码23的二进制位 00100011,因此:

00110110
00100011

进行交配的话,二进制就变成:00010101,转换成10进制为 21,那么转换成16进制就是 15了。

6-7: 字符7的二进制位 00110111,掩码52的二进制位 01010010,因此:

00110111
01010010

进行交配的话,二进制就变成:01100101,转换成10进制为 101,那么转换成16进制就是 65了。

6-8: 字符8的二进制位 00111000,掩码 5a 的二进制位 01011010,因此:

00111000
01011010

进行交配的话,二进制就变成:01100010,转换成10进制为 98,那么转换成16进制就是 62了。

6-9: 字符9的二进制位 00111001,掩码b0的二进制位 10110000, 因此:

00111001
10110000

进行交配的话,二进制就变成:10001001,转换成10进制为 137,那么转换成16进制就是 89了。

字符123456789与掩码加密的整个过程如上面分析,可以看到,字符分别依次与掩码交配,如果掩码不够的话,依次从头循环即可。

因此我们可以编写如下encodeDataFrame.js代码:

var crypto = require('crypto');var WS = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';

require('net').createServer(function(o) {  var key;
  o.on('data', function(e) {    if (!key) {

      key = e.toString().match(/Sec-WebSocket-Key: (.+)/)[1];      
      // WS的字符串 加上 key, 变成新的字符串后做一次sha1运算,最后转换成Base64
      key = crypto.createHash('sha1').update(key+WS).digest('base64');      // 输出字段数据,返回到客户端,
      o.write('HTTP/1.1 101 Switching Protocol\r\n');
      o.write('Upgrade: websocket\r\n');
      o.write('Connection: Upgrade\r\n');
      o.write('Sec-WebSocket-Accept:' +key+'\r\n');      // 输出空行,使HTTP头结束
      o.write('\r\n');      // 握手成功后给客户端发送数据      o.write(encodeDataFrame({
        FIN: 1,
        Opcode: 1,
        PayloadData: "123456789"
      }))
    } else {
      
    }
  })
}).listen(8001);/* 
 >> 含义是右移运算符,
   右移运算符是将一个二进制位的操作数按指定移动的位数向右移动,移出位被丢弃,左边移出的空位一律补0.
 比如 11 >> 2, 意思是说将数字11右移2位。
 首先将11转换为二进制数为 0000 0000 0000 0000 0000 0000 0000 1011 , 然后把低位的最后2个数字移出,因为该数字是正数,
 所以在高位补零,则得到的最终结果为:0000 0000 0000 0000 0000 0000 0000 0010,转换为10进制是2.

 << 含义是左移运算符
   左移运算符是将一个二进制位的操作数按指定移动的位数向左移位,移出位被丢弃,右边的空位一律补0.
 比如 3 << 2, 意思是说将数字3左移2位,
 首先将3转换为二进制数为 0000 0000 0000 0000 0000 0000 0000 0011 , 然后把该数字高位(左侧)的两个零移出,其他的数字都朝左平移2位,
 最后在右侧的两个空位补0,因此最后的结果是 0000 0000 0000 0000 0000 0000 0000 1100,则转换为十进制是12(1100 = 1*2的3次方 + 1*2的2字方)

 注意1: 在使用补码作为机器数的机器中,正数的符号位为0,负数的符号位为1(一般情况下). 
       比如:十进制数13在计算机中表示为00001101,其中第一位0表示的是符号

 注意2:负数的二进制位如何计算?
       比如二进制的原码为 10010101,它的补码怎么计算呢?
       首先计算它的反码是 01101010; 那么补码 = 反码 + 1 = 01101011

 再来看一个列子:
 -7 >> 2 意思是将数字 -7 右移2位。
 负数先用它的绝对值正数取它的二进制代码,7的二进制位为: 0000 0000 0000 0000 0000 0000 0000 0111 ,那么 -7的二进制位就是 取反,
 取反后再加1,就变成补码。
 因此-7的二进制位: 1111 1111 1111 1111 1111 1111 1111 1001,
 因此 -7右移2位就成 1111 1111 1111 1111 1111 1111 1111 1110 因此转换成十进制的话 -7 >> 2 ,值就变成 -2了。*/function decodeDataFrame(e) {  var i = 0, j, s, arrs = [],
    frame = {      // 解析前两个字节的基本数据
      FIN: e[i] >> 7,
      Opcode: e[i++] & 15,
      Mask: e[i] >> 7,
      PayloadLength: e[i++] & 0x7F
    };    // 处理特殊长度126和127
    if (frame.PayloadLength === 126) {
      frame.PayloadLength = (e[i++] << 8) + e[i++];
    }    if (frame.PayloadLength === 127) {
      i += 4; // 长度一般用4个字节的整型,前四个字节一般为长整型留空的。
      frame.PayloadLength = (e[i++] << 24)+(e[i++] << 16)+(e[i++] << 8) + e[i++];
    }    // 判断是否使用掩码
    if (frame.Mask) {      // 获取掩码实体
      frame.MaskingKey = [e[i++], e[i++], e[i++], e[i++]];      // 对数据和掩码做异或运算
      for(j = 0, arrs = []; j < frame.PayloadLength; j++) {
        arrs.push(e[i+j] ^ frame.MaskingKey[j%4]);
      }
    } else {      // 否则的话 直接使用数据
      arrs = e.slice(i, i + frame.PayloadLength);
    }    // 数组转换成缓冲区来使用
    arrs = new Buffer(arrs);    // 如果有必要则把缓冲区转换成字符串来使用
    if (frame.Opcode === 1) {
      arrs = arrs.toString();
    }    // 设置上数据部分
    frame.PayloadLength = arrs;    // 返回数据帧
    return frame;
}function encodeDataFrame(e) {  var arrs = [],
    o = new Buffer(e.PayloadData),
    l = o.length;  // 处理第一个字节
  arrs.push((e.FIN << 7)+e.Opcode);  // 处理第二个字节,判断它的长度并放入相应的后溪长度
  if (l < 126) {
    arrs.push(l);
  } else if(l < 0x0000) {
    arrs.push(126, (1&0xFF00) >> 8, 1&0xFF);
  } else {
    arrs.push(127, 0, 0, 0, 0, 
      (l&0xFF000000)>>24,(l&0xFF0000)>>16,(l&0xFF00)>>8,l&0xFF 
    );
  }  // 返回头部分和数据部分的合并缓冲区
  return Buffer.concat([new Buffer(arrs), o]);
}

然后index.html代码如下:

<html><head>
  <title>WebSocket Demo</title></head><body>
  <script type="text/javascript">
    var ws = new WebSocket("ws://127.0.0.1:8001");
    ws.onerror = function(e) {
      console.log(e);
    };
    ws.onopen = function(e) {
      console.log('握手成功');
      ws.send('123456789');
    }
    ws.onmessage = function(e) {
      console.log(e);
    }  </script></body></html>

进入目录后,运行node encodeDataFrame.js后,打开index.html页面,在控制台看待效果图如下:

查看git上代码

使用分片的方式重新修改代码:

上面是基本的使用方法,但是有时候我们需要将一个大的数据包需要分成多个数据帧来传输,因此分片它分为3个部分:

1个开始帧:FIN=0, Opcode > 0;
零个或多个传输帧: FIN=0, Opcode=0;
1个终止帧:FIN=1, Opcode=0;

因此之前的握手成功后发送的数据代码:

o.write(encodeDataFrame({
  FIN: 1,
  Opcode: 1,
  PayloadData: "123456789"}))

需要分成三部分来发送了;

改成如下代码:

// 握手成功后给客户端发送数据o.write(encodeDataFrame({
  FIN: 0,
  Opcode: 1,
  PayloadData: "123"}));
o.write(encodeDataFrame({
  FIN: 0,
  Opcode: 0,
  PayloadData: "456"}));
o.write(encodeDataFrame({
  FIN: 1,
  Opcode: 0,
  PayloadData: "789"}));

(五) 心跳及重连机制

    在使用websocket的过程中,有时候会遇到网络断开的情况,但是在网络断开的时候服务器端并没有触发onclose的事件。这样会有:服务器会继续向客户端发送多余的链接,并且这些数据还会丢失。所以就需要一种机制来检测客户端和服务端是否处于正常的链接状态。因此就有了websocket的心跳了。还有心跳,说明还活着,没有心跳说明已经挂掉了。

1. 为什么叫心跳包呢?
它就像心跳一样每隔固定的时间发一次,来告诉服务器,我还活着。

2. 心跳机制是?
心跳机制是每隔一段时间会向服务器发送一个数据包,告诉服务器自己还活着,同时客户端会确认服务器端是否还活着,如果还活着的话,就会回传一个数据包给客户端来确定服务器端也还活着,否则的话,有可能是网络断开连接了。需要重连~

那么需要怎么去实现它呢?如下所有代码:

<html>
<head>
  <meta charset="utf-8">
  <title>WebSocket Demo</title>
</head>
<body>
  <script type="text/javascript">    // var ws = new WebSocket("wss://echo.websocket.org");
    /*
    ws.onerror = function(e) {
      console.log('已关闭');
    };
    ws.onopen = function(e) {
      console.log('握手成功');
      ws.send('123456789');
    }
    ws.onclose = function() {
      console.log('已关闭');
    }
    ws.onmessage = function(e) {
      console.log('收到消息');
      console.log(e);
    }    */
    
    var lockReconnect = false;//避免重复连接
    var wsUrl = "wss://echo.websocket.org";    var ws;    var tt;    function createWebSocket() {      try {
        ws = new WebSocket(wsUrl);
        init();
      } catch(e) {
        console.log('catch');
        reconnect(wsUrl);
      }
    }    function init() {
      ws.onclose = function () {
        console.log('链接关闭');
        reconnect(wsUrl);
      };
      ws.onerror = function() {
        console.log('发生异常了');
        reconnect(wsUrl);
      };
      ws.onopen = function () {        //心跳检测重置        heartCheck.start();
      };
      ws.onmessage = function (event) {
        //拿到任何消息都说明当前连接是正常的
        console.log('接收到消息');
        heartCheck.start();
      }
    }    function reconnect(url) {      if(lockReconnect) {        return;
      };
      lockReconnect = true;      //没连接上会一直重连,设置延迟避免请求过多
      tt && clearTimeout(tt);
      tt = setTimeout(function () {
        createWebSocket(url);
        lockReconnect = false;
      }, 4000);
    }    //心跳检测
    var heartCheck = {
      timeout: 3000,
      timeoutObj: null,
      serverTimeoutObj: null,
      start: function(){
        console.log('start');        var self = this;        this.timeoutObj && clearTimeout(this.timeoutObj);        this.serverTimeoutObj && clearTimeout(this.serverTimeoutObj);        this.timeoutObj = setTimeout(function(){          //这里发送一个心跳,后端收到后,返回一个心跳消息,
          console.log('55555');
          ws.send("123456789");
          self.serverTimeoutObj = setTimeout(function() {
            console.log(111);
            console.log(ws);
            ws.close();            // createWebSocket();          }, self.timeout);

        }, this.timeout)
      }
    }
    createWebSocket(wsUrl);  </script>
</body>
</html>

具体的思路如下:
1. 第一步页面初始化,先调用createWebSocket函数,目的是创建一个websocket的方法:new WebSocket(wsUrl);因此封装成函数内如下代码:

function createWebSocket() {  try {
    ws = new WebSocket(wsUrl);
    init();
  } catch(e) {
    console.log('catch');
    reconnect(wsUrl);
  }
}

2. 第二步调用init方法,该方法内把一些监听事件封装如下:

function init() {
  ws.onclose = function () {
    console.log('链接关闭');
    reconnect(wsUrl);
  };
  ws.onerror = function() {
    console.log('发生异常了');
    reconnect(wsUrl);
  };
  ws.onopen = function () {    //心跳检测重置    heartCheck.start();
  };
  ws.onmessage = function (event) {
    //拿到任何消息都说明当前连接是正常的
    console.log('接收到消息');
    heartCheck.start();
  }
}

3. 如上第二步,当网络断开的时候,会先调用onerror,onclose事件可以监听到,会调用reconnect方法进行重连操作。正常的情况下,是先调用
onopen方法的,当接收到数据时,会被onmessage事件监听到。

4. 重连操作 reconnect代码如下:

var lockReconnect = false;//避免重复连接function reconnect(url) {  if(lockReconnect) {    return;
  };
  lockReconnect = true;  //没连接上会一直重连,设置延迟避免请求过多
  tt && clearTimeout(tt);
  tt = setTimeout(function () {
    createWebSocket(url);
    lockReconnect = false;
  }, 4000);
}

如上代码,如果网络断开的话,会执行reconnect方法,使用了一个定时器,4秒后会重新创建一个新的websocket链接,重新调用createWebSocket函数,
重新会执行及发送数据给服务器端。

5. 最后一步就是实现心跳检测的代码:如下:

//心跳检测var heartCheck = {
  timeout: 3000,
  timeoutObj: null,
  serverTimeoutObj: null,
  start: function(){
    console.log('start');    var self = this;    this.timeoutObj && clearTimeout(this.timeoutObj);    this.serverTimeoutObj && clearTimeout(this.serverTimeoutObj);    this.timeoutObj = setTimeout(function(){      //这里发送一个心跳,后端收到后,返回一个心跳消息,
      //onmessage拿到返回的心跳就说明连接正常
      console.log('55555');
      ws.send("123456789");
      self.serverTimeoutObj = setTimeout(function() {
        console.log(111);
        console.log(ws);
        ws.close();        // createWebSocket();      }, self.timeout);

    }, this.timeout)
  }
}

实现心跳检测的思路是:每隔一段固定的时间,向服务器端发送一个ping数据,如果在正常的情况下,服务器会返回一个pong给客户端,如果客户端通过
onmessage事件能监听到的话,说明请求正常,这里我们使用了一个定时器,每隔3秒的情况下,如果是网络断开的情况下,在指定的时间内服务器端并没有返回心跳响应消息,因此服务器端断开了,因此这个时候我们使用ws.close关闭连接,在一段时间后(在不同的浏览器下,时间是不一样的,firefox响应更快),
可以通过 onclose事件监听到。因此在onclose事件内,我们可以调用 reconnect事件进行重连操作。

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多