跳转到主要内容

Nest 使用笔记

第十七章:实时通信——WebSocket 聊天室与 SSE

打开 learnhub 的 ChatGateway,在真实代码里讲透 WebSocket:socket.io 网关、连接时手验 JWT、房间广播、先存库再广播;再用一个 SSE 控制器对比「服务端单向推送」的轻量写法。每段代码都能在 learnhub 里指到对应文件。

  • Nest
  • WebSocket
  • SSE
  • 实时

前面十六章里所有接口都是「请求-响应」模式——客户端发请求、服务端立刻返回。但聊天、通知、实时行情这类场景,服务端需要主动推消息,HTTP 这种「客户端问一句服务端答一句」就不够用了。这一章打开 learnhub 的 ChatGateway,在真实代码里讲透两件事:怎么用 WebSocket(socket.io)做出一个能用的群聊,以及它在 Nest 里和 HTTP 接口在认证、广播上有什么不一样;再用一个 SSE 控制器对比「服务端单向推送」的轻量写法。

先搞懂:实时通信的三种方案

前端想拿到「最新数据」,本质上有三种办法:

  • 轮询(polling):前端每隔几秒发一次 HTTP 请求问「有新消息吗」。最简单但有上限——一来 1 秒问一次,100 个在线就是 100 QPS 白白烧着;二来消息延迟取决于轮询间隔,永远不会真「实时」。只适合数据刷新要求低、客户端少的场景。
  • SSE(Server-Sent Events):HTTP 长连接,服务端单向往客户端推。基于 HTTP,浏览器原生支持,写起来就像写普通接口。但只能服务端→客户端单向,客户端要发消息还得另外发 HTTP 请求。适合通知推送、进度条、行情推送这种「只需要服务端往下发」的场景。
  • WebSocket:双向、全双工、持久连接。客户端和服务端随时互发消息,开销低、延迟小。适合聊天、协作编辑、多人游戏这种高频双向交互。

learnhub 把两者都用上了:聊天走 WebSocket(src/modules/chat/chat.gateway.ts),同时有一个 SSE 例子(src/modules/sse/sse.controller.ts)每秒推一次服务器时间,演示单向推送。

Nest 里 WebSocket 一般用 @nestjs/websockets + socket.io(learnhub 用的就是这个),socket.io 在 WebSocket 之上加了一层「命名空间、房间、自动重连、降级回退」的工程能力,是 Nest 生态最主流的实时方案。

这一章你会做出什么

  • 打开 learnhub 的 src/modules/chat/chat.gateway.ts,逐方法讲清楚它为什么这么写。
  • 在真实代码里吃透:socket.io 网关生命周期连接时手验 JWT(这是最容易踩的坑)、按房间路由先存库再广播
  • 用 SSE 控制器对比「单向推送」的写法,理解什么时候该选它而不是 WebSocket。

前置:了解第十一章 JWT 怎么签发 token(这一章要复用同一个 JwtService),第八章 TypeORM 的基本 Repository 用法(聊天消息要落库)。

第一步:装好 socket.io,注册 ChatModule

socket.io 不是 Nest 自带的,得自己装:

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

装好之后把 ChatModule 接进根模块(learnhub 已经做好了),ChatModule 自己长这样:

// learnhub/src/modules/chat/chat.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([ChatRoom, ChatMessage, User])],
  controllers: [ChatController],
  providers: [ChatService, ChatGateway],
})
export class ChatModule {}

ChatGateway 在这里就是个普通的 provider——Nest 的 Gateway 本质是个被 IoC 管理的类,能注入 JwtServiceChatService,和你写 Service 没两样。TypeOrmModule.forFeature([ChatRoom, ChatMessage, User]) 把三个实体注册进来,ChatService 才能拿到对应的 Repository。

第二步:实体——ChatRoom / ChatMessage 把消息落库

聊天最容易踩的「玩具思维」是:消息只往内存里推、广播给当前在线的人就完事。结果有人晚进来一分钟,前面聊的全看不到。learnhub 的做法是先把消息存进 MySQL,新加入的人可以拉历史——所以先定义两张表:

// learnhub/src/modules/chat/entities/chat-room.entity.ts
@Entity('chat_room')
export class ChatRoom {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 50 })
  name: string;

  @Column({ type: 'varchar', length: 10, default: 'group', comment: 'group | single' })
  type: 'group' | 'single';

  // 成员(群聊)。一对一房间则固定两个成员。
  @ManyToMany(() => User)
  @JoinTable({ name: 'chat_room_member' })
  members: User[];

  @OneToMany(() => ChatMessage, (m) => m.room)
  messages: ChatMessage[];

  @CreateDateColumn({ name: 'create_time' })
  createTime: Date;
}
// learnhub/src/modules/chat/entities/chat-message.entity.ts
@Entity('chat_message')
export class ChatMessage {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: 'text' })
  content: string;

  @ManyToOne(() => ChatRoom, (r) => r.messages)
  room: ChatRoom;

  @ManyToOne(() => User)
  sender: User;

  @CreateDateColumn({ name: 'create_time' })
  createTime: Date;
}

ChatRoomUser 多对多(中间表 chat_room_member,谁在群里走这张表),ChatMessage 一头连房间一头连发送者。这层关系映射第九章细讲过,这里只看用法——下面 Gateway 发消息前要靠它做「是不是房间成员」的校验。

第三步:Gateway 骨架——namespace、生命周期、@WebSocketServer

Gateway 就是 WebSocket 版的 Controller。learnhub 的 ChatGateway 起手是这样的:

// learnhub/src/modules/chat/chat.gateway.ts
@WebSocketGateway({ namespace: 'chat', cors: { origin: '*' } })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
  private readonly logger = new Logger(ChatGateway.name);

  @WebSocketServer()
  server: Server;

  constructor(
    private readonly jwtService: JwtService,
    private readonly chatService: ChatService,
  ) {}

几个关键点:

  • @WebSocketGateway({ namespace: 'chat' }) 把这个网关挂到 /chat 命名空间下,客户端连的时候是 io('/chat', ...)。一个应用可以有多个命名空间,互相隔离——比如再来个 /notification 给通知中心,互不打架。cors: { origin: '*' } 是开发期图方便,生产得收紧成具体域名。
  • OnGatewayConnection / OnGatewayDisconnect 是两个生命周期接口,分别实现 handleConnectionhandleDisconnect。客户端一连上就进 handleConnection,断开就进 handleDisconnect——这是后面做认证(连进来时验 token)和 presence(上下线通知)的钩子。
  • @WebSocketServer() server: Server 把 socket.io 的 Server 实例注入进来,后面要用它做房间广播(this.server.to(roomId).emit(...))。
  • 构造函数能直接注入 JwtServiceChatService——因为 JwtModule 在 auth.module.ts 里是 global: true 注册的,ChatModule 又在 providers 里声明了 ChatService。Gateway 不是「特殊公民」,它就是个普通 Nest 类。

第四步:在 handleConnection 里手验 JWT(本章关键)

这是这一章最重要的一节。先看 learnhub 的 handleConnection

// learnhub/src/modules/chat/chat.gateway.ts
async handleConnection(client: Socket): Promise<void> {
  const token =
    (client.handshake.auth as any)?.token ||
    (client.handshake.headers.authorization || '').replace(/^Bearer\s+/i, '');

  if (!token) {
    client.emit('error', { message: '缺少登录凭证' });
    client.disconnect();
    return;
  }
  try {
    const payload = await this.jwtService.verifyAsync<RequestUser>(token);
    client.data.user = { userId: payload.userId, username: payload.username };
    this.logger.log(`已连接:${payload.username} (${client.id})`);
  } catch {
    client.emit('error', { message: '登录凭证无效' });
    client.disconnect();
  }
}

为什么要自己验?因为 WebSocket 连接不走全局的 JwtAuthGuard。回想第十一章:JwtAuthGuard 是用 APP_GUARD 注册的、全局生效。但 APP_GUARD 实际是 Nest 在 HTTP 请求拦截链里的钩子(见第五章 AOP 五件套),它处理的是 Express 的 Request。WebSocket 的握手虽然借了 HTTP 升级这一步,但升级完之后双向通信就走的是 WS 帧,根本不经过那条守卫链——全局守卫一个都不会跑。

如果不在这里手验,谁都能匿名连上 /chat,进而 join 房间偷听消息。所以 learnhub 的做法是:

  1. 从握手的 client.handshake.auth(socket.io 客户端约定)或 Authorization 头里取 token——这两种方式都支持,前端怎么传都接得住。
  2. 拿不到 token 直接 client.disconnect() 把连接踢掉。
  3. 用和登录接口同一个 JwtService.verifyAsync 验签,成功就把用户信息挂到 client.data.user 上(socket.io 给每个连接分配的 data 字段,后面 @SubscribeMessage 处理器都能取到),失败也直接踢掉。

注意:WS 上的认证是你自己的责任,没有任何全局守卫帮你兜底。所有需要知道「这条连接是谁」的逻辑(发消息、加房间、presence),都只能读 client.data.user——如果 handleConnection 没把它挂上去,后面全是 undefined。所以认证失败的连接不仅要 emit('error'),还必须 disconnect(),否则它会以「未认证」状态留下来,后面所有 client.data.user 都拿不到。

思考:为什么不能信任 HTTP 升级时的 cookie/header、复用登录态?——因为浏览器对 WebSocket 握手的处理和普通 XHR 不一样:cookie 确实会带,但你没法像 HTTP 那样在守卫里集中拦截。更本质的原因是 WS 握手是一次性的「升级」请求,之后双向通道就脱离 HTTP 框架了,Nest 的请求/守卫/拦截器模型对它不生效。所以业界惯例就是把 token 显式塞进 auth 字段或 Authorization 头,在 handleConnection 自己验——一次认证、长期有效(token 过期了想续期还得自己加逻辑)。

第五步:socket.io rooms——按房间路由消息

聊天室的本质是「分组广播」:A 房间里的人说话,B 房间听不到。socket.io 原生支持 rooms,不用自己维护「谁在哪个房间」的映射。learnhub 的 onJoin

// learnhub/src/modules/chat/chat.gateway.ts
@SubscribeMessage('join')
async onJoin(
  @ConnectedSocket() client: Socket,
  @MessageBody() body: { roomId: number },
) {
  const user = client.data.user as RequestUser | undefined;
  if (!user) return { event: 'error', data: { message: '未认证' } };

  const ok = await this.chatService.isMember(body.roomId, user.userId);
  if (!ok) return { event: 'error', data: { message: '不在该房间' } };

  await client.join(roomKey(body.roomId));
  // 通知房间内其他人:有人上线(presence)
  client.to(roomKey(body.roomId)).emit('presence', { userId: user.userId, username: user.username, online: true });
  return { event: 'joined', data: { roomId: body.roomId } };
}

几个要点:

  • @SubscribeMessage('join') 监听客户端 emit('join', { roomId }) 这种消息——这是「客户端→服务端」的事件。处理器返回的对象会作为 ack 回给客户端(前端 emit 的回调能收到),所以 return { event: 'joined', data } 是给调用方的确认。
  • @ConnectedSocket() client 拿到这次事件来源的 socket,@MessageBody() body 是消息体(自动反序列化)。
  • 成员资格校验不能省chatService.isMember 去库里查「这个用户在不在这个房间」,查得到才让 client.join(roomKey(roomId))。否则知道 roomId 就能 join 进去偷听,第四步的认证就白做了。
  • client.join(roomKey(roomId)) 把这条 socket 加进 socket.io 的 room,之后这个 room 里 emit 的事件这条 socket 都能收到。roomKey 是个本地工具函数('room:' + roomId),加前缀是为了避免和 socket.io 内置 room 名撞。
  • client.to(room).emit('presence', ...) 是「广播给房间里的其他人」——不包括自己。新人自己不用收到「自己上线了」的通知。this.server.to(room).emit(...) 才是广播给房间所有人(含自己),下一步发消息就用它。

handleDisconnect 是 presence 的另一端:连接断开时记一行日志,业务上可以在这里 emit online: false 给他所在房间的人(learnhub 这里只打日志,presence 主要演示 join 时那一发):

// learnhub/src/modules/chat/chat.gateway.ts
handleDisconnect(client: Socket): void {
  const u = client.data.user as RequestUser | undefined;
  if (u) this.logger.log(`已断开:${u.username} (${client.id})`);
}

注意:socket.io 的 rooms 是进程内存里的数据结构——这条连接在哪个进程,它就只在这个进程的 room 里。单进程跑没事,一旦你横向扩到多个 Nest 实例(每条连接连到不同进程),A 进程里给 room:1 广播,B 进程里那个房间的成员根本收不到。多实例必须接 @socket.io/redis-adapter(或类似的 pub/sub 适配器),把 room 状态同步到所有实例。learnhub 是单实例部署,暂时不用考虑;上生产要做横向扩展时这是绕不开的一步。

第六步:先存库再广播——persist-then-broadcast

发消息这一步藏着第二个玩具思维:「直接把消息广播给房间」。问题在于,如果先广播、再存库,万一存库失败消息就丢了;更常见的是有人在你广播之后才加入房间,他想看历史只能拉库——如果消息没落库,他就永远看不到了。learnhub 的顺序是反过来的:

// learnhub/src/modules/chat/chat.gateway.ts
@SubscribeMessage('message')
async onMessage(
  @ConnectedSocket() client: Socket,
  @MessageBody() body: { roomId: number; content: string },
) {
  const user = client.data.user as RequestUser | undefined;
  if (!user) return { event: 'error', data: { message: '未认证' } };

  const ok = await this.chatService.isMember(body.roomId, user.userId);
  if (!ok) return { event: 'error', data: { message: '不在该房间,不能发言' } };

  const msg = await this.chatService.saveMessage(body.roomId, user.userId, body.content);
  const payload = {
    id: msg.id,
    roomId: body.roomId,
    content: msg.content,
    senderId: user.userId,
    senderName: msg.sender?.username,
    createTime: msg.createTime,
  };
  // 广播给房间所有人(含自己,前端用来确认落屏)
  this.server.to(roomKey(body.roomId)).emit('message', payload);
  return { event: 'sent', data: { id: msg.id } };
}

顺序是:校验成员资格 → 存库(拿回带 id 的消息对象)→ 广播。先存库的好处是广播的 payload 里已经有 msg.idcreateTime,前端收到就能直接用;万一广播的瞬间有人的连接断了,他重连后拉历史也能拿到同一条消息(同一个 id)。如果反过来先广播再存库,广播出去的消息没有 id,前端怎么去重?历史里又出现一条带 id 的同内容消息,前端就会出现重复气泡。

ChatService.saveMessage 干的就是「插一条 ChatMessage 记录,再带 sender 关联查回来」:

// learnhub/src/modules/chat/chat.service.ts
async saveMessage(roomId: number, senderId: number, content: string): Promise<ChatMessage> {
  const msg = this.msgRepo.create({
    content,
    room: { id: roomId } as ChatRoom,
    sender: { id: senderId } as User,
  });
  const saved = await this.msgRepo.save(msg);
  return this.msgRepo.findOne({ where: { id: saved.id }, relations: { sender: true } });
}

room: { id: roomId } as ChatRoom 这种写法是 TypeORM 里「只设外键不查整行」的惯用法——存消息时不需要把整个 room 拉出来,只要写对 roomId 这个外键就行,第八章 TypeORM 章节里多次见过。save 之后再 findOne 是因为 save 返回的对象不带 relations,广播时需要 sender.username,所以再查一次。

注意:广播要在 saveMessage 之后,那个 await 不能省——省了就变成「先广播、库可能还没写进去」的竞态,正是我们要避免的。同时 this.server.to(room).emit 用的是 this.server(广播给全房间,含发送者本人),不是 client.to(不含自己)。让发送者本人也收到自己发出去的消息,是因为前端发出去的事件不一定立即可靠送达——服务端广播回来一份反而是「服务端已收到」的确认,前端按 id 去重就行。这是聊天应用的常见模式。

第七步:SSE——单向推送的轻量方案

聊天是双向的,必须用 WebSocket;但很多时候你只需要「服务端往下推」:通知、进度条、行情推送、构建日志。这种场景下 WebSocket 是杀鸡用牛刀——你得维护连接、做认证、定协议,写一遍 SSE 你会发现省了一大半事。learnhub 的 SSE 例子:

// learnhub/src/modules/sse/sse.controller.ts
@Controller({ path: 'sse', version: '1' })
export class SseController {
  @Public()
  @Sse('time')
  time(): Observable<MessageEvent> {
    // interval 每秒触发,map 成 SSE 消息(data 字段必填)
    return interval(1000).pipe(
      map(() => ({
        data: { time: new Date().toISOString(), msg: 'hello from SSE' },
      })),
    );
  }
}

@Sse('time') 是 Nest 专门给 SSE 准备的装饰器,本质是个 HTTP GET 接口(路径 /api/v1/sse/time),但响应头会带 Content-Type: text/event-stream,连接保持打开,服务端可以一直往下推。返回的是一个 RxJS Observable<MessageEvent>——每发一个值,框架就把它序列化成一条 data: ...\n\n 写到响应里。interval(1000) 每秒触发一次,map{ data: ... },于是客户端每秒收到一条服务器时间。

几个值得知道的事:

  • 为什么 SSE 比 WebSocket 简单:它走 HTTP,浏览器原生 EventSource 一行就能订阅;服务端就是个普通 controller,能用 @Public()、能走全局 JwtAuthGuard(因为它就是 HTTP),不用像 WebSocket 那样在 handleConnection 里自己验 token。认证、CORS、Nginx 都是现成的。
  • MessageEventdata 字段必填:SSE 协议规定一条消息至少有 data,否则会被浏览器丢弃。data 是字符串,传对象框架会自动 JSON.stringify
  • 真正动态推送怎么写:例子里的 interval(1000) 是冷启动演示。真实场景下你会把 interval 换成一个 Subject 或者基于业务事件的 Observable——比如构建任务进度变化时 subject.next({ data: { progress } }),所有订阅了这个接口的客户端就都收到。
  • 限制:SSE 是单向的(服务端→客户端),客户端要发消息得另外发 HTTP 请求;浏览器对同域 SSE 连接数有上限(HTTP/1.1 下一般 6 个),上 HTTP/2 才放宽。所以 SSE 适合「服务端推、客户端只听」的场景,互动式聊天还是得 WebSocket。

思考:learnhub 为什么不把聊天也用 SSE 做?——因为聊天是双向的:客户端要发消息、要加房间、要 ack。SSE 只能服务端往下推,客户端发消息还得另起一个 HTTP POST,前后端协议就割裂了。WebSocket 双向、低延迟、自带 ack(emit 的回调),匹配聊天的交互模式。把技术选型对齐到「数据流向 + 交互模式」,是实时方案选择的核心思路。

第八步:把 REST 和 Gateway 分工清楚

聊天功能不是只有 WebSocket——建房间、拉历史这些「请求-响应」的操作走 REST 更顺手。learnhub 把两条线分得很清:

// learnhub/src/modules/chat/chat.controller.ts
@ApiTags('聊天')
@ApiBearerAuth()
@Controller({ path: 'chat', version: '1' })
export class ChatController {
  constructor(private readonly chatService: ChatService) {}

  @Post('rooms')
  createRoom(@Body() dto: CreateRoomDto, @CurrentUser() user: RequestUser): Promise<ChatRoom> {
    return this.chatService.createRoom(user.userId, dto);
  }

  @Get('rooms/mine')
  myRooms(@CurrentUser() user: RequestUser): Promise<ChatRoom[]> {
    return this.chatService.findMyRooms(user.userId);
  }

  @Post('rooms/:id/join')
  join(@Param('id') id: number, @CurrentUser() user: RequestUser): Promise<void> {
    return this.chatService.joinRoom(Number(id), user.userId);
  }

  @Get('rooms/:id/messages')
  messages(
    @Param('id') id: number,
    @CurrentUser() user: RequestUser,
    @Query('page') page = 1,
    @Query('pageSize') pageSize = 20,
  ): Promise<PageResult<ChatMessage>> {
    return this.chatService.findMessages(Number(id), user.userId, Number(page), Number(pageSize));
  }
}

Controller 管「建房间、我加入的房间列表、加群、历史消息分页」——这些都是一次请求拿一个结果的,走 REST + @CurrentUser() 拿登录态,全自动走 JwtAuthGuard。Gateway 只管「实时收发消息、上下线 presence」——这部分必须长连接。

注意一个细节:Gateway 里用的是 client.data.user(连接时手验 token 挂上去的),Controller 里用的是 @CurrentUser()(全局 JwtAuthGuard 解出来挂到 request.user 的)。两套认证、两套取用户的方式——这是 WebSocket 和 HTTP 在 Nest 里的根本分野,理解了这点就理解了为什么前面第四步反复强调「WS 认证要自己来」。

历史消息分页走的是 ChatService.findMessages,里头先 isMember 校验、再 findAndCount 分页,套路和第八章帖子的 findMany 一致,不重复展开。

第九步:跑起来

cd learnhub
docker compose -f docker/docker-compose.yml up -d mysql
npm install
npm run migration:run
npm run start:dev

服务起在 3000 端口。先调 REST 建房间、登录拿 token:

# 1. 登录拿 token(第十一章)
curl -X POST http://localhost:3000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin123456"}'
# {"accessToken":"eyJ...","refreshToken":"eyJ..."}

# 2. 建房间
curl -X POST http://localhost:3000/api/v1/chat/rooms \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJ..." \
  -d '{"name":"Nest 学习群","type":"group","memberIds":[2]}'
# {"id":1,"name":"Nest 学习群",...}

WebSocket 用 socket.io 客户端连,token 走 auth 字段(对应 handleConnectionclient.handshake.auth.token):

import { io } from 'socket.io-client';
const socket = io('http://localhost:3000/chat', {
  auth: { token: '<accessToken>' },
});
socket.on('connect', () => console.log('connected', socket.id));
socket.on('error', (e) => console.log('err', e));
socket.emit('join', { roomId: 1 }, (ack) => console.log('joined ack', ack));
socket.emit('message', { roomId: 1, content: 'hello' }, (ack) => console.log('sent ack', ack));
socket.on('message', (msg) => console.log('recv', msg));

不带 token 或带错 token,handleConnection 立刻 emit ‘error’ 并 disconnect,浏览器看到的就是连上就被踢。SSE 那边用浏览器原生 EventSource 就能测:

const es = new EventSource('http://localhost:3000/api/v1/sse/time');
es.onmessage = (e) => console.log(JSON.parse(e.data));
// 每秒打一条 { time: '...', msg: 'hello from SSE' }

这一章的成果

  1. @WebSocketGateway + socket.io 做出一个能用的群聊 Gateway,理解了 namespace、@WebSocketServerOnGatewayConnection/Disconnect 生命周期。
  2. handleConnection 里手验 JWT,搞清楚了全局 APP_GUARD 不覆盖 WebSocket 这个最容易踩的坑——WS 认证完全是开发者的责任。
  3. 用 socket.io 的 rooms 做按房间路由,理解了「先校验成员资格、再 join」的安全边界,以及 rooms 是进程内存、多实例需要 redis-adapter。
  4. 把握住先存库再广播的顺序——晚加入房间的人能拉历史,广播的 payload 也有 id 可去重。
  5. 用 SSE 做了一个轻量单向推送,知道了「服务端→客户端」单向场景下,SSE 比 WebSocket 简单得多。
  6. Gateway 和 Controller 的分工清晰:实时收发走 Gateway(用 client.data.user),建房间/历史消息走 REST(用 @CurrentUser())。

常见问题

  • 连上 WebSocket 立刻被踢handleConnection 里 token 没传或验签失败。检查前端 auth.token 是不是真的发了、token 是不是过期了(WS 不像 HTTP 有 401,只会 emit ‘error’ 然后 disconnect)。
  • 消息发了房间收不到:先确认发的人 join 了房间(client.join 调过),再确认广播用的是 this.server.to(roomKey).emit 而不是 client.emit(后者只发给自己)。roomKey 用的是 'room:' + roomId,前后端对齐。
  • 新加入的人看不到历史消息:那是 REST 接口的事,不是 Gateway 的活——前端 join 成功后调 GET /chat/rooms/:id/messages 拉历史。Gateway 只负责实时推,过去的事靠库里查。
  • 多实例广播不到对方:单实例无问题,多实例必须加 @socket.io/redis-adapter,否则 A 实例上的连接 emit 到 room,B 实例上同房间的成员收不到。learnhub 目前单实例,不需要。
  • token 过期了 WS 怎么续期:socket.io 的连接是长期的,token 在 handleConnection 时验一次,之后 token 过期连接依然能用。要严格的话得自己加「定期验 client.data.user 的 token、过期就 disconnect」的逻辑,或者客户端用 refreshToken 续期后重连。learnhub 暂时没做这层。
  • 聊天消息怎么排重:服务端广播的 payload 带 id(来自落库后的 ChatMessage.id),前端按 id 去重。乐观 UI(先用自己的临时 id 渲染、收到服务端 ack 后替换成真 id)是聊天应用的标准做法。

下一章讲 MongoDB——learnhub 用它存行为日志,配一个全局埋点拦截器把每次请求自动落盘,再做 DAU 分析。和这一章的实时推送是两个方向:这一章是「服务端主动推」,MongoDB 那章是「写多读多的非结构化数据怎么存」。