- GitHub项目地址:balatro-realtime-backend
- 📌 对应代码版本:
feat: implement custom WebSocket adapter with unified message parsing and logging(2026年4月24日 16:40)- ⚠️ 本文基于当前项目阶段代码实现
📌 主线关联说明
本文属于「Balatro后端进阶系列」,是在主线开发过程中,对某一技术点的深入拆解。
👉 当前主线已发布博文进度:
✅本篇实现了什么
在这篇文章中,我们将会写到:
- 为什么
NestJS默认WsAdapter不足以满足生产需求 - 如何在消息进入
handler前拦截原始message - 如何自定义
WebSocket Adapter,实现日志与协议校验 - 如何设计一条完整的
WebSocket消息处理链路
👉最终将掌握一种可复用的 WebSocket 接入层设计方案,适用于游戏后端、实时系统等场景
文章目录
- [一、为什么需要自定义 WebSocket Adapter](#一、为什么需要自定义 WebSocket Adapter)
- [二、默认 WsAdapter 的处理流程](#二、默认 WsAdapter 的处理流程)
- 三、设计目标
- [四、WebSocket 消息处理链路设计(核心)](#四、WebSocket 消息处理链路设计(核心))
- [五、Adapter 应该放在哪里?](#五、Adapter 应该放在哪里?)
- 六、实现思路
-
- [1. 自定义的 adapter 实现思路](#1. 自定义的 adapter 实现思路)
- [2. bindMessageHandlers() 实现思路](#2. bindMessageHandlers() 实现思路)
- [3. bindMessageHandlers() 的伪代码步骤:](#3. bindMessageHandlers() 的伪代码步骤:)
- [4. 最终的完整链路](#4. 最终的完整链路)
- 七、代码实现
-
- [1. main.ts 修改](#1. main.ts 修改)
-
- [1.1 在 main.ts 中接入自定义 Adapter](#1.1 在 main.ts 中接入自定义 Adapter)
- [1.2 自定义 WsAdapter 实现](#1.2 自定义 WsAdapter 实现)
- 八、关键设计点解析
-
- [1. 为什么 extends WsAdapter](#1. 为什么 extends WsAdapter)
- [2. 为什么在 Adapter 做协议校验](#2. 为什么在 Adapter 做协议校验)
- [3. 为什么统一 error 格式](#3. 为什么统一 error 格式)
- 九、总结
一、为什么需要自定义 WebSocket Adapter
使用Adapter的根本原因是我想要一个在 handler 之前、之后,都插一层逻辑(打印日志)。在打印的日志中标注该链接的唯一标识。
根据官方展示的 WsAdapter 示例里,底层就是在绑定消息处理时先拿到原始的 message ,再 JSON.parse ,也就是说,解析前的那一层,才是我想要做打印日志的地方。
这样可以避免收到的消息不符合 WsAdapter 的默认解析规则时,匹配不到WebSocket 对应的handleMessage 中,有的时候会因为消息的格式不对,'吞掉'了这条消息,也可能会把这个 error 返回给前端,但不管哪种方式,都不是后端实际需要的。
二、默认 WsAdapter 的处理流程
前面的版本在 Adapter 的使用中,只是在 main.ts 简单了做了引用及创建项目,也就是使用的是 Nest 内置的 WsAdapter,
底层流程(简化)是:
客户端发消息 (string)
↓
WsAdapter 内部 JSON.parse
↓
拿到 { event, data }
↓
匹配 @SubscribeMessage("event")
↓
调用 handler
👉 问题在于 :开发者拿不到 JSON.parse 之前的原始 message
👉 这意味着:一旦消息格式异常,开发者将无法定位问题来源
三、设计目标
这里的重点是:
- 原始文本是什么
- 哪怕不是
JSON,也要打印出来 - 哪怕后面解析失败,也不能漏掉消息
后端的日志对比前端来讲,需要更全面,可以说是方方面面都要考虑到,因为从项目的角度来讲,前端的很多缓存数据是存到的用户手机中,那么出现问题,或者测试去测试功能的时候,一些问题是获取不到的,这个时候后端的日志就尤其重要。所以后端的日志必须是全面的,那么收到和发送给前端的最初始和最后的步骤,需要掌握在自己的手中,以便日志的统一管理及问题查看。
四、WebSocket 消息处理链路设计(核心)
所以最终的链路应该是:
Client → Adapter(log + parse) → Gateway → Service → Adapter → Client
👉这一层的核心作用是 :将"通信协议处理"前置到 Adapter 层,而不是混入业务逻辑
五、Adapter 应该放在哪里?
本次要做的,其实就是改造 WsAdapter。也就是自己继承 WsAdapter,重写消息进入那一刻的逻辑,在那里加日志 + try/catch。
我们需要非法消息在入口层直接拦截,但回给客户端的格式依然统一为 event: "error"。这样前端能统一处理,后端又不会把协议错误混进业务分发链。
那么问题来了,这层继承是放到 main.ts 里面,还是放到 game 的 gateway 中呢?
首先我们要知道,这层 message 的打印,不是游戏业务逻辑,不属于 game.gateway.ts ,也不属于 game.service.ts 。更不是因为最初的 WsAdapter 是在 main.ts 中引用的就要在 main.ts 中修改,加到里面未来只会越来越乱。
不放到 game 下,因为这个 adapter 是全局给整个 Nest WebSocket 用的,不只是给 game 模块。
它更像是:应用启动时 WebSocket 基础设施配置。所以我们需要单独创建一个新的文件,暂时先放到 src/ 下,与 main.ts 同级。命名为 ws.adapter.ts。
六、实现思路
1. 自定义的 adapter 实现思路
ws.adapter.ts 里我们需要写一个自定义的 adapter。它可以:
- 拿到
client - 拿到原始
message - 打
<---ip rawMessage try/catch JSON.parse- 非法时直接
client.send(...)回错误 - 合法时再按
event分发给对应handler
这正好和官方 advanced adapter 示例的职责一致:
bindMessageHandlers(client, handlers, process) 里订阅 client 的 message,匹配 handler,没有 handler 时可忽略,有结果时通过 client.send(JSON.stringify(response)) 回发。(官网指路 -> docs.nestjs.com)
2. bindMessageHandlers() 实现思路
在官方示例里,bindMessageHandlers(client, handlers, process) 合法路径是:
- 从
client订阅message - 对每条消息执行一个绑定处理
- 匹配到
handler process(...)- 最后
client.send(JSON.stringify(response))
也就是说,这一层是真正接管"消息进来之后,怎么路由、怎么返回"的地方。
3. bindMessageHandlers() 的伪代码步骤:
1. 监听 client 的 message 事件
2. 拿到 raw message
3. 从 client 上取 ip / 唯一标识
4. 打 <---ip rawMessage
5. try/catch JSON.parse
6. 如果非法:
- 记录 error log
- client.send(errorResponse)
- 结束
7. 如果合法:
- 从 handlers 中找到匹配 event 的 handler
- 调用 process(...)
- 得到 response
- 打 -ip---> response
- client.send(response)
4. 最终的完整链路
client 发来原始消息
↓
adapter 拿到 raw message
↓
自己 parse / try-catch / 校验
↓
从 handlers 里找到 handler.message === message.event
↓
执行对应的 callback(也就是 @SubscribeMessage 对应的方法)
↓
把 callback 的返回值交给 process(...)
↓
最终 subscribe 里 client.send(JSON.stringify(response))
七、代码实现
1. main.ts 修改
1.1 在 main.ts 中接入自定义 Adapter
原来 main.ts 的 app.useWebSocketAdapter(new WsAdapter(app)); 我们也要改。
但思路不是"删掉 WsAdapter",而是:还是用 WsAdapter,只是不要再直接裸 new。而是先把它提出来,配置好 parser,再交给 app.useWebSocketAdapter(...)。
也就是说,方向上会变成:
ts
// 修改后 main.ts 中对 adpter 的使用
import { CustomWsAdapter } from "./ws.adapter";
async function bootstrap() {
...
const wsAdapter = new CustomWsAdapter(app);
app.useWebSocketAdapter(wsAdapter);
...
}
1.2 自定义 WsAdapter 实现
wa.adapter.ts 代码:
- 注意,这里是继承
extends WsAdapter,不要implements WebSocketAdapter。implements需要把WsAdapter里的方法全部拿出来override,会导致main.ts一个listen(8088),wa.adapter.ts里因implements一个create(),create()是Nest内部在启动gateway时调用的,不应该我们自己调用- 我们的目标不是从零写一个
adapter,而是"扩展官方adapter的消息处理层"。
下面代码重点 关注 bindMessageHandlers 与 handleRawMessage 两个方法:
ts
import { INestApplicationContext } from "@nestjs/common";
import { WsAdapter } from "@nestjs/platform-ws";
import { MessageMappingProperties } from "@nestjs/websockets";
import { fromEvent, Observable } from "rxjs";
import { mergeMap, filter } from "rxjs/operators";
import * as WebSocket from "ws";
import { Logger } from "@nestjs/common";
export class CustomWsAdapter extends WsAdapter {
private clientIdCounter = 1;
constructor(app: INestApplicationContext) {
super(app);
}
bindMessageHandlers(
client: WebSocket,
handlers: MessageMappingProperties[],
process: (data: any) => Observable<any>,
) {
let clientId = (client as any).__clientId;
if (!clientId) {
clientId = `client_${this.clientIdCounter++}`;
(client as any).__clientId = clientId;
}
Logger.log(`Client connected: ${clientId}`);
//监听 message
fromEvent(client, "message")
.pipe(
//把"原始 message" → "处理后的结果流",负责的是数据流转换
mergeMap((data) => {
return this.handleRawMessage(data, handlers, process, clientId);
}),
//过滤掉无效结果
filter((result) => result !== undefined && result !== null),
)
//真正发送给客户端
.subscribe((response) => {
Logger.log(`-${clientId}---> ${JSON.stringify(response)}`);
client.send(JSON.stringify(response));
});
}
//业务处理(解析 + 校验 + handler 调用)
handleRawMessage(
buffer,
handlers: MessageMappingProperties[],
process: (data: any) => Observable<any>,
clientId: string,
): Observable<any> {
let errorMsg: null | object = null;
try {
const message = JSON.parse(buffer.data);
Logger.log(`<---${clientId}- ${JSON.stringify(message)}`);
if (typeof message !== "object" || message === null) {
errorMsg = {
event: "error",
data: { code: "INVALID_MESSAGE_FORMAT", message: "Message must be a JSON object" },
};
} else if (!message.event || typeof message.event !== "string") {
errorMsg = {
event: "error",
data: { code: "INVALID_MESSAGE_FORMAT", message: `Not found event: ${message.event}` },
};
}
if (errorMsg) {
return new Observable((observer) => {
observer.next(errorMsg);
observer.complete();
});
}
const messageHandler = handlers.find((handler) => handler.message === message.event);
if (!messageHandler) {
errorMsg = {
event: "error",
data: { code: "UNKNOWN_EVENT", message: `Unknown event: ${message.event}` },
};
return new Observable((observer) => {
observer.next(errorMsg);
observer.complete();
});
}
return process(messageHandler.callback(message.data));
} catch (error) {
Logger.error(`Error processing message: `, error);
errorMsg = {
event: "error",
data: { code: "INVALID_JSON", message: "Message must be valid JSON" },
};
return new Observable((observer) => {
observer.next(errorMsg);
observer.complete();
});
}
}
}
八、关键设计点解析
1. 为什么 extends WsAdapter
避免重复实现底层逻辑,只扩展消息处理层
2. 为什么在 Adapter 做协议校验
避免非法消息进入业务层
3. 为什么统一 error 格式
- 前端处理统一
- 后端日志统一
九、总结
在本篇中,我们完成了 WebSocket 接入层的改造:
- 在
Adapter层接管消息入口 - 实现原始
message日志记录 - 增加协议校验与错误统一返回
- 构建
Client→Adapter→Gateway→Service的处理链路
这种设计将"通信协议处理"与"业务逻辑"解耦,
不仅适用于游戏后端,也适用于所有需要 WebSocket 的实时系统。
这一阶段的完成,意味着项目已经具备了"可控的 WebSocket 接入层",为后续游戏逻辑扩展提供了稳定基础