美文网首页
聊聊从Websocket到协议设计的思考

聊聊从Websocket到协议设计的思考

作者: 德林_87d9 | 来源:发表于2018-12-29 11:13 被阅读0次

    前言

    因为最近团队在改造Nodejs的Accs、MTop等中间件,其中在采集数据方面用到了Websocket,所以对其进行了研究和拓展:

    1、WebSocket是什么

    HTML5开始提供的一种浏览器与服务器进行全双工通讯的网络技术,属于应用层协议。它基于TCP传输协议,并复用HTTP的握手通道。所遵守协议为RFC6455

    RFC是什么?请求意见稿(英语:Request For Comments,缩写:RFC),是由互联网工程任务组(IETF)发布的一系列备忘录。功能是制定了一些互联网的规定和协议等等,例如HTTP是2616,IPv6是2460。

    要了解上边的定义,首先要了解The WebSocket Handshake(Websocket 握手通讯),明白当建立一个ws时候,内部发生了些什么:

    (1)当客户端建立连接是,通过HTTP GET发送请求报文,如下所示:

    
    GET /chat HTTP/1.1
    
    Host: example.com:8000
    
    Upgrade: websocket
    
    Connection: Upgrade
    
    Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
    
    Sec-WebSocket-Version: 13  // websocket的版本
    
    

    (2)当服务端在处理完请求后,返回的报文如下:

    
    HTTP/1.1 101 Switching Protocols
    
    Upgrade: websocket
    
    Connection: Upgrade
    
    Sec-WebSocket-Accept: Oy4NRAQ13jhfONC7bP8dTKb4PTU=
    
    Sec-WebSocket-Protocol:chat  //子协议的列表,用于自定义XML schema或文件类型声明
    
    

    与普通的HTTP请求报文不同,ws报文多了Upgrade,Connection,Sec-WebSocket-*等字段,

    
    Connection: Upgrade  //表示要升级协议
    
    Upgrade: websocket  //表示要升级到websocket协议
    
    

    这两个字段表示请求服务端升级协议为Websocket,当服务端接收到这个HTTP请求之后,会自动切换到ws协议进行通讯,正如服务端返回的状态码101(状态代码101表示协议切换)所示,已经切换协议规范。

    请求报文中的Sec-WebSocket-Key和返回报文中Sec-WebSocket-Accept是一对加密对,Sec-WebSocket-Accept根据Sec-WebSocket-Key计算出来,用于安全校验。计算公式如下:

    (1)将Sec-WebSocket-Key跟258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接

    (2)然后将拼接串通过sha1散列算法计算结果后进行base64编码,返回给客户端

    验证代码如下:

    
    const crypto = require('crypto');
    
    const defayltKey = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
    
    const secWebSocketKey = 'dGhlIHNhbXBsZSBub25jZQ==';
    
    let secWebSocketAccept = crypto.createHash('sha1')
    
        .update(secWebSocketKey + defayltKey)
    
        .digest('base64');
    
    console.log(secWebSocketAccept);
    
    // Oy4NRAQ13jhfONC7bP8dTKb4PTU=
    
    

    其中“258EAFA5-E914-47DA-95CA-C5AB0DC85B11”是官方定义的固定字符串,也就是俗称写死的配置项

    一旦Websocket握手成功,服务端和客户端就会呈现对等效果,就能双工发送和接收消息。并且当前连接不再进行HTTP交互,而是使用Websocket数据帧进行交互。

    可以看出,ws建立会基于HTTP请求并进行协议切换,底层还是基于TCP传输,但是会在建立的时候,复用HTTP的请求。

    2、数据帧讲解

    既然是基于TCP连接的,那么在客户端、服务端数据的交换,离不开数据帧格式的定义。需要根据RFC6455定义的格式进行数据帧的设计,这是有固定格式的数据帧实现,即官方已经定义了数据帧格式,当然也可以自定义frame,实现私有协议传输等等。

    下边我们一步步来看ws协议数据帧的设计思路和代码实现

    (1)ws数据帧格式

    
          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、RSV1各占据1比特,opcode占据4比特。

    分别说明各个字段的意义:

    • FIN:标识这一帧数据是否是该分块的最后一帧

    1 为最后一帧

    0  不是最后一帧。需要分为多个帧传输
    
    • rsv1-3: 默认为0,用于扩展,当有已协商扩展可能被设置成1

    • opcode:长度为4的操作码,就是定义了该数据是什么,如果不为定义内的值则连接中断。

    0 表示一个继续帧,顾名思义,就是完整消息对应的数据帧还没接收完

    1  表示一个文本帧
    
    2  表示一个二进制帧
    
    3-7  为以后的非控制帧保留
    
    8  表示一个连接关闭
    
    9  表示一个ping(心跳检测使用,Ping Frame用来对验证对方是否有响应)
    
    10  表示一个pong (Pong Frame就是对Ping的回应)
    
    11-15  为以后的控制帧保留
    
    • masked:是否掩码处理,长度1。安全用

    1 客户端发送数据到服务端

    0  服务端发送数据到客户端
    
    • payload length:表示Payload data的总长度

    0-125 则是payload的真实长度

    126  则后边16位值是payload的真实长度,125<数据长度<65535
    
    127  则后面64位值是payload的真实长度,数据长度>65535
    
    • masking key:0或4字节,当masked为1的时候才存在,为4个字节,否则为0,用于对我们需要的数据进行解密

    • payload data:真正数据。

    说了这么多,不如举个例子:

    发送方传递‘hello’,接收方回复‘Taobao’:

    由于‘hello’比较短,不存在分帧形式,以文本形式发送,那么它的payload length长度是40,二进制表示为0101000,所以报文应该如下:

    fin(1)+rsv(000)+opcode(0001)+masked(1)+payload length(0101000)+masking key(32位)+payload data(hello加密后的二进制)

    当接收方通过data事件接收到这些二进制数据之后,会解析相应的帧,通过掩码将真正的数据解密出来,触发onmessage()执行。回复无须掩码,如下所示:

    fin(1)+rsv(000)+opcode(0001)+masked(0)+payload length(0000110) +payload data(Taobao的二进制)

    如果是多帧情况,会进行数据分帧操作:

    • FIN=0,opcode=0x1,表示发送的是文本类型,且消息还没发送完成,还有后续的数据帧。

    • FIN=0,opcode=0x0,表示消息还没发送完成,还有后续的数据帧,当前的数据帧需要接在上一条数据帧之后。

    • FIN=1,opcode=0x0,表示消息已经发送完成,没有后续的数据帧,当前的数据帧需要接在上一条数据帧之后。服务端可以将关联的数据帧组装成完整的消息。

    发送一个“hello and a happy new !”进行分四帧操作

    Client: FIN=1, opcode=0x1, msg="hello"

    Server: (process complete message immediately) Hi.

    Client: FIN=0, opcode=0x1, msg="and a"

    Server: (listening, new message containing text started)

    Client: FIN=0, opcode=0x0, msg="happy new"

    Server: (listening, payload concatenated to previous message)

    Client: FIN=1, opcode=0x0, msg="year!"

    Server: (process complete message) Happy new year to you too!

    (2)协议代码实现

    可以看到ws的协议帧设计的还是比较复杂的,下边抽重要来讲解一下,那些背后的故事之如何来设计实现一套协议。

    1、获取标志位

    从报文格式可以看出,里面的最重要的就是怎么获取到相应的标志位,这里要用到的就是位运算,比如上文中的一串标志位:

    fin(1)+rsv(000)+opcode(0001)+masked(1)+payload length(0101000)

    我们先取前三个标志位,fin+rsv+opcode,这三个标志位,一共对应8位,那么利用readUInt8(0)可以读取前8 bit的值,怎么获取fin值呢,fin是里面的第一个数值,也是最高位,利用与0x80进行与运算,0x80中0x是十六进制,对应十进制就是816+01=128,二进制是10000000,与运算特点是当有1进行与运算时候,结果都是1,那么前八位和0x80进行与运算,就只有最高位有1被特别标明计算。

    
    10000001
    
    &10000000(0x80)
    
    ---------------
    
    1...(fin只关注第一位)
    
    

    同理,我们要获取opcode,后四位,它对应的关键钥匙是0x0f。

    那么怎么找这种对应的关键钥匙,用反推法即可,比如我要找payload length对应的关键钥匙,那么可以看到它对应7位Bit,那么二进制应该是(01111111),对应的十六进制是0x7f,那么拿这个关键钥匙进行与运算就可以了。

    
    processBuffer() {
    
        const buf = this.buffer;
    
        if (buf.length < 2) {
    
          return;
    
        }
    
        let idx = 2;  //操作指针,指针指哪里,就操作哪里
    
        const byte1 = buf.readUInt8(0); // 读取数据帧的前 8 bit
    
        const FIN = byte1 & 0x80; // 获取高位 bit
    
        const opcode = byte1 & 0x0f; //截取第一个字节的后 4 位,即 opcode 码
    
        const byte2 = buf.readUInt8(1); // 读取数据帧第二个字节
    
        const MASK = byte2 & 0x80; // 判断是否有掩码,客户端必须要有,获取高位 bit
    
        const length = byte2 & 0x7f; //获取length属性,也是小于126数据长度的数据真实值
    
      ......
    
        }
    
    }
    
    
    
    

    2、数据帧分帧

    当长度大于MAX_FRAME_SIZE,进行分帧处理

    
    const MAX_FRAME_SIZE = 1024; // 最长长度限制
    
    //在内部发送数据方法中进行分帧操作
    
      _doSend(opcode, payload) {
    
        const len = Buffer.byteLength(payload); // 获取buffer长度
    
        // 分片的距离逻辑
    
        let count = 0;
    
        while (len > MAX_FRAME_SIZE) {
    
          const framePayload = payload.slice(0, MAX_FRAME_SIZE);
    
          payload = payload.slice(MAX_FRAME_SIZE);
    
          this.socket.write(
    
            encodeMessage(
    
              count > 0 ? OPCODES.CONTINUE : opcode,//opcode 0的时候继续接收数据
    
              framePayload,
    
              false
    
            )
    
          ); //编码后直接通过socket发送
    
          count++;
    
          len = Buffer.byteLength(payload);
    
        }
    
        this.socket.write(
    
          encodeMessage(count > 0 ? OPCODES.CONTINUE : opcode, payload)
    
        ); //编码后直接通过socket发送
    
      }
    
    

    抽取了一些点进行了讲解,如果需要自己实现一套协议,还需要考虑很多方面,例如压缩方案(ws用的是deflatenode zlib模块自带,安全方案(ws里面的掩码和wss),断线重连(onclose中重连和心跳检测处理))

    3、私有协议的思考

    如果想设计私有协议,首先考虑使用的业务场景,根据需求点,设计相应的frame帧,然后编解码方法,最后考虑特殊情况。

    大体思路步骤如下:

    • (1)数据帧格式设计,其中应该包括数据位、标志位(控制状态开启,记录长度、数据等)

    • (2)接收方和发送方的对数据帧的编码和解码,即拼接二进制buffer和解码二进制buffer数据

    • (3)特殊情况和异常情况处理,例如大数据下的分段发送、压缩报文,数据安全防篡改等等,多和frame特定字段有关,需要读取相应帧字段后再操作,根据帧字段值进行处理。

    上边需要的技术储备:

    • (1)Node buffer的各种操作:新建、拼接、处理frame Node Buffer

    • (2)位操作:获取标志位等 MDN 位操作

    • (3)Node 流操作:在传输数据中,大数据情况下,stream优于buffer。很多协议都是基于双向流或者管道流进行封装

    当然,技术选型上最好还是使用已有方案和协议,避免过多的雷和坑,但是事情不是绝对的~

    未完待续:

    上边是我从ws开始着手并对协议的一点思考,后续会根据实际的业务需求,再进行多方面的扩展,会在接下来几期进行其他方面的讲解,敬请期待

    • 压缩策略

    • 报文操作之算法篇

    • 安全策略

    相关文章

      网友评论

          本文标题:聊聊从Websocket到协议设计的思考

          本文链接:https://www.haomeiwen.com/subject/fnsulqtx.html