Skip to content

WebSocket Socket.IO Resource

本页对应 nest g resource chat 后生成的 src/chat。选择 WebSocket Gateway 后,CLI 保留 Module、Service、DTO、Entity,但将 HTTP Controller 换成 Gateway;每个 CRUD 动作变为由 @SubscribeMessage() 订阅的 Socket.IO 事件。

bash
nest g resource chat
bash
src/chat
├── dto
   ├── create-chat.dto.ts
   └── update-chat.dto.ts
├── entities
   └── chat.entity.ts
├── chat.gateway.ts
├── chat.module.ts
└── chat.service.ts
mermaid
sequenceDiagram
  participant C as Socket.IO Client
  participant G as ChatGateway
  participant S as ChatService
  participant R as Repository
  C->>G: emit("chat:create", payload)
  G->>S: create(payload)
  S->>R: 写入消息
  R-->>S: ChatMessage
  S-->>G: ChatMessage
  G-->>C: emit("chat:created", message)

生成代码的结构

HTTP ResourceWebSocket Resource职责
UsersControllerChatGateway接收外部输入并委托 Service
@Get()@Post()@SubscribeMessage('event')映射请求或事件
@Param()@Body()@MessageBody()@ConnectedSocket()提取输入与客户端连接
HTTP 状态码、HttpException事件响应、WsException传达处理结果或错误

参考源码的 @WebSocketGateway() 未传配置,使用默认命名空间和当前 HTTP 服务端口。main.tsapp.setGlobalPrefix('api') 只影响 HTTP 路由,不会为 Socket.IO 事件加 api 前缀。

完整的聊天事件示例

1. DTO 与消息实体

ts
// dto/create-chat.dto.ts
import { IsString, IsUUID, MaxLength, MinLength } from 'class-validator'

export class CreateChatDto {
  @IsUUID()
  roomId!: string

  @IsString()
  @MinLength(1)
  @MaxLength(2000)
  content!: string
}
ts
// entities/chat.entity.ts
export class ChatMessage {
  id!: number
  roomId!: string
  content!: string
  createdAt!: Date
}

全局 ValidationPipe 同样可以用于 Gateway;本例也在 Gateway 内显式注册,保证该模块独立时仍校验事件负载。

2. Service:与传输层无关

ts
import { Injectable } from '@nestjs/common'

import { CreateChatDto } from './dto/create-chat.dto'
import { ChatMessage } from './entities/chat.entity'

@Injectable()
export class ChatService {
  private readonly messages: ChatMessage[] = []
  private nextId = 1

  create(input: CreateChatDto): ChatMessage {
    const message: ChatMessage = {
      id: this.nextId++,
      ...input,
      createdAt: new Date(),
    }
    this.messages.push(message)
    return message
  }

  findByRoom(roomId: string): ChatMessage[] {
    return this.messages.filter((message) => message.roomId === roomId)
  }
}

Service 不依赖 SocketServer。它可以被 Gateway、HTTP Controller、队列消费者或测试直接复用;持久化实现替换内存数组即可。

3. Gateway:连接、房间、事件

ts
import { UsePipes, ValidationPipe } from '@nestjs/common'
import {
  ConnectedSocket,
  MessageBody,
  SubscribeMessage,
  WebSocketGateway,
  WebSocketServer,
  WsException,
} from '@nestjs/websockets'
import type { Server, Socket } from 'socket.io'

import { ChatService } from './chat.service'
import { CreateChatDto } from './dto/create-chat.dto'

@WebSocketGateway({
  namespace: 'chat',
  cors: { origin: ['http://localhost:5173'] },
})
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
export class ChatGateway {
  @WebSocketServer()
  private server!: Server

  constructor(private readonly chatService: ChatService) {}

  @SubscribeMessage('chat:join')
  join(@MessageBody('roomId') roomId: string, @ConnectedSocket() client: Socket) {
    if (!roomId) throw new WsException('roomId 不能为空')
    client.join(roomId)
    client.emit('chat:joined', { roomId })
  }

  @SubscribeMessage('chat:create')
  create(@MessageBody() input: CreateChatDto) {
    const message = this.chatService.create(input)
    this.server.to(message.roomId).emit('chat:created', message)
  }

  @SubscribeMessage('chat:list')
  list(@MessageBody('roomId') roomId: string, @ConnectedSocket() client: Socket) {
    client.emit('chat:list:result', this.chatService.findByRoom(roomId))
  }
}
  • @SubscribeMessage() 的字符串是事件名,不是 URL。
  • @MessageBody() 取得客户端 emit() 的负载;按字段提取可写成 @MessageBody('roomId')
  • @ConnectedSocket() 取得当前连接,用于加入房间或读取已认证用户。
  • @WebSocketServer() 注入 Socket.IO Serverserver.to(roomId).emit() 广播给房间内全部客户端。
  • 使用 client.emit() 仅回复发起事件的连接;使用 server.emit() 则广播给所有连接。

如果不需要命名空间,去掉 namespace: 'chat',客户端连接根命名空间即可。cors.origin 生产环境必须替换为明确前端来源,不能设置为任意来源并携带凭据。

4. Module

ts
import { Module } from '@nestjs/common'

import { ChatGateway } from './chat.gateway'
import { ChatService } from './chat.service'

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

Gateway 是 Provider,必须出现在 providers 中;它不属于 controllersAppModule 导入 ChatModule 后,Nest 才会创建 Gateway 并注册事件处理器。

5. Socket.IO 客户端

浏览器客户端安装 socket.io-client

bash
pnpm add socket.io-client
ts
import { io } from 'socket.io-client'

const socket = io('http://localhost:3000/chat')

socket.on('connect', () => {
  socket.emit('chat:join', { roomId: 'd933c8be-73a9-4e99-8fb1-5c21e8792f20' })
  socket.emit('chat:create', {
    roomId: 'd933c8be-73a9-4e99-8fb1-5c21e8792f20',
    content: 'Hello, Socket.IO!',
  })
})

socket.on('chat:created', (message) => {
  console.log(message)
})

socket.on('exception', (error) => {
  console.error(error)
})

/chat 是命名空间,不是 HTTP 全局前缀。Socket.IO 使用自己的握手与事件协议;它不是原生 WebSocket API 的简单别名,客户端必须使用兼容的 Socket.IO 客户端。

上线前补充

  • 在握手阶段验证令牌,并将已认证用户挂到 socket.data;事件中还需校验房间成员资格。
  • 限制每条消息长度、事件频率和单连接房间数;高频事件需要限流与背压策略。
  • 多实例部署时使用兼容的 Socket.IO 适配器共享房间与广播状态,不能只依赖进程内内存。
  • 存储聊天记录、审计日志和异常格式;不要将鉴权信息或敏感负载写入日志。

参考

基于 MIT 许可发布