WebSocket

292 阅读13分钟

为什么需要WebSocket?

我们已经有了 HTTP 协议,为什么还需要另一个协议?它能带来什么好处?

答案很简单,因为 HTTP 协议有一个缺陷:通信只能由客户端发起。

举例来说,我们想了解今天的天气,只能是客户端向服务器发出请求,服务器返回查询结果。HTTP 协议做不到服务器主动向客户端推送信息。

什么是WebSocket

WebSocket 是一种网络传输协议,可在单个TCP连接上进行全双工通信,位于OSI模型的应用层。WebSocket协议在2008年诞生,在2011年由IETF标准化为RFC 6455,后由RFC 7936补充规范。Web IDL中的WebSocket API由W3C标准化。

WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性的连接,并进行双向数据传输。

特点

可以由服务端向客户端主动推送消息,也可以由客户端主动向服务器端发送消息

  • 建立在 TCP 协议之上,服务器端的实现比较容易。

  • 与 HTTP 协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。

  • 数据格式比较轻量,性能开销小,通信高效。

  • 可以发送文本,也可以发送二进制数据,多字节的消息作为整体、按照顺序到达。

  • 没有同源限制,客户端可以与任意服务器通信。

  • 协议标识符是ws(如果加密,则为wss),服务器网址就是 URL。

HTML中的WebSocket

不同于http协议的短连接、无状态等特点,WebSocket 是一种长链接、持久性链接。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。(在 WebSocket API 中,浏览器和服务器只需要做一个握手的动作,然后,浏览器和服务器之间就形成了一条快速通道。两者之间就直接可以数据互相传送。)

使用场景:

可用于主动向客户端推送消息,用于聊天室或消息提醒等功能

优点:

websockets建立一次链接,长久通讯;节省服务器资源和带宽,并且能够更实时地进行通讯,避免轮询所产生的 请求一次都要三次握手。请求完毕就会断开链接,每断开一次都要四次挥手;

WebSocket连接

WebSocket(url[, protocols]) 返回一个 WebSocket 对象

var ws = new WebSocket('ws://localhost:8080');

执行上面语句之后,客户端就会与服务器进行连接。Websocket是应用层第七层上的一个应用层协议,它必须依赖 HTTP 协议进行一次握手 ,握手成功后,数据就直接从 TCP 通道传输,与 HTTP 无关了。即:websocket分为握手和数据传输阶段,即进行了HTTP握手 + TCP连接。

握手阶段

可以看到:

Request URL: ws://localhost:3001/ifc/userinfo
Request Method: GET
Status Code: 101 Switching Protocols

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

版本号和子协议规定了双方能理解的数据格式,以及是否支持压缩等等。如果仅使用WebSocket的API,就不需要关心这些。

现在,一个WebSocket连接就建立成功,浏览器和服务器就可以随时主动发送消息给对方。消息有两种,一种是文本,一种是二进制数据。通常,我们可以发送JSON格式的文本,这样,在浏览器处理起来就十分容易。

为什么WebSocket连接可以实现全双工通信而HTTP连接不行呢?实际上HTTP协议是建立在TCP协议之上的,TCP协议本身就实现了全双工通信,但是HTTP协议的请求-应答机制限制了全双工通信。WebSocket连接建立以后,其实只是简单规定了一下:接下来,咱们通信就不使用HTTP协议了,直接互相发数据吧。

安全的WebSocket连接机制和HTTPS类似。首先,浏览器用 wss://xxx 创建WebSocket连接时,会先通过HTTPS创建安全的连接,然后,该HTTPS连接升级为WebSocket连接,底层通信走的仍然是安全的SSL/TLS协议。 接下来先看Request Headers

  • Connection: Upgrade 表示要升级协议
  • Upgrade: websocket 要升级协议到websocket协议
  • Sec-WebSocket-Version 表示websocket的版本。如果服务端不支持该版本,需要返回一个Sec-WebSocket-Versionheader,里面包含服务端支持的版本号。
  • Sec-WebSocket-Key 对应服务端响应头的Sec-WebSocket-Accept,由于没有同源限制,websocket客户端可任意连接支持websocket的服务。这个就相当于一个钥匙一把锁,避免多余的,无意义的连接。

以及服务端响应的 Response Headers

下面是 HTTP最后负责的区域,告知客户端,我已成功切换协议

Upgrade: websocket 
Connection: Upgrade

固定的,告诉客户端即将升级的是Websocket协议,Sec-WebSocket-Accept 这个则是经过服务器确认,并且加密过后的 Sec-WebSocket-Key,加密前的为 Request Headers 中的 Sec-WebSocket-Key

Sec-WebSocket-Accept: 1UICHxfa2f/7BPbUtf8Y4160J/k=

实现方式如下:

// 指定拼接字符 该值貌似为固定常量
var ws_key = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
// 生成相应key
function getAccpectKey(rSWKey) {
  return crypto.createHash('sha1').update(rSWKey + ws_key).digest('base64')
}

数据传输阶段

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

一条消息可以分为几个frame,按照先后顺序传输出去。这样做有几个好处:

  • 大数据的传输可以分片传输,不用考虑到数据大小导致的长度标志位不足够的情况。
  • 和http的chunk一样,可以边生成数据边传递消息,即提高传输效率。 基本帧协议如下:
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位,用来表明这是一个消息的最后的消息片断,当然第一个消息片断也可能是最后的一个消息片断,否则为0;

  • RSV1, RSV2, RSV3: 分别都是1位,如果双方之间没有约定自定义协议,那么这几位的值都必须为0,否则必须断掉WebSocket连接;

  • Opcode: 4位操作码,定义有效负载数据,表示帧的类型,比如是文本类型还是二进制类型,如果收到了一个未知的操作码,连接也必须断掉,以下是定义的操作码:

*  %x0 表示一个延续帧。当 Opcode 为 0 时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片。
*  %x1 表示文本消息片断
*  %x2 表示二进制消息片断
*  %x3-7 为将来的非控制消息片断保留的操作码
*  %x8 表示连接关闭
*  %x9 表示心跳检查的ping
*  %xA 表示心跳检查的pong
*  %xB-F 为将来的控制消息片断的保留操作码
  • Mask: 1位,定义传输的数据是否有加掩码,如果设置为1,掩码键必须放在masking-key区域,客户端发送给服务端的所有消息,此位的值都是1;为 0 时,不添加掩码,没有masking-key

  • Payload length: 传输数据的长度,以字节的形式表示:7位、7+16位、或者7+64位。

    如果这个值以字节表示是0-125这个范围,那这个值就表示传输数据的长度;如果这个值是126,则随后的两个字节表示的是一个16进制无符号数,用来表示传输数据的长度;如果这个值是127,则随后的是8个字节表示的一个64位无符合数,这个数用来表示传输数据的长度。多字节长度的数量是以网络字节的顺序表示。负载数据的长度为扩展数据及应用数据之和,扩展数据的长度可能为0,因而此时负载数据的长度就为应用数据的长度。

  • Masking-key: 0或4个字节,客户端发送给服务端的数据,都是通过内嵌的一个32位值作为掩码的;掩码键只有在掩码位设置为1的时候存在。

  • Payload data: (x+y)位,负载数据为扩展数据及应用数据长度之和。

    • Extension data: x位,如果客户端与服务端之间没有特殊约定,那么扩展数据的长度始终为0,任何的扩展都必须指定扩展数据的长度,或者长度的计算方式,以及在握手时如何确定正确的握手方式。如果存在扩展数据,则扩展数据就会包括在负载数据的长度之内。

    • Application data: y位,任意的应用数据,放在扩展数据之后,应用数据的长度=负载数据的长度-扩展数据的长度。

小结

基础数据帧协议通过ABNF(增强型巴科斯范式)进行了正式的定义。需要重点知道的是,这些数据都是二进制的,而不是ASCII字符。例如,长度为1 bit的字段的值为%x0 / %x1代表的是一个值为0/1的单独的bit,而不是一整个字节(8 bit)来代表ASCII编码的字符“0”和“1”。一个长度为4 bit的范围是%x0-F的字段值代表的是4个bit,而不是字节(8 bit)对应的ASCII码的值。不要指定字符编码:“规则解析为一组最终的值,有时候是字符。在ABNF中,字符仅仅是一个非负的数字。在特定的上下文中,会根据特定的值的映射(编码)编码集(例如ASCII)”。在这里,指定的编码类型是将每个字段编码为特定的bits数组的二进制编码的最终数据。

WebSocket中的属性及事件

事件

  • open

    当客户和websocket客户端连接成功的时候就会触发,事件需要用addEventListener绑定:比如open事件注册示例

    websocket.addEventListener('open',function(e){
      console.log(e);
    });
    websocket.onopen= function(event) {
      // handle open event
    };
    
  • message

    客户端接受到服务器返回的数据时,会触发message事件,所以我们监听这个事件并注册回调函数就行:

    websocket.addEventListener('message',function(e){
      console.log(event);
      console.log(event.data);// event里的data属性,就是服务器返回的数据
    });
    websocket.onmessage= function(event) {
      // handle message event
    };
    
  • close 连接断开的时候触发的事件,同上绑定方法。

    websocket.addEventListener('close',function(e){
      console.log(event);
      console.log(event.data);// event里的data属性,就是服务器返回的数据
    });
    websocket.onclose= function(event) {
      // handle close event
    };
    
  • error 用于指定连接失败后的回调函数,同上绑定方法。

    websocket.addEventListener('error',function(e){
      console.log(event);
      console.log(event.data);// event里的data属性,就是服务器返回的数据
    });
    websocket.onerror= function(event) {
      // handle error event
    };
    

属性

  • WebSocket.protocol 是个只读属性,用于返回服务器端选中的子协议的名字;本质是客户端与服务器协商处理数据的方式,类似于HTTP请求头里带的Accept和Accept-Encoding头字段一样,说明自己你可以接受那些类型文件,或者编码。在建立连接的时候进行设置:
    this.socket = new WebSocket("ws://localhost:3001/ifc/userinfo",'jsonrpc');
    

  • WebSocket.extensions 是只读属性,返回服务器已选择的扩展值。目前,链接可以协定的扩展值只有空字符串或者一个扩展列表。

    就像其他的 HTTP 请求头字段一样,这个请求头字段可以被切割成几行或者几行合并成一行。因此,下面这两段是等价的:

    Sec-WebSocket-Extensions: foo
    Sec-WebSocket-Extensions: bar; baz=2
    

    是等价于:

    Sec-WebSocket-Extensions: foo, bar; baz=2
    
  • WebSocket.url 是一个只读属性,返回值为当构造函数创建WebSocket实例对象时URL的绝对路径。

  • WebSocket.readyState 返回当前 WebSocket 的链接状态,只读,共有四种。

    CONNECTING:值为0,表示正在连接。
    OPEN:值为1,表示连接成功,可以通信了。
    CLOSING:值为2,表示连接正在关闭。
    CLOSED:值为3,表示连接已经关闭,或者打开连接失败。
    

方法

  • WebSocket.close() 关闭当前连接或连接尝试(如果有的话)。如果连接已经关闭,则此方法不执行任何操作。
    • code (可选)

      一个数字状态码,它解释了连接关闭的原因。如果没有传这个参数,默认使用1005,如果客户端主动发起关闭请求,则code应传 1000。CloseEvent的允许的状态码见状态码列表。无效code则会抛出异常 INVALID_ACCESS_ERR

    • reason (可选)

      一个人类可读的字符串,它解释了连接关闭的原因。这个UTF-8编码的字符串不能超过123个字节,如超过则会抛出异常 SYNTAX_ERR

  • WebSocket.send(data) 方法将需要通过 WebSocket 链接传输至服务器的数据排入队列,并根据所需要传输的data bytes的大小来增加 bufferedAmount的值 。若数据无法传输(例如数据需要缓存而缓冲区已满)时,套接字会自行关闭。
    • data 用于传输至服务器的数据。它必须是以下类型之一:
      • USVString

        文本字符串。字符串将以 UTF-8 格式添加到缓冲区,并且 bufferedAmount 将加上该字符串以 UTF-8 格式编码时的字节数的值。

      • ArrayBuffer

        您可以使用一有类型的数组对象发送底层二进制数据;其二进制数据内存将被缓存于缓冲区,bufferedAmount 将加上所需字节数的值。

      • Blob

        Blob 类型将队列 blob 中的原始数据以二进制中传输。 bufferedAmount 将加上原始数据的字节数的值。blob的数据是逐渐上传,参见demo

        const blob = new Blob([sen], { type: "text/plain" });;
        this.socket.send(blob);
        console.log(`未发送至服务器的字节数:${this.socket.bufferedAmount}`);
        let timer = setInterval(()=>{
          console.log(`未发送至服务器的字节数:${this.socket.bufferedAmount}`);
          if (this.socket.bufferedAmount === 0) {
            clearInterval(timer)
          }
        },2)
        

      • ArrayBufferView

        您可以以二进制帧的形式发送任何 JavaScript 类数组对象 ;其二进制数据内容将被队列于缓冲区中。值 bufferedAmount 将加上必要字节数的值。