代码改变世界

WebSocket原理与实践(四)--生成数据帧

2018-03-18 16:20 by 龙恩0707, ... 阅读, ... 评论, 收藏, 编辑

WebSocket原理与实践(四)--生成数据帧

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

消息分片:
   有时候数据需要分成多个数据包发送,需要使用到分片,也就是说多个数据帧来传输一个数据。比如将大数据分成多个数据包传输,分片的目的是允许发送未知长度的消息。
这样做的好处是:
  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      10001001
b0          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       01010010
5a          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       00111101
6e          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"
}));