深入浅出WebSocket

为什么会有WebSocket

HTTP的生命周期通过Request来界定,也就是一个Request一个Response,那么在HTTP1.0中,这次HTTP请求就结束了。在HTTP1.1中进行了改进,使得有一个keep-alive,也就是说,在一个HTTP连接中,可以发送多个Request,接收多个Response。但是请记住Request = Response, 在HTTP中永远是这样,一个request对应一个response(像不像Map的key-value键值对)。而且这个response也是被动的,不能主动发起。

WebSocket

WebSocket是一种在单个TCP连接上进行全双工通信的协议(全双工、双向通信),属于应用层协议。它基于TCP传输协议,并复用HTTP的握手通道。

WebSocket是基于HTTP协议的,或者说借用了HTTP的协议来完成一部分握手。WebSocket允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

一、 WebSockst协议

webSocket使用了自定义的协议,未加密的连接时ws://,加密的连接是wss://

1. 使用自定义协议而非HTTP协议的好处

能够在客户端和服务器之间发送非常少量的数据,而不必担心HTTP那样字节级的开销。传送的数据包很小,适用于移动应用。

2. 使用自定义协议的缺点

制定协议的时间比指定JS API的事件还要久(安全问题)

3. WebSockst协议的优点

开销少、时时性高、二进制支持完善、支持扩展、压缩更优。

  1. 支持双向通信,实时性更强
  2. 更好的二进制支持
  3. 较少的控制开销

连接创建后,ws客户端、服务端进行数据交换时,协议控制的数据包头部较小。在不包含头部的情况下,服务端到客户端的包头只有2~10字节(取决于数据包长度),客户端到服务端的的话,需要加上额外的4字节的掩码。而HTTP协议每次通信都需要携带完整的头部;

  1. 支持扩展

    ws协议定义了扩展,用户可以扩展协议,或者实现自定义的子协议(比如支持自定义压缩算法等)

4. 应用

  • 聊天
  • 实时弹幕
  • 多人游戏
  • 多用户编辑
  • 实时股价

二、 WebSocket API

1. WebSocket的实例

实例一个webSocket对象并传入要连接的URL(绝对URL)。同源策略对webSocket不适用,因此可以通过它打开任何站点的连接。至于是否与某个于中的页面通信,则完全取决于服务器(通过握手信息知道请求来自何方)

1
const ws= new WebSocket(URL)

实例化WebSocket对象之后,浏览器就会马上尝试创建连接。与XHR类似,WebSocket也有一个表示当前状态的readyState属性

1
2
3
4
WebSocket.OPENING(0):正在建立连接
WebSocket.OPEN(1):已经建立连接
WebSocket.CLOSING(2):正在关闭连接
WebSocket.CLOSE(3):已经关闭连接

要关闭WebSOcket连接,可以再任何时候调用close()方法。调用了close方法之后,readyState的值立即变成2(正在关闭),而在关闭连接后就会变成3

1
socket.close()

2. 发送和接收数据

WebSocket打开之后就可以通过连接发送/接收数据了

  1. 发送数据:socket.send(data)

    只能通过连接发送纯文本数据,可以通过JSON.stringfy对数据进行序列化成一个JSON字符串

  2. 接收数据:socket.onmessage= (event)=>{}

    当服务器向客户端发送消息时,ws对象就会触发message事件

3. 其他事件

ws对象还有其他三个事件,在连接生命周期的不同阶段触发

  • open:成功建立连接时触发
  • error:在发生错误时触发,连接不能持续
  • close:在连接关闭时触发

这三个事件中只有close事件的event有额外的信息:wasClean、code、reason

  1. wasClean:布尔值,表示连接是否已经明确的关闭
  2. code:服务器返回的数值状态码
  3. reason:字符串,包含服务器返回的信息

    ws对象不支持DOM2级事件侦听器,必须使用DOM0级语法分别定义每个事件处理程序

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    const ws= new WebSocket('ws://localhost:2222')
    ws.onopen= ()=>{
    console.log('open')
    ws.send('客户端发送的消息')

    }
    // 监听服务端发来的消息
    ws.onmessage= (e)=>{
    console.log(e.data)
    }

nodejs:nodejs-websocket

1
2
3
4
5
6
7
8
9
10
11
12
13
const ws= require('nodejs-websocket')

const server= ws.createServer((conn)=>{
console.log('new connection')
// 监听客户端发送的消息
conn.on('text',(data)=>{
console.log(data)
})

setTimeout(()=>{
conn.sendText('服务端发送的消息')
},3000)
}).listen(2222)

三、 如何建立连接

  • Websocket 通过HTTP/1.1协议的101状态码进行握手
  • 客户端通过HTTP请求与WebSocket服务端协商升级协议。协议升级完成后,后续的数据交换则遵照WebSocket的协议。

    在JS创建webSocket之后,会有一个HTTP请求发送到浏览器以发起连接。在取得服务器响应后,建立的连接会使用HTTP升级从HTTP协议交换为WebSocket协议。使用标准的HTTP服务器无法实现WebSocket,只有支持这种协议的专门服务器才能正常工作。

  1. 客户端:发起协议升级请求,采用的是标准的HTTP报文格式,且只支持GET方法
    1
    2
    3
    4
    5
    6
    7
    8
    GET / HTTP/1.1
    Host: localhost:8080
    Origin: [url=http://127.0.0.1:3000]http://127.0.0.1:3000[/url]
    Connection: Upgrade
    Upgrade: websocket
    Sec-WebSocket-Version: 13
    Sec-WebSocket-Key: w4v7O6xFTi36lq3RNcgctw==
    Sec-WebSocket-Protocol: chat, superchat

WebSocket 握手时使用的并不是 WebSocket 协议,而是 HTTP协议,握手时发出的请求可以叫做升级请求。客户端在握手阶段通过

1
2
Upgrade: websocket  表示要升级到websocket协议
Connection: Upgrade 表示要升级协议
  • Connection、Upgrade:告知服务端,要求将通信的协议转换为 websocket
  • Sec-WebSocket-Version:表示websocket的版本。如果服务端不支持该版本,需要返回一个Sec-WebSocket-Versionheader,里面包含服务端支持的版本号。
  • Sec-WebSocket-Protocol:协议约定
  • Sec-WebSocket-Key:作为一个防止无端连接的保障(其实并没有什么保障作用,因为 key 的值完全由客户端控制,服务端并无验证机制),与后面服务端响应首部的Sec-WebSocket-Accept是配套的,提供基本的防护,比如恶意的连接,或者无意的连接。

上面请求省略了部分非重点请求首部。由于是标准的HTTP请求,类似Host、Origin、Cookie等请求首部会照常发送。在握手阶段,可以通过相关请求首部进行 安全限制、权限校验等。

  1. 服务端:响应协议升级
1
2
3
4
HTTP/1.1 101 Switching Protocols
Connection:Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: Oy4NRAQ13jhfONC7bP8dTKb4PTU=
  • 状态码101:表示服务器已经理解了客户端的请求,进行协议切换
  • 并且回复 Connection、Upgrade:表示已经切换成 websocket 协议。
  • Sec-WebSocket-Accept:是经过服务器确认,并且加密过后的 Sec-WebSocket-Key

这样,客户端与服务端就完成了握手操作,达成一致,后续的数据交互都使用 WebSocket 协议进行通信

每个header都以\r\n结尾,并且最后一行加上一个额外的空行\r\n。此外,服务端回应的HTTP状态码只能在握手阶段使用。过了握手阶段后,就只能采用特定的错误码。

Sec-WebSocket-Accept的计算

Sec-WebSocket-Accept根据客户端请求首部的Sec-WebSocket-Key计算出来

  1. 将Sec-WebSocket-Key跟258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接
  2. 通过SHA1计算出摘要,并转成base64字符串
1
>toBase64( sha1( Sec-WebSocket-Key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 )  )

验证下前面的返回结果

1
2
3
4
5
6
7
8
9
10
const crypto = require('crypto');
const magic = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const secWebSocketKey = 'w4v7O6xFTi36lq3RNcgctw==';

let secWebSocketAccept = crypto.createHash('sha1')
.update(secWebSocketKey + magic)
.digest('base64');

console.log(secWebSocketAccept);
// Oy4NRAQ13jhfONC7bP8dTKb4PTU=

四、数据帧格式

  • bit比特| Byte字节,1Byte=8bit(位)

WebSocket协议中规定传输时并不是直接使用 unicode 编码进行传输,而是使用帧(frame),由1个或多个帧组成一条完整的消息(message)

数据帧协议定义了带有操作码的帧类型,有效载荷长度,以及“扩展数据”和指定位置应用程序数据,它们共同定义“有效载荷数据”。某些位和操作码保留用于将来的扩展协议。
image

1. FIN(1bit)

  • 0:不是消息的最后一个分片(fragment)
  • 1:是消息的最后一个分片

    表明该frame 是否是信息的最后一个,因为信息可以分多个frame包传送,但最终客户端接收的是整个数据;

2. RSV1、RSV2、RSV3(1bit)

一般情况下全为 0。当客户端、服务端协商采用 WebSocket 扩展时,这三个标志位可以非 0,且值的含义由扩展进行定义。如果出现非零的值,且并没有采用 WebSocket 扩展,连接出错。

3. Opcode(4bit)

操作代码, 表示传送frame的类型,Opcode的值决定了应该如何解析后续的数据载荷(data payload)

  • %x0:表示一个延续帧。当 Opcode为0 时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片
  • %x1:表示这是一个文本帧(text frame)
  • %x2:表示这是一个二进制帧(binary frame)
  • %x3-7:保留的操作代码,用于后续定义的非控制帧
  • %x8:表示连接断开
  • %x9:表示这是一个心跳请求(ping)
  • %xA:表示这是一个心跳响应(pong)
  • %xB-F:保留的操作代码,用于后续定义的控制帧

4. mask(1bit)

表示是否要对数据载荷进行掩码异或操作。(0否/1是)

掩码操作

  • 从客户端向服务端发送数据时,需要对数据进行掩码操作;从服务端向客户端发送数据时,不需要对数据进行掩码操作。
  • 如果服务端接收到的数据没有进行过掩码操作,服务端需要断开连接。
  • 如果Mask是1,那么在Masking-key中会定义一个掩码键(masking key),并用这个掩码键来对数据载荷进行反掩码。所有客户端发送到服务端的数据帧,Mask都是1。

5. Payload length

表示数据载荷的长度,单位是字节。为7位或7+16位或7+64位

  • 0~126:数据的长度等于该值字节
  • 126:后续 2 个字节代表一个 16 位的无符号整数,该无符号整数的值为数据的长度
  • 127:后续 8 个字节代表一个 64 位的无符号整数(最高位为 0),该无符号整数的值为数据的长度。

如果payload length占用了多个字节的话,payload length的二进制表达采用网络序(big endian,重要的位在前)

6. Masking-key(0 or 4字节)

所有从客户端传送到服务端的数据帧,数据载荷都进行了掩码操作,Mask为1,且携带了4字节的Masking-key。如果Mask为0,则没有Masking-key

载荷数据的长度,不包括mask key的长度

7. Payload Data: 载荷数据

包括了扩展数据、应用数据。其中,扩展数据x字节,应用数据y字节

  1. 扩展数据:
    如果没有协商使用扩展的话,扩展数据数据为0字节。所有的扩展都必须声明扩展数据的长度,或者可以如何计算出扩展数据的长度。此外,扩展如何使用必须在握手阶段就协商好。如果扩展数据存在,那么载荷数据长度必须将扩展数据的长度包含在内
  2. 应用数据:
    任意的应用数据,在扩展数据之后(如果存在扩展数据),占据了数据帧剩余的位置。载荷数据长度 减去 扩展数据长度,就得到应用数据的长度。

掩码的算法

掩码算法:按位做循环异或运算,先对该位的索引取模来获得 Masking-key 中对应的值 x,然后对该位与 x 做异或,从而得到真实的 byte 数据。

掩码的作用并不是为了防止数据泄密,而是为了防止早期版本的协议中存在的代理缓存污染攻击(proxy cache poisoning attacks)等问题。

五、数据传输

一旦WebSocket客户端、服务端建立连接后,后续的操作都是基于数据帧的传递。WebSocket根据opcode来区分操作的类型。比如0x8表示断开连接,0x0-0x2表示数据交互。

1. 数据分片

WebSocket的每条消息可能被切分成多个数据帧。当WebSocket的接收方收到一个数据帧时,会根据FIN的值来判断,是否已经收到消息的最后一个数据帧。

2. 连接保持+心跳

WebSocket为了保持客户端、服务端的实时双向通信,需要确保客户端、服务端之间的TCP通道保持连接没有断开。然而,对于长时间没有数据往来的连接,如果依旧长时间保持着,可能会浪费包括的连接资源。

服务端不可能让所有的连接都一直保持,所以服务端通常会在一个定期的时间给客户端发送一个 ping 帧,而客户端收到 Ping 帧后则回复一个 Pong 帧,如果客户端不响应,那么服务端就会主动断开连接。

  • opcode 帧为 0x09 则代表这是一个 Ping
  • 0x0A 则代表这是一个 Pong。
1
2
// WebSocket服务端向客户端发送ping
ws.ping('', false, true);