从零用 TypeScript 写一个 TCP 聊天室(下)------数据持久化、登录验证与管理指令
上篇写完之后,我把这个聊天室往深处又推了好几层。纯内存的方案当然能跑,但服务端一重启所有数据就归零,这在实际场景里是不可接受的。所以这周的核心工作就是让数据能够存下来,同时把用户体系搭起来,最后加上管理员指令。下面按模块一个个拆开讲。
一、为什么选择 JSON 文件而不是数据库
写到持久化这一步的时候,我面临一个选择:引入 SQLite,还是用纯 TypeScript 自己实现文件存储?
我最后选了后者。原因很简单:这个项目本身就是为了学习网络编程和 TypeScript 类型系统,如果直接引入 SQLite,虽然省了事,但文件 I/O、索引设计、并发控制这些概念就被封装在库里面了,看不见摸不着。用 JSON 文件自己实现一套 CRUD,反而能逼着我把这些底层逻辑想清楚。
当然这个方案有它的局限。JSON 文件不适合高频写入场景,单文件也不能太大。不过对于一个学习项目来说,几百人同时在线的数据量完全够用。真的到了瓶颈,再迁移到 SQLite 也不迟------接口层已经抽象好了,替换成本很低。
二、文件持久化的核心设计
整个持久化层分成四个部分:一个抽象基类负责文件读写的基础能力,三个具体 Store 分别管用户、消息、房间。还有一个 StoreManager 作为门面,统一向外暴露接口。
2.1 BaseStore ------ 文件操作的三种防护
BaseStore 是所有 Store 的父类,它不做具体的业务逻辑,只解决一个问题:怎么把数据安全地写进文件,同时保证进程崩溃也不丢数据。
这里面我实现了三种防护机制,逐个说明。
防抖写入(Debounced Write)
聊天室的消息写入频率很高,一秒内可能有几十条。如果每条消息都触发一次文件写入,磁盘 I/O 很快就会成为瓶颈。我采用的方案是延迟写入:数据变更后不立刻写文件,而是设置一个 500 毫秒的定时器,如果这段时间内又有新的变更,就取消旧的定时器重新计时。直到 500 毫秒内没有新的变更,才真正执行写入。
这样做的好处是明显的:假设 1 秒内有 50 条消息,不使用防抖要写入 50 次,使用防抖后只写入 1 次。500 毫秒的延迟对用户来说几乎无感知,但写入次数可以减少 90% 以上。
代码实现上用一个 dirty 标志位记录数据是否被修改,一个 writeTimer 保存定时器引用:
typescript
protected scheduleWrite(): void {
this.dirty = true;
if (this.writeTimer) clearTimeout(this.writeTimer);
this.writeTimer = setTimeout(() => this.flush(), 500);
}
原子写入(Atomic Write)
防抖解决了写入频率问题,但还有一个风险:写入过程中进程崩溃了,文件会处于半写状态,变成损坏的 JSON。Node.js 的 fs.writeFileSync 虽然是同步的,但它不会保证写入过程的原子性。
我的解决方案是先写入一个临时文件,写入成功后再通过 rename 操作把临时文件覆盖成正式文件。操作系统层面的 rename 是原子操作,要么成功要么不执行,不会出现"写了一半"的中间状态。
typescript
// 1. 写入临时文件
writeFileSync(`${this.filePath}.tmp`, JSON.stringify(this.data));
// 2. 原子重命名
renameSync(`${this.filePath}.tmp`, this.filePath);
自动备份
即使有了原子写入,极端情况下仍然可能出问题(比如 rename 前磁盘满了)。所以我在每次写入前先把原文件复制一份 .backup:
typescript
if (existsSync(this.filePath)) {
copyFileSync(this.filePath, `${this.filePath}.backup`);
}
启动时如果检测到主文件损坏,会自动尝试从 .backup 恢复。这个机制在我开发过程中真的救过一次------某次写代码时引入了一个 bug,把 users.json 写成了空对象,靠备份文件恢复了数据。
2.2 UserStore ------ 内存索引加速查询
用户数据的查询模式很清晰:根据用户名找用户、根据昵称找用户、根据 ID 找用户。如果每次查询都遍历整个用户数组,时间复杂度是 O(n),用户多了之后会很慢。
我的做法是在启动时从 JSON 文件加载数据,然后在内存中构建三个 Map(映射):
indexByUsername:用户名 -> 用户记录indexByNickname:昵称 -> 用户记录indexById:用户 ID -> 用户记录
typescript
private buildIndexes(): void {
for (const user of this.data.users) {
this.indexByUsername.set(user.username, user);
this.indexByNickname.set(user.nickname, user);
this.indexById.set(user.id, user);
}
}
查询时直接走 Map,时间复杂度降到 O(1)。写入时同时更新 Map 和底层数组,然后触发防抖写入。
这里有一个细节需要注意:用户的 email 字段也需要建立索引,因为注册时要检查邮箱是否已被占用。原理和上面三个索引完全一样,只是在 buildIndexes 里多一行 this.indexByEmail.set(user.email, user)。
2.3 MessageStore ------ 按房间分桶
消息数据的查询模式跟用户不同。用户是"根据某个字段精确查找一条",消息是"查询某个房间最近 N 条"。所以我采用了分桶存储:每个房间对应一个消息数组,私聊消息单独存一个数组。
typescript
interface MessagesData {
version: number;
lastId: number;
rooms: Record<string, { totalMessages: number; messages: MessageRecord[] }>;
whispers: MessageRecord[];
}
lastId 是自增的消息 ID,每条新消息的 ID 在此基础上加 1,保证全局唯一。这个 ID 同时也是分页查询的游标------客户端传入 beforeId,服务端返回此 ID 之前的 N 条消息。这种游标分页比传统的 OFFSET/LIMIT 性能好很多,不会因为页数增加而变慢。
每个房间的消息数组我设置了 5000 条的上限,超出时丢弃最旧的消息。这个数值可以根据实际需求调整。
离线消息的实现方式是在私聊记录里加一个 isOffline 布尔字段。目标用户不在线时设为 true,用户上线后查询所有 targetId 等于自己且 isOffline 为 true 的消息推送过去,然后把 isOffline 改成 false。
2.4 RoomStore ------ 最简单的那个
RoomStore 的复杂度最低,因为房间数量通常不多(几十到几百个),不需要太多优化。启动时从 rooms.json 加载房间列表,创建房间时追加写入,删除房间时从数组中移除。
有一个默认规则:系统启动时如果 Lobby 房间不存在,会自动创建一个。这是保证新用户始终有地方去的兜底逻辑。
三、登录验证
这部分是我花时间最多的地方,因为涉及好几个安全领域的专有名词。如果你之前没有接触过密码学和认证机制,这些名词确实让人摸不着头脑。我把它们逐个拆开解释。
3.1 bcrypt ------ 密码为什么需要"哈希"
bcrypt 是一个密码哈希函数(Password Hash Function)。
哈希函数的意思是把任意长度的输入转换成固定长度的输出,而且这个过程是不可逆的------你不能从哈希值反推出原始密码。
为什么不能直接把用户密码明文存进文件?因为一旦文件泄露,所有用户的密码就直接暴露了。很多用户在多个网站用同一个密码,一个站点泄露可能导致连锁反应。
哈希解决了这个问题:即使攻击者拿到了 users.json,他也只能看到一串哈希值,无法知道用户的真实密码。
bcrypt 的特殊之处在于它设计得很慢。普通的哈希函数(比如 SHA-256)追求速度越快越好,但密码哈希恰恰相反------你需要它慢,因为攻击者会用暴力穷举的方式尝试大量密码,如果哈希计算很快,穷举的效率也会很高。bcrypt 的"慢"是通过一个叫做 cost factor(成本因子)的参数控制的,我设置的是 12,意味着内部会进行 2^12 = 4096 轮迭代。验证一次密码大约需要 100 毫秒,对正常登录来说无感知,但对暴力攻击者来说大大降低了效率。
typescript
// 注册时:明文密码 → 哈希值
const hash = await bcrypt.hash(password, 12);
// 存储 hash
// 登录时:用户输入的密码 + 存储的哈希值 → 是否匹配
const match = await bcrypt.compare(inputPassword, storedHash);
3.2 JWT(JSON Web Token)------ 登录后怎么证明"我是我"
用户登录成功后,服务端需要给他发一个"凭证",之后每次请求都带上这个凭证,服务端就能识别出他是谁。这个凭证就是 JWT。
JWT 本质上是一个经过签名的 JSON 字符串,包含三部分:
-
Header(头部):说明签名算法
-
Payload(载荷):包含用户 ID、用户名、角色、过期时间等信息
-
Signature(签名):用密钥对前两部分的签名,防止篡改
xxxxx.yyyyy.zzzzz
↑ ↑ ↑
Header Payload Signature
JWT 的优势在于它是"自包含"的------服务端收到 JWT 后,只需要验证签名是否有效,就能知道用户信息,不需要去数据库查询。这很契合我们的文件存储架构:验证签名不需要读 users.json。
但 JWT 也有一个缺点:一旦签发,服务端无法主动撤销。所以如果用户修改了密码,旧的 JWT 在过期前仍然是有效的。在我们的场景下这个风险可以接受(Token 有效期 24 小时),如果需要更强的控制,可以在内存中维护一个 Token 黑名单。
3.3 完整的认证流程
把上面两个概念串起来,整个认证流程是这样的:
- 用户注册:输入用户名 + 密码 + 昵称 + 邮箱验证码 → 服务端用 bcrypt 哈希密码 → 用户信息写入 users.json → 生成 JWT 返回给客户端
- 用户登录:输入用户名 + 密码 → 服务端查 users.json → bcrypt 比对密码 → 更新最后登录时间 → 生成 JWT 返回
- 连接认证:客户端发送 JWT → 服务端验证签名 → 从 Payload 取出用户 ID → 查 users.json 确认用户存在 → 登录成功
- 断开重连:客户端再次发送 JWT → 同上流程 → 恢复之前的登录状态
客户端把 JWT 存在内存中,下次连接时直接发送,不需要重新输入用户名密码。
四、管理员指令系统
有了用户认证和角色体系之后,管理员指令的实现就比较直白了。核心思路是:每条管理员命令在执行前检查调用者的角色,不满足权限就拒绝。
4.1 角色设计
我设计了三层角色:
- MEMBER(普通成员):能聊天、私聊、切换房间
- MODERATOR( moderators,版主):以上 + 踢人、禁言
- ADMIN(管理员):以上 + 封禁账号、删号、删房间、改角色、消息搜索
第一个注册用户自动成为 ADMIN,解决了"第一个管理员怎么来"的问题。
4.2 命令实现
所有管理员命令以 /admin 开头,服务端解析后进入 handleAdminCommand 方法:
typescript
private handleAdminCommand(user: User, text: string): void {
const parts = text.split(' ');
const cmd = parts[1]; // /admin 后面的子命令
switch (cmd) {
case 'kick': {
// 检查权限:ADMIN 或 MODERATOR
if (user.role === UserRole.MEMBER) {
user.sendSystemMessage('权限不足');
return;
}
// ... 执行踢人逻辑
break;
}
case 'ban': {
// 仅 ADMIN
if (user.role !== UserRole.ADMIN) {
user.sendSystemMessage('仅管理员可用');
return;
}
// ... 执行封禁
break;
}
// ... 其他命令
}
}
每个命令内部做三件事:权限校验 → 参数解析 → 执行操作。
4.3 几个有意思的实现细节
踢出房间(kick)不是直接断开用户的连接,而是设置一个"禁止进入"的截止时间戳。被踢的用户在截止时间前尝试加入该房间会被拒绝,但可以去其他房间。这样比直接断连更合理------被踢不等于被踢出服务器。
禁言(mute)也是类似的思路:设置一个截止时间戳,用户尝试发送消息时检查当前时间是否小于截止时间,如果是就拒绝发送。
消息搜索(search)是 ADMIN 专属的功能,基于 MessageStore 的 search 方法实现。它在指定房间的消息中做字符串匹配,从最新消息开始往前搜,返回前 50 条匹配结果。
五、QQ 邮箱验证码注册
注册流程加入了邮箱验证,主要是为了防止机器人批量注册和确保用户能找回密码。
实现方式是在注册流程中插入一步验证码校验:
- 用户输入邮箱地址,请求发送验证码
- 服务端生成 6 位随机数字,通过 QQ 邮箱的 SMTP 服务发送到用户邮箱
- 用户查收邮件,输入验证码
- 服务端校验验证码(5 分钟有效、一次性使用),通过后继续注册流程
SMTP(Simple Mail Transfer Protocol,简单邮件传输协议)配置方面,QQ 邮箱需要开启 SMTP 服务并获取授权码。服务端的 EmailService 用 nodemailer 库封装了邮件发送逻辑,SMTP 地址、端口、授权码通过环境变量配置,适配不同邮箱服务商。
验证码存储在内存中(不需要持久化),5 分钟过期,验证成功后立即删除防止重复使用。每 60 秒自动清理一次过期的验证码条目,避免内存泄漏。
六、密码找回
密码找回复用了验证码服务的核心逻辑:
- 用户选择"找回密码",输入注册邮箱
- 服务端校验邮箱是否已注册 → 发送验证码
- 用户输入验证码 → 设置新密码
- 服务端用 bcrypt 哈希新密码 → 更新 users.json → 生成新 JWT → 自动登录
整个流程走下来,用户不需要记住旧密码就能重置,只要他能收到注册邮箱的邮件就行。
七、写在最后
从纯内存的 MVP 走到现在,这个项目已经有了一套比较完整的体系:协议层、认证层、持久化层、权限层、邮件服务,每一层都是 TypeScript 从零实现的。虽然代码量不算大(大约 3000 行),但涉及的概念不少------TCP 编程、JSON 协议设计、文件 I/O 优化、密码学基础、JWT 认证、RBAC 权限模型、SMTP 邮件发送。
接下来的计划是把这套东西接到 Web 前端上去。协议层已经完全准备好了,只需要在 TCP 传输层旁边加一个 WebSocket 传输层,客户端用 React 写一个网页版,就能实现终端和网页双端并存。这部分内容如果做出来,可能会再写一篇。
如果你对这个项目有什么想法或者发现了 bug,欢迎在 GitHub 提 issue。