MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Node.js中WebSocket实现即时通讯功能

2022-05-282.1k 阅读

一、WebSocket 基础概念

(一)传统 HTTP 协议的局限性

在深入探讨 Node.js 中使用 WebSocket 实现即时通讯功能之前,我们先来了解一下传统 HTTP 协议的局限性。HTTP 协议是一种无状态的协议,它采用请求 - 响应的模式进行通信。这意味着每次客户端发起请求,服务器处理并返回响应后,连接就会关闭。对于一些实时性要求较高的应用场景,如在线聊天、实时游戏状态更新、股票行情实时显示等,传统 HTTP 协议存在明显不足。

例如,若要实现实时聊天功能,使用 HTTP 长轮询方式,客户端需要频繁地向服务器发送请求,询问是否有新消息。这不仅会增加服务器的负载,而且在请求间隔期间,客户端无法及时获取新消息,存在一定延迟。而且长轮询方式消耗的网络资源较多,会影响应用的性能和用户体验。

(二)WebSocket 协议的诞生与优势

WebSocket 协议正是为了解决传统 HTTP 协议在实时通信方面的不足而诞生的。WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议,它使得客户端和服务器之间可以在任意时刻相互发送消息。WebSocket 协议的握手过程基于 HTTP 协议,通过 HTTP 的 Upgrade 首部字段将协议从 HTTP 转换为 WebSocket 协议。

WebSocket 协议具有以下显著优势:

  1. 全双工通信:客户端和服务器可以同时发送和接收数据,无需像 HTTP 那样请求 - 响应的模式,极大地提高了实时性。
  2. 较少的开销:WebSocket 协议在建立连接后,数据传输的头部信息相对简单,减少了不必要的带宽消耗。
  3. 持久连接:一旦 WebSocket 连接建立,除非一方主动关闭,连接会一直保持,便于实时数据的持续推送。

(三)WebSocket 协议的工作原理

WebSocket 的工作流程主要包括握手和数据传输两个阶段。

  1. 握手阶段:客户端通过 HTTP 协议发起一个带有特定头部信息的请求,例如:
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec - WebSocket - Key: dGhlIHNhbXBsZSBub25jZQ==
Sec - WebSocket - Version: 13

服务器接收到请求后,若支持 WebSocket 协议,会返回一个带有响应头部的 HTTP 响应:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec - WebSocket - Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

这个过程中,服务器会验证客户端发送的 Sec - WebSocket - Key,并生成 Sec - WebSocket - Accept 进行响应。一旦握手成功,连接就从 HTTP 协议转换为 WebSocket 协议。 2. 数据传输阶段:握手成功后,客户端和服务器之间就可以通过这个持久连接进行双向数据传输。数据在传输过程中会被封装成帧,WebSocket 协议定义了多种类型的帧,如文本帧、二进制帧等,以适应不同的数据传输需求。

二、Node.js 中的 WebSocket 实现

(一)选择合适的 WebSocket 库

在 Node.js 中实现 WebSocket 功能,有多个第三方库可供选择。其中,ws 库是一个广泛使用且功能强大的库。它提供了简单易用的 API,支持 WebSocket 协议的各种特性。 安装 ws 库非常简单,通过 npm(Node Package Manager)即可完成安装:

npm install ws

(二)简单的 WebSocket 服务器示例

下面我们通过一个简单的示例来展示如何在 Node.js 中使用 ws 库创建一个 WebSocket 服务器:

const WebSocket = require('ws');

// 创建 WebSocket 服务器实例
const wss = new WebSocket.Server({ port: 8080 });

// 监听连接事件
wss.on('connection', function connection(ws) {
    console.log('新的客户端连接');

    // 监听客户端发送的消息事件
    ws.on('message', function incoming(message) {
        console.log('接收到客户端消息: %s', message);

        // 向客户端发送响应消息
        ws.send('服务器已收到你的消息: ' + message);
    });

    // 监听连接关闭事件
    ws.on('close', function close() {
        console.log('客户端连接关闭');
    });
});

在上述代码中:

  1. 首先通过 require('ws') 引入 ws 库。
  2. 然后使用 new WebSocket.Server({ port: 8080 }) 创建一个 WebSocket 服务器实例,并监听 8080 端口。
  3. 当有新的客户端连接到服务器时,会触发 connection 事件,在这个事件处理函数中,我们可以对新连接进行处理,比如记录日志等。
  4. 接着监听 message 事件,当服务器接收到客户端发送的消息时,会触发这个事件,我们可以在事件处理函数中对消息进行处理,并向客户端发送响应消息。
  5. 最后监听 close 事件,当客户端关闭连接时,会触发这个事件,我们可以在事件处理函数中进行相应的清理操作。

(三)简单的 WebSocket 客户端示例

接下来,我们创建一个简单的 WebSocket 客户端,与上述服务器进行通信:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF - 8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebSocket 客户端</title>
</head>

<body>
    <input type="text" id="inputMessage" placeholder="输入消息">
    <button onclick="sendMessage()">发送消息</button>
    <div id="messageList"></div>

    <script>
        const socket = new WebSocket('ws://localhost:8080');

        socket.onopen = function () {
            console.log('连接到服务器');
        };

        socket.onmessage = function (event) {
            const messageList = document.getElementById('messageList');
            const newMessage = document.createElement('div');
            newMessage.textContent = '服务器消息: ' + event.data;
            messageList.appendChild(newMessage);
        };

        socket.onclose = function () {
            console.log('与服务器的连接已关闭');
        };

        function sendMessage() {
            const inputMessage = document.getElementById('inputMessage').value;
            socket.send(inputMessage);
            const messageList = document.getElementById('messageList');
            const newMessage = document.createElement('div');
            newMessage.textContent = '你发送的消息: ' + inputMessage;
            messageList.appendChild(newMessage);
            document.getElementById('inputMessage').value = '';
        }
    </script>
</body>

</html>

在这个 HTML 页面中:

  1. 创建了一个 WebSocket 实例,连接到 ws://localhost:8080,也就是我们刚才创建的 WebSocket 服务器。
  2. 监听 onopen 事件,当连接成功建立时,在控制台打印消息。
  3. 监听 onmessage 事件,当接收到服务器发送的消息时,在页面上显示服务器消息。
  4. 监听 onclose 事件,当连接关闭时,在控制台打印消息。
  5. 定义了一个 sendMessage 函数,当用户点击按钮时,获取输入框中的消息并发送给服务器,同时在页面上显示自己发送的消息。

三、实现多人即时通讯功能

(一)存储连接与广播消息

在多人即时通讯场景下,我们需要存储所有客户端的连接,并将消息广播给所有连接的客户端。修改服务器代码如下:

const WebSocket = require('ws');

// 创建 WebSocket 服务器实例
const wss = new WebSocket.Server({ port: 8080 });

// 存储所有连接的客户端
const clients = new Set();

// 监听连接事件
wss.on('connection', function connection(ws) {
    console.log('新的客户端连接');
    clients.add(ws);

    // 监听客户端发送的消息事件
    ws.on('message', function incoming(message) {
        console.log('接收到客户端消息: %s', message);

        // 向所有客户端广播消息
        clients.forEach(client => {
            if (client!== ws) {
                client.send('客户端发送的消息: ' + message);
            }
        });
    });

    // 监听连接关闭事件
    ws.on('close', function close() {
        console.log('客户端连接关闭');
        clients.delete(ws);
    });
});

在上述代码中:

  1. 新增了一个 clients 变量,使用 Set 数据结构来存储所有连接的客户端 WebSocket 实例。
  2. 当有新的客户端连接时,将其加入到 clients 中。
  3. 当接收到客户端发送的消息时,遍历 clients,将消息广播给除发送者之外的所有客户端。
  4. 当客户端连接关闭时,从 clients 中删除该客户端。

(二)处理用户标识与消息格式

为了更好地管理用户和消息,我们需要为每个客户端分配一个唯一标识,并定义一个统一的消息格式。以下是改进后的代码:

const WebSocket = require('ws');

// 创建 WebSocket 服务器实例
const wss = new WebSocket.Server({ port: 8080 });

// 存储所有连接的客户端,格式为 { id: ws }
const clients = new Map();
let nextId = 1;

// 监听连接事件
wss.on('connection', function connection(ws) {
    const clientId = nextId++;
    clients.set(clientId, ws);
    console.log(`新的客户端连接,ID: ${clientId}`);

    // 监听客户端发送的消息事件
    ws.on('message', function incoming(message) {
        try {
            const { type, content } = JSON.parse(message);
            if (type === 'chat') {
                console.log(`接收到聊天消息: ${content}`);
                clients.forEach((client, id) => {
                    if (client!== ws) {
                        client.send(JSON.stringify({
                            type: 'chat',
                            senderId: clientId,
                            content
                        }));
                    }
                });
            }
        } catch (error) {
            console.error('解析消息出错:', error);
        }
    });

    // 监听连接关闭事件
    ws.on('close', function close() {
        clients.delete(clientId);
        console.log(`客户端 ID ${clientId} 连接关闭`);
    });
});

在这个版本中:

  1. 使用 Map 来存储客户端连接,键为客户端的唯一标识 id,值为 WebSocket 实例。
  2. 为每个新连接的客户端分配一个唯一的 id,并记录日志。
  3. 客户端发送的消息采用 JSON 格式,包含 type(消息类型)和 content(消息内容)。这里只处理 chat 类型的消息,接收到消息后,解析 JSON 数据,然后将消息广播给其他客户端,并在消息中添加发送者的 id
  4. 当客户端连接关闭时,从 clients 中删除对应的记录。

(三)客户端的改进

客户端也需要相应地进行修改,以适应新的消息格式和用户标识处理:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF - 8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>多人即时通讯客户端</title>
</head>

<body>
    <input type="text" id="inputMessage" placeholder="输入消息">
    <button onclick="sendMessage()">发送消息</button>
    <div id="messageList"></div>

    <script>
        const socket = new WebSocket('ws://localhost:8080');
        let clientId;

        socket.onopen = function () {
            console.log('连接到服务器');
        };

        socket.onmessage = function (event) {
            try {
                const { type, senderId, content } = JSON.parse(event.data);
                if (type === 'chat') {
                    const messageList = document.getElementById('messageList');
                    const newMessage = document.createElement('div');
                    newMessage.textContent = `用户 ${senderId}: ${content}`;
                    messageList.appendChild(newMessage);
                }
            } catch (error) {
                console.error('解析消息出错:', error);
            }
        };

        socket.onclose = function () {
            console.log('与服务器的连接已关闭');
        };

        function sendMessage() {
            const inputMessage = document.getElementById('inputMessage').value;
            if (inputMessage) {
                socket.send(JSON.stringify({
                    type: 'chat',
                    content: inputMessage
                }));
                const messageList = document.getElementById('messageList');
                const newMessage = document.createElement('div');
                newMessage.textContent = `你: ${inputMessage}`;
                messageList.appendChild(newMessage);
                document.getElementById('inputMessage').value = '';
            }
        }
    </script>
</body>

</html>

在这个客户端代码中:

  1. 新增了 clientId 变量,用于存储客户端的唯一标识。
  2. 接收到服务器消息时,解析 JSON 数据,根据 type 判断消息类型,对于 chat 类型的消息,在页面上显示发送者的 id 和消息内容。
  3. 发送消息时,将消息包装成 JSON 格式,包含 typecontent,并在页面上显示自己发送的消息。

四、处理复杂场景与优化

(一)房间功能的实现

在实际的即时通讯应用中,可能需要支持多个房间,不同房间的用户相互隔离。以下是实现房间功能的服务器代码示例:

const WebSocket = require('ws');

// 创建 WebSocket 服务器实例
const wss = new WebSocket.Server({ port: 8080 });

// 存储所有房间,格式为 { roomId: { clients: Set(), name: string } }
const rooms = new Map();

// 监听连接事件
wss.on('connection', function connection(ws) {
    let clientRoom;
    let clientId;

    // 监听客户端发送的消息事件
    ws.on('message', function incoming(message) {
        try {
            const { type, content } = JSON.parse(message);
            if (type === 'join') {
                const { roomId } = content;
                if (!rooms.has(roomId)) {
                    rooms.set(roomId, { clients: new Set(), name: `房间 ${roomId}` });
                }
                clientRoom = rooms.get(roomId);
                clientId = Date.now();
                clientRoom.clients.add(ws);
                console.log(`用户 ${clientId} 加入房间 ${roomId}`);
            } else if (type === 'chat') {
                if (clientRoom) {
                    clientRoom.clients.forEach(client => {
                        if (client!== ws) {
                            client.send(JSON.stringify({
                                type: 'chat',
                                senderId: clientId,
                                content
                            }));
                        }
                    });
                }
            }
        } catch (error) {
            console.error('解析消息出错:', error);
        }
    });

    // 监听连接关闭事件
    ws.on('close', function close() {
        if (clientRoom && clientId) {
            clientRoom.clients.delete(ws);
            console.log(`用户 ${clientId} 离开房间 ${clientRoom.name}`);
        }
    });
});

在上述代码中:

  1. 使用 Map 来存储所有房间,每个房间包含一个 clientsSet 用于存储该房间内的客户端,以及房间名称。
  2. 当客户端发送 join 类型的消息时,根据消息中的 roomId 判断房间是否存在,不存在则创建房间,并将客户端加入该房间,记录客户端的 id
  3. 当客户端发送 chat 类型的消息时,只将消息广播给所在房间的其他客户端。
  4. 当客户端连接关闭时,从所在房间的 clients 中删除该客户端。

(二)安全性与身份验证

在即时通讯应用中,安全性至关重要。身份验证是确保只有合法用户能够连接到服务器的重要手段。我们可以在握手阶段进行身份验证,例如通过 JWT(JSON Web Token)。以下是一个简单的身份验证示例:

const WebSocket = require('ws');
const jwt = require('jsonwebtoken');

// 创建 WebSocket 服务器实例
const wss = new WebSocket.Server({ port: 8080 });

// 模拟 JWT 密钥
const JWT_SECRET = 'your - secret - key';

// 监听连接事件
wss.on('connection', function connection(ws, req) {
    const token = req.url.split('=')[1];
    try {
        const decoded = jwt.verify(token, JWT_SECRET);
        const { userId } = decoded;
        console.log(`用户 ${userId} 连接成功`);

        // 后续处理与之前类似
        //...
    } catch (error) {
        console.error('身份验证失败:', error);
        ws.close(1008, '身份验证失败');
    }
});

在这个示例中:

  1. 引入 jsonwebtoken 库来处理 JWT。
  2. connection 事件处理函数中,从请求的 URL 中获取 JWT 令牌(假设令牌以 token=xxx 的形式传递)。
  3. 使用 jwt.verify 方法验证令牌的有效性,如果验证成功,则可以获取到用户的相关信息,如 userId,并继续后续的处理;如果验证失败,则关闭连接并返回错误信息。

(三)性能优化

为了提高即时通讯应用的性能,可以从以下几个方面进行优化:

  1. 减少内存占用:合理管理客户端连接和房间数据,避免不必要的数据存储。例如,及时清理已关闭连接的客户端数据,避免内存泄漏。
  2. 优化消息处理:对于大量的消息广播,可以采用更高效的数据结构和算法。比如,在广播消息时,可以先将消息缓存,然后批量发送,减少频繁的网络 I/O 操作。
  3. 负载均衡:当用户量较大时,引入负载均衡机制,将客户端请求分配到多个服务器实例上,提高整体的处理能力。可以使用 Nginx 等工具实现负载均衡。

通过以上对 WebSocket 在 Node.js 中实现即时通讯功能的详细讲解,从基础概念到实际代码实现,再到复杂场景处理与性能优化,希望能够帮助读者全面掌握相关技术,开发出高效、稳定的即时通讯应用。