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 管理的类,能注入 JwtService、ChatService,和你写 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;
}
ChatRoom 和 User 多对多(中间表 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是两个生命周期接口,分别实现handleConnection和handleDisconnect。客户端一连上就进handleConnection,断开就进handleDisconnect——这是后面做认证(连进来时验 token)和 presence(上下线通知)的钩子。@WebSocketServer() server: Server把 socket.io 的Server实例注入进来,后面要用它做房间广播(this.server.to(roomId).emit(...))。- 构造函数能直接注入
JwtService和ChatService——因为 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 的做法是:
- 从握手的
client.handshake.auth(socket.io 客户端约定)或Authorization头里取 token——这两种方式都支持,前端怎么传都接得住。 - 拿不到 token 直接
client.disconnect()把连接踢掉。 - 用和登录接口同一个
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.id 和 createTime,前端收到就能直接用;万一广播的瞬间有人的连接断了,他重连后拉历史也能拿到同一条消息(同一个 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 都是现成的。 MessageEvent的data字段必填: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 字段(对应 handleConnection 里 client.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' }
这一章的成果
- 用
@WebSocketGateway+ socket.io 做出一个能用的群聊 Gateway,理解了 namespace、@WebSocketServer、OnGatewayConnection/Disconnect生命周期。 - 在
handleConnection里手验 JWT,搞清楚了全局APP_GUARD不覆盖 WebSocket 这个最容易踩的坑——WS 认证完全是开发者的责任。 - 用 socket.io 的 rooms 做按房间路由,理解了「先校验成员资格、再 join」的安全边界,以及 rooms 是进程内存、多实例需要 redis-adapter。
- 把握住先存库再广播的顺序——晚加入房间的人能拉历史,广播的 payload 也有
id可去重。 - 用 SSE 做了一个轻量单向推送,知道了「服务端→客户端」单向场景下,SSE 比 WebSocket 简单得多。
- 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 那章是「写多读多的非结构化数据怎么存」。