为什么会有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协议的优点
开销少、时时性高、二进制支持完善、支持扩展、压缩更优。
- 支持双向通信,实时性更强
- 更好的二进制支持
- 较少的控制开销
连接创建后,ws客户端、服务端进行数据交换时,协议控制的数据包头部较小。在不包含头部的情况下,服务端到客户端的包头只有2~10字节(取决于数据包长度),客户端到服务端的的话,需要加上额外的4字节的掩码。而HTTP协议每次通信都需要携带完整的头部;
支持扩展
ws协议定义了扩展,用户可以扩展协议,或者实现自定义的子协议(比如支持自定义压缩算法等)
4. 应用
- 聊天
- 实时弹幕
- 多人游戏
- 多用户编辑
- 实时股价
二、 WebSocket API
1. WebSocket的实例
实例一个webSocket对象并传入要连接的URL(绝对URL)。同源策略对webSocket不适用,因此可以通过它打开任何站点的连接。至于是否与某个于中的页面通信,则完全取决于服务器(通过握手信息知道请求来自何方)
1 | const ws= new WebSocket(URL) |
实例化WebSocket对象之后,浏览器就会马上尝试创建连接。与XHR类似,WebSocket也有一个表示当前状态的readyState属性
1 | WebSocket.OPENING(0):正在建立连接 |
要关闭WebSOcket连接,可以再任何时候调用close()方法。调用了close方法之后,readyState的值立即变成2(正在关闭),而在关闭连接后就会变成3
1 | socket.close() |
2. 发送和接收数据
WebSocket打开之后就可以通过连接发送/接收数据了
发送数据:socket.send(data)
只能通过连接发送纯文本数据,可以通过JSON.stringfy对数据进行序列化成一个JSON字符串
接收数据:socket.onmessage= (event)=>{}
当服务器向客户端发送消息时,ws对象就会触发message事件
3. 其他事件
ws对象还有其他三个事件,在连接生命周期的不同阶段触发
- open:成功建立连接时触发
- error:在发生错误时触发,连接不能持续
- close:在连接关闭时触发
这三个事件中只有close事件的event有额外的信息:wasClean、code、reason
- wasClean:布尔值,表示连接是否已经明确的关闭
- code:服务器返回的数值状态码
reason:字符串,包含服务器返回的信息
ws对象不支持DOM2级事件侦听器,必须使用DOM0级语法分别定义每个事件处理程序
1
2
3
4
5
6
7
8
9
10const ws= new WebSocket('ws://localhost:2222')
ws.onopen= ()=>{
console.log('open')
ws.send('客户端发送的消息')
}
// 监听服务端发来的消息
ws.onmessage= (e)=>{
console.log(e.data)
}
nodejs:nodejs-websocket1
2
3
4
5
6
7
8
9
10
11
12
13const 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,只有支持这种协议的专门服务器才能正常工作。
- 客户端:发起协议升级请求,采用的是标准的HTTP报文格式,且只支持GET方法
1
2
3
4
5
6
7
8GET / 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 | Upgrade: websocket 表示要升级到websocket协议 |
- 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 | HTTP/1.1 101 Switching Protocols |
- 状态码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计算出来
- 将Sec-WebSocket-Key跟258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接
- 通过SHA1计算出摘要,并转成base64字符串
1 | >toBase64( sha1( Sec-WebSocket-Key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 ) ) |
验证下前面的返回结果
1 | const crypto = require('crypto'); |
四、数据帧格式
- bit比特| Byte字节,1Byte=8bit(位)
WebSocket协议中规定传输时并不是直接使用 unicode 编码进行传输,而是使用帧(frame),由1个或多个帧组成一条完整的消息(message)
数据帧协议定义了带有操作码的帧类型,有效载荷长度,以及“扩展数据”和指定位置应用程序数据,它们共同定义“有效载荷数据”。某些位和操作码保留用于将来的扩展协议。
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字节
- 扩展数据:
如果没有协商使用扩展的话,扩展数据数据为0字节。所有的扩展都必须声明扩展数据的长度,或者可以如何计算出扩展数据的长度。此外,扩展如何使用必须在握手阶段就协商好。如果扩展数据存在,那么载荷数据长度必须将扩展数据的长度包含在内 - 应用数据:
任意的应用数据,在扩展数据之后(如果存在扩展数据),占据了数据帧剩余的位置。载荷数据长度 减去 扩展数据长度,就得到应用数据的长度。
掩码的算法
掩码算法:按位做循环异或运算,先对该位的索引取模来获得 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 | // WebSocket服务端向客户端发送ping |