如何在Node.js中使用WebSocket创建实时应用

本教程演示如何在Node.js中使用WebSocket实现浏览器与服务器之间的双向、交互式通信。这一技术对于快速、实时的应用程序至关重要,如仪表板、聊天应用和多人游戏。

Table of Contents

网络基于请求-响应的HTTP消息。浏览器发起URL请求,服务器响应数据。这可能导致浏览器进一步请求图片、CSS、JavaScript等,以及服务器的相应响应,但服务器不能随意向浏览器发送数据。

长轮询Ajax技术可以使Web应用看似实时更新,但对于真正的实时应用来说,这一过程过于局限。每秒轮询在某些时候效率低下,在其他时候又太慢。

在浏览器发起初始连接后,服务器发送事件是一种标准的(流式)HTTP响应,可以随时向服务器发送消息。然而,通道是单向的,浏览器无法发送消息回服务器。对于真正的快速双向通信,你需要WebSocket

WebSocket概述

术语WebSocket指的是通过ws://或安全加密的wss://运行的TCP通信协议。它不同于HTTP,尽管它可以运行在80或443端口上,以确保在阻止非Web流量的地方也能工作。自2012年以来发布的大多数浏览器支持WebSocket协议

在典型的实时Web应用中,你至少需要一个Web服务器来提供Web内容(HTML、CSS、JavaScript、图片等)和一个WebSocket服务器来处理双向通信。

浏览器仍需向服务器发起初始的WebSocket请求,以开启通信通道。此后,无论是浏览器还是服务器,均可通过该通道发送消息,从而在对方设备上触发相应事件。

与其他连接的浏览器通信

初始请求完成后,浏览器即可与WebSocket服务器进行消息的收发。WebSocket服务器同样能与任意已连接的客户端浏览器进行消息交换。

点对点通信并不可行。即便BrowserA与BrowserB运行于同一设备且连接至同一WebSocket服务器,BrowserA也无法直接向BrowserB发送消息。它只能将消息发给服务器,期待服务器按需转发给其他浏览器。

WebSocket服务器支持

尽管有传闻称即将到来,但Node.js目前尚未原生支持WebSocket。本文使用第三方ws模块,市面上还存在众多其他选择。

内置WebSocket支持已在DenoBun这两个JavaScript运行时中实现。

WebSocket库适用于多种运行时环境,包括PHPPythonRuby。此外,第三方SaaS解决方案如PusherPubNub也提供托管的WebSocket服务。

WebSocket演示快速入门

聊天应用是WebSocket演示的Hello, World!,因此我为以下几点道歉:

  1. 缺乏原创性。尽管如此,聊天应用是解释这些概念的绝佳方式。

  2. 无法提供完全托管的在线解决方案。我宁愿不监控和审核匿名消息流!

克隆或下载GitHub上的node-wschat仓库:

git clone https://github.com/craigbuckler/node-wschat

安装Node.js依赖:

cd node-wschat
npm install

启动聊天应用:

npm start

在多个浏览器或标签页中打开http://localhost:3000/(你也可以在查询字符串中定义你的聊天名称,例如http://localhost:3000/?Craig)。在其中任意一个窗口输入内容并点击发送或按下回车键,你将看到该内容在所有已连接的浏览器中出现。

Node.js代码概览

该Node.js应用程序的入口文件index.js启动了两个服务器:

  1. 一个运行在http://localhost:3000/的Express应用,使用EJS模板提供包含客户端HTML、CSS和JavaScript的单页服务。浏览器JavaScript利用WebSocket API建立初始连接并发送及接收消息。

  2. A WebSocket server running at ws://localhost:3001/, which listens for incoming client connections, handles messages, and monitors disconnections. The full code:

    // WebSocket 服务器
    import WebSocket, { WebSocketServer } from 'ws';
    
    const ws = new WebSocketServer({ port: cfg.wsPort });
    
    // 客户端连接
    ws.on('connection', (socket, req) => {
    
      console.log(`connection from ${ req.socket.remoteAddress }`);
    
      // 接收消息
      socket.on('message', (msg, binary) => {
    
        // 向所有客户端广播
        ws.clients.forEach(client => {
          client.readyState === WebSocket.OPEN && client.send(msg, { binary });
        });
    
      });
    
      // 关闭
      socket.on('close', () => {
        console.log(`disconnection from ${ req.socket.remoteAddress }`);
      });
    
    });

使用Node.js ws 库:

  • 当浏览器尝试连接时,触发"connection"事件。处理函数接收一个socket对象,用于与该设备进行通信。此对象在整个连接期间必须保持。

  • 当浏览器发送消息时,触发socket "message"事件。处理函数将消息广播回每个已连接的浏览器(包括发送消息的浏览器)。

  • 当浏览器断开连接时,通常是关闭或刷新标签页时,触发socket "close"事件。

客户端JavaScript代码概览

应用程序的static/main.js文件运行一个wsInit()函数,并将WebSocket服务器的地址(页面域名加上在HTML页面模板中定义的端口值)传递给它:

wsInit(`ws://${ location.hostname }:${ window.cfg.wsPort }`);

// 处理WebSocket通信
function wsInit(wsServer) {

  const ws = new WebSocket(wsServer);

  // 连接到服务器
  ws.addEventListener('open', () => {
    sendMessage('entered the chat room');
  });

当浏览器连接到WebSocket服务器时,open事件被触发。处理函数通过调用sendMessage()发送一条已进入聊天室消息:

// 发送消息
function sendMessage(setMsg) {

  let
    name = dom.name.value.trim(),
    msg =  setMsg || dom.message.value.trim();

  name && msg && ws.send( JSON.stringify({ name, msg }) );

}

sendMessage()函数从HTML表单中获取用户名和消息,尽管消息可以通过任何传递的setMsg参数覆盖。这些值被转换为JSON对象,并使用ws.send()方法发送到WebSocket服务器。

WebSocket服务器接收传入的消息,触发"message"处理程序(见上文),并将其广播回所有浏览器。这会在每个客户端触发"message"事件:

// 接收消息
ws.addEventListener('message', e => {

  try {

    const
      chat = JSON.parse(e.data),
      name = document.createElement('div'),
      msg  = document.createElement('div');

    name.className = 'name';
    name.textContent = (chat.name || 'unknown');
    dom.chat.appendChild(name);

    msg.className = 'msg';
    msg.textContent = (chat.msg || 'said nothing');
    dom.chat.appendChild(msg).scrollIntoView({ behavior: 'smooth' });

  }
  catch(err) {
    console.log('invalid JSON', err);
  }

});

处理程序在事件对象的.data属性上接收传输的JSON数据。该函数将其解析为JavaScript对象并更新聊天窗口。

最后,每当表单的"submit"处理程序触发时,都会使用sendMessage()函数发送新消息:

// 表单提交
dom.form.addEventListener('submit', e => {
  e.preventDefault();
  sendMessage();
  dom.message.value = '';
  dom.message.focus();
}, false);

处理错误

当WebSocket通信失败时,会触发"error"事件。这可以在服务器上处理:

// 在服务器上处理WebSocket错误
socket.on('error', e => {
  console.log('WebSocket error:', e);
});

和/或在客户端上:

// 在客户端上处理WebSocket错误
ws.addEventListener('error', e => {
  console.log('WebSocket error:', e);
})

只有客户端能通过再次运行new WebSocket()构造函数来重新建立连接。

关闭连接

任何一方都可以随时使用连接的.close()方法关闭WebSocket。你可以选择性地提供code整数和reason字符串(最多123字节)参数,这些参数在断开连接前会传输到另一方。

高级WebSocket

在Node.js中管理WebSocket很容易:一台设备使用.send()方法发送消息,这会在另一台设备上触发"message"事件。每台设备如何创建和响应这些消息可能会更具挑战性。以下部分描述了你可能需要考虑的问题。

WebSocket安全

WebSocket协议不处理授权或认证。你不能保证传入的通信请求来自浏览器或登录到你的Web应用程序的用户——特别是当Web服务器和WebSocket服务器可能在不同的设备上时。初始连接接收包含Cookie和服务器Origin的HTTP头,但有可能伪造这些值。

以下技术确保将WebSocket通信限制为授权用户:

  1. 在进行初始WebSocket请求之前,浏览器会与HTTP Web服务器联系(可能使用Ajax)。

  2. 服务器检查用户的凭据并返回一个新的授权票据。该票据通常会引用包含用户ID、IP地址、请求时间、会话过期时间以及其他所需数据的数据库记录。

  3. 浏览器在初始握手时将票据传递给WebSocket服务器。

  4. WebSocket服务器验证票据并在允许连接前检查IP地址、过期时间等因素。当票据无效时,它执行WebSocket .close() 方法。

  5. WebSocket服务器可能需要不时地重新检查数据库记录,以确保用户会话保持有效。

重要的是,始终验证传入数据:

  • 与HTTP类似,WebSocket服务器容易受到SQL注入和其他攻击。

  • 客户端绝不应将原始值注入DOM或执行JavaScript代码。

单个与多个WebSocket服务器实例

考虑一个在线多人游戏。该游戏有多个宇宙在独立的游戏实例中进行:universeAuniverseBuniverseC。玩家连接到一个特定的宇宙:

  • universeA:由player1player2player3加入
  • universeB:由player99加入

你可以实施以下方案:

  1. A separate WebSocket server for each universe.

    A player action in universeA would never be seen by those in universeB. However, launching and managing separate server instances could be difficult. Would you stop universeC because it has no players, or continue to manage that resource?

  2. 为所有游戏宇宙使用一个WebSocket服务器。

    这使用较少的资源且更易于管理,但WebSocket服务器必须记录每个玩家加入的宇宙。当player1执行一个动作时,它必须广播给player2player3,而不是player99

多WebSocket服务器

示例聊天应用能处理数百并发用户,但一旦受欢迎程度和内存使用超过临界点,应用将崩溃。最终需通过增加更多服务器进行水平扩展

每台WebSocket服务器仅能管理其连接的客户端。浏览器向serverX发送的消息无法广播给连接到serverY的用户。可能需要实施后端发布者-订阅者(pub-sub)消息系统,例如:

  1. WebSocket serverX希望向所有客户端发送消息,它在pub-sub系统上发布该消息。

  2. 所有订阅pub-sub系统的WebSocket服务器接收到新消息事件(包括serverX),各自处理消息并广播给其连接的客户端。

WebSocket消息传输效率

WebSocket通信快速,但服务器需管理所有连接的客户端。构建多人动作游戏时,必须考虑消息机制与效率,尤其是:

  • 如何将玩家动作同步至所有客户端设备?

  • 玩家1玩家2位置不同,是否需向玩家2发送其无法观察到的动作信息?

  • 如何应对网络延迟或通信滞后?拥有高速设备与连接的玩家是否会享有不公平优势?

快节奏游戏需做出权衡。设想在本地设备上游戏,但某些对象受其他玩家活动影响。游戏通常不持续发送每个对象的确切位置,而是发送更简单、频率较低的消息,例如:

  • 物体X出现在点X
  • 物体Y获得新方向与速度
  • 物体Z已被摧毁。

每个客户端游戏都在填补空白。当objectZ爆炸时,无论在每个设备上爆炸效果看起来是否不同,都无关紧要。

结论

Node.js使得处理WebSockets变得简单。它不一定使实时应用的设计或编码变得更容易,但这项技术不会成为你的阻碍!

主要缺点:

  • WebSockets需要自己的独立服务器实例。Ajax Fetch()请求和服务器推送事件可以由你已经在运行的网络服务器处理。

  • WebSocket服务器需要自己的安全和授权检查。

  • 断开的WebSocket连接必须手动重新建立。

但不要因此而放弃!

关于使用WebSockets和Server-Sent Events的实时应用的常见问题(FAQs)

WebSockets在性能和功能上与HTTP有何不同?

WebSockets提供了一个基于单一TCP连接的全双工通信通道,意味着数据可以同时双向发送和接收。这相较于HTTP有了显著改进,因为HTTP每发起一次请求都需要建立新的连接。WebSockets还支持实时数据传输,非常适合需要即时更新的应用,例如聊天应用或实时体育更新。相比之下,HTTP是无状态的,每个请求-响应对都是独立的,这更适合不需要实时更新的应用场景。

能否解释一下WebSocket连接的生命周期?

WebSocket连接的生命周期始于握手阶段,这一阶段将HTTP连接升级为WebSocket连接。一旦连接建立,客户端和服务器之间就可以来回传输数据,直到任何一方决定关闭连接。连接可以通过客户端或服务器发送关闭帧来终止,随后另一方确认该关闭帧。

如何在Android应用中实现WebSockets?

在Android应用中实现WebSockets涉及创建一个能连接到WebSocket服务器的WebSocket客户端。这可以通过使用OkHttp或Scarlet等库来完成。设置好客户端后,你可以打开与服务器的连接,发送和接收消息,并处理诸如连接开启、消息接收和连接关闭等不同事件。

什么是服务器发送事件(Server-Sent Events),它们与WebSockets有何不同?

服务器发送事件(SSE)是一种标准,允许服务器通过HTTP向客户端推送更新。与WebSocket不同,SSE是单向的,意味着它们仅允许数据从服务器发送到客户端。这使得它们不太适合需要双向通信的应用程序,但对于仅需要服务器更新的应用程序来说,它们可以是一种更简单、更高效的解决方案。

WebSocket和Server-Sent Events的常见用例有哪些?

WebSocket常用于需要实时双向通信的应用,如聊天应用、多人在线游戏和协作工具。而Server-Sent Events则常用于需要从服务器接收实时更新的应用,例如实时新闻更新、股票价格更新或长时间运行任务的进度报告。

如何在Spring Boot应用中处理WebSocket连接?

Spring Boot通过Spring WebSocket模块支持WebSocket通信。您可以使用@EnableWebSocket注解来启用WebSocket支持,并定义一个WebSocketHandler来处理连接生命周期和消息处理。您还可以使用SimpMessagingTemplate向已连接的客户端发送消息。

使用WebSocket时有哪些安全考虑?

与任何网络技术一样,WebSockets可能面临多种安全威胁,例如跨站WebSocket劫持(CSWSH)和拒绝服务(DoS)攻击。为降低这些风险,应始终使用安全的WebSocket连接(wss://),并对所有传入数据进行验证和净化。同时,考虑采用认证和授权机制以控制对WebSocket服务器的访问。

我能否将WebSockets与REST API结合使用?

可以,您可以将WebSockets与REST API协同使用。虽然REST API非常适合无状态的请求-响应通信,但WebSockets适用于实时双向通信。这在需要即时更新的应用中特别有用,如聊天应用或实时体育更新。

如何测试WebSocket服务器?

有多种工具可用于测试WebSocket服务器,如WebSocket.org的回显测试或Postman。这些工具允许您打开到服务器的WebSocket连接,发送消息并接收响应。您还可以使用Jest或Mocha等库为WebSocket服务器编写自动化测试。

WebSockets和服务器发送事件(Server-Sent Events)有哪些限制?

尽管WebSockets和服务器发送事件为实时通信提供了强大的功能,但它们也有其局限性。例如,并非所有浏览器和网络都支持这些技术,如果不妥善管理,它们可能会消耗大量资源。此外,与传统HTTP通信相比,它们的实现和管理可能更为复杂。

Source:
https://www.sitepoint.com/real-time-apps-websockets-server-sent-events/