Node.js 实时应用开发

核心知识点

实时应用概述

实时应用是指能够实时更新数据、提供即时反馈的应用程序,用户无需手动刷新页面即可获取最新信息。实时应用在现代 Web 开发中越来越重要,广泛应用于聊天应用、在线游戏、实时协作工具、金融交易平台等场景。

实时应用的主要特征:

  • 低延迟:数据传输和处理延迟低
  • 双向通信:客户端和服务器之间可以双向通信
  • 实时更新:数据变化时立即更新
  • 并发处理:支持大量并发连接

实时应用的技术挑战:

  • 连接管理:管理大量并发连接
  • 消息传递:确保消息的可靠传递
  • 状态同步:保持客户端和服务器的状态同步
  • 扩展性:系统能够水平扩展
  • 可靠性:在网络不稳定时保持连接

实时通信技术

  1. WebSocket

    • 全双工通信协议
    • 建立在 TCP 之上
    • 浏览器和服务器之间的持久连接
    • 支持双向实时通信
    • 低延迟,适合实时应用
  2. Socket.io

    • 基于 WebSocket 的库
    • 提供降级机制(在不支持 WebSocket 的环境中使用长轮询)
    • 简化了 WebSocket 的使用
    • 提供事件系统和房间功能
  3. **Server-Sent Events (SSE)**:

    • 服务器向客户端单向推送
    • 基于 HTTP 协议
    • 简单易用,适合单向实时更新
    • 支持自动重连
  4. Long Polling

    • 客户端发送请求,服务器保持连接直到有数据或超时
    • 基于 HTTP 协议
    • 兼容性好,但延迟较高
    • 资源消耗较大
  5. WebRTC

    • 点对点实时通信
    • 适合音视频通话
    • 直接在浏览器之间传输数据
    • 无需经过服务器中转

实时应用架构

  1. 客户端架构

    • 浏览器端:使用 WebSocket API 或 Socket.io 客户端
    • 移动应用:使用相应平台的 WebSocket 客户端库
    • 桌面应用:使用 WebSocket 客户端库
  2. 服务器架构

    • 单服务器:适合小规模应用
    • 集群:使用负载均衡和会话共享
    • 消息队列:处理高并发消息
    • 缓存:存储会话状态
  3. 数据架构

    • 内存存储:快速访问会话状态
    • 数据库:持久化数据
    • 缓存:Redis 用于会话管理和发布/订阅
  4. 扩展架构

    • 水平扩展:增加服务器实例
    • 垂直扩展:增加单服务器资源
    • 负载均衡:分发客户端连接
    • 会话粘性:确保客户端始终连接到同一服务器

实用案例分析

案例 1:基本 WebSocket 服务器

问题:需要构建一个基本的 WebSocket 服务器,实现客户端和服务器的双向通信

解决方案:使用 Node.js 内置的 ws 模块或第三方库 websocket 构建 WebSocket 服务器。

实现代码

// server.js
const WebSocket = require('ws');
const http = require('http');

// 创建 HTTP 服务器
const server = http.createServer((req, res) => {
  res.writeHead(200);
  res.end('WebSocket 服务器运行中');
});

// 创建 WebSocket 服务器
const wss = new WebSocket.Server({ server });

// 监听连接事件
wss.on('connection', (ws) => {
  console.log('新客户端连接');
  
  // 发送欢迎消息
  ws.send('欢迎连接到 WebSocket 服务器!');
  
  // 监听消息事件
  ws.on('message', (message) => {
    console.log(`收到消息: ${message}`);
    
    // 广播消息给所有客户端
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(`服务器转发: ${message}`);
      }
    });
  });
  
  // 监听关闭事件
  ws.on('close', () => {
    console.log('客户端断开连接');
  });
  
  // 监听错误事件
  ws.on('error', (error) => {
    console.error('WebSocket 错误:', error);
  });
});

// 启动服务器
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`服务器运行在 http://localhost:${PORT}`);
  console.log(`WebSocket 服务运行在 ws://localhost:${PORT}`);
});

客户端代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>WebSocket 客户端</title>
</head>
<body>
  <h1>WebSocket 客户端</h1>
  <div id="messages"></div>
  <input type="text" id="messageInput" placeholder="输入消息">
  <button id="sendButton">发送</button>
  
  <script>
    // 创建 WebSocket 连接
    const ws = new WebSocket('ws://localhost:3000');
    
    // 连接打开
    ws.onopen = () => {
      console.log('连接到服务器');
      addMessage('系统', '连接成功');
    };
    
    // 接收消息
    ws.onmessage = (event) => {
      console.log('收到消息:', event.data);
      addMessage('服务器', event.data);
    };
    
    // 连接关闭
    ws.onclose = () => {
      console.log('连接关闭');
      addMessage('系统', '连接已关闭');
    };
    
    // 连接错误
    ws.onerror = (error) => {
      console.error('连接错误:', error);
      addMessage('系统', '连接错误');
    };
    
    // 发送消息
    document.getElementById('sendButton').addEventListener('click', () => {
      const message = document.getElementById('messageInput').value;
      if (message) {
        ws.send(message);
        addMessage('我', message);
        document.getElementById('messageInput').value = '';
      }
    });
    
    // 添加消息到页面
    function addMessage(sender, text) {
      const messagesDiv = document.getElementById('messages');
      const messageDiv = document.createElement('div');
      messageDiv.innerHTML = `<strong>${sender}:</strong> ${text}`;
      messagesDiv.appendChild(messageDiv);
      messagesDiv.scrollTop = messagesDiv.scrollHeight;
    }
  </script>
</body>
</html>

案例 2:使用 Socket.io 构建实时聊天应用

问题:需要构建一个功能完整的实时聊天应用,支持多个房间和用户管理

解决方案:使用 Socket.io 构建实时聊天应用,利用其房间功能和事件系统。

安装依赖

npm install express socket.io

服务器代码

// server.js
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');

const app = express();
const server = http.createServer(app);
const io = new Server(server);

// 静态文件服务
app.use(express.static('public'));

// 存储在线用户
const users = {};

// 监听连接事件
io.on('connection', (socket) => {
  console.log('新用户连接:', socket.id);
  
  // 监听用户加入
  socket.on('join', (username) => {
    users[socket.id] = username;
    console.log(`${username} 加入了聊天室`);
    
    // 广播用户加入消息
    io.emit('user joined', {
      username,
      message: `${username} 加入了聊天室`
    });
  });
  
  // 监听发送消息
  socket.on('chat message', (msg) => {
    const username = users[socket.id] || '匿名用户';
    console.log(`${username}: ${msg}`);
    
    // 广播消息给所有用户
    io.emit('chat message', {
      username,
      message: msg
    });
  });
  
  // 监听加入房间
  socket.on('join room', (room) => {
    socket.join(room);
    const username = users[socket.id] || '匿名用户';
    console.log(`${username} 加入了房间: ${room}`);
    
    // 发送消息给房间内的用户
    io.to(room).emit('user joined room', {
      username,
      room,
      message: `${username} 加入了房间`
    });
  });
  
  // 监听房间消息
  socket.on('room message', ({ room, message }) => {
    const username = users[socket.id] || '匿名用户';
    console.log(`${username} 在房间 ${room} 发送消息: ${message}`);
    
    // 发送消息给房间内的用户
    io.to(room).emit('room message', {
      username,
      room,
      message
    });
  });
  
  // 监听断开连接
  socket.on('disconnect', () => {
    const username = users[socket.id];
    if (username) {
      console.log(`${username} 断开了连接`);
      
      // 广播用户离开消息
      io.emit('user left', {
        username,
        message: `${username} 离开了聊天室`
      });
      
      // 从用户列表中删除
      delete users[socket.id];
    } else {
      console.log('匿名用户断开了连接');
    }
  });
});

// 启动服务器
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`服务器运行在 http://localhost:${PORT}`);
});

客户端代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>实时聊天应用</title>
  <style>
    body {
      font-family: Arial, sans-serif;
      max-width: 800px;
      margin: 0 auto;
      padding: 20px;
    }
    #chat {
      border: 1px solid #ddd;
      border-radius: 5px;
      padding: 10px;
      height: 400px;
      overflow-y: scroll;
      margin-bottom: 10px;
    }
    .message {
      margin-bottom: 10px;
      padding: 5px;
      border-radius: 5px;
    }
    .user {
      font-weight: bold;
    }
    .system {
      color: #888;
      font-style: italic;
    }
    #messageInput {
      width: 80%;
      padding: 10px;
      border: 1px solid #ddd;
      border-radius: 5px;
    }
    #sendButton {
      padding: 10px;
      background-color: #4CAF50;
      color: white;
      border: none;
      border-radius: 5px;
      cursor: pointer;
    }
    #roomInput {
      margin-top: 10px;
      padding: 5px;
      border: 1px solid #ddd;
      border-radius: 5px;
    }
    #joinRoomButton {
      padding: 5px;
      background-color: #2196F3;
      color: white;
      border: none;
      border-radius: 5px;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <h1>实时聊天应用</h1>
  
  <div id="usernameInput">
    <input type="text" id="username" placeholder="输入用户名">
    <button id="joinButton">加入聊天室</button>
  </div>
  
  <div id="chatContainer" style="display: none;">
    <div id="chat"></div>
    <input type="text" id="messageInput" placeholder="输入消息">
    <button id="sendButton">发送</button>
    
    <div>
      <input type="text" id="roomInput" placeholder="输入房间名称">
      <button id="joinRoomButton">加入房间</button>
    </div>
  </div>
  
  <script src="https://cdn.socket.io/4.0.0/socket.io.min.js"></script>
  <script>
    const socket = io();
    let username = '';
    
    // 加入聊天室
    document.getElementById('joinButton').addEventListener('click', () => {
      username = document.getElementById('username').value;
      if (username) {
        socket.emit('join', username);
        document.getElementById('usernameInput').style.display = 'none';
        document.getElementById('chatContainer').style.display = 'block';
      }
    });
    
    // 发送消息
    document.getElementById('sendButton').addEventListener('click', () => {
      const message = document.getElementById('messageInput').value;
      if (message) {
        socket.emit('chat message', message);
        addMessage('我', message);
        document.getElementById('messageInput').value = '';
      }
    });
    
    // 加入房间
    document.getElementById('joinRoomButton').addEventListener('click', () => {
      const room = document.getElementById('roomInput').value;
      if (room) {
        socket.emit('join room', room);
      }
    });
    
    // 接收消息
    socket.on('chat message', (data) => {
      addMessage(data.username, data.message);
    });
    
    // 用户加入
    socket.on('user joined', (data) => {
      addSystemMessage(data.message);
    });
    
    // 用户离开
    socket.on('user left', (data) => {
      addSystemMessage(data.message);
    });
    
    // 用户加入房间
    socket.on('user joined room', (data) => {
      addSystemMessage(data.message);
    });
    
    // 房间消息
    socket.on('room message', (data) => {
      addMessage(`${data.username} (${data.room})`, data.message);
    });
    
    // 添加消息到聊天窗口
    function addMessage(user, message) {
      const chatDiv = document.getElementById('chat');
      const messageDiv = document.createElement('div');
      messageDiv.className = 'message';
      messageDiv.innerHTML = `<span class="user">${user}:</span> ${message}`;
      chatDiv.appendChild(messageDiv);
      chatDiv.scrollTop = chatDiv.scrollHeight;
    }
    
    // 添加系统消息
    function addSystemMessage(message) {
      const chatDiv = document.getElementById('chat');
      const messageDiv = document.createElement('div');
      messageDiv.className = 'message system';
      messageDiv.textContent = message;
      chatDiv.appendChild(messageDiv);
      chatDiv.scrollTop = chatDiv.scrollHeight;
    }
  </script>
</body>
</html>

案例 3:使用 Server-Sent Events 实现实时通知

问题:需要实现服务器向客户端单向推送实时通知,如新闻更新、股票行情等

解决方案:使用 Server-Sent Events (SSE) 实现服务器向客户端的单向推送。

服务器代码

// server.js
const express = require('express');
const app = express();

// 静态文件服务
app.use(express.static('public'));

// SSE 端点
app.get('/events', (req, res) => {
  // 设置响应头
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('Access-Control-Allow-Origin', '*');
  
  // 发送初始消息
  res.write('event: connected\n');
  res.write('data: 连接成功\n\n');
  
  // 定期发送消息
  const intervalId = setInterval(() => {
    const data = {
      time: new Date().toLocaleTimeString(),
      message: `实时更新: ${Math.random().toFixed(2)}`
    };
    
    // 发送消息
    res.write(`event: update\n`);
    res.write(`data: ${JSON.stringify(data)}\n\n`);
  }, 2000);
  
  // 监听连接关闭
  req.on('close', () => {
    console.log('客户端断开连接');
    clearInterval(intervalId);
    res.end();
  });
});

// 启动服务器
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`服务器运行在 http://localhost:${PORT}`);
  console.log(`SSE 端点: http://localhost:${PORT}/events`);
});

客户端代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Server-Sent Events 示例</title>
  <style>
    #notifications {
      border: 1px solid #ddd;
      border-radius: 5px;
      padding: 10px;
      height: 300px;
      overflow-y: scroll;
    }
    .notification {
      margin-bottom: 10px;
      padding: 5px;
      border-bottom: 1px solid #eee;
    }
    .time {
      font-size: 0.8em;
      color: #888;
    }
  </style>
</head>
<body>
  <h1>Server-Sent Events 示例</h1>
  <div id="notifications"></div>
  
  <script>
    // 创建 EventSource 连接
    const eventSource = new EventSource('/events');
    
    // 监听消息
    eventSource.onmessage = (event) => {
      console.log('收到消息:', event.data);
      addNotification('消息', event.data);
    };
    
    // 监听特定事件
    eventSource.addEventListener('update', (event) => {
      console.log('收到更新:', event.data);
      const data = JSON.parse(event.data);
      addNotification('实时更新', `${data.message} <span class="time">${data.time}</span>`);
    });
    
    // 监听连接打开
    eventSource.onopen = () => {
      console.log('连接已打开');
      addNotification('系统', '连接已打开');
    };
    
    // 监听错误
    eventSource.onerror = (error) => {
      console.error('错误:', error);
      addNotification('系统', '连接错误');
      
      // 如果连接关闭,尝试重连
      if (eventSource.readyState === EventSource.CLOSED) {
        console.log('连接已关闭');
        addNotification('系统', '连接已关闭');
      }
    };
    
    // 添加通知到页面
    function addNotification(type, message) {
      const notificationsDiv = document.getElementById('notifications');
      const notificationDiv = document.createElement('div');
      notificationDiv.className = 'notification';
      notificationDiv.innerHTML = `<strong>${type}:</strong> ${message}`;
      notificationsDiv.appendChild(notificationDiv);
      notificationsDiv.scrollTop = notificationsDiv.scrollHeight;
    }
  </script>
</body>
</html>

案例 4:实时协作白板应用

问题:需要构建一个实时协作白板应用,多个用户可以同时在白板上绘图

解决方案:使用 Socket.io 实现实时协作,共享绘图数据。

服务器代码

// server.js
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');

const app = express();
const server = http.createServer(app);
const io = new Server(server);

// 静态文件服务
app.use(express.static('public'));

// 存储绘图数据
const drawingData = [];

// 监听连接事件
io.on('connection', (socket) => {
  console.log('新用户连接:', socket.id);
  
  // 发送历史绘图数据
  if (drawingData.length > 0) {
    socket.emit('draw history', drawingData);
  }
  
  // 监听绘图事件
  socket.on('draw', (data) => {
    // 存储绘图数据
    drawingData.push(data);
    
    // 广播绘图事件给其他用户
    socket.broadcast.emit('draw', data);
  });
  
  // 监听清除白板
  socket.on('clear', () => {
    // 清空绘图数据
    drawingData.length = 0;
    
    // 广播清除事件
    io.emit('clear');
  });
  
  // 监听断开连接
  socket.on('disconnect', () => {
    console.log('用户断开连接:', socket.id);
  });
});

// 启动服务器
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`服务器运行在 http://localhost:${PORT}`);
});

客户端代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>实时协作白板</title>
  <style>
    #whiteboard {
      border: 1px solid #ddd;
      cursor: crosshair;
    }
    #controls {
      margin: 10px 0;
    }
    button {
      padding: 10px;
      margin-right: 10px;
      border: none;
      border-radius: 5px;
      cursor: pointer;
    }
    #clearButton {
      background-color: #f44336;
      color: white;
    }
    #colorPicker {
      margin-right: 10px;
    }
    #brushSize {
      width: 100px;
    }
  </style>
</head>
<body>
  <h1>实时协作白板</h1>
  
  <div id="controls">
    <input type="color" id="colorPicker" value="#000000">
    <input type="range" id="brushSize" min="1" max="10" value="2">
    <button id="clearButton">清除白板</button>
  </div>
  
  <canvas id="whiteboard" width="800" height="500"></canvas>
  
  <script src="https://cdn.socket.io/4.0.0/socket.io.min.js"></script>
  <script>
    const socket = io();
    const canvas = document.getElementById('whiteboard');
    const ctx = canvas.getContext('2d');
    
    let isDrawing = false;
    let lastX = 0;
    let lastY = 0;
    let color = '#000000';
    let brushSize = 2;
    
    // 绘制函数
    function draw(e) {
      if (!isDrawing) return;
      
      ctx.strokeStyle = color;
      ctx.lineWidth = brushSize;
      ctx.lineCap = 'round';
      ctx.lineJoin = 'round';
      
      // 绘制线条
      ctx.beginPath();
      ctx.moveTo(lastX, lastY);
      ctx.lineTo(e.offsetX, e.offsetY);
      ctx.stroke();
      
      // 发送绘图数据
      socket.emit('draw', {
        x1: lastX,
        y1: lastY,
        x2: e.offsetX,
        y2: e.offsetY,
        color,
        brushSize
      });
      
      // 更新最后位置
      [lastX, lastY] = [e.offsetX, e.offsetY];
    }
    
    // 绘制从其他用户收到的线条
    function drawFromServer(data) {
      ctx.strokeStyle = data.color;
      ctx.lineWidth = data.brushSize;
      ctx.lineCap = 'round';
      ctx.lineJoin = 'round';
      
      ctx.beginPath();
      ctx.moveTo(data.x1, data.y1);
      ctx.lineTo(data.x2, data.y2);
      ctx.stroke();
    }
    
    // 事件监听
    canvas.addEventListener('mousedown', (e) => {
      isDrawing = true;
      [lastX, lastY] = [e.offsetX, e.offsetY];
    });
    
    canvas.addEventListener('mousemove', draw);
    canvas.addEventListener('mouseup', () => isDrawing = false);
    canvas.addEventListener('mouseout', () => isDrawing = false);
    
    // 颜色选择
    document.getElementById('colorPicker').addEventListener('change', (e) => {
      color = e.target.value;
    });
    
    // 画笔大小
    document.getElementById('brushSize').addEventListener('change', (e) => {
      brushSize = e.target.value;
    });
    
    // 清除白板
    document.getElementById('clearButton').addEventListener('click', () => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      socket.emit('clear');
    });
    
    // 接收绘图事件
    socket.on('draw', drawFromServer);
    
    // 接收历史绘图数据
    socket.on('draw history', (data) => {
      data.forEach(drawFromServer);
    });
    
    // 接收清除事件
    socket.on('clear', () => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
    });
  </script>
</body>
</html>

案例 5:实时多人游戏

问题:需要构建一个简单的实时多人游戏,如贪吃蛇或乒乓球

解决方案:使用 Socket.io 实现实时游戏状态同步。

服务器代码

// server.js
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');

const app = express();
const server = http.createServer(app);
const io = new Server(server);

// 静态文件服务
app.use(express.static('public'));

// 游戏状态
const gameState = {
  players: {},
  ball: {
    x: 400,
    y: 300,
    dx: 5,
    dy: 5,
    radius: 10
  },
  score: {
    player1: 0,
    player2: 0
  }
};

// 游戏设置
const GAME_WIDTH = 800;
const GAME_HEIGHT = 600;
const PADDLE_WIDTH = 15;
const PADDLE_HEIGHT = 100;
const PADDLE_SPEED = 8;

// 监听连接事件
io.on('connection', (socket) => {
  console.log('新玩家连接:', socket.id);
  
  // 分配玩家
  let playerId;
  if (!gameState.players.player1) {
    playerId = 'player1';
    gameState.players.player1 = {
      x: 30,
      y: GAME_HEIGHT / 2 - PADDLE_HEIGHT / 2,
      width: PADDLE_WIDTH,
      height: PADDLE_HEIGHT,
      speed: PADDLE_SPEED,
      socketId: socket.id
    };
  } else if (!gameState.players.player2) {
    playerId = 'player2';
    gameState.players.player2 = {
      x: GAME_WIDTH - 30 - PADDLE_WIDTH,
      y: GAME_HEIGHT / 2 - PADDLE_HEIGHT / 2,
      width: PADDLE_WIDTH,
      height: PADDLE_HEIGHT,
      speed: PADDLE_SPEED,
      socketId: socket.id
    };
  } else {
    // 游戏已满
    socket.emit('game full');
    socket.disconnect();
    return;
  }
  
  console.log(`玩家 ${playerId} 加入游戏`);
  
  // 发送游戏状态
  socket.emit('game state', {
    ...gameState,
    playerId
  });
  
  // 广播玩家加入
  socket.broadcast.emit('player joined', {
    playerId,
    player: gameState.players[playerId]
  });
  
  // 监听移动事件
  socket.on('move', (direction) => {
    if (!gameState.players[playerId]) return;
    
    const player = gameState.players[playerId];
    
    if (direction === 'up') {
      player.y = Math.max(0, player.y - player.speed);
    } else if (direction === 'down') {
      player.y = Math.min(GAME_HEIGHT - player.height, player.y + player.speed);
    }
    
    // 广播游戏状态
    io.emit('game state', gameState);
  });
  
  // 监听断开连接
  socket.on('disconnect', () => {
    console.log(`玩家 ${playerId} 断开连接`);
    
    // 移除玩家
    delete gameState.players[playerId];
    
    // 广播玩家离开
    io.emit('player left', playerId);
  });
});

// 游戏循环
function gameLoop() {
  // 更新球的位置
  gameState.ball.x += gameState.ball.dx;
  gameState.ball.y += gameState.ball.dy;
  
  // 碰撞检测:上下边界
  if (gameState.ball.y + gameState.ball.radius > GAME_HEIGHT || 
      gameState.ball.y - gameState.ball.radius < 0) {
    gameState.ball.dy = -gameState.ball.dy;
  }
  
  // 碰撞检测:左右边界(得分)
  if (gameState.ball.x + gameState.ball.radius > GAME_WIDTH) {
    // 玩家 1 得分
    gameState.score.player1++;
    resetBall();
  } else if (gameState.ball.x - gameState.ball.radius < 0) {
    // 玩家 2 得分
    gameState.score.player2++;
    resetBall();
  }
  
  // 碰撞检测: paddle
  for (const id in gameState.players) {
    const player = gameState.players[id];
    if (
      gameState.ball.x + gameState.ball.radius > player.x &&
      gameState.ball.x - gameState.ball.radius < player.x + player.width &&
      gameState.ball.y + gameState.ball.radius > player.y &&
      gameState.ball.y - gameState.ball.radius < player.y + player.height
    ) {
      gameState.ball.dx = -gameState.ball.dx;
      
      // 调整球的角度
      const hitPosition = (gameState.ball.y - (player.y + player.height / 2)) / (player.height / 2);
      gameState.ball.dy = hitPosition * 5;
    }
  }
  
  // 广播游戏状态
  io.emit('game state', gameState);
  
  // 继续游戏循环
  setTimeout(gameLoop, 16); // 约 60 FPS
}

// 重置球的位置
function resetBall() {
  gameState.ball.x = GAME_WIDTH / 2;
  gameState.ball.y = GAME_HEIGHT / 2;
  gameState.ball.dx = -gameState.ball.dx;
  gameState.ball.dy = (Math.random() - 0.5) * 10;
}

// 启动游戏循环
gameLoop();

// 启动服务器
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`服务器运行在 http://localhost:${PORT}`);
});

客户端代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>实时乒乓球游戏</title>
  <style>
    #game {
      position: relative;
      width: 800px;
      height: 600px;
      border: 2px solid #000;
      margin: 0 auto;
      background-color: #f0f0f0;
    }
    .paddle {
      position: absolute;
      background-color: #000;
    }
    #ball {
      position: absolute;
      width: 20px;
      height: 20px;
      border-radius: 50%;
      background-color: #000;
    }
    #score {
      position: absolute;
      top: 10px;
      left: 50%;
      transform: translateX(-50%);
      font-size: 24px;
      font-weight: bold;
    }
    #instructions {
      text-align: center;
      margin: 20px 0;
    }
  </style>
</head>
<body>
  <h1>实时乒乓球游戏</h1>
  <div id="instructions">
    <p>使用 W/S 键控制左侧 paddle</p>
    <p>使用 上/下 箭头键控制右侧 paddle</p>
  </div>
  <div id="game">
    <div id="score">0 - 0</div>
    <div id="ball"></div>
    <div id="paddle1" class="paddle"></div>
    <div id="paddle2" class="paddle"></div>
  </div>
  
  <script src="https://cdn.socket.io/4.0.0/socket.io.min.js"></script>
  <script>
    const socket = io();
    let playerId;
    
    // 接收游戏状态
    socket.on('game state', (state) => {
      updateGameState(state);
    });
    
    // 接收玩家加入
    socket.on('player joined', (data) => {
      console.log(`玩家 ${data.playerId} 加入游戏`);
    });
    
    // 接收玩家离开
    socket.on('player left', (id) => {
      console.log(`玩家 ${id} 离开游戏`);
    });
    
    // 游戏已满
    socket.on('game full', () => {
      alert('游戏已满,请稍后再试');
    });
    
    // 更新游戏状态
    function updateGameState(state) {
      // 更新玩家 ID
      if (state.playerId) {
        playerId = state.playerId;
      }
      
      // 更新 paddle 位置
      if (state.players.player1) {
        const paddle1 = document.getElementById('paddle1');
        paddle1.style.width = `${state.players.player1.width}px`;
        paddle1.style.height = `${state.players.player1.height}px`;
        paddle1.style.left = `${state.players.player1.x}px`;
        paddle1.style.top = `${state.players.player1.y}px`;
      }
      
      if (state.players.player2) {
        const paddle2 = document.getElementById('paddle2');
        paddle2.style.width = `${state.players.player2.width}px`;
        paddle2.style.height = `${state.players.player2.height}px`;
        paddle2.style.left = `${state.players.player2.x}px`;
        paddle2.style.top = `${state.players.player2.y}px`;
      }
      
      // 更新球的位置
      const ball = document.getElementById('ball');
      ball.style.left = `${state.ball.x - state.ball.radius}px`;
      ball.style.top = `${state.ball.y - state.ball.radius}px`;
      ball.style.width = `${state.ball.radius * 2}px`;
      ball.style.height = `${state.ball.radius * 2}px`;
      
      // 更新分数
      const score = document.getElementById('score');
      score.textContent = `${state.score.player1} - ${state.score.player2}`;
    }
    
    // 键盘控制
    document.addEventListener('keydown', (e) => {
      switch (e.key) {
        case 'w':
        case 'W':
          socket.emit('move', 'up');
          break;
        case 's':
        case 'S':
          socket.emit('move', 'down');
          break;
        case 'ArrowUp':
          socket.emit('move', 'up');
          break;
        case 'ArrowDown':
          socket.emit('move', 'down');
          break;
      }
    });
  </script>
</body>
</html>

实时应用最佳实践

1. 连接管理

  • 连接状态监控

    • 监控连接的健康状态
    • 实现自动重连机制
    • 处理网络不稳定情况
  • 连接限制

    • 设置合理的连接超时
    • 限制单个 IP 的连接数
    • 实现速率限制,防止滥用
  • 资源管理

    • 及时释放不再使用的连接
    • 避免内存泄漏
    • 监控连接数和资源使用情况

2. 消息传递

  • 消息格式

    • 使用 JSON 格式传递消息
    • 定义清晰的消息结构
    • 包含消息类型和时间戳
  • 消息可靠性

    • 实现消息确认机制
    • 处理消息丢失情况
    • 考虑使用消息队列确保消息传递
  • 消息压缩

    • 压缩大型消息
    • 减少网络传输量
    • 提高传输速度

3. 状态管理

  • 客户端状态

    • 维护客户端本地状态
    • 与服务器状态同步
    • 处理状态冲突
  • 服务器状态

    • 存储会话状态
    • 使用 Redis 等缓存存储状态
    • 实现状态持久化
  • 状态同步

    • 增量更新状态
    • 避免全量同步
    • 处理并发状态更新

4. 性能优化

  • 网络优化

    • 使用 WebSocket 替代长轮询
    • 减少消息大小
    • 批量处理消息
  • 服务器优化

    • 使用集群模式
    • 优化事件循环
    • 合理使用内存
  • 客户端优化

    • 减少 DOM 操作
    • 使用 requestAnimationFrame 进行动画
    • 优化渲染性能

5. 安全性

  • 认证与授权

    • 实现用户认证
    • 验证消息来源
    • 限制访问权限
  • 数据验证

    • 验证客户端输入
    • 防止注入攻击
    • 过滤恶意数据
  • 传输安全

    • 使用 WSS (WebSocket Secure)
    • 加密敏感数据
    • 防止中间人攻击

6. 扩展性

  • 水平扩展

    • 使用负载均衡
    • 实现会话共享
    • 考虑使用 Redis 进行发布/订阅
  • 服务拆分

    • 将实时服务与其他服务分离
    • 使用消息队列解耦服务
    • 独立扩展实时服务
  • 监控与告警

    • 监控连接数和消息吞吐量
    • 设置合理的告警阈值
    • 实时监控系统状态

常见问题与解决方案

问题 1:连接断开

症状

  • 客户端连接频繁断开
  • 连接不稳定
  • 重连失败

解决方案

  • 实现自动重连机制:使用 Socket.io 的重连功能或自定义重连逻辑
  • 检查网络状况:确保网络连接稳定
  • 调整心跳间隔:设置合理的心跳间隔,及时检测断开的连接
  • 服务器配置:调整服务器的连接超时设置

问题 2:消息延迟

症状

  • 消息传递延迟高
  • 实时性差
  • 消息堆积

解决方案

  • 优化网络传输:减少消息大小,使用压缩
  • 服务器优化:增加服务器资源,使用集群
  • 消息批处理:批量发送小消息
  • 减少中间环节:直接传输消息,避免不必要的处理

问题 3:并发连接限制

症状

  • 无法处理大量并发连接
  • 服务器崩溃
  • 连接被拒绝

解决方案

  • 使用集群模式:Node.js 的 cluster 模块或 PM2
  • 负载均衡:使用 Nginx 等负载均衡器
  • 优化服务器配置:调整最大连接数和内存限制
  • 水平扩展:增加服务器实例

问题 4:状态同步冲突

症状

  • 客户端和服务器状态不一致
  • 数据冲突
  • 操作被覆盖

解决方案

  • 实现乐观锁或悲观锁
  • 使用版本号管理状态
  • 冲突解决策略:最后写入获胜或合并冲突
  • 状态同步协议:定义清晰的状态同步规则

问题 5:内存泄漏

症状

  • 服务器内存使用持续增长
  • 性能下降
  • 服务器崩溃

解决方案

  • 及时释放连接:监听连接关闭事件,清理相关资源
  • 避免全局变量:减少全局变量的使用
  • 定期检查内存使用:使用工具监控内存使用情况
  • 代码审查:检查可能导致内存泄漏的代码

实时应用技术比较

技术 优点 缺点 适用场景
WebSocket 全双工通信,低延迟,高性能 浏览器兼容性,需要特殊服务器支持 实时聊天,在线游戏,协作工具
Socket.io 自动降级,易用,功能丰富 增加了开销,依赖库 一般实时应用,需要兼容性
SSE 简单易用,基于 HTTP,自动重连 单向通信,浏览器兼容性 新闻推送,股票行情,通知
Long Polling 兼容性好,实现简单 延迟高,资源消耗大 旧浏览器,简单实时更新
WebRTC 点对点通信,低延迟,适合音视频 实现复杂,浏览器支持有限 音视频通话,屏幕共享

总结

实时应用开发是现代 Web 开发的重要组成部分,它可以提供更好的用户体验,满足实时交互的需求。通过本文的学习,你应该:

  1. 理解实时应用的核心概念和技术挑战
  2. 掌握 WebSocket、Socket.io、SSE 等实时通信技术
  3. 学会构建实时聊天、协作白板、多人游戏等应用
  4. 了解实时应用的最佳实践和常见问题解决方案
  5. 能够根据具体场景选择合适的实时通信技术

实时应用开发需要考虑多方面的因素,包括性能、可靠性、安全性和扩展性。随着技术的不断发展,实时应用的实现方式也在不断进化。选择合适的技术栈,结合最佳实践,可以构建出高性能、可靠的实时应用系统。

记住,实时应用的核心是提供良好的用户体验,因此在开发过程中要始终关注用户需求,不断优化系统性能,确保应用的稳定性和可靠性。

« 上一篇 Node.js 微服务架构 下一篇 » Node.js CLI 工具开发