【NestJs】Websocket 通关指南:从入门到实战

12 阅读4分钟

NestJS WebSocket 通关指南:从入门到实战

一文掌握 NestJS WebSocket 开发的核心技巧,涵盖鉴权、房间管理、异常处理等实战场景。

前言

WebSocket 是实现实时通信的核心技术,NestJS 提供了优雅的 WebSocket 抽象层,让我们可以用装饰器和类的形式组织代码。本文将从零开始,带你掌握 NestJS WebSocket 开发的核心技能。


一、基础架构

1.1 安装依赖

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

1.2 Gateway 基本结构

import {
  WebSocketGateway,
  WebSocketServer,
  SubscribeMessage,
  ConnectedSocket,
  MessageBody,
  OnGatewayInit,
  OnGatewayConnection,
  OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Injectable, Logger } from '@nestjs/common';
import { Server, Socket } from 'socket.io';

@Injectable()
@WebSocketGateway(3000, { namespace: 'api/v1/chat' })
export class ChatGateway 
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect 
{
  @WebSocketServer()
  server: Server;

  private readonly logger = new Logger(ChatGateway.name);

  // 初始化完成后调用
  async afterInit(server: Server) {
    this.logger.log('WebSocket Gateway initialized');
  }

  // 客户端连接时调用
  async handleConnection(client: Socket) {
    this.logger.log(`Client connected: ${client.id}`);
  }

  // 客户端断开时调用
  async handleDisconnect(client: Socket) {
    this.logger.log(`Client disconnected: ${client.id}`);
  }
}

1.3 生命周期接口说明

接口方法触发时机
OnGatewayInitafterInit(server)Gateway 初始化完成
OnGatewayConnectionhandleConnection(client)客户端连接成功
OnGatewayDisconnecthandleDisconnect(client)客户端断开连接

二、中间件鉴权

2.1 为什么用中间件?

很多开发者会在 handleConnection 中做鉴权,但这会导致:

  • 代码职责混乱
  • 鉴权失败需要手动断开连接
  • 错误处理复杂

最佳实践:在 afterInit 中注册中间件,鉴权失败直接拒绝连接。

2.2 中间件鉴权实现

async afterInit(server: Server) {
  // 注册中间件
  server.use(async (socket: Socket, next) => {
    try {
      // 从 handshake 中获取 token
      const token = this.extractToken(socket);
      
      // 验证 token
      const payload = await this.jwtService.verifyAsync(token, {
        secret: this.config.jwt.secret,
      });

      // 检查黑名单
      const isBlacklisted = await this.authService.isTokenBlacklisted(token);
      if (isBlacklisted) {
        return next(new Error('Token expired'));
      }

      // 挂载用户信息到 socket
      socket['user'] = await this.userService.findById(payload.sub);
      
      next();
    } catch (error) {
      // 鉴权失败,拒绝连接
      next(new Error('Unauthorized'));
    }
  });
}

private extractToken(socket: Socket): string | null {
  // 方式1:从 query 参数获取
  const queryToken = socket.handshake.query?.token as string;
  if (queryToken) return queryToken;

  // 方式2:从 Authorization header 获取
  const authorization = socket.handshake.headers['authorization'] as string;
  if (authorization?.startsWith('Bearer ')) {
    return authorization.slice(7);
  }

  // 方式3:从 auth 对象获取
  return socket.handshake.auth?.token as string;
}

2.3 鉴权失败处理

// 自定义错误类
export class SocketError extends Error {
  constructor(
    public code: number,
    public message: string,
  ) {
    super(message);
  }

  static fromCode(code: number, message: string): SocketError {
    return new SocketError(code, message);
  }
}

// 中间件中使用
server.use(async (socket, next) => {
  try {
    await this.validateAuth(socket);
    next();
  } catch (error) {
    next(SocketError.fromCode(401, 'Unauthorized'));
  }
});

三、事件处理

3.1 定义事件类型

// 客户端发送的事件
export enum ClientEventType {
  SEND_MESSAGE = 'send_message',
  JOIN_ROOM = 'join_room',
  LEAVE_ROOM = 'leave_room',
}

// 服务端推送的事件
export enum ServerEventType {
  MESSAGE = 'message',
  ERROR = 'error',
  STATUS = 'status',
  USER_JOINED = 'user_joined',
  USER_LEFT = 'user_left',
}

// 事件数据类型
export interface SendMessageEventData {
  content: string;
  roomId?: string;
}

export interface MessageEventData {
  id: string;
  content: string;
  senderId: number;
  timestamp: number;
}

export interface UserActionEventData {
  userId: number;
  timestamp: number;
}

export interface ErrorEventData {
  code: number;
  message: string;
}

3.2 订阅事件

@SubscribeMessage(ClientEventType.SEND_MESSAGE)
async handleSendMessage(
  @MessageBody() data: SendMessageEventData,
  @ConnectedSocket() client: Socket,
) {
  const userId = client['user']?.id;

  // 参数校验
  if (!data.content?.trim()) {
    this.emitError(client, 400, '消息内容不能为空');
    return;
  }

  try {
    // 业务处理
    const message = await this.messageService.create({
      content: data.content,
      userId,
    });

    // 推送给房间内所有用户
    this.server.to(`room:${data.roomId}`).emit(
      ServerEventType.MESSAGE,
      {
        id: message.id,
        content: message.content,
        senderId: userId,
        timestamp: Date.now(),
      } as MessageEventData,
    );
  } catch (error) {
    this.emitError(client, 500, error.message);
  }
}

3.3 推送消息的四种方式

// 1. 推送给单个客户端
client.emit(ServerEventType.MESSAGE, data);

// 2. 推送给房间内所有客户端(包括发送者)
this.server.to('room:123').emit(ServerEventType.MESSAGE, data);

// 3. 推送给房间内除发送者外的所有客户端
client.to('room:123').emit(ServerEventType.MESSAGE, data);

// 4. 推送给所有连接的客户端
this.server.emit(ServerEventType.MESSAGE, data);

四、房间管理

4.1 房间的本质

Socket.IO 的 Room 是一个消息分组机制:

  • 每个 Socket 默认加入以自己 id 命名的房间
  • 同一房间的 Socket 共享消息
  • 服务端可随时让 Socket 加入/离开房间

4.2 房间操作

async handleConnection(client: Socket) {
  const roomId = client.handshake.query?.roomId as string;
  
  if (roomId) {
    // 加入房间
    client.join(`room:${roomId}`);
    client['roomId'] = roomId;
    
    // 广播通知房间内其他人
    client.to(`room:${roomId}`).emit(ServerEventType.USER_JOINED, {
      userId: client['user'].id,
      timestamp: Date.now(),
    } as UserActionEventData);
  }
}

async handleDisconnect(client: Socket) {
  const roomId = client['roomId'];
  
  if (roomId) {
    // 广播通知房间内其他人(断开连接时自动离开房间,但仍可主动通知)
    client.to(`room:${roomId}`).emit(ServerEventType.USER_LEFT, {
      userId: client['user'].id,
      timestamp: Date.now(),
    } as UserActionEventData);
  }
}

// 主动加入房间
@SubscribeMessage(ClientEventType.JOIN_ROOM)
handleJoinRoom(
  @MessageBody() data: { roomId: string },
  @ConnectedSocket() client: Socket,
) {
  const userId = client['user']?.id;
  
  // 加入房间
  client.join(data.roomId);
  client['roomId'] = data.roomId;

  // 广播通知房间内其他人
  client.to(data.roomId).emit(ServerEventType.USER_JOINED, {
    userId,
    timestamp: Date.now(),
  } as UserActionEventData);
}

// 主动离开房间
@SubscribeMessage(ClientEventType.LEAVE_ROOM)
handleLeaveRoom(
  @MessageBody() data: { roomId: string },
  @ConnectedSocket() client: Socket,
) {
  const userId = client['user']?.id;
  
  // 广播通知房间内其他人
  client.to(data.roomId).emit(ServerEventType.USER_LEFT, {
    userId,
    timestamp: Date.now(),
  } as UserActionEventData);

  // 离开房间
  client.leave(data.roomId);
  client['roomId'] = null;
}

五、异常处理

5.1 全局异常过滤器

import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common';
import { Socket } from 'socket.io';

@Catch()
export class SocketExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(SocketExceptionFilter.name);

  catch(exception: any, host: ArgumentsHost) {
    const client = host.switchToWs().getClient<Socket>();
    
    const errorResponse = {
      code: exception.code || 500,
      message: exception.message || 'Internal server error',
      timestamp: new Date().toISOString(),
    };

    this.logger.error(`WebSocket Error: ${JSON.stringify(errorResponse)}`);
    
    client.emit(ServerEventType.ERROR, errorResponse);
  }
}

// 在 Gateway 上使用
@UseFilters(SocketExceptionFilter)
@WebSocketGateway(3000, { namespace: 'api/v1/chat' })
export class ChatGateway { ... }

5.2 业务错误处理

// 错误码枚举
export enum ErrorCode {
  BAD_REQUEST = 400,
  UNAUTHORIZED = 401,
  FORBIDDEN = 403,
  NOT_FOUND = 404,
  SERVER_ERROR = 500,
}

// 推送错误并断开连接
private emitErrorAndDisconnect(
  client: Socket,
  code: ErrorCode,
  message: string,
): void {
  client.emit(ServerEventType.ERROR, { code, message });
  client.disconnect(true); // true = 关闭底层连接
}

// 仅推送错误,不断开连接
private emitError(
  client: Socket,
  code: ErrorCode,
  message: string,
): void {
  client.emit(ServerEventType.ERROR, { code, message });
}

六、最佳实践总结

6.1 代码组织

src/
├── chat/
│   ├── chat.gateway.ts          # WebSocket 网关
│   ├── chat.service.ts          # 业务逻辑
│   ├── chat.module.ts           # 模块定义
│   └── dto/
│       ├── event.dto.ts         # 事件类型定义
│       └── message.dto.ts       # 消息数据结构
├── common/
│   ├── filter/
│   │   └── socket-exception.filter.ts
│   └── exception/
│       └── socket-error.ts

6.2 关键原则

原则说明
鉴权前置在中间件中完成鉴权,而非 handleConnection
职责分离Gateway 只负责消息收发,业务逻辑放 Service
类型安全定义 DTO 和枚举,避免字符串硬编码
错误处理统一错误格式,区分可恢复错误和致命错误
房间广播加入/离开房间时广播通知房间内其他成员

6.3 常见坑点

  1. 导入错误UseFiltersInjectableLogger@nestjs/common 导入,不是 @nestjs/websockets

  2. CORS 配置

@WebSocketGateway(3000, {
  namespace: 'api/v1/chat',
  cors: {
    origin: ['http://localhost:3001'],
    credentials: true,
  },
})
  1. Namespace 隔离:不同业务使用不同 namespace,避免事件冲突

  2. 心跳超时:默认心跳间隔可能不适用于移动端,需要调整:

@WebSocketGateway(3000, {
  namespace: 'api/v1/chat',
  pingInterval: 25000,
  pingTimeout: 60000,
})

七、总结

NestJS WebSocket 开发的核心要点:

  1. 生命周期管理:理解 afterInithandleConnectionhandleDisconnect 的流程
  2. 中间件鉴权:在连接建立前验证身份,失败直接拒绝
  3. 房间机制:灵活运用 Room 实现消息分组,加入/离开时广播通知
  4. 异常处理:统一错误格式,优雅降级

掌握这些,你就能从容应对大多数实时通信场景!


如果觉得有帮助,欢迎点赞收藏 ❤️