Nodejs系统设计的主要目的是支撑Web应用的开发,其基础在于在通用的计算机程序和语言实现的基础之上,特别增加和强化对HTTP协议支持和实现。但除了HTTP协议之外,它还需要提供一些网络基础协议的支持功能,同时还需要提供一些拓展的网络功能以支持更丰富的应用场景。本文中讨论的WebSocket(WS)和SSE,就是其中不太常用,但是可能有其特别的应用场景和需求的内容。
关于WebSocket协议
在此之前笔者对WebSocket协议,并没有太多深入的了解,原来在项目中的少量应用,也大多数在应用层面,使用的第三方框架和库,并没有在原理方面有所研究。
所以,在编著本文的过程中,刚开始的时候,笔者对于WS技术的理解是有偏差的。笔者原来以为WS协议,就是升级和扩展的HTTP协议,增加了KeepAlive特性而已。后来发现情况其实不是这样的,WS是相对独立的协议和技术,它确实是借助了HTTP作为其初始化方式,已经借助了其网络端口和通道,但它有自己独立的数据传输的指令和编码结构,并不是HTTP的简单扩展。
在理解了上面的基本概念之后,我们再来讨论WS就比较简单清晰了。顾名思义,WebSocket就是在基于Web技术实现的Socket。Socket在原来的意义中,就是一个TCP连接的端口,Socket本身就是双工的,就是可以双向传输数据。这里的Web,指的应该是基于Web的技术框架和体系,比如标准浏览器和Web服务器。WS技术的引入,改善了原有Web技术只有请求/响应这种单一工作模式的限制,极大的扩展了Web应用的使用场景,可以加入如数据实时更新、文字聊天、游戏交互等更丰富强大的功能。
Web技术早期设计和制定的简单的标准请求-响应模型,在互联网技术和应用快速发展的背景下,已经显得有点简陋,不能满足日益复杂的应用和体验需求。行业急需一种可以进行双向通信,同时能够继承Web应用体系的标准化技术,来进一步推动Web应用的发展。所以,WebSocket技术在2008年被提出,推荐进入HTML5技术体系;2009年Google的Chrome4首先完整实现了WS协议和应用;2009年WS协议被正式标准化(RFC6455)。随后,主流的浏览器和Web服务器都逐步实现了对WS的支持。
现在,WS的应用场景已经非常广泛,其实已经远超原本作为浏览器的一项双工通信机制的构想,而是作为和HTTP协议并列和相辅相成的,一种主流的实时通信协议,下面是一些典型的WS应用场景:
- 增强聊天会话体验
- 实时事件数据广播,如天气、比分、新闻和交通信息等
- 多客户端和用户项目和文档协作
- 通知和警告信息发送
- 多客户端、服务端间数据同步
- 电子商务订单和物流信息跟踪
- 应用程序的配置信息和业务状态分发
- ...
WS基本原理和流程
WS作为一个标准协议,其基本工作原理和流程如下(图):
-
客户端(通常是Web浏览器中的WS API程序)向支持WS协议的服务器发起一个HTTP请求,该请求的内容包括一个信息头,用于协商请求升级到 WebSocket协议
-
服务器接收到请求后,如果支持WS协议,则会发送一个升级响应,告知客户端可以进行,这个过程通常叫协议升级
-
随后,客户端使用WS协议,和服务器之间建立起一个WS连接,该连接是一个持久化的、双向的TCP通信通道(仍然使用原HTTP的配置)
-
客户端和服务器可以在 WebSocket 连接上发送和接收消息,消息可以是文本或二进制数据
-
当客户端或服务器决定关闭连接时,可以发送一个关闭帧,告知对方关闭连接
可以看到,WS协议工作的关键是请求和响应的相关HTTP头信息,它指示服务端和客户端需要使用WS协议进行协议的升级和连接创建。
在连接创建后,WS通信时使用数据帧的方式,也和HTTP(指令+数据)有很大的不同。这也是WS作为独立的协议而非HTTP协议扩展的一个重要特性。如下图为典型的WS数据帧结构。
NodeJS对WS的支持
虽然在越来越重视应用和实时性的现代Web技术体系中,WS的地位已经越来越重要,已经成为实时Web的一个主流的技术选项,但NodeJS对WS的支持,还没有达到一个令人满意的程度。
简单而言,就是在Nodejs20版本,只是“实验性”的提供了内置的WS客户端,而没有内置WS的服务端的实现。所以,像HTTP协议支持那种的完整和成熟,可能还需要等待一段时间。
而且,在Nodejs中,WS并不是一个独立的功能模块,而是作为一个Globals类存在的。方便之处就是可以不用引用和声明,直接使用。并且使用方式和浏览器中的实现是完全一样的。
下面,我们使用一段示例代码进行分析和说明。因为没有WS的服务端,这里需要使用WS NPM(后详)作为其服务端。
// 安装 ws npm
npm install ws
// 服务端
const
ws = require("ws"),
WPORT = 8301;
// id生成器
const uuidv4 = ()=>
"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"
.replace(/[xy]/g, c=>(c=="x"? 0xF & Math.random() * 16 : 0x3 & Math.random() * 16 | 8 )
.toString(16));
const startWS = ()=>{
const wss = new ws.WebSocketServer({ port: WPORT });
wss.on('connection', function connection(conn) {
conn.clientid = uuidv4(); // clientid
console.log("SocketID:", conn.clientid);
conn.on('message', function message(data) {
console.log('received: %s', data);
});
conn.send('ClientID:'+conn.clientid);
});
}; startWS();
// start client
const startClient =()=>{
// Create WebSocket connection.
const socket = new global.WebSocket("ws://localhost:" + WPORT);
// Connection opened
socket.addEventListener("open", (event) => {
socket.send("Hello From Client!");
});
// Listen for messages
socket.addEventListener("message", event => {
console.log("Message from server ", event.data);
});
}; setTimeout(startClient, 2000);
// 运行示例程序
node w
(node:15888) [UNDICI-WS] Warning: WebSockets are experimental, expect them to change at any time.
(Use `node --trace-warnings ...` to show where the warning was created)
SocketID: 3017d286-7fc9-4143-a03f-69207b85a8e1
Message from server ClientID:3017d286-7fc9-4143-a03f-69207b85a8e1
received: Hello From Client!
我们先来重点看客户端这一部分,从示例代码中,我们可以看到:
- 可以直接使用global.WebSocket,以ws协议url作为参数,创建一个WS客户端实例(socket)
- WS客户端实例的工作方式,和浏览器中基本相同,都使用标准的事件侦听模型
- 常用的标准侦听事件包括open,message等
- message事件,可以用于接收来自服务端的消息,并且进行处理
- 使用socket的send方法,可以发送消息或者数据
- 如果有业务需求,可以通过为客户端设置ID来进行标识
- 程序执行的警告信息可以看到,WS现在还是一个实验性的状态
关于WS NPM和服务器的部分,我们在下一章节中进行讨论。
WS NPM 和Socket.IO
前面已经提到,Nodejs没有WS服务器的实现,所以在线阶段,需要第三方技术实现和框架。一般可选的技术包括WS NPM和Socket.IO。
WS NPM
WS是一个被广泛使用的Nodejs WS框架。它的项目主页在:
之所以称其为"广泛应用",不仅仅是它占据了ws这个好名字,还因为它在github上每周的下载和安装数量高达5000多万次。
WS是一个全功能的WS协议实现,也就是说,它同时提供了客户端和服务端,以及和Nodejs系统进行集成的多个功能实现和模块。上面的章节中,我们已经简单演示了它的服务端的实现,基本和Nodejs Net模块差不多,都是基于事件监听和回调实现的编程方式,这里就不再赘述了。
但本章节作为对其扩展性的研究,笔者想要讨论几个更高级的特性。我们的讨论将围绕一系列示例代码展开,这些代码来源于其官方示例,作者根据示例需要进行了一些简单的改动。
HTTP服务集成
从前面的示例我们可以看到,WS实际上是作为独立的服务器来运行的,就是创建时,指定了其侦听的端口。但在很多现实的部署场景中,我们希望它可以和普通的HTTP服务一起部署,这一就不会作为一个单独的网络组件,来需要进行相关的网络配置,比如防火墙策略设置等等。
WS支持两种和Nodejs HTTP服务集成的方式。简单模式可以直接将Http Server和WS进行关联;稍微复杂一点的模式可以通过改写协议升级过程,实现同一个HTTP服务器提供多个WS服务。下面是相关示例代码:
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
const server = createServer();
const wss1 = new WebSocketServer({ noServer: true });
const wss2 = new WebSocketServer({ noServer: true });
wss1.on('connection', function connection(ws) {
ws.on('error', console.error);
// ...
});
wss2.on('connection', function connection(ws) {
ws.on('error', console.error);
// ...
});
server.on('upgrade', function upgrade(request, socket, head) {
const { pathname } = new URL(request.url, 'wss://base.url');
if (pathname === '/foo') {
wss1.handleUpgrade(request, socket, head, function done(ws) {
wss1.emit('connection', ws, request);
});
} else if (pathname === '/bar') {
wss2.handleUpgrade(request, socket, head, function done(ws) {
wss2.emit('connection', ws, request);
});
} else {
socket.destroy();
}
});
server.listen(8080);
作为WS和普通HTTP服务的集成,这段代码的要点如下:
- 首先使用非独立的方式,创建WS服务实例
- 编写WS实例的onConnection事件,在回调方法中,实现对应的业务逻辑
- onConnection事件回调方法的参数,就是当前建立的ws连接对象
- 改写HTTP服务程序的upgrade事件方法,来拦截协议升级事件,并实现WS和HTTP服务的集成
- 可以基于请求路径,来配置不同的WS实例
- 关联的方式,是在合适的位置,调用WS实例的handleUpgrade方法,并在协议升级完成后,触发WS的connection事件
- 随后,如果有基于HTTP的连接请求,该请求将会被升级,成功后,触发调用onConnection方法
- 后续处理,和独立的WS服务无异
客户端认证
和HTTP原生协议一样,WS本身并没有提供客户端认证的特性或者框架,完全由具体实现来确定。在这方面,笔者觉得可以有以下几种场景和考量:
- 标识连接
在认证之前,需要对WS的连接进行标识,这个可以在连接的时候,使用一个UUID或者编码方式来对连接对象进行标记,后续的消息发送和认证过程,都需要这个标记来区分不同的WS连接。
- 集成HTTP应用
在很多情况下,WS服务是和HTTP服务集成在一起的。WS的认证可以沿用和集成HTTP服务的认证结果。比如,正常的HTTP登录完成后,会取得一个访问token。然后在WS建立的时候,就可以提交这个token作为认证的依据。
- WS客户端发送认证凭证
前面已经提到,如果需要对WS连接进行认证,需要在WS连接时再次由客户端提交相关的认证凭证来完成。这里由两种解决方案,可以直接将token带到连接请求参数中,也可以在连接建立完成后,由客户端发送一个特殊的认证数据包(包含token内容)来完成。
- WS服务端验证凭证
基于实现方式的不同。在WS服务端,也可以在不同的地方实现对WS连接的认证。如果使用url token方式,可能需要在onUpgrade阶段进行处理;而如果使用认证消息,则需要在onMessage中对认证消息进行特别的处理。
笔者感觉,基于系统安全和简化的考量,在upgrade阶段进行处理的方式是比较好的。它满足认证应该尽量提早的基本安全规则,另外在upgrade阶段进行处理,也可以和真正的消息处理逻辑隔离和解构,方便业务应有的开发。
- 应用系统WS连接
WS的客户端不仅仅可以用在浏览器环境中,在nodejs环境中,也可以使用,这样应用系统直接,也可以通过WS进行连接和集成,而且使用方式和浏览器/服务器模式无异。有差异的地方就是认证凭证可能就不是简单的token沿用,可能需要实现一个完整的认证过程。
由于确定性的原因,应用系统的认证还可以实现的更加安全。比如可配置的共享密钥结合OTP,还可以实现动态的认证信息提交和检查,从而提高了认证过程的安全性。
广播
从服务器向当前所有连接的WS客户端发送消息,就是消息广播,是一种常见的应用需求。它可以通过遍历wss.clients集合(set)来实现,参考代码如下:
import WebSocket, { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data, isBinary) {
wss.clients.forEach(function each(client) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(data, { binary: isBinary });
}
});
});
});
上面代码的核心,在于WSS服务器对象会维护一个客户端列表clients,我们可以遍历这个列表,并检查每个连接的当前状态,来按需发送消息。
看到了广播的实现方式,我们也会了解,如果在客户端连接时,记录客户端的ID,并进一步关联当前认证的用户,我们也可以根据业务需求,给特定的客户发送数据,实现更为细粒度的业务需求。
二进制模式
WS协议是支持纯二进制数据的消息格式的。当然也可以选择使用二进制格式,这也是和HTTP协议差别比较大的地方(HTTP默认使用文本方式)。
这个二进制数据,在浏览器中的版本,应该就是Uint8Array,在nodejs中,可以简化为buffer。需要注意的地方就是发送的时候,需要提供一个 { binary: isBinary }的选项。然后在接收的时候,检查isBinary属性,并且由针对性的进行相关的解码和转换处理。
Socket.IO
Socket.IO也是一个NPM,它的项目主页在:socket.io/
笔者在一项业务项目中使用了Socket.IO,是觉得它的功能相对更加丰富和完善。包括自动重连,协议自动降级到HTTP long-polling来支持老旧浏览器,频道和广播管理,多服务器扩展等等。
在基础原理方面,其实Socket.IO和WS的差异并不大,所以这里不会再做深入讨论。
SSE
SSE意为Server Side Event,这是一种在HTTP连接建立并保持之后,可以由服务器发起并向客户端发送数据的技术。它同样可以实现服务器到客户端的消息发送,实现一定的业务需求。但严格而言,它并不是完全体的双工通信,因为客户端只有一次机会在连接建立的时候,向服务器发送参数和信息,然后都是服务器向客户端的单向数据传输了。但它由一个很大的优势是简单和代价低廉,适合于一些特别的使用场景(chatGPT的推理响应?)。
关于这一技术的相关细节和实现,笔者在另外两篇博文中已经有了很详细的讨论和说明,这里就不再赘述了。这里将其例举出来的主要原因,是笔者想要表述,作为一个完整的技术方案,是其和WS技术一样,可以作为服务端主动信息推送的一种实现选项的。
前面提到的两篇相关博文是:
有兴趣的读者可以移步参考和了解一下。
服务端数据推送技术方案比较
本文讨论的主要内容,包括WS和SSE,都是为了解决和改善标准的基于HTTP协议的Web应用程序的一个天然和结构性的不足:就是请求-响应模式的局限,服务端只能被动的接收客户端的请求,并基于这个请求来做出响应。有了WS和SSE之后,服务端可以在连接建立之后,根据业务的需求,主动的给客户端发送信息和数据,这样就极大的扩展了Web应用的灵活性和使用的场景,毕竟,一个实时的,主动的Web应用,可以提高更好的应用体验。
笔者认为,在Web技术发展的初期,这样的设计和实现,也是有它的理由的,因为请求响应模式的实现是最为简单,并且相关的计算和网络代价是最低的,符合那个技术条件下情况。但后来Web应用技术、软硬件网络环境的完善和需求的快速发展,使WS和SSE这种双向传输技术,得到了越来越多的重视和应用。
其实,即使没有全双工的应用模式和技术(如WS和SSE),只是标准的HTTP,也是可以在某些条件和模式下(基于AJAX),模拟实现类似的双向通信的业务需求的,就是使用HTTP轮询的方式。原理很简单,就是使用一个定时器,定时的基于当前的状态和需求,向服务器发出请求,并处理响应。但这种做法并不适合于所有的应用场景,请求间隔太长,则实时性不足;而间隔时间太短,在如何数据变化不频繁就会造成很多不必要的处理负担,降低系统运行和处理效率。
至此,我们已经完整的讨论了在经典的HTTP应用请求/响应的运行模式之外,可以实现的可以由服务端发起的主动数据发送的应用架构的技术方案,包括HTTP轮询、WebSocket和SSE,它们都具有自己的技术特性和适用的场景,这里再统一的比较总结一下。
| 项目\技术 | HTTP轮询 | SSE | WS |
|---|---|---|---|
| 原理 | 定时请求 | 长连接 | WS协议升级 |
| 工作模式 | 请求响应 | 请求后半双工 | 连接后全双工 |
| 协议 | HTTP | HTTP | TCP/WS |
| 数据 | 文本 | 文本 | 文本/二进制 |
| 格式 | 不限 | 有限,但可自定义 | 不限 |
| 重连 | 定时请求 | 自动(浏览器实现)或自实现 | 自己实现 |
| 实现 | 简单但有浪费 | 轻量 | 稍复杂 |
| 支持 | 内置 | 内置 | 第三方WS服务 |
| 实时性 | 轮询周期 | 实时 | 实时 |
小结
本文探讨了nodejs中,另外两个非常重要的网络功能模块和功能: WebSocket和SSE,它们都用于来实现可以从服务器端发起向客户端主动的“信息推送”。关于WS,从某种意义上而言,Nodejs中只实现了“一半”,即客户端这一部分。服务端实现还是要借助第三方库来实现。而SSE笔者结合这几个方面,本文讨论了相关的具体实现和操作,以及在应用中需要注意的问题。