WebSocket

WebSocket和Http协议一样,都是在tcp协议上的应用层协议。而和Http协议不一样的是,WebSocket可以在单条tcp连接上进行全双工通信。

Features

这意味这要实现ws协议,必须要允许两台设备同时进行双向数据传输。客户端可以发送data payload给服务端,服务端同时也可以发送data payload给客户端。

  • 单条请求

在Http/1.1中,已经有keep-alive请求头,多个http请求可以使用同一条tcp连接。但是这种情况下每条http请求还是必须要带上自己的请求头,而在ws协议中,只有一开始的握手需要传输请求头(也会有用户登录后登录态不能及时更新的问题)。

和轮询的对比

对于客户端监听最新的服务端信息,一般可以有两种操作:pull和push。push的话就是WebSocket, 服务端主动的去push信息,pull的话就是轮询,客户端主动的去请求信息。轮询可以分为轮询和长轮询。

  1. 轮询:每隔一段时间持续发起请求。
  2. 长轮询:发起一次请求并pending,当服务端有需要push的信息时才会有返回。典型的如微信网页版。

轮询会发起很多没必要的请求,所以主要是长轮询和WebSocket的比较。

长轮询的缺点主要在于:

  1. 一直要pending一个请求,比较耗费服务器端的资源
  2. 每次请求都需要建立一个tcp连接和携带http请求头,比较耗费资源

WebSocket的缺点在于:

  1. 一些老版本的浏览器可能不支持
  2. 一些基于http的基建无法复用

在握手成功后,传输的就是数据帧(frame)了,由1个或多个帧组成一条完整的消息(message)。

发送端将消息切割成多个帧,并发送给服务端,接收端接收消息帧,并将关联的帧重新组装成完整的消息。

ws数据帧的统一格式如下:

  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,这是message的最后一个分片,如果是0,则不是。

opcode来区分操作的类型。比如0x8表示断开连接,0x0-0x2表示数据交互(其中0x1是文本,0x2是二进制)。

mask表示负载数据是否被掩码,如果设置为 1,那么负载数据应该按照后面的 Masking-key 解码。

可以观察一下wireshark抓取的WebSocket包的样子:

点击B站的某个视频之后的ws连接,他们应该是把对象stringify后转换成了ArrayBuffer之后,直接emit,所以opcode是2:

00000000: 0000 0062 0012 0001 0000 0007 0000 0001  ...b............
00000001: 0000 7b22 726f 6f6d 5f69 6422 3a22 7669  ..{"room_id":"vi
00000002: 6465 6f3a 2f2f 3431 3337 3632 3334 332f  deo://413762343/
00000003: 3231 3233 3634 3938 3722 2c22 706c 6174  212364987","plat
00000004: 666f 726d 223a 2277 6562 222c 2261 6363  form":"web","acc
00000005: 6570 7473 223a 5b31 3030 302c 3130 3135  epts":[1000,1015
00000006: 5d7d                                     ]}

心跳

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

但不排除有些场景,客户端、服务端虽然长时间没有数据往来,但仍需要保持连接。这个时候,可以采用心跳来实现。

发送方 -> 接收方:ping 接收方 -> 发送方:pong ping、pong 的操作,对应的是 WebSocket 的两个控制帧,opcode分别是0x9、0xA。

有一点,心跳的间隔如果超过了60s,需要额外设置Nginx的两个proxy_read_timeout, proxy_send_timeout,默认都是60s自动断开连接,需要大于心跳的间隔。

同时,socket.io可以在服务端设置pingInterval(ping的间隔)和pingTimeout(ping之后多久没收到pong自动断开连接), 在握手成功后,服务端会把这两个参数和sid马上发到客户端, 目的是让客户端和服务端的pingIntervalpingTimeout保持一致。

请求头

  • Connection: Upgrade:表示要升级协议
  • Upgrade: websocket:表示要升级到 websocket 协议
  • Sec-WebSocket-Version: 13:表示 websocket 的版本。如果服务端不支持该版本,需要返回一个Sec-WebSocket-Versionheader,里面包含服务端支持的版本号。
  • Sec-WebSocket-Key:与后面服务端响应首部的Sec-WebSocket-Accept是配套的,提供基本的防护,比如恶意的连接,或者无意的连接

两个和升级有关的请求头ConnectionUpgrade, 都是hop-by-hop headers, 所以在nginx转发的时候都要额外再次proxy_set_header。成功升级后会返回101状态码,由http升级为websocket请求。

关于两个Sec开头的请求头,也是WebSocket设计者为了安全的特意设计,以Sec-开头的 Header 可以避免被浏览器脚本读取到,这样攻击者就不能利用 XMLHttpRequest 伪造 WebSocket 请求来执行跨协议攻击,因为 XMLHttpRequest 接口不允许设置Sec-开头的Header。

同时,和Http协议不同,WebSocket是允许跨域的, 而且会携带cookie,成为Cross Site WebSocket Hijacking(CSWSH)。为什么是劫持而不是伪造,因为ws没有跨域限制,攻击者可以控制整个读取/修改双向沟通通道(为所欲为)。现在的方式是验证Origin的请求头。

其他的大部分请求头Websocket和Http是一样的。

生产的请求头如下:

NestJs对ws的接入

类似于Http Adapter, NestJs也可以用Adapter来使用不同的基础ws服务端框架,比如wssocket.io

在事件的处理上,用Gateways去分派不同的事件。

部署

因为在生产环境中,ws服务会被部署在多台设备或者进程中,和Http不同,ws是有状态的,而且一个进程中的广播事件需要被所有进程同时也广播,这带来了一些问题。

保持粘性会话

因为ws是有状态的单条连接,如果经过nginx转发,转发到了不是其handshake的进程,会导致当前连接到的进程无法处理,因为根本没有handshake。

第三方库都有自己实现关于粘性会话的检查,而不是直接400,比如在socket.io中,会根据handshake的内容,计算其hash作为session id(sid),作为其唯一的client id,如果错误会返回sid not found

现有的解决方式是设置slb的负载均衡策略为ip_hash, 根据用户的ip转发到固定的服务,一台ecs只通过pm2起一个进程。

Q: 如果仍然用pm2管理进程,在单台ecs里如何保持粘性会话?

多进程广播事件

一个进程中的一次emit事件,必须要同时通知到其他进程,让他们一起广播,不然就会发生只有某一些用户收到了消息的情况。

现有的解决方式是用redis的发布订阅功能, 基于一个第三方库socket.io-redis, 来完成不同进程间的同时广播。

基于node-redis的发布订阅demo:

const redis = require("redis");

const subscriber = redis.createClient();
const publisher = redis.createClient();

let messageCount = 0;

subscriber.on("subscribe", function(channel, count) {
  publisher.publish("a channel", "a message");
  publisher.publish("a channel", "another message");
});

subscriber.on("message", function(channel, message) {
  messageCount += 1;

  console.log("Subscriber received message in channel '" + channel + "': " + message);

  if (messageCount === 2) {
    subscriber.unsubscribe();
    subscriber.quit();
    publisher.quit();
  }
});

subscriber.subscribe("a channel");

WebSocket网关

一个统一的对外WebSocket网关,这个想法,在架构上主要是想解决上文中提出的一个问题:无法复用原有的基于http的一些基建。

客户端的ws连接和统一的WebSocket网关连接,然后网关向背后的后端服务发送http请求,完成后再向客户端推送内容。基于此,可以最大化复用原有的http基础设施,需要做的仅仅是中间的一层统一的WebSocket网关。

大致的架构如图:

Reference