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

前言

因为最近团队在改造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开始着手并对协议的一点思考,后续会根据实际的业务需求,再进行多方面的扩展,会在接下来几期进行其他方面的讲解,敬请期待

  • 压缩策略

  • 报文操作之算法篇

  • 安全策略

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,588评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,456评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,146评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,387评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,481评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,510评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,522评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,296评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,745评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,039评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,202评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,901评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,538评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,165评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,415评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,081评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,085评论 2 352

推荐阅读更多精彩内容

  • 一、内容概览 WebSocket的出现,使得浏览器具备了实时双向通信的能力。本文由浅入深,介绍了WebSocket...
    Calvin李阅读 2,507评论 2 10
  • 昨日,梦中频作各种怪境,且敷衍连连:或悄然忘身于茅屋竹月,或昏惨悬系于荒岛孤舟、再或焦灼曝栗于烈焰峡谷、亦或奔逃回...
    阜易阅读 222评论 0 1
  • 《若苒若琪妈成长日记第80篇》 《笠翁对韵》 《黄帝内经 《易经》上 最近突然感到力不从心。每天的生活都很忙碌。给...
    赵洁_da6b阅读 309评论 0 0
  • 我们常常会因为别人的行为 不符合自己的价值观而烦恼, 看不惯甚至生气, 其实这种心态只是自寻烦恼而已, 既帮不了他...
    心若了无尘阅读 379评论 0 4