概述
学习WebSocket的一个背景:
- 平时开发很少有场景需要使用 websocket 技术。
- 之前想研究 WebSocket 但拖延一段时间就放弃了。
- 随着5G的到来,WebSocket 应用场景广。
WebSocket目前应用场景有:
- 通知: 由业务服务端发起,由客户端接收的场景,这类场景下业务通常会有兜底逻辑
- 聊天:服务端和客户端发双向消息进行交互,用在聊天场景
- 游戏:服务端和客户端做高频消息交互
- 语音:从客户端持续不断产生语音包,语音包由大语音包切分而来,需要在服务端重新做组合,要求大包传输 + 顺序性保证
- 直播:大量用户加入同一个直播间,同一直播间内的用户可发弹幕,礼物
- ioT:边缘节点设备,如单车,共享充电宝等
- 数据上报:从客户端持续上报数据到服务端
WebSocket理论知识
主要从 What/ Why 两个方面介绍:
1.什么是 WebSocket
简单理解可以分为两个部分 Web + Socket:
- Web 可以简单理解为浏览器环境;
- Socket 也就是 TCP 的 Socket(套接字)。说起 Socket ,先看看 tcp/ip的四层网络模型(下面两个图片的来源:揭开Socket编程的面纱):
而 Socket是在应用层和传输层之间的一个抽象层,它把TCP/IP层复杂的操作抽象为几个简单的接口供应用层调用已实现进程在网络中通信:
而浏览器环境是无法直接创建Socket的,一般情况网络请求都是通过 Http 来完成,而 WebSocket 可以近似理解为 Web 环境的基于 Socket 的网络传输协议,巧了 Http 也是基于 Socket 的协议,这也就是接下来需要讲的为什么需要WebSocket?
2.为什么需要WebSocket
一般情况下业务开发都是使用Http获取服务端数据,由客户端发起,服务器响应,对于Http来说有个天然的缺陷:通信只能由客户端发起。虽然H2支持服务端推送,但也只是针对再某一个请求的基础上,再推送一些相关内容,存在比较大的局限性。
举例来说,我们想了解今天的天气,只能是客户端向服务器发出请求,服务器返回查询结果。HTTP 协议做不到服务器主动向客户端推送信息。
来源:WebSocket 教程
这种单向请求的特点,注定了如果服务器有连续的状态变化,客户端要获知就非常麻烦。我们只能使用"轮询":每隔一段时候,就发出一个询问,了解服务器有没有新的信息。最典型的场景就是聊天室。
轮询的效率低,非常浪费资源(因为必须不停连接,或者 HTTP 连接始终打开)。因此,工程师们一直在思考,有没有更好的方法。WebSocket 就是这样发明的。WebSocket 协议在2008年诞生,2011年成为国际标准。所有浏览器都已经支持了。
一般说到 Http 和 WebSocket,会涉及Duplex的概念,Http 是 Half-Duplex protocol,而 WebSocket 是一个 Full Duplex protocol。 两者区别区别:独木桥和阳关道的区别。 HDP是指同一个连接,只能单向传输数据,FDP是可以双向传输数据。
3.WebSocket的实现原理
连接过程
试想一下,一个新技术出现,如果需要快速推广,快速方式有哪些? 我能想到的方式可能有:
- 像 JS 一样,抱 Java 的大腿,命名为 JavaScript,这可能也就是各种冒名品牌的原因。
- 像 Google 一样,创造SPDY, QUIC协议,然后挟用户以领标准组织,而成为h2,h3。
为了便于推广和应用,WebSocket 也借鉴JS当初的思路——“抱大腿,搭便车”,在使用习惯上尽量向HTTP靠拢。WebSocket没有使用TCP的“IP地址+端口号”,而是延用了HTTP的URI格式,但开头的协议名不是“http”,引入的是两个新的名字:“ws”和“wss”,分别表示明文和加密的WebSocket协议。在Http协议上面扩展一些请求/响应头,来实现客户端、服务器端的数据交互。WebSocket的默认端口也选择了80和443,因为现在互联网上的防火墙屏蔽了绝大多数的端口,只对HTTP的80、443端口“放行”,所以WebSocket就可以“伪装”成HTTP协议,比较容易地“穿透”防火墙,与服务器建立连接。
所谓的“搭便车”,是利用了HTTP本身的“协议升级”特性,“伪装”成HTTP。
WebSocket的握手是一个标准的HTTP GET请求,但要带上两个协议升级的专用头字段:
“Connection: Upgrade”,表示要求协议“升级”;
“Upgrade: websocket”,表示要“升级”成WebSocket协议。
另外,为了防止普通的HTTP消息被“意外”识别成WebSocket,握手消息还增加了两个额外的认证用头字段:
Sec-WebSocket-Key:一个Base64编码的16字节随机数,作为简单的认证密钥;
Sec-WebSocket-Version:协议的版本号,当前必须是13。
服务器收到HTTP请求报文,看到上面的四个字段,就知道这不是一个普通的GET请求,而是WebSocket的升级请求,于是就不走普通的HTTP处理流程,而是构造一个特殊的“101 Switching Protocols”响应报文,通知客户端,接下来就不用HTTP了,全改用WebSocket协议通信。(有点像TLS的“Change Cipher Spec”)
WebSocket的握手响应报文也是有特殊格式的,要用字段“Sec-WebSocket-Accept”验证客户端请求报文,同样也是为了防止误连接。
具体的做法是把请求头里“Sec-WebSocket-Key”的值,加上一个专用的UUID “258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,再计算`SHA-1摘要。
encode_base64( sha1( Sec-WebSocket-Key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' ))
客户端收到响应报文,就可以用同样的算法,比对值是否相等,如果相等,就说明返回的报文确实是刚才握手时连接的服务器,认证成功。
握手完成,后续传输的数据就不再是HTTP报文,而是WebSocket格式的二进制帧了。
二进制帧
WebSocket 客户端、服务端通信的最小单位是帧(frame),由 1 个或多个帧组成一条完整的消息(message),结构为:
开头的两个字节是必须的,也是最关键的。
- 第一个字节的第一位“FIN”是消息结束的标志位,表示数据发送完毕。一个消息可以拆成多个帧,接收方看到“FIN”后,就可以把前面的帧拼起来,组成完整的消息。
- “FIN”后面的三个位是保留位,目前没有任何意义,但必须是 0 。
- 第一个字节的后4位很重要,叫“Opcode”,操作码,其实就是帧类型,比如 1 表示帧内容是纯文本,2 表示帧内容是二进制数据,8 是关闭连接,9 和 10 分别是连接保活的 PING 和 PONG。
- 第二个字节第一位是掩码标志位“MASK”,表示帧内容是否使用异或操作(xor)做简单的加密。目前的 WebSocket 标准规定,客户端发送数据必须使用掩码,而服务器发送则必须不使用掩码。
- 第二个字节后7位是“Payload len”,表示帧内容的长度。它是另一种变长编码, 可以是 7 位, 7+16 位, 7 + 64位。
- 长度字段后面是“Masking-key”,掩码密钥,它是由上面的标志位“MASK”决定的,如果使用掩码就是4个字节的随机数,否则就不存在。
示例:
- 一个单帧未添加掩码的文本消息: 0x81 0x05 0x48 0x65 0x6c 0x6c 0x6f (内容为"Hello") => 转成二进制形式为:
10000001 00000101 // FIN为 1 Opcode 为 1, payload length为 5
H => 72(对应的二进制数字,可以通过 charCodeAt获得 ) = 16 * 4 + 8 = 0x48 其他字面同理
- 一个单帧添加掩码的文本消息: 0x81 0x85 0x37 0xfa 0x21 0x3d 0x7f 0x9f 0x4d 0x51 0x58 (内容为Hello")
1000 0001 1000 0101 // FIN为 1 Opcode 为 1, payload length为 5
0x37(55) 0xfa(250) 0x21(33) 0x3d(61) // 四个字节为掩码
掩码计算过程为:
const maskKey = [0x37, 0xfa, 0x21, 0x3d]
const data = 'Hello'
const payload = []
for (let i = 0; i < data.length; i++) {
let j = i % 4;
const masked = data.charCodeAt(i) ^ maskKey[j]
payload.push(masked.toString(16))
}
// 执行的结果为: ["7f", "9f", "4d", "51", "58"]
小结
HTTP的“请求-应答”模式不适合开发“实时通信”应用,效率低,难以实现动态页面,所以出现了WebSocket; 而 WebSocket是一个“全双工”的通信协议,并使用兼容HTTP的URI来发现服务,但定义了新的协议名“ws”和“wss”,端口号也沿用了80和443; WebSocket利用HTTP协议实现连接握手,发送GET请求要求“协议升级”,传输使用二进制帧,可通过掩码对数据进行加密,避免被攻击。