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 生命周期接口说明
| 接口 | 方法 | 触发时机 |
|---|---|---|
OnGatewayInit | afterInit(server) | Gateway 初始化完成 |
OnGatewayConnection | handleConnection(client) | 客户端连接成功 |
OnGatewayDisconnect | handleDisconnect(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 常见坑点
-
导入错误:
UseFilters、Injectable、Logger从@nestjs/common导入,不是@nestjs/websockets -
CORS 配置:
@WebSocketGateway(3000, {
namespace: 'api/v1/chat',
cors: {
origin: ['http://localhost:3001'],
credentials: true,
},
})
-
Namespace 隔离:不同业务使用不同 namespace,避免事件冲突
-
心跳超时:默认心跳间隔可能不适用于移动端,需要调整:
@WebSocketGateway(3000, {
namespace: 'api/v1/chat',
pingInterval: 25000,
pingTimeout: 60000,
})
七、总结
NestJS WebSocket 开发的核心要点:
- 生命周期管理:理解
afterInit→handleConnection→handleDisconnect的流程 - 中间件鉴权:在连接建立前验证身份,失败直接拒绝
- 房间机制:灵活运用 Room 实现消息分组,加入/离开时广播通知
- 异常处理:统一错误格式,优雅降级
掌握这些,你就能从容应对大多数实时通信场景!
如果觉得有帮助,欢迎点赞收藏 ❤️