这篇文章将探讨用Node.js作为WebSocket服务器实现几个低级别的WebSockets库。我们将看看每个库是如何使用的,以及为什么人们可能会为他们的项目选择它。然后,我们将讨论人们可能选择第三方服务来管理其WebSockets连接的原因。
WebSockets和Node.js
WebSockets让开发者在他们的应用程序中建立实时功能,通过单一的持久性连接,双向发送小块的数据。在前端使用WebSockets是相当直接的,因为所有现代浏览器中都有WebSocket API。要在服务器上使用它们,需要一个后台应用程序。
这就是Node.js的作用。Node.js可以同时维护数百个WebSockets连接。服务器上的WebSockets会变得很复杂,因为从HTTP到WebSockets的连接升级需要处理。这就是为什么开发人员通常使用一个库来为他们管理这个问题。有几个常见的WebSocket服务器库使管理WebSockets更容易--特别是WS和Socket.io。
WS - 一个Node.js WebSocket库
WS是Node.js的一个WebSockets服务器。它是相当低级的:你监听传入的连接请求,并以字符串或字节缓冲区的形式响应原始消息。由于所有现代浏览器都原生支持WebSockets,因此可以在服务器上使用WS,在客户端使用浏览器的WebSocket API。
为了演示如何用Node和WS设置WebSockets,我们建立了一个演示应用程序,实时分享用户的光标位置。我们在下文中介绍了它的构建过程。
用WS建立一个交互式光标位置共享演示
这是一个为每个连接的用户创建一个彩色光标图标的演示。当他们移动鼠标时,他们的光标图标会在屏幕上移动,并且也会显示在每个连接用户的屏幕上。这是实时发生的,因为鼠标正在被移动。
WebSockets服务器
首先,需要WS库并使用WebSocket.Server 方法在7071端口创建一个新的WebSocket服务器(没有意义,任何端口都可以!)。
注意:为了简洁起见,我们在代码中称它为wss 。任何与WebSocket Secure(通常称为WSS)的相似之处都是一种巧合:
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 7071 });
接下来,创建一个地图来存储客户端的元数据(我们希望与WebSocket客户端相关联的任何数据):
const clients = new Map();
使用wss.on 函数订阅WSSconnection 事件,提供一个回调。每当有新的WebSocket客户端连接到服务器时,这将被调用:
wss.on('connection', (ws) => {
const id = uuidv4();
const color = Math.floor(Math.random() * 360);
const metadata = { id, color };
clients.set(ws, metadata);
每次客户端连接时,我们都会生成一个新的唯一ID,用来识别他们。客户端还通过使用Math.random() 来分配光标颜色;这将生成一个介于0和360之间的数字,它对应于HSV颜色的色调值。然后,ID和光标颜色被添加到一个对象中,我们称之为metadata, ,我们使用地图将它们与我们的ws WebSocket实例联系起来。
该地图是一个字典--我们可以通过调用get ,并在以后提供一个WebSocket实例来检索该元数据。
使用新连接的WebSocket实例,我们订阅该实例的message 事件,并提供一个回调函数,只要这个特定的客户端向服务器发送消息,就会触发该函数:
ws.on('message', (messageAsString) => {
注意:该事件发生在WebSocket实例(ws )本身,以及 不是在WebSocketServer实例(wss)上。
每当我们的服务器收到一条消息,我们就使用JSON.parse来获取消息内容,并使用clients.get(ws) ,从我们的地图中为这个套接字加载我们的客户端元数据。
我们要把我们的两个元数据属性添加到消息中,作为sender 和color:
const message = JSON.parse(messageAsString);
const metadata = clients.get(ws);
message.sender = metadata.id;
message.color = metadata.color;
然后,我们再次将我们的消息stringify ,并将其发送至每个连接的客户端:
const outbound = JSON.stringify(message);
[...clients.keys()].forEach((client) => {
client.send(outbound);
});
});
最后,当一个客户端关闭其连接时,我们从我们的Map ,删除其metadata :
ws.on("close", () => {
clients.delete(ws);
});
});
在底部我们有一个函数来生成一个唯一的ID:
function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
console.log("wss up");
这个服务器执行multicasts ,将它收到的任何消息发送给所有连接的客户端。
我们现在需要编写一些客户端代码,以连接到WebSocket服务器,并在用户移动时传输其光标位置。
客户端的WebSockets
我们将从一些标准的HTML5模板开始:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
接下来我们添加对样式表的引用,以及作为ES模块添加的index.js 文件(使用type="module" ):
<link rel="stylesheet" href="style.css">
<script src="index.js" type="module"></script>
</head>
主体包含一个HTML template ,其中包含一个指针的SVG image 。我们将使用JavaScript来克隆这个模板,每当有新用户连接到我们的服务器时:
<body id="box">
<template id="cursor">
<svg viewBox="0 0 16.3 24.7" class="cursor">
<path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" d="M15.6 15.6L.6.6v20.5l4.6-4.5 3.2 7.5 3.4-1.3-3-7.2z" />
</svg>
</template>
</body>
</html>
接下来,我们需要使用JavaScript来连接到WebSocket Server :
(async function() {
const ws = await connectToServer();
...
我们调用connectToServer() 函数,该函数解决了一个包含连接的WebSocket 的承诺。
(函数的定义将在后面写)。
一旦连接成功,我们为onmousemove 添加一个处理程序到document.body 。messageBody 非常简单:它包括来自鼠标移动事件的当前clientX 和clientY 属性(应用程序视口中光标的水平和垂直坐标)。
我们对这个对象进行字符串化处理,并将其作为消息文本发送到我们现在连接的ws WebSocket 实例中:
document.body.onmousemove = (evt) => {
const messageBody = { x: evt.clientX, y: evt.clientY };
ws.send(JSON.stringify(messageBody));
};
现在我们需要添加另一个处理程序,这次是为WebSocket实例ws 的onmessage 事件。请记住,每次WebSocketServer 收到消息时,它都会将其转发给所有连接的客户端。
你可能会注意到,这里的语法与服务器端的WebSocket 代码略有不同。这是因为我们使用的是浏览器的本地WebSocket 类,而不是npm 库ws :
ws.onmessage = (webSocketMessage) => {
const messageBody = JSON.parse(webSocketMessage.data);
const cursor = getOrCreateCursorFor(messageBody);
cursor.style.transform = `translate(${messageBody.x}px, ${messageBody.y}px)`;
};
当我们通过WebSocket 收到一个消息时,我们解析消息的data 属性,其中包含onmousemove 处理程序发送到WebSocketServer 的字符串化数据,以及服务器端代码添加到消息中的额外sender 和color 属性。
使用解析后的messageBody ,我们调用getOrCreateCursorFor 。这个函数返回一个HTML元素,它是DOM的一部分,我们将在后面看它是如何工作的。
然后我们使用来自messageBody 的x和y值,使用CSS transform 来调整光标位置。
我们的代码依赖于两个实用函数。第一个是connectToServer ,它打开了一个与我们的WebSocketServer 的连接,然后返回一个Promise ,当WebSockets readystate 属性是1 - CONNECTED ,就可以解决。
这意味着我们只需要await 这个函数,我们就可以知道我们有一个连接的和工作的WebSocket 连接:
async function connectToServer() {
const ws = new WebSocket('ws://localhost:7071/ws');
return new Promise((resolve, reject) => {
const timer = setInterval(() => {
if(ws.readyState === 1) {
clearInterval(timer)
resolve(ws);
}
}, 10);
});
}
我们还使用我们的getOrCreateCursorFor 函数。
这个函数首先试图找到任何具有HTML数据属性的现有元素data-sender ,其值与我们信息中的sender 属性相同。如果它找到一个,我们就知道我们已经为这个用户创建了一个光标,我们只需要返回它,这样调用代码就可以调整它的位置:
function getOrCreateCursorFor(messageBody) {
const sender = messageBody.sender;
const existing = document.querySelector(`[data-sender='${sender}']`);
if (existing) {
return existing;
}
HTML template如果我们找不到一个现有的元素,我们clone ,把带有当前sender ID的data-attribute加到它上面,并在返回它之前把它附加到document.body :
const template = document.getElementById('cursor');
const cursor = template.content.firstElementChild.cloneNode(true);
const svgPath = cursor.getElementsByTagName('path')[0];
cursor.setAttribute("data-sender", sender);
svgPath.setAttribute('fill', `hsl(${messageBody.color}, 50%, 50%)`);
document.body.appendChild(cursor);
return cursor;
}
})();
现在,当你运行网络应用程序时,每个查看页面的用户都会有一个光标出现在每个人的屏幕上,因为我们正在使用WebSockets ,将数据发送到所有的客户端。
运行演示
如果你一直跟着教程走,那么你可以运行了:
> npm install
> npm run start
如果没有,你可以克隆一个工作版本的演示
> git clone https://github.com/ably-labs/WebSockets-cursor-sharing.git
> npm install
> npm run start
这个演示包括两个应用:一个我们通过Snowpack提供服务的网络应用,以及一个Node.js网络服务器。NPM的启动任务将同时启动API和Web服务器。
这看起来应该如下。

然而,如果你在一个不支持WebSockets的浏览器中运行演示(例如IE9或以下),或者如果你受到特别严格的公司代理的限制,你会得到一个错误,说浏览器不能建立连接。
这是因为如果WebSockets不可用,WS库没有提供后备传输协议。如果这是你的项目的要求,或者你想为你的信息提供更高层次的可靠性,那么你将需要一个提供多种传输协议的库,如SockJS。
SockJS - 一个在客户端和服务器之间提供类似WebSocket通信的JavaScript库
SockJS是一个模仿本地WebSockets API的库。此外,只要WebSocket连接失败,或者使用的浏览器不支持WebSockets,它就会退回到HTTP。与WS一样,SockJS也需要一个对应的服务器;其维护者同时提供了一个JavaScript客户端库和一个Node.js服务器库。
在客户端使用SockJS与本地WebSockets API类似,但有一些小的区别。我们可以在之前构建的演示中替换掉WS,而使用SockJS来包括回退支持。
更新交互式光标位置共享演示以使用SockJS
为了在客户端使用SockJS,我们首先需要从他们的CDN中加载SockJS JavaScript库。在我们之前建立的index.html文档的头部,在index.js的脚本include上面添加以下一行。
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js" defer></script>
注意defer 关键字--它确保SockJS库在index.js运行前被加载。
在app/script.js文件中,我们然后更新JavaScript以使用SockJS。我们现在将使用一个SockJS 对象,而不是WebSocket 对象。在connectToServer 函数中,我们将建立与SockJS服务器的连接。
const ws = new SockJS('http://localhost:7071/ws');
注意:SockJS需要在服务器的URL上有一个前缀路径。app/script.js文件的其他部分不需要改变。
现在要更新API/script.js文件,使我们的服务器使用SockJS。这意味着改变一些事件钩子的名称,但API与WS非常相似。
首先,我们需要安装sockjs-node. 在你的终端运行:
> npm install sockjs
然后我们需要要求sockjs 模块和Node的内置HTTP模块。删除需要ws的那一行,用下面的内容代替:
const http = require('http');
const sockjs = require('sockjs');
然后,我们将wss 的声明改为:
const wss = sockjs.createServer();
在API/index.js文件的最底部,我们将创建HTTPS服务器并添加SockJS HTTP处理程序:
const server = http.createServer();
wss.installHandlers(server, {prefix: '/ws'});
server.listen(7071, '0.0.0.0');
我们将处理程序映射到配置对象中提供的前缀('/ws') 。 我们告诉HTTP服务器在机器上的所有网络接口上监听端口7071(任意选择)。
最后的工作是更新事件名称,以便与SockJS一起工作:
ws.on('message', will become ws.on('data',
client.send(outbound); will become client.write(outbound);
就这样,在支持WebSockets的地方,演示现在将使用WebSockets运行;而在不支持WebSockets的地方,它将降级为使用HTTP的彗星长轮询。后者的后备选项将显示一个稍不顺畅的光标移动,但它比完全没有连接更实用
运行演示
如果你一直跟着教程走,那么你可以运行了:
> npm install
> npm run start
如果没有,你可以克隆一个工作版本的演示:
> git clone -b sockjs https://github.com/ably-labs/WebSockets-cursor-sharing.git
> npm install
> npm run start
这个演示包括两个应用:一个我们通过Snowpack提供服务的网络应用,以及一个Node.js网络服务器。NPM的启动任务同时启动了API和Web服务器。

这样做有规模吗?
你可能会注意到,在这两个例子中,我们都将状态存储在Node.jsWebSocketServer 中--有一个Map可以跟踪连接的WebSockets和它们相关的元数据。这意味着,为了使解决方案发挥作用,并使每个用户都能看到彼此,他们必须连接到完全相同的WebSocketServer 。
因此,你能支持的活跃用户的数量与你的服务器有多少硬件直接相关。Node.js在管理并发方面相当出色,但一旦你达到几百到几千个用户,你就需要垂直扩展你的硬件,以保持所有用户的同步性。
纵向扩展通常是一个昂贵的提议,你将始终面临着你能采购到的最强大的硬件的性能上限。(它也没有弹性,你必须提前做。)一旦你用完了垂直扩展的选择,你将被迫考虑水平扩展--而水平扩展WebSockets明显更困难。
是什么让WebSockets难以扩展?
为了扩展不需要持久连接的常规应用服务器,标准的方法是在它们前面引入一个负载均衡器。负载平衡器将流量导向当前可用的节点(通过测量节点性能,或使用轮流系统)。
WebSockets从根本上说更难扩展,因为与你的WebSocketServer 的连接需要持久化。即使你已经纵向和横向地扩展了你的WebSocketServer 节点,你也需要为节点之间的数据共享提供一个解决方案。任何状态都需要在进程外存储--这通常涉及到使用类似Redis的东西,或者传统的数据库,以确保所有的节点有相同的状态视图。
除了必须使用额外的技术来共享状态外,向所有订阅的客户端广播也变得很困难,因为任何给定的WebSocketServer 节点只知道与自己连接的客户端的情况。
有多种方法来解决这个问题:要么在处理流量的集群节点之间使用某种形式的直接连接,要么使用外部pub/sub机制(如Redis)。这有时被称为在你的基础设施中 "增加一个背板",这也是使WebSockets的扩展变得困难的另一个移动部分。
Node.js中的WebSockets性能非常好,但随着流量的增加,增长它们变得越来越困难。这就是Ably的用武之地!

使用Ably来扩展WebSockets
如果你正在构建一个需要可靠、可扩展的实时数据交换的应用程序,那么第三方平台可能是你正在寻找的答案。Ably为您处理所有的基础设施和硬工程挑战,如可靠地扩展WebSockets,同时大大减少您的团队的开发负担。Ably为您提供回退协议和负载平衡,并覆盖全球,这意味着可以逐个地区地扩展。
此外,Ably还具有以下功能:
- 开箱即用的连接ID - 不需要在上面的演示中分配我们自己的UUID
- 轻松创建和管理多个有状态的通道以发布和接收数据
- 存在数据,以提供关于其他连接者的通知
- 如果一个设备瞬间掉线,自动重新连接
- 历史记录和倒退,以便在失去连接时接收错过的信息
所有这些都是额外的因素,必须由使用WS或SockJS等低级库的开发团队手工操作。这一切都需要专门的工程知识,使这个提议既昂贵又耗时。想知道更多吗?请利用我们的API进行体验。