Node.js WebSocket 通信

章节概述

在传统的 Web 应用中,客户端和服务器之间的通信主要基于 HTTP 请求-响应模式,这种模式在需要实时数据更新的场景下存在局限性。WebSocket 协议的出现解决了这个问题,它提供了一种全双工的通信通道,允许服务器主动向客户端推送数据。本集将介绍如何在 Node.js 中实现 WebSocket 通信,以及如何使用 socket.io 库简化 WebSocket 应用的开发。

核心知识点讲解

1. WebSocket 协议概述

WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议,它的主要特点包括:

  • 全双工通信:客户端和服务器可以同时发送和接收数据
  • 持久性连接:连接建立后保持打开状态,避免了 HTTP 的重复连接开销
  • 低延迟:数据传输延迟低,适合实时应用
  • 基于 TCP:使用 TCP 作为传输层协议,保证了数据传输的可靠性
  • 与 HTTP 兼容:WebSocket 连接的建立基于 HTTP 握手

2. WebSocket 连接的建立

WebSocket 连接的建立过程如下:

  1. 客户端发送一个 HTTP 请求,包含特殊的头部信息,表明想要升级到 WebSocket 协议
  2. 服务器验证请求,如果支持 WebSocket,则返回 101 Switching Protocols 响应
  3. 连接从 HTTP 协议升级到 WebSocket 协议,此后客户端和服务器可以通过这个连接自由通信

3. 使用 ws 模块实现 WebSocket 服务器

在 Node.js 中,可以使用 ws 模块来实现 WebSocket 服务器。首先需要安装 ws 模块:

npm install ws

3.1 创建 WebSocket 服务器

const WebSocket = require('ws');

// 创建 WebSocket 服务器,监听端口 8080
const wss = new WebSocket.Server({ port: 8080 });

// 处理新连接
wss.on('connection', (ws) => {
  console.log('新客户端已连接');
  
  // 向客户端发送欢迎消息
  ws.send('欢迎连接到 WebSocket 服务器!');
  
  // 处理客户端发送的消息
  ws.on('message', (message) => {
    console.log(`接收到客户端消息: ${message}`);
    
    // 向客户端发送响应
    ws.send(`服务器已收到: ${message}`);
  });
  
  // 处理连接关闭
  ws.on('close', () => {
    console.log('客户端已断开连接');
  });
  
  // 处理错误
  ws.on('error', (err) => {
    console.error('连接错误:', err);
  });
});

console.log('WebSocket 服务器已启动,监听端口 8080');

3.2 创建 WebSocket 客户端

const WebSocket = require('ws');

// 创建 WebSocket 客户端,连接到服务器
const ws = new WebSocket('ws://localhost:8080');

// 处理连接建立
ws.on('open', () => {
  console.log('已连接到服务器');
  
  // 向服务器发送消息
  ws.send('Hello, WebSocket Server!');
});

// 处理服务器发送的消息
ws.on('message', (message) => {
  console.log(`接收到服务器消息: ${message}`);
});

// 处理连接关闭
ws.on('close', () => {
  console.log('已断开与服务器的连接');
});

// 处理错误
ws.on('error', (err) => {
  console.error('连接错误:', err);
});

4. 使用 socket.io 库

socket.io 是一个流行的库,它基于 WebSocket 协议,提供了更多的功能和更好的兼容性。它的主要特点包括:

  • 自动降级:当 WebSocket 不可用时,自动降级到其他传输方式(如长轮询)
  • 房间支持:可以将客户端分组到不同的房间,实现定向消息广播
  • 事件系统:基于事件的通信模型,使代码更加清晰
  • 命名空间:支持多个独立的通信通道

首先需要安装 socket.io 模块:

npm install socket.io

4.1 创建 socket.io 服务器

const http = require('http');
const socketIO = require('socket.io');

// 创建 HTTP 服务器
const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('WebSocket 服务器运行中\n');
});

// 将 HTTP 服务器包装为 socket.io 服务器
const io = socketIO(server);

// 处理新连接
io.on('connection', (socket) => {
  console.log('新客户端已连接,ID:', socket.id);
  
  // 向客户端发送欢迎消息
  socket.emit('message', '欢迎连接到 socket.io 服务器!');
  
  // 处理客户端发送的消息
  socket.on('chat message', (msg) => {
    console.log(`接收到客户端消息: ${msg}`);
    
    // 向所有客户端广播消息
    io.emit('chat message', msg);
  });
  
  // 处理连接关闭
  socket.on('disconnect', () => {
    console.log('客户端已断开连接,ID:', socket.id);
  });
});

// 启动服务器,监听端口 3000
server.listen(3000, () => {
  console.log('服务器已启动,监听端口 3000');
});

4.2 创建 socket.io 客户端

<!DOCTYPE html>
<html>
<head>
  <title>Socket.io 客户端</title>
  <!-- 引入 socket.io 客户端库 -->
  <script src="https://cdn.socket.io/4.0.0/socket.io.min.js"></script>
</head>
<body>
  <h1>Socket.io 聊天客户端</h1>
  <div id="messages"></div>
  <form id="chat-form">
    <input id="message-input" type="text" placeholder="输入消息...">
    <button type="submit">发送</button>
  </form>
  
  <script>
    // 连接到 socket.io 服务器
    const socket = io('http://localhost:3000');
    
    // 处理连接建立
    socket.on('connect', () => {
      console.log('已连接到服务器');
    });
    
    // 处理服务器发送的消息
    socket.on('message', (msg) => {
      console.log('接收到服务器消息:', msg);
      addMessage('服务器', msg);
    });
    
    // 处理广播消息
    socket.on('chat message', (msg) => {
      console.log('接收到广播消息:', msg);
      addMessage('其他用户', msg);
    });
    
    // 处理连接关闭
    socket.on('disconnect', () => {
      console.log('已断开与服务器的连接');
    });
    
    // 发送消息
    document.getElementById('chat-form').addEventListener('submit', (e) => {
      e.preventDefault();
      const input = document.getElementById('message-input');
      const message = input.value;
      if (message) {
        socket.emit('chat message', message);
        addMessage('我', message);
        input.value = '';
      }
    });
    
    // 添加消息到页面
    function addMessage(sender, message) {
      const messagesDiv = document.getElementById('messages');
      const messageElement = document.createElement('div');
      messageElement.innerHTML = `<strong>${sender}:</strong> ${message}`;
      messagesDiv.appendChild(messageElement);
      messagesDiv.scrollTop = messagesDiv.scrollHeight;
    }
  </script>
</body>
</html>

5. socket.io 的高级特性

5.1 房间(Rooms)

房间是 socket.io 中的一个重要概念,它允许将客户端分组,实现定向消息广播:

// 加入房间
io.on('connection', (socket) => {
  // 加入名为 'room1' 的房间
  socket.join('room1');
  console.log('客户端已加入 room1');
  
  // 向房间内的所有客户端发送消息(不包括发送者)
  socket.to('room1').emit('message', '新用户加入了房间');
  
  // 向房间内的所有客户端发送消息(包括发送者)
  io.to('room1').emit('message', '房间内的所有用户请注意');
  
  // 离开房间
  socket.leave('room1');
});

5.2 命名空间(Namespaces)

命名空间允许在同一个服务器上创建多个独立的通信通道:

// 创建命名空间
const nsp = io.of('/chat');

// 处理命名空间中的连接
nsp.on('connection', (socket) => {
  console.log('客户端已连接到 /chat 命名空间');
  
  // 处理消息
  socket.on('message', (msg) => {
    console.log('接收到消息:', msg);
    nsp.emit('message', msg);
  });
});

// 客户端连接到命名空间
// const socket = io('http://localhost:3000/chat');

实用案例分析

案例:实时聊天应用

下面我们将使用 socket.io 实现一个完整的实时聊天应用,支持多个用户同时聊天。

服务器端代码

const http = require('http');
const express = require('express');
const socketIO = require('socket.io');

// 创建 Express 应用
const app = express();

// 提供静态文件
app.use(express.static('public'));

// 创建 HTTP 服务器
const server = http.createServer(app);

// 将 HTTP 服务器包装为 socket.io 服务器
const io = socketIO(server);

// 存储在线用户
const users = [];

// 处理新连接
io.on('connection', (socket) => {
  console.log('新客户端已连接,ID:', socket.id);
  
  // 处理用户登录
  socket.on('login', (username) => {
    // 存储用户信息
    users.push({ id: socket.id, username });
    console.log(`${username} 已登录`);
    
    // 向所有客户端广播用户登录消息
    io.emit('user joined', username);
    
    // 向所有客户端广播在线用户列表
    io.emit('users list', users.map(user => user.username));
    
    // 向当前客户端发送欢迎消息
    socket.emit('message', { sender: '系统', text: `欢迎 ${username} 加入聊天室!` });
  });
  
  // 处理客户端发送的消息
  socket.on('chat message', (message) => {
    // 查找发送者
    const user = users.find(u => u.id === socket.id);
    if (user) {
      console.log(`${user.username}: ${message}`);
      
      // 向所有客户端广播消息
      io.emit('chat message', { sender: user.username, text: message });
    }
  });
  
  // 处理连接关闭
  socket.on('disconnect', () => {
    // 查找并移除用户
    const index = users.findIndex(u => u.id === socket.id);
    if (index !== -1) {
      const username = users[index].username;
      users.splice(index, 1);
      console.log(`${username} 已断开连接`);
      
      // 向所有客户端广播用户离开消息
      io.emit('user left', username);
      
      // 向所有客户端广播更新后的在线用户列表
      io.emit('users list', users.map(user => user.username));
    }
  });
});

// 启动服务器,监听端口 3000
server.listen(3000, () => {
  console.log('服务器已启动,监听端口 3000');
  console.log('访问 http://localhost:3000 开始聊天');
});

客户端代码(public/index.html)

<!DOCTYPE html>
<html>
<head>
  <title>实时聊天应用</title>
  <style>
    * {
      box-sizing: border-box;
    }
    
    body {
      font-family: Arial, sans-serif;
      margin: 0;
      padding: 20px;
      background-color: #f0f0f0;
    }
    
    .container {
      max-width: 800px;
      margin: 0 auto;
      background-color: white;
      border-radius: 8px;
      box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
      overflow: hidden;
    }
    
    .header {
      background-color: #4CAF50;
      color: white;
      padding: 15px;
      text-align: center;
    }
    
    .users-list {
      background-color: #f9f9f9;
      padding: 10px;
      border-bottom: 1px solid #ddd;
    }
    
    .messages {
      padding: 20px;
      height: 400px;
      overflow-y: auto;
      border-bottom: 1px solid #ddd;
    }
    
    .message {
      margin-bottom: 15px;
      padding: 10px;
      border-radius: 8px;
      max-width: 70%;
    }
    
    .message.own {
      background-color: #e3f2fd;
      margin-left: auto;
    }
    
    .message.other {
      background-color: #f1f1f1;
    }
    
    .message.system {
      background-color: #fff3cd;
      max-width: 100%;
      text-align: center;
    }
    
    .message .sender {
      font-weight: bold;
      margin-bottom: 5px;
      font-size: 12px;
    }
    
    .message .text {
      word-break: break-word;
    }
    
    .chat-form {
      padding: 15px;
      display: flex;
    }
    
    .chat-form input {
      flex: 1;
      padding: 10px;
      border: 1px solid #ddd;
      border-radius: 4px;
      margin-right: 10px;
    }
    
    .chat-form button {
      padding: 10px 20px;
      background-color: #4CAF50;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    }
    
    .chat-form button:hover {
      background-color: #45a049;
    }
    
    .login-form {
      padding: 40px;
      text-align: center;
    }
    
    .login-form input {
      padding: 10px;
      border: 1px solid #ddd;
      border-radius: 4px;
      margin-right: 10px;
    }
    
    .login-form button {
      padding: 10px 20px;
      background-color: #4CAF50;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="header">
      <h1>实时聊天应用</h1>
    </div>
    
    <div class="users-list">
      <strong>在线用户:</strong> <span id="users-list">加载中...</span>
    </div>
    
    <div id="login-form" class="login-form">
      <h2>请输入用户名登录</h2>
      <input type="text" id="username" placeholder="用户名">
      <button id="login-btn">登录</button>
    </div>
    
    <div id="chat-container" style="display: none;">
      <div class="messages" id="messages"></div>
      <form class="chat-form" id="chat-form">
        <input type="text" id="message-input" placeholder="输入消息...">
        <button type="submit">发送</button>
      </form>
    </div>
  </div>
  
  <script src="https://cdn.socket.io/4.0.0/socket.io.min.js"></script>
  <script>
    // 连接到 socket.io 服务器
    const socket = io();
    
    let username;
    
    // 处理登录
    document.getElementById('login-btn').addEventListener('click', () => {
      username = document.getElementById('username').value.trim();
      if (username) {
        // 发送登录事件
        socket.emit('login', username);
        
        // 隐藏登录表单,显示聊天界面
        document.getElementById('login-form').style.display = 'none';
        document.getElementById('chat-container').style.display = 'block';
      }
    });
    
    // 处理按 Enter 键登录
    document.getElementById('username').addEventListener('keypress', (e) => {
      if (e.key === 'Enter') {
        document.getElementById('login-btn').click();
      }
    });
    
    // 发送消息
    document.getElementById('chat-form').addEventListener('submit', (e) => {
      e.preventDefault();
      const input = document.getElementById('message-input');
      const message = input.value.trim();
      if (message) {
        socket.emit('chat message', message);
        input.value = '';
      }
    });
    
    // 处理服务器发送的消息
    socket.on('message', (msg) => {
      addMessage(msg.sender, msg.text, msg.sender === '系统' ? 'system' : msg.sender === username ? 'own' : 'other');
    });
    
    // 处理聊天消息
    socket.on('chat message', (msg) => {
      addMessage(msg.sender, msg.text, msg.sender === username ? 'own' : 'other');
    });
    
    // 处理用户加入
    socket.on('user joined', (user) => {
      addMessage('系统', `${user} 加入了聊天室`, 'system');
    });
    
    // 处理用户离开
    socket.on('user left', (user) => {
      addMessage('系统', `${user} 离开了聊天室`, 'system');
    });
    
    // 处理在线用户列表更新
    socket.on('users list', (users) => {
      document.getElementById('users-list').textContent = users.join(', ');
    });
    
    // 添加消息到页面
    function addMessage(sender, text, type) {
      const messagesDiv = document.getElementById('messages');
      const messageElement = document.createElement('div');
      messageElement.className = `message ${type}`;
      
      if (type !== 'system') {
        messageElement.innerHTML = `
          <div class="sender">${sender}</div>
          <div class="text">${text}</div>
        `;
      } else {
        messageElement.innerHTML = `<div class="text">${text}</div>`;
      }
      
      messagesDiv.appendChild(messageElement);
      messagesDiv.scrollTop = messagesDiv.scrollHeight;
    }
  </script>
</body>
</html>

代码解析:

  1. 服务器端:

    • 使用 Express 提供静态文件服务
    • 使用 socket.io 创建 WebSocket 服务器
    • 存储在线用户信息
    • 处理用户登录、消息发送和断开连接等事件
    • 向客户端广播用户状态变化和消息
  2. 客户端:

    • 提供登录界面,让用户输入用户名
    • 连接到 socket.io 服务器
    • 处理服务器发送的消息和事件
    • 提供聊天界面,显示消息和在线用户列表
    • 允许用户发送消息

运行方法:

  1. 安装依赖:npm install express socket.io
  2. 创建 public 目录,将客户端代码保存为 public/index.html
  3. 将服务器端代码保存为 server.js
  4. 启动服务器:node server.js
  5. 打开多个浏览器窗口,访问 http://localhost:3000
  6. 输入不同的用户名登录,开始聊天

WebSocket 与 HTTP 的比较

特性 HTTP WebSocket
连接方式 无状态,每次请求都需要建立连接 有状态,连接建立后保持打开
通信方向 单向,客户端请求,服务器响应 双向,客户端和服务器可以自由通信
数据格式 文本(JSON、XML 等) 文本或二进制数据
适用场景 传统 Web 应用,请求-响应模式 实时应用,如聊天、游戏、实时数据更新
开销 每次请求都有 HTTP 头部开销 只有建立连接时有开销,后续通信开销小

学习目标

通过本集的学习,你应该能够:

  1. 理解 WebSocket 协议的基本概念和工作原理
  2. 掌握使用 ws 模块创建 WebSocket 服务器的方法
  3. 掌握使用 socket.io 库实现实时通信的方法
  4. 理解 socket.io 中的房间和命名空间概念
  5. 实现一个完整的实时聊天应用
  6. 了解 WebSocket 与 HTTP 的区别和适用场景

小结

WebSocket 协议为 Web 应用提供了实时双向通信的能力,弥补了 HTTP 请求-响应模式的不足。在 Node.js 中,可以使用 ws 模块或更高级的 socket.io 库来实现 WebSocket 服务器。socket.io 库不仅简化了 WebSocket 应用的开发,还提供了房间、命名空间等高级特性,以及在 WebSocket 不可用时的自动降级机制。

通过本集的学习,你已经掌握了 WebSocket 通信的核心概念和实现方法,并通过一个完整的实时聊天应用案例展示了 WebSocket 的实际应用。WebSocket 在需要实时数据更新的场景中非常有用,如聊天应用、在线游戏、实时协作工具等。

在下一集中,我们将学习 Node.js MongoDB 连接,了解如何在 Node.js 应用中使用 MongoDB 数据库。

« 上一篇 Node.js 网络编程基础 下一篇 » Node.js MongoDB 连接