知其然:主动推送与实时双向通信

WebSocket协议

  • RFC文档:RFC6455
  • 工作层次:应用层
  • 定位:为服务器提供主动推送数据的方式并在TCP连接的基础上实现全双工的实时通信

关键特征

  • 全双工通信:允许服务器和客户端同时发送和接收数据

  • 持久连接:Websocket连接会在长时间内保持开放状态

  • 可靠连接:基于TCP协议

  • 消息格式:Websocket消息允许为文本或二进制格式

  • 跨平台兼容性强:Webscoket通过HTTP进行协议升级建立连接,通过80/443进行通信,提供了为HTTP协议场景提供服务的强大兼容性

  • 高效节能:建立Websocket仅需要1次请求,避免了HTTP频繁请求、频繁连接带来的资源消耗

应用场景

以实时聊天(IM)为例,当用户打开聊天窗口,客户端与服务器之间通过 Websocket 建立起连接。用户发送的消息能够迅速地被服务器接收并转发给其他相关客户端,同时,用户也能实时收到其他参与者发送的新消息,无需像传统网页应用那样频繁刷新页面来获取最新信息。(我想吐槽的是,在的实习经历中得到的教训:写一个IM最好不要从WebSocket开始搓,我会考虑单写一篇开发应用讲述我的经历)

此外,在我实习的团队认为,在大部分情况下使用Websocket来进行“通知”而不是“传输”。经过调研后,我并不完全赞同这一观点,Websocket的消息传递能力足够高效,特别是对于绝大部分IM和数据更新场景,用Websocket直接传递信息是更优解,也不会造成网络负担或系统设计的复杂化。

相比之下,前端同学提出的方案是我深表赞同的的,即在首次加载页面以及更多“初始化”的设计中使用HTTP请求进行数据获取,而在后续的所有通信中采用Websocket由后端向前端主动推送。

知其所以然:WebSocket的工作流程

  • 启动连接:客户端向服务端发送一个HTTP请求,该请求头包含以下字段,表明通信需要升级到Websocket协议,我们关注这些信息:

    GET ws://example.com/api/ws/demo/chat-demo-id HTTP/1.1
    Host: example.com
    Connection: Upgrade
    Pragma: no-cache
    Cache-Control: no-cache
    User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X [OS Version]) AppleWebKit/[WebKit Version] (KHTML, like Gecko) Chrome/[Chrome Version] Safari/[Safari Version]
    Upgrade: websocket
    Origin: http://example.com
    Sec-WebSocket-Version: 13
    Accept-Encoding: gzip, deflate
    Accept-Language: zh-CN,zh;q=0.9
    Cookie: [Cookie-related values]
    Sec-WebSocket-Key: Dktqa9fL1I5V+h1AetA2+Q==
    Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
    
    • Connection: Upgrade:当前HTTP连接需要升级
    • Upgrade: websocket:客户端希望升级的协议是Websocket
    • Sec - WebSocket - Version: 13:客户端支持的协议版本是13
    • Sec - WebSocket - Key: Dktqa9fL1I5V+h1AetA2+Q==:随机生成的Base64编码密钥,用于在Websocket握手过程中进行安全验证,本文不展开安全验证相关过程
    • Sec - WebSocket - Extensions: permessage - deflate; client_max_window_bits:客户端期望使用permessage - deflate扩展对Websocket消息进行压缩,并指定了参数client_max_window_bits
  • 握手协议:服务端收到升级请求后,会检查自己是否兼容Websocket协议,随后返回状态码为101 - swithching protocals的响应如下

    HTTP/1.1 101 Switching Protocols
    Server: nginx/1.18.0 (Ubuntu)
    Date: Mon, 30 Dec 2024 06:16:41 GMT
    Connection: upgrade
    Upgrade: websocket
    Sec-WebSocket-Accept: SYRKbDm8G658Rr+K2PStQPEu+oU=
    Sec-WebSocket-Extensions: permessage-deflate
    
    • HTTP/1.1 101 Switching Protocols:服务器理解并同意客户端升级请求
    • Sec-WebSocket-Accept: SYRKbDm8G658Rr+K2PStQPEu+oU=:对Sec - WebSocket - Key的回应,服务器会将 “Sec-WebSocket-Key” 与一个固定的字符串拼接,然后进行 SHA-1 哈希计算,再把结果进行 Base64 编码,生成这个 “Sec-WebSocket-Accept” 的值
    • Sec-WebSocket-Extensions: permessage-deflate:服务器同意使用该扩展
  • 数据传输:Websocket通信以帧为单位组织数据,区分为普通文本和二进制两种。其中opcode占4 bits,其含义为负载数据,这一标志会区分负载类型,完整的定义可以参考RFC文档:

    • %x1:文本帧(text frame)
    • %x2:二进制帧(binary frame)
          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 ...                |
         +---------------------------------------------------------------+
    
  • 关闭连接:Websocket可能在这些情况下关闭连接:通信结束、网络异常、主动退出等等。连接关闭会释放端口、连接内存等资源

知其曾已然:实时通信不止于此

最早设计HTTP协议时,我们考虑“请求 - 响应”的工作模式,即服务端请求,服务器响应后整个连接过程结束。但后来发现,在许多场景下,我们需要将服务端的数据更新主动推送到客户端,而HTTP的工作模式显然无法满足我们的需求,于是开发人员想出了一个办法——短轮询。

1. 短轮询

为了实现向客户端实时呈现数据更新,我们让客户端每间隔一段时间就向服务器发送请求,更新数据,当这个间隔足够短时,我们便呈现出了显示实时数据的效果。这个方案简洁有效,且无需在现有环境下做出任何改变。例如这样一个js客户端:

function poll() {
    fetch('/api/data')
       .then(response => response.json())
       .then(data => {
            console.log(data);
            // 处理接收到的数据
        })
       .catch(error => console.error(error))
       .finally(() => setTimeout(poll, 5000)); // 5 秒后再次轮询
}
poll();

而这也带来了一些问题:

  1. 数据并非是定时更新的,定时轮询可能导致数据高频率数据更新延迟,或在低频率的更新中产生大量不必要的请求导致网络资源的浪费
  2. 在很多场景下,我们需要获取的数据资源往往很少,而HTTP请求的头部与之相比就显得繁重不堪

2. Comet

随着对实时性的要求提高,短轮询的低效率问题凸显,Comet 应运而生,旨在解决短轮询实时性差的问题。Comet有两种常见的实现形式,一是长轮询,二是流模式。

a. 长轮询

长轮询的原理是在客户端向服务器发起请求后,服务器会保持连接直到有数据要返回时再响应,或者超时后返回。相对来说,更适合请求频率较低,对实时性要求一般的场景。

优点:简单易实现,适合小规模的实时通信

缺点:每次请求都会建立和关闭连接,存在一定的延迟和资源消耗,尤其是在高并发场景下

b. 流模式

流模式(通常指服务器推送数据到客户端)不同于长轮询,服务器会在连接上持续不断地发送数据,直到客户端断开连接为止。

  • 优点:更高效,客户端和服务器之间保持一个持久连接,适合高频实时数据传输

  • 缺点:实现起来较复杂,尤其是要处理连接保持、错误恢复和并发

3. SSE

作为另一种实现服务器到客户端实时推送的技术,SSE(Server-Sent Events) 专注于服务器向客户端的单向推送,在某些场景下可以作为 Websocket 的补充。

  • 优点:
    • 实现简单,专门用于服务器向客户端的单向数据推送,适合一些仅需服务器推送的场景
    • 基于 HTTP 协议,兼容性好,无需额外的协议升级,适用于大多数浏览器
  • 缺点:
    • 仅支持服务器向客户端的单向通信,无法满足需要双向通信的场景

参考阅读