title: NestJS WebSockets
description: 深入学习NestJS中的WebSockets实现,包括WebSocket网关、事件处理、实时通信、命名空间和房间
keywords: NestJS, WebSockets, 实时通信, 网关, 事件处理, 聊天应用

NestJS WebSockets

学习目标

通过本章节的学习,你将能够:

  • 理解WebSocket的基本概念和工作原理
  • 掌握NestJS中WebSocket网关的创建和配置方法
  • 实现WebSocket事件处理和消息传递
  • 理解并使用命名空间和房间进行消息分组
  • 构建完整的实时聊天应用
  • 理解WebSocket的最佳实践和常见问题解决方案
  • 实现WebSocket的认证和授权

核心知识点

WebSocket基础

WebSocket是一种在单个TCP连接上进行全双工通信的协议,它允许服务器和客户端之间进行实时、双向的通信。WebSocket的主要优势包括:

  • 实时通信:服务器可以主动向客户端推送数据
  • 减少网络开销:避免了HTTP请求的头部开销
  • 持久连接:不需要重复建立连接
  • 双向通信:客户端和服务器可以随时发送消息

NestJS WebSocket网关

NestJS通过@nestjs/websockets模块提供了对WebSocket的支持,使用网关(Gateway)来处理WebSocket连接。NestJS WebSocket网关的主要特性包括:

  • 装饰器驱动的事件处理
  • 支持命名空间和房间
  • 支持多种传输方式(WebSocket、Socket.IO等)
  • 与NestJS依赖注入系统集成
  • 支持中间件和守卫

事件处理

WebSocket通信基于事件模型,主要包括以下事件:

  • 连接事件:客户端连接到服务器时触发
  • 断开连接事件:客户端断开连接时触发
  • 自定义事件:应用定义的业务事件
  • 错误事件:处理通信错误

命名空间和房间

为了更好地组织WebSocket连接,NestJS支持:

  • 命名空间:将WebSocket连接划分为不同的逻辑组
  • 房间:在命名空间内进一步分组连接,实现消息广播

实时通信模式

常见的实时通信模式包括:

  • 一对一通信:服务器向特定客户端发送消息
  • 广播:服务器向所有连接的客户端发送消息
  • 组播:服务器向特定房间或命名空间的客户端发送消息
  • 发布/订阅:客户端订阅特定主题,服务器向订阅者发送消息

实用案例分析

案例:实时聊天应用

我们将构建一个完整的实时聊天应用,支持用户加入房间、发送消息、接收消息通知等功能。

1. 安装依赖

首先,我们需要安装必要的依赖:

npm install @nestjs/websockets @nestjs/platform-socket.io socket.io

2. 创建聊天网关

创建一个聊天网关,处理WebSocket连接和消息:

// src/chat/chat.gateway.ts
import { WebSocketGateway, WebSocketServer, SubscribeMessage, OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect, MessageBody, ConnectedSocket } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';

@WebSocketGateway({ 
  cors: {
    origin: '*',
  },
  namespace: 'chat'
})
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
  @WebSocketServer()
  server: Server;

  private users: Map<string, string> = new Map(); // socketId -> username

  afterInit(server: Server) {
    console.log('WebSocket gateway initialized');
  }

  handleConnection(client: Socket) {
    console.log(`Client connected: ${client.id}`);
  }

  handleDisconnect(client: Socket) {
    const username = this.users.get(client.id);
    if (username) {
      this.users.delete(client.id);
      this.server.emit('user-left', { username, message: `${username} has left the chat` });
      console.log(`Client disconnected: ${client.id} (${username})`);
    } else {
      console.log(`Client disconnected: ${client.id}`);
    }
  }

  @SubscribeMessage('join')
  handleJoin(@MessageBody() data: { username: string }, @ConnectedSocket() client: Socket) {
    this.users.set(client.id, data.username);
    client.join('general'); // 加入默认房间
    this.server.emit('user-joined', { username: data.username, message: `${data.username} has joined the chat` });
    return { status: 'ok', message: `Welcome ${data.username}!` };
  }

  @SubscribeMessage('send-message')
  handleMessage(@MessageBody() data: { message: string, room?: string }, @ConnectedSocket() client: Socket) {
    const username = this.users.get(client.id);
    if (!username) {
      return { status: 'error', message: 'You need to join first' };
    }

    const room = data.room || 'general';
    const messageData = {
      username,
      message: data.message,
      timestamp: new Date().toISOString(),
      room
    };

    if (room) {
      this.server.to(room).emit('new-message', messageData);
    } else {
      this.server.emit('new-message', messageData);
    }

    return { status: 'ok', message: 'Message sent' };
  }

  @SubscribeMessage('join-room')
  handleJoinRoom(@MessageBody() data: { room: string }, @ConnectedSocket() client: Socket) {
    const username = this.users.get(client.id);
    if (!username) {
      return { status: 'error', message: 'You need to join first' };
    }

    client.join(data.room);
    return { status: 'ok', message: `Joined room ${data.room}` };
  }

  @SubscribeMessage('leave-room')
  handleLeaveRoom(@MessageBody() data: { room: string }, @ConnectedSocket() client: Socket) {
    const username = this.users.get(client.id);
    if (!username) {
      return { status: 'error', message: 'You need to join first' };
    }

    client.leave(data.room);
    return { status: 'ok', message: `Left room ${data.room}` };
  }

  @SubscribeMessage('get-users')
  handleGetUsers() {
    const users = Array.from(this.users.values());
    return { status: 'ok', users };
  }
}

3. 创建聊天模块

创建一个聊天模块,组织WebSocket相关的组件:

// src/chat/chat.module.ts
import { Module } from '@nestjs/common';
import { ChatGateway } from './chat.gateway';

@Module({
  providers: [ChatGateway],
})
export class ChatModule {} 

4. 在应用模块中导入聊天模块

AppModule中导入聊天模块:

// src/app.module.ts
import { Module } from '@nestjs/common';
import { ChatModule } from './chat/chat.module';

@Module({
  imports: [ChatModule],
})
export class AppModule {} 

5. 创建前端客户端

创建一个简单的前端HTML页面,测试WebSocket连接:

<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Chat App</title>
  <script src="https://cdn.socket.io/4.0.1/socket.io.min.js"></script>
  <style>
    body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
    #chat { border: 1px solid #ddd; padding: 10px; height: 400px; overflow-y: scroll; margin-bottom: 10px; }
    .message { margin-bottom: 10px; }
    .username { font-weight: bold; }
    .timestamp { font-size: 0.8em; color: #999; }
    input, button { padding: 8px; margin-right: 5px; }
    #message-input { width: 70%; }
  </style>
</head>
<body>
  <h1>Real-time Chat App</h1>
  
  <div id="login">
    <h2>Join Chat</h2>
    <input type="text" id="username" placeholder="Enter your username">
    <button onclick="joinChat()">Join</button>
  </div>
  
  <div id="chat-container" style="display: none;">
    <div id="chat"></div>
    <div>
      <input type="text" id="message-input" placeholder="Type your message">
      <input type="text" id="room-input" placeholder="Room (optional)" value="general">
      <button onclick="sendMessage()">Send</button>
      <button onclick="getUsers()">Get Users</button>
    </div>
  </div>

  <script>
    let socket;
    let username;

    function joinChat() {
      username = document.getElementById('username').value;
      if (!username) {
        alert('Please enter a username');
        return;
      }

      // Connect to WebSocket server
      socket = io('http://localhost:3000/chat');

      // Event listeners
      socket.on('connect', () => {
        console.log('Connected to server');
        socket.emit('join', { username });
      });

      socket.on('disconnect', () => {
        console.log('Disconnected from server');
      });

      socket.on('user-joined', (data) => {
        addMessage('System', data.message);
      });

      socket.on('user-left', (data) => {
        addMessage('System', data.message);
      });

      socket.on('new-message', (data) => {
        addMessage(data.username, data.message, data.timestamp);
      });

      // Show chat container
      document.getElementById('login').style.display = 'none';
      document.getElementById('chat-container').style.display = 'block';
    }

    function sendMessage() {
      const message = document.getElementById('message-input').value;
      const room = document.getElementById('room-input').value;
      if (!message) return;

      socket.emit('send-message', { message, room });
      document.getElementById('message-input').value = '';
    }

    function getUsers() {
      socket.emit('get-users', {}, (response) => {
        if (response.status === 'ok') {
          alert('Online users: ' + response.users.join(', '));
        }
      });
    }

    function addMessage(sender, text, timestamp) {
      const chatDiv = document.getElementById('chat');
      const messageDiv = document.createElement('div');
      messageDiv.className = 'message';
      messageDiv.innerHTML = `
        <span class="username">${sender}:</span>
        <span class="text">${text}</span>
        ${timestamp ? `<span class="timestamp"> (${new Date(timestamp).toLocaleTimeString()})</span>` : ''}
      `;
      chatDiv.appendChild(messageDiv);
      chatDiv.scrollTop = chatDiv.scrollHeight;
    }
  </script>
</body>
</html>

6. 配置静态文件服务

main.ts中配置静态文件服务,使前端页面可以访问:

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { join } from 'path';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  // 配置静态文件服务
  app.useStaticAssets(join(__dirname, '..', 'public'));
  
  await app.listen(3000);
}
bootstrap();

7. 实现WebSocket认证

创建一个WebSocket认证守卫,确保只有已认证的用户可以访问WebSocket:

// src/chat/guards/ws-auth.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { WsException } from '@nestjs/websockets';

@Injectable()
export class WsAuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const client = context.switchToWs().getClient();
    const data = context.switchToWs().getData();
    
    // 简单的认证示例,实际应用中应该验证token
    if (!data.token) {
      throw new WsException('Unauthorized');
    }
    
    // 验证token逻辑
    const isValidToken = this.validateToken(data.token);
    if (!isValidToken) {
      throw new WsException('Invalid token');
    }
    
    return true;
  }
  
  private validateToken(token: string): boolean {
    // 实际应用中应该使用JWT等进行验证
    return token === 'valid-token';
  }
}

8. 使用WebSocket守卫

在网关中使用守卫:

// src/chat/chat.gateway.ts (续)
import { UseGuards } from '@nestjs/common';
import { WsAuthGuard } from './guards/ws-auth.guard';

// ... 其他代码

  @UseGuards(WsAuthGuard)
  @SubscribeMessage('protected-message')
  handleProtectedMessage(@MessageBody() data: { message: string }, @ConnectedSocket() client: Socket) {
    const username = this.users.get(client.id);
    if (!username) {
      return { status: 'error', message: 'You need to join first' };
    }

    const messageData = {
      username,
      message: data.message,
      timestamp: new Date().toISOString(),
      protected: true
    };

    this.server.emit('new-protected-message', messageData);
    return { status: 'ok', message: 'Protected message sent' };
  }

// ... 其他代码

9. 实现命名空间

创建多个命名空间,用于不同的功能:

// src/notifications/notifications.gateway.ts
import { WebSocketGateway, WebSocketServer, SubscribeMessage } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';

@WebSocketGateway({ 
  cors: {
    origin: '*',
  },
  namespace: 'notifications'
})
export class NotificationsGateway {
  @WebSocketServer()
  server: Server;

  @SubscribeMessage('subscribe-to-notifications')
  handleSubscribe(@MessageBody() data: { userId: string }, @ConnectedSocket() client: Socket) {
    // 订阅用户的通知频道
    client.join(`user-${data.userId}`);
    return { status: 'ok', message: `Subscribed to notifications for user ${data.userId}` };
  }

  // 发送通知给特定用户
  sendNotification(userId: string, notification: any) {
    this.server.to(`user-${userId}`).emit('notification', notification);
  }
}

代码优化建议

  1. 结构优化

    • 将WebSocket事件处理逻辑分离到不同的服务中
    • 使用DTO验证WebSocket消息数据
    • 实现错误处理和异常捕获
  2. 性能优化

    • 使用Redis适配器实现水平扩展
    • 限制单个连接的消息频率,防止DoS攻击
    • 实现消息队列,处理高并发情况
    • 优化房间和命名空间的使用,减少广播范围
  3. 安全性优化

    • 实现WebSocket认证和授权
    • 验证所有输入数据,防止注入攻击
    • 使用HTTPS保护WebSocket连接
    • 实现速率限制,防止滥用
  4. 可靠性优化

    • 实现重连机制
    • 处理连接超时和断开连接
    • 记录WebSocket事件日志
    • 监控WebSocket连接状态
  5. 可维护性优化

    • 使用TypeScript类型定义WebSocket消息结构
    • 实现消息版本控制
    • 编写单元测试和集成测试
    • 文档化WebSocket API

常见问题与解决方案

1. WebSocket连接失败

问题:客户端无法连接到WebSocket服务器

解决方案

  • 检查服务器是否正在运行
  • 检查CORS配置是否正确
  • 验证WebSocket URL是否正确
  • 检查防火墙设置

2. 消息丢失

问题:WebSocket消息有时会丢失

解决方案

  • 实现消息确认机制
  • 使用消息队列确保消息传递
  • 处理连接中断和重连
  • 实现消息重试机制

3. 性能问题

问题:WebSocket服务器在高并发时性能下降

解决方案

  • 使用Redis适配器实现水平扩展
  • 优化事件处理逻辑
  • 减少广播范围,使用房间和命名空间
  • 实现消息批处理

4. 认证问题

问题:WebSocket连接的认证和授权

解决方案

  • 在连接时传递认证token
  • 使用WebSocket守卫进行授权
  • 定期验证连接状态
  • 实现连接过期机制

5. 跨域问题

问题:WebSocket连接遇到跨域限制

解决方案

  • 正确配置CORS选项
  • 使用代理服务器
  • 考虑使用Socket.IO的跨域支持

小结

本章节我们学习了NestJS中的WebSocket实现,包括:

  • WebSocket的基本概念和工作原理
  • NestJS WebSocket网关的创建和配置
  • 事件处理和消息传递
  • 命名空间和房间的使用
  • 实时聊天应用的实现
  • WebSocket认证和授权
  • WebSocket的最佳实践和常见问题解决方案

通过这些知识,你可以构建实时、响应式的应用,如聊天应用、实时游戏、协作工具等,为用户提供更好的交互体验。

互动问答

  1. 问题:WebSocket和HTTP的主要区别是什么?
    答案:WebSocket是一种全双工通信协议,允许服务器主动向客户端推送数据,而HTTP是一种请求-响应协议,只能由客户端发起请求。WebSocket建立一次连接后可以持续通信,减少了网络开销。

  2. 问题:NestJS中如何创建WebSocket网关?
    答案:通过以下步骤创建WebSocket网关:1. 安装必要的依赖;2. 使用@WebSocketGateway装饰器创建网关类;3. 实现OnGatewayInit、OnGatewayConnection、OnGatewayDisconnect等接口;4. 使用@SubscribeMessage装饰器处理事件;5. 在模块中注册网关。

  3. 问题:什么是WebSocket命名空间和房间?
    答案:命名空间是将WebSocket连接划分为不同的逻辑组,如/chat、/notifications等。房间是在命名空间内进一步分组连接,用于实现消息的定向广播,如特定用户组或讨论组。

  4. 问题:如何实现WebSocket的认证?
    答案:可以通过以下方式实现WebSocket认证:1. 在连接时传递认证token;2. 使用WebSocket守卫验证token;3. 在事件处理中检查用户权限;4. 实现连接状态验证。

  5. 问题:如何处理WebSocket连接断开的情况?
    答案:处理WebSocket连接断开的方法包括:1. 实现重连机制;2. 保存用户会话状态;3. 处理断开连接事件,清理资源;4. 实现消息队列,确保消息不丢失。

实践作业

  1. 作业1:扩展聊天应用,添加私人消息功能,支持用户之间的一对一聊天

  2. 作业2:实现消息历史记录,使用数据库存储聊天消息,支持用户查看历史消息

  3. 作业3:集成WebSocket认证,使用JWT进行身份验证

  4. 作业4:实现WebSocket速率限制,防止消息轰炸

  5. 作业5:构建一个实时协作白板应用,支持多个用户同时绘制

通过完成这些作业,你将能够更加深入地理解WebSocket的实现细节,为构建实时、响应式的应用打下坚实的基础。

« 上一篇 18-file-upload 下一篇 » 20-graphql