用 TypeScript 从零写一个 TCP 聊天室(上)------ 网络编程入门实战
本文面向想入门网络编程的开发者。我们将从零开始,用纯 TypeScript + Node.js 实现一个支持多房间、私聊、心跳保活的命令行聊天室。本篇聚焦"网络通信"本身,所有数据先存放在内存中。用户注册登录、数据持久化、历史消息分页等进阶话题,将留在下篇讲解。
零、最终效果预览
打开两个终端,一个运行服务端,一个运行客户端:
bash
# 终端 1:启动服务端
npm run build
npm run start:server
# → 聊天服务器启动在端口 3000
# 终端 2:启动客户端
npm run start:client
# → ✅ 已连接到服务器
# → 请输入昵称: Alice
# → 🎉 认证成功!
# → Alice[Lobby]> 大家好!
你可以同时启动多个客户端,它们会进入同一个大厅,彼此能看到对方的消息。输入 /join game 可以创建并切换到新房间,输入 /w Bob 你好 可以给 Bob 发送私聊。
一、我们要做什么?
在写第一行代码之前,先理清我们要构建的系统长什么样。你不用关心具体怎么实现,只需要知道:这个聊天室有哪些功能模块。
1.1 核心功能清单
| 模块 | 功能描述 |
|---|---|
| TCP 服务端 | 基于 Node.js net 模块,监听一个端口,接受多个客户端的 TCP 连接 |
| 昵称认证 | 客户端连接后,需要先发送一个昵称;服务端检查是否重复,通过后允许进入聊天 |
| 大厅与房间 | 默认有一个 "Lobby" 大厅;用户可以创建新房间(如 game、music),房间可以设密码;用户同一时间只能在一个房间 |
| 房间广播 | 用户在某个房间内发送的消息,只会被该房间内的其他用户收到 |
| 私聊 | 用户可以通过 /w <昵称> <内容> 给指定在线用户发送点对点消息 |
| 心跳保活 | 服务端每 30 秒发送一次 Ping,客户端回复 Pong;如果 60 秒未回复,服务端认为连接已死,主动断开 |
| 用户上下线通知 | 有人进入或离开房间时,房间内其他用户会收到系统通知 |
| 命令系统 | 客户端支持 /join、 /w、 /quit 等命令 |
| 文件传输(预留) | 协议层预留了文件分片传输的消息类型,方便后续扩展 |
1.2 数据存储策略(本篇)
本篇所有数据都存在内存里:
- 在线用户列表 →
Map<Socket, User> - 昵称到用户的映射 →
Map<string, User> - 房间列表 →
Map<string, Room> - 房间内的用户列表 →
Set<string> - 房间消息历史 → 内存数组(最多保留最近 100 条)
这意味着服务端重启后,所有数据会丢失。我们先学会"怎么让客户端和服务端对话",下篇再解决"怎么把对话存下来"。现在可能太早了,因为编者也正在抓耳挠腮的写数据持久化。
1.3 通信协议的选择
客户端和服务端之间通过 TCP 长连接 通信。我们需要定义一套"对话规则",让双方知道对方发过来的数据是什么意思。
这里选择最简单的 JSON 行协议(Line-Delimited JSON):
- 每条消息是一个 JSON 对象
- 对象末尾加上换行符
- 接收方按行读取,逐条解析 JSON
例如,客户端发送一条聊天消息:
json
{"type":"CHAT","payload":{"content":"你好","room":"Lobby"},"timestamp":"2024-01-01T00:00:00.000Z","sender":"Alice","id":"550e8400-e29b-41d4-a716-446655440000"}
这种协议实现简单、人类可读、易于调试,非常适合入门学习。
二、核心依赖与它们的关键 API
在动手写代码之前,先熟悉我们会用到的几个 Node.js 内置模块和第三方库。
2.1 net ------ Node.js 网络模块
net 是 Node.js 内置模块,提供基于 TCP 的 socket 通信能力。
创建服务端
typescript
import { createServer, Server, Socket } from 'net';
// createServer 创建一个 TCP 服务器
const server: Server = createServer();
// 当有新的客户端连接时,触发 'connection' 事件
// socket 参数代表与这个客户端的连接
server.on('connection', (socket: Socket) => {
console.log('新客户端连接');
});
// 开始监听端口
server.listen(3000, () => {
console.log('服务器启动在端口 3000');
});
Socket 的核心事件与方法
Socket 对象代表一个 TCP 连接,无论是服务端还是客户端都会用到:
| API | 类型 | 说明 |
|---|---|---|
socket.on('data', callback) |
事件 | 收到对方发送的数据时触发,callback(chunk) 中的 chunk 是 Buffer |
socket.on('close', callback) |
事件 | 连接关闭时触发 |
socket.on('error', callback) |
事件 | 连接发生错误时触发 |
socket.write(data) |
方法 | 向对方发送数据,data 可以是字符串或 Buffer |
socket.destroy() |
方法 | 强制销毁连接,释放资源 |
socket.remoteAddress |
属性 | 获取对端 IP 地址 |
创建客户端连接
typescript
import { createConnection, Socket } from 'net';
// 连接到指定地址和端口
const socket: Socket = createConnection({ host: '127.0.0.1', port: 3000 });
socket.on('connect', () => {
console.log('连接成功');
socket.write('Hello Server!
');
});
socket.on('data', (chunk: Buffer) => {
console.log('收到数据:', chunk.toString());
});
2.2 readline ------ 命令行交互
readline 是 Node.js 内置模块,用于读取命令行输入。我们的客户端需要它来实现"用户打字 → 发送消息"的交互。
typescript
import * as readline from 'readline';
const rl = readline.createInterface({
input: process.stdin, // 标准输入(键盘)
output: process.stdout // 标准输出(屏幕)
});
// 提问并等待用户输入
rl.question('请输入昵称: ', (answer) => {
console.log('你输入了:', answer);
});
// 进入交互模式,每次用户按回车触发 'line' 事件
rl.on('line', (input) => {
console.log('你输入了一行:', input);
});
// 设置命令行提示符
rl.setPrompt('> ');
rl.prompt(); // 显示提示符
2.3 uuid ------ 生成唯一 ID
uuid 是一个第三方库,用于生成全局唯一标识符。我们用它来为每条消息生成唯一的 id,方便追踪和去重。
typescript
import { v4 as uuidv4 } from 'uuid';
const id: string = uuidv4();
// → '550e8400-e29b-41d4-a716-446655440000'
2.4 dotenv ------ 环境变量管理(可选)
dotenv 用于从 .env 文件加载环境变量,方便配置端口号、心跳间隔等参数,不用硬编码在代码里。
typescript
import * as dotenv from 'dotenv';
dotenv.config(); // 加载当前目录下的 .env 文件
const port = process.env.CHAT_SERVER_PORT || '3000';
三、项目结构
chat-room/
├── src/
│ ├── shared/
│ │ └── protocol.ts # 协议层:消息类型、接口、类型守卫
│ ├── server/
│ │ ├── index.ts # 服务端入口
│ │ ├── ChatServer.ts # 服务端核心:连接管理、消息路由、房间调度
│ │ ├── User.ts # 用户模型:封装 socket、心跳、禁言等
│ │ └── Room.ts # 房间模型:用户集合、密码、消息历史
│ └── client/
│ ├── index.ts # 客户端入口
│ └── client.ts # 客户端核心:连接、交互、命令解析
├── package.json
├── tsconfig.json
└── .env.example
四、协议层:定义"对话规则"
协议层是整个项目的基石。客户端和服务端都依赖 protocol.ts 来理解对方在说什么。
4.1 设计思路
我们定义一个统一的基接口 BaseMessage,所有具体消息都继承它:
typescript
interface BaseMessage {
type: MessageType; // 消息类型(枚举)
payload: unknown; // 具体载荷,每种消息不一样
timestamp: string; // ISO 格式时间戳
sender: string; // 发送者昵称,或 'system'
id: string; // 唯一 ID(uuid)
}
然后为每种业务场景定义具体的 MessageType 和对应的接口。例如:
- 用户刚连接时要发送
AUTH消息,带上昵称 - 用户想聊天时发送
CHAT消息,带上内容和房间名 - 服务端想通知全员时发送
SYSTEM消息
为什么要这样设计? 这种"基接口 + 派生接口"的设计模式在网络协议中非常常见。BaseMessage 定义了所有消息都必须携带的"元信息":时间戳用于消息排序和日志追溯,sender 用于标识来源,id 用于全局去重和幂等性控制。而 payload 使用 unknown 类型是刻意为之------它强制开发者在访问具体字段之前必须先做类型判断,避免了 any 类型带来的"静默错误"。比如如果你错误地把一个 AuthMessage 当成 ChatMessage 来访问 payload.content,TypeScript 编译器会直接报错,而不是在运行时产生难以追踪的 undefined 错误。
为什么选择字符串枚举而非数字枚举? 在调试网络程序时,可读性是第一优先级。如果你看到日志里打印 type: 3,你需要去查表才知道这是 CHAT;但如果你看到 type: "CHAT",一眼就能明白。此外,字符串枚举在前后端分离或跨语言通信时更友好------JSON 中直接携带语义信息,不需要额外的协议文档来解释每个数字的含义。虽然字符串会稍微增加一点点网络传输开销(在现代带宽下完全可以忽略不计),但换来的可维护性和调试效率是巨大的。
4.2 什么是类型守卫(Type Guard)?
由于 BaseMessage 的 payload 是 unknown,我们在收到消息后,需要先判断它到底是什么类型,才能安全地访问 payload 里的字段。
TypeScript 的类型守卫就是干这个的:
typescript
function isChatMessage(msg: BaseMessage): msg is ChatMessage {
return msg.type === MessageType.CHAT;
}
这个函数返回 boolean,但返回值类型是 msg is ChatMessage。当它在 if 语句里返回 true 时,TypeScript 编译器就会自动把 msg 收窄为 ChatMessage ,之后你就可以安全地访问 msg.payload.content 了。
类型守卫的本质是"编译时信任与运行时检查的统一"。 在传统的开发中,我们通常会写 if (msg.type === 'CHAT') 然后强行把 msg 转换成任意类型来访问字段。这种做法有两个问题:第一,类型转换(如 as ChatMessage)是开发者对编译器的"谎言"------如果判断条件写错了,运行时就会崩溃;第二,类型转换代码散落在业务逻辑中,难以维护。类型守卫把"判断逻辑"封装成可复用的函数,编译器在 if 分支内自动理解类型收窄,在 else 分支内也知道类型不匹配。这是一种"防御式编程":先证明数据的形状,再使用数据的字段。在我们的聊天室中,handleMessage 方法会收到各种消息,如果没有类型守卫,我们不得不用大量的 as 断言,代码会变得脆弱且难以重构。这就是使用ts的优势所在,我们严格规定了类型的形状,只要操作程序合法,大部分错误都会被提前规避。
4.3 代码实现
下面是本篇需要的基础协议定义:
typescript
// src/shared/protocol.ts
import { v4 as uuidv4 } from 'uuid';
// ==================== 消息类型枚举 ====================
export enum MessageType {
AUTH = 'AUTH', // 客户端发送昵称进行认证
AUTH_OK = 'AUTH_OK', // 认证成功
AUTH_FAIL = 'AUTH_FAIL', // 认证失败(昵称重复等)
CHAT = 'CHAT', // 普通聊天消息
WHISPER = 'WHISPER', // 私聊消息
SYSTEM = 'SYSTEM', // 系统通知
JOIN = 'JOIN', // 加入房间
LEAVE = 'LEAVE', // 离开房间
ROOM_LIST = 'ROOM_LIST', // 房间列表响应
COMMAND = 'COMMAND', // 命令请求
LIST = 'LIST', // 请求在线用户列表
USER_LIST = 'USER_LIST', // 在线用户列表响应
CMD_RESULT = 'CMD_RESULT', // 命令执行结果
PING = 'PING', // 服务端心跳
PONG = 'PONG', // 客户端心跳回复
FILE_OFFER = 'FILE_OFFER', // 发起文件传输
FILE_CHUNK = 'FILE_CHUNK', // 文件分片
FILE_ACK = 'FILE_ACK', // 文件接收确认
PRESENCE = 'PRESENCE', // 用户上下线通知
ERROR = 'ERROR', // 错误消息
}
// ==================== 消息接口定义 ====================
/** 统一消息基接口 */
export interface BaseMessage {
type: MessageType;
payload: unknown;
timestamp: string;
sender: string;
id: string;
}
/** 认证消息:客户端连接后发送 */
export interface AuthMessage extends BaseMessage {
type: MessageType.AUTH;
payload: { nickname: string; password?: string };
}
/** 认证成功响应 */
export interface AuthOkMessage extends BaseMessage {
type: MessageType.AUTH_OK;
payload: { nickname: string; room: string };
}
/** 认证失败响应 */
export interface AuthFailMessage extends BaseMessage {
type: MessageType.AUTH_FAIL;
payload: { reason: string };
}
/** 聊天消息 */
export interface ChatMessage extends BaseMessage {
type: MessageType.CHAT;
payload: { content: string; room: string; encrypted?: boolean };
}
/** 私聊消息 */
export interface WhisperMessage extends BaseMessage {
type: MessageType.WHISPER;
payload: { target: string; content: string };
}
/** 系统通知 */
export interface SystemMessage extends BaseMessage {
type: MessageType.SYSTEM;
payload: { content: string; level?: 'info' | 'warning' | 'error' };
}
/** 加入房间 */
export interface JoinMessage extends BaseMessage {
type: MessageType.JOIN;
payload: { room: string; password?: string };
}
/** 离开房间 */
export interface LeaveMessage extends BaseMessage {
type: MessageType.LEAVE;
payload: { room: string };
}
/** 房间列表响应 */
export interface RoomListMessage extends BaseMessage {
type: MessageType.ROOM_LIST;
payload: {
rooms: { name: string; userCount: number; hasPassword: boolean }[];
};
}
/** 心跳检测 */
export interface PingMessage extends BaseMessage {
type: MessageType.PING;
payload: { timestamp: number };
}
/** 心跳回复 */
export interface PongMessage extends BaseMessage {
type: MessageType.PONG;
payload: { timestamp: number };
}
/** 用户上下线通知 */
export interface PresenceMessage extends BaseMessage {
type: MessageType.PRESENCE;
payload: { nickname: string; action: 'join' | 'leave'; room: string };
}
/** 错误消息 */
export interface ErrorMessage extends BaseMessage {
type: MessageType.ERROR;
payload: { code: string; message: string };
}
// ==================== 工具函数 ====================
export function generateUniqueId(): string {
return uuidv4();
}
// ==================== 类型守卫函数 ====================
export function isAuthMessage(msg: BaseMessage): msg is AuthMessage {
return msg.type === MessageType.AUTH;
}
export function isChatMessage(msg: BaseMessage): msg is ChatMessage {
return msg.type === MessageType.CHAT;
}
export function isWhisperMessage(msg: BaseMessage): msg is WhisperMessage {
return msg.type === MessageType.WHISPER;
}
export function isJoinMessage(msg: BaseMessage): msg is JoinMessage {
return msg.type === MessageType.JOIN;
}
export function isPongMessage(msg: BaseMessage): msg is PongMessage {
return msg.type === MessageType.PONG;
}
export function isPresenceMessage(msg: BaseMessage): msg is PresenceMessage {
return msg.type === MessageType.PRESENCE;
}
export function isErrorMessage(msg: BaseMessage): msg is ErrorMessage {
return msg.type === MessageType.ERROR;
}
4.4 代码解析
MessageType枚举:用字符串枚举定义所有消息类型。使用字符串而不是数字的好处是调试时可以直接从日志里看出消息类型。BaseMessage:所有消息的"骨架"。payload用unknown而不是any,强制我们在使用前先做类型判断。- 具体消息接口 :如
ChatMessage、AuthMessage,它们都extends BaseMessage,但把payload的类型收窄为具体的对象结构。 generateUniqueId:封装uuidv4(),方便统一替换生成策略。- 类型守卫 :以
isChatMessage为例,它检查msg.type === MessageType.CHAT,返回类型是msg is ChatMessage。后续在ChatServer里你会看到它的实际用法。
关于接口继承的深层考量: 你可能注意到每个具体接口都 extends BaseMessage,但又重新声明了 type 字段。这是 TypeScript 中一种称为"可辨识联合(Discriminated Union)"的模式。type 字段在这里充当"判别式(discriminant)",TypeScript 编译器通过它来判断一个联合类型的具体分支。当我们把所有消息类型组合成一个联合类型 type AllMessage = AuthMessage | ChatMessage | ... 时,只要检查 msg.type,编译器就能自动推断出 msg 对应的具体接口,从而精确知道 payload 里有哪些字段。这比使用类继承加 instanceof 判断要轻量得多,因为接口在编译后完全消失,不会增加任何运行时开销。
关于类型守卫:读者可能一次就注意到,我们的类型守卫写的过于简单了,并没有进行一个个属性级别的对比与类型收窄,这是因为这个项目只是简单的小项目,这种bug不需要考虑,仅仅体现了类型守卫就行。
五、用户模型与房间模型
这两个类是服务端的"数据结构",负责封装业务状态。
5.1 User.ts ------ 封装一个在线用户
一个用户对应一个 TCP 连接(Socket)。User 类封装了所有与用户状态相关的行为:发送消息、记录心跳、禁言检查等。
typescript
// src/server/User.ts
import { Socket } from 'net';
import { BaseMessage, MessageType } from '../shared/protocol';
import { generateUniqueId } from '../shared/protocol';
export enum UserRole {
MEMBER = 'MEMBER',
ADMIN = 'ADMIN',
MODERATOR = 'MODERATOR',
}
export interface UserMetadata {
joinTime: Date;
ip: string;
messageCount: number;
lastPongTime: Date;
}
export class User {
nickname: string; // 认证后赋值
readonly socket: Socket;
role: UserRole;
metadata: UserMetadata;
isAuthenticated: boolean = false;
mutedUnixTime: number = 0; // 禁言截止时间(秒级时间戳)
missedPings: number = 0; // 连续未回复心跳次数
currentRoom: string = ''; // 当前所在房间
constructor(socket: Socket, role: UserRole = UserRole.MEMBER) {
this.nickname = '';
this.socket = socket;
this.role = role;
this.metadata = {
joinTime: new Date(),//这个模块会直接获取当前时间
ip: socket.remoteAddress || 'unknown',
messageCount: 0,
lastPongTime: new Date(),
};
}
/** 发送任意消息 */
sendMessage(msg: BaseMessage): boolean {
try {
if (this.socket.destroyed || this.socket.closed) return false;
this.socket.write(JSON.stringify(msg) + '');//这里是一个缓冲区用来分割不同消息
return true;
} catch {
return false;
}
}
/** 发送系统通知 */
sendSystemMessage(content: string): void {
this.sendMessage({
type: MessageType.SYSTEM,
payload: { content },
timestamp: new Date().toISOString(),
sender: 'system',
id: generateUniqueId(),
} as BaseMessage);
}
/** 发送错误消息 */
sendError(code: string, message: string): void {
this.sendMessage({
type: MessageType.ERROR,
payload: { code, message },
timestamp: new Date().toISOString(),
sender: 'system',
id: generateUniqueId(),
} as BaseMessage);
}
/** 记录心跳回复 */
recordPong(): void {
this.metadata.lastPongTime = new Date();
this.missedPings = 0;
}
/** 增加未回复心跳计数 */
addMissedPing(): void {
this.missedPings++;
}
/** 检查是否被禁言 */
isMuted(): boolean {
if (this.mutedUnixTime === 0) return false;
return Date.now() / 1000 < this.mutedUnixTime;
}
/** 禁言用户(分钟) */
mute(minutes: number): void {
this.mutedUnixTime = Date.now() / 1000 + minutes * 60;
}
/** 检查心跳是否超时(传入毫秒) */
isHeartbeatTimedOut(timeoutMs: number): boolean {
return Date.now() - this.metadata.lastPongTime.getTime() > timeoutMs;
}
}
解析要点
-
sendMessage:核心方法。把消息对象序列化为 JSON,末尾加上,写入 socket。这里做了防御性检查:如果 socket 已销毁,直接返回false,避免异常抛到上层。为什么需要防御性检查? 在 TCP 网络编程中,Socket 的状态是异步变化的。当用户突然断网、关闭笔记本电脑、或者按下 Ctrl+C 终止客户端时,TCP 连接不会立即通知服务端。服务端可能还在尝试向这个"半死"的连接写入数据,此时
socket.write()会抛出异常,或者写入的数据会进入内核缓冲区但永远不会被对端接收。如果不捕获这些异常,整个 Node.js 进程可能因为一个未处理的Error事件而崩溃。我们在sendMessage中检查socket.destroyed和socket.closed,就是在做"前置防御"------在写入之前确认通道是否还活着。返回boolean而不是void,让上层调用者(如ChatServer.broadcast)知道这条消息是否成功发出,从而决定是否记录日志或重试。 -
sendSystemMessage/sendError:两个便捷方法,避免在业务代码里重复构造BaseMessage。便捷方法的价值不仅仅是少写代码。 在一个长期维护的项目中,消息格式可能会调整------比如某天我们需要给所有系统消息增加一个
level字段,或者把错误消息的code改成枚举类型。如果业务代码里散落着几十处手动构造BaseMessage的地方,修改将是一场噩梦。通过sendSystemMessage和sendError这样的封装,我们把"如何构造一条系统消息"的知识集中在User类内部。这遵循了"单一职责原则":User类知道如何与用户通信,而ChatServer只需要说"告诉这个用户某件事情",不需要关心消息的具体格式。 -
心跳相关 :
recordPong()在收到客户端 PONG 时调用,重置计时器;isHeartbeatTimedOut()在服务端定时检查时调用。心跳机制的设计: TCP 协议本身有保活机制,为什么我们还要自己实现心跳?因为操作系统级别的 TCP keep-alive 间隔通常很长(默认 2 小时),对于需要及时感知用户离线的聊天室来说太慢了。应用层心跳让我们可以自定义间隔(30 秒)和超时阈值(60 秒),实现秒级的故障检测。
lastPongTime使用Date对象而非原始时间戳数字,是因为Date提供了更丰富的 API(如toISOString()),便于日志记录。missedPings和lastPongTime是两个互补的指标:lastPongTime是绝对时间检查,用于判定"多久没回复了";missedPings是计数检查,用于判定"连续丢了多少次"。在某些实现中,可以允许偶尔丢一个包(比如网络抖动),只有连续丢 3 个才断开------这就是missedPings的用途。我们的实现中两者结合使用:先检查绝对时间是否超时,如果超时直接断开;如果没超时,发送新的 PING 并增加计数。 -
禁言 :用
mutedUnixTime存储解禁时间戳,isMuted()实时比较当前时间。为什么用 时间戳而不是
setTimeout? 禁言的本质是"在某个时间点之前不允许发言"。如果用setTimeout来解禁,当服务端重启时,所有的定时器都会丢失,用户将永远被禁言。而用时间戳存储在内存中,即使重启后数据丢,至少逻辑是一致的------重启后所有用户都是未禁言状态(相当于重置)。更重要的是,时间戳检查是"无状态"的:每次用户尝试发言时,只需要比较当前时间和截止时间,不需要维护任何定时器或回调。这在高并发场景下尤为重要,因为定时器是昂贵的资源,而比较操作是廉价的。
5.2 Room.ts ------ 管理一个聊天房间
typescript
// src/server/Room.ts
import { BaseMessage } from "../shared/protocol";
import { User } from "./User";
export class Room {
readonly name: string;
readonly createTime: Date;
private password?: string;
private users: Set<string> = new Set();
readonly messageHistory: MessageHistory;
private maxUsers: number = 100;
constructor(name: string, options?: { password?: string; maxUsers?: number }) {
this.name = name;
this.createTime = new Date();
if (options?.password) {
this.password = options.password;
}
if (options?.maxUsers) {
this.maxUsers = options.maxUsers;
}
this.messageHistory = new MessageHistory();
}
addUser(user: User): boolean {
if (this.users.size >= this.maxUsers) {
return false; // 房间已满
}
this.users.add(user.nickname);
return true;
}
removeUser(user: User): void {
this.users.delete(user.nickname);
}
getUserList(): string[] {
return Array.from(this.users);//直接通过集合构造数组
}
getUserCount(): number {
return this.users.size;
}
validatePassword(password: string): boolean {
if (!this.password) return true;
return this.password === password;
}
addMessage(msg: BaseMessage): void {
this.messageHistory.addMessage(msg);
}
getMessageHistory(count: number = 20): BaseMessage[] {
return this.messageHistory.getHistory(count);
}
getInfo() {
return {
name: this.name,
createTime: this.createTime,
hasPassword: !!this.password,
userCount: this.getUserCount(),
};
}
}
class MessageHistory {
private messages: BaseMessage[] = [];
private maxHistory: number = 100;
addMessage(msg: BaseMessage): void {
if (this.messages.length >= this.maxHistory) {
this.messages.shift();
}
this.messages.push(msg);
}
getHistory(count: number = 20): BaseMessage[] {
return this.messages.slice(-count);
}
}
解析要点
-
users: Set<string>:用Set存储房间内用户的昵称。选择昵称而不是User对象,是为了避免内存中持有过多对象引用,也方便通过昵称快速判断成员。内存管理视角的考量: 在 JavaScript 中,对象引用关系直接影响垃圾回收。如果
Room直接持有User对象的引用,而User又持有Socket引用,就会形成复杂的引用链。当用户离开房间时,如果Room忘记清理引用(比如代码有 bug),User对象和Socket对象可能永远无法被 GC 回收,造成内存泄漏。通过只存储nickname(字符串),Room对用户对象没有任何直接引用。ChatServer通过nicknamesMap 来管理User对象的生命周期,Room只关心"谁在房间里"这个名单。这种"解耦"设计让内存管理更清晰:断开连接时,只需要从ChatServer.users和ChatServer.nicknames中删除User,所有Room中的昵称字符串会在下次getUserList()调用时自然失效(因为User已经不存在了,虽然昵称还在Set里,但ChatServer.broadcast通过nicknames.get(nickname)查找时会得到undefined,从而跳过)。Set 而非 Array 的选择:
Set的add、delete和has操作都是 O(1) 时间复杂度,而Array的includes和indexOf是 O(n)。在聊天室场景中,用户频繁进出房间,我们需要快速判断"某用户是否在这个房间里"(比如防止重复加入)。Set天然去重,也天然支持高效查找。 -
MessageHistory:内部类,管理房间的最近消息。addMessage时如果超过 100 条就丢弃最旧的;getHistory返回最新的 N 条。用户加入房间时,服务端会把这 N 条历史消息推送给用户,让用户看到之前的对话上下文。消息历史的设计权衡: 100 条是一个经验值。太少(如 10 条)会导致用户进入房间后看不到有效上下文;太多(如 10000 条)会消耗大量内存,且
shift()操作的性能会下降。slice(-count)返回数组最后 N 个元素,这是一个 O(count) 的操作,且不会修改原数组。如果你需要更高性能,可以考虑用环形缓冲区(Ring Buffer)替代数组,实现真正的 O(1) 添加和删除。但在入门项目中,数组方案简单且足够。为什么要在加入房间时推送历史? 这是聊天室 UX 的核心需求。想象你进入一个已经活跃了半小时的频道,如果屏幕是空的,你会感到茫然;但如果能看到最近 20 条对话,你就能立即融入上下文。注意历史推送是"拉取"还是"推送"?在我们的实现中,是服务端主动推送(push)给刚加入的用户,而不是用户请求(pull)。这减少了交互步骤,让体验更流畅。
-
validatePassword:简单明了的明文密码比对。下篇我们会把它换成哈希存储。安全层次的演进思考: 当前使用明文比对是因为房间密码是"临时会话级"的,不像用户登录密码那样需要长期安全存储。但即使是临时密码,在日志中打印消息时也可能泄露。
六、服务端核心:ChatServer
ChatServer 是整个项目最复杂的部分。它的职责可以概括为:管理连接、解析消息、路由到对应的处理器。
6.1 整体架构
┌─────────────────────────────────────┐
│ ChatServer │
│ │
│ ┌─────────┐ ┌─────────────────┐ │
│ │ users │ │ nicknames │ │ ← Socket/User 映射、昵称索引
│ │(Map) │ │ (Map) │ │
│ └────┬────┘ └────────┬────────┘ │
│ │ │ │
│ ┌────┴────────────────┴────────┐ │
│ │ handleMessage() │ │ ← 消息路由中心
│ │ ┌────────┬────────┬───────┐ │ │
│ │ │handleAuth│handleChat│handleWhisper│handleJoin│...│ │
│ │ └────────┴────────┴───────┘ │ │
│ └───────────────────────────────┘ │
│ │
│ ┌───────────────────────────────┐ │
│ │ heartbeatTimer │ │ ← 定时心跳检测
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
6.2 代码实现
下面是聚焦基础功能的教学版代码:
typescript
// src/server/ChatServer.ts
import { createServer, Server, Socket } from "net";
import { User } from "./User";
import { Room } from "./Room";
import {
BaseMessage, MessageType, AuthMessage, ChatMessage, WhisperMessage, JoinMessage,
isAuthMessage, isChatMessage, isJoinMessage, isPongMessage, isWhisperMessage
} from '../shared/protocol';
import { v4 as uuidv4 } from 'uuid';
const HEARTBEAT_INTERVAL = parseInt(process.env.CHAT_HEARTBEAT_INTERVAL || '30000', 10);
const HEARTBEAT_TIMEOUT = parseInt(process.env.CHAT_HEARTBEAT_TIMEOUT || '60000', 10);
//这两行代码是读取环境变量中的心跳心跳配置并且当成默认值
export class ChatServer {
private server: Server;
private users: Map<Socket, User>;
private nicknames: Map<string, User>;
private rooms: Map<string, Room>;
private port: number;
private heartbeatTimer: NodeJS.Timeout | null = null;
constructor(port: number) {
this.port = port;
this.server = createServer();
this.users = new Map();
this.nicknames = new Map();
this.rooms = new Map();
this.rooms.set('Lobby', new Room('Lobby'));
}
public start(): void {
this.server.on('connection', (socket) => this.handleConnection(socket));
this.server.listen(this.port, () => {
console.log(`聊天服务器启动在端口 ${this.port}`);
});
this.heartbeatTimer = setInterval(() => this.sendPing(), HEARTBEAT_INTERVAL);
//每30秒箱所有用户发送一次心跳ping确定socket在线
}
public stop(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
//清理循环定时器,不清理node进程不会停止
this.heartbeatTimer = null;
}
this.server.close();
for (const [socket] of this.users) {
socket.destroy();
}
this.users.clear();
this.nicknames.clear();
this.rooms.clear();
}
生命周期管理
constructor:初始化三个Map,并创建默认的 "Lobby" 房间。start():注册connection事件监听器,开始监听端口;同时启动心跳定时器。stop():清理定时器、关闭服务器、销毁所有连接、清空内存数据。
三个 Map 的分工与协作: 为什么需要三个 Map,而不是一个?这是典型的"多索引"设计模式。users 以 Socket 为键,用于在 socket.on('data') 或 socket.on('close') 时快速找到对应的 User 对象------因为事件回调里我们拿到的是 Socket 实例。nicknames 以 nickname 为键,用于业务逻辑中的用户查找:比如私聊时通过昵称找目标用户,或者判断昵称是否已被占用。rooms 以房间名为键,管理房间本身。这三个 Map 构成了服务端的"索引系统",让不同场景下的查找都能达到 O(1) 时间复杂度。
为什么预创建 Lobby? 预创建默认房间有两个好处:第一,新用户认证后可以直接加入,不需要检查房间是否存在;第二, Lobby 作为"根房间",保证了系统始终有一个有效的广播目标。如果不预创建,在 handleAuth 中就需要写 if (!this.rooms.has('Lobby')) this.rooms.set('Lobby', new Room('Lobby')),这会增加代码复杂度和运行时开销。
心跳定时器的资源管理: NodeJS.Timeout 是 Node.js 的定时器句柄类型。在 stop() 中,我们不仅要 clearInterval,还要把 heartbeatTimer 设为 null。这是为了释放引用,帮助 GC,同时也防止重复调用 stop() 时尝试清理已经清理过的定时器。遍历 this.users 销毁 socket 时,注意我们使用了 for (const [socket] of this.users) 解构语法,只取 Map 的键(Socket),因为我们只需要销毁连接,不需要通过 User 对象做任何操作。
typescript
private sendPing(): void {
for (const [socket, user] of this.users) {
if (!user.isAuthenticated) continue;//跳过未认证用户
if (user.isHeartbeatTimedOut(HEARTBEAT_TIMEOUT)) {
console.log(`用户 ${user.nickname} 心跳超时`);
socket.destroy();
continue;
}
user.addMissedPing();
user.sendMessage({
type: MessageType.PING,
payload: { timestamp: Date.now() },
timestamp: new Date().toISOString(),
sender: 'system',
id: uuidv4()
} as BaseMessage);
}
}
心跳机制
每隔 HEARTBEAT_INTERVAL(默认 30 秒),服务端遍历所有在线用户:
- 跳过未认证的用户
- 检查该用户是否已经超时(
isHeartbeatTimedOut)------如果距离上次收到 PONG 超过 60 秒,直接socket.destroy()断开连接 - 如果没有超时,给该用户发送
PING消息,并把missedPings加 1
客户端收到 PING 后,会立即回复 PONG,User.recordPong() 会更新 lastPongTime 并清空 missedPings。
心跳状态机的详细解析: 心跳检测本质上是一个简单的状态机。每个已认证用户处于以下两种状态之一:"健康(Healthy)"或"可疑(Suspected)"。当用户刚认证时,状态为健康,lastPongTime 被设为当前时间。每隔 30 秒,服务端执行一次检查:首先看"绝对时间"------如果 Date.now() - lastPongTime > 60000,说明至少连续两个心跳周期(30秒×2)没有收到回复,此时直接判定为死亡,调用 socket.destroy()。这个 destroy() 是强制性的,它会立即释放内核资源,触发 socket.on('close') 事件,从而进入 handleDisconnect 流程。
如果绝对时间没有超时,说明用户至少在一个周期内回复过。但我们仍然增加 missedPings 计数,然后发送一个新的 PING。这里有一个微妙的设计:我们在发送 PING 之前就增加 missedPings,而不是等到超时后再增加。这意味着如果客户端在下一个周期内没有回复,missedPings 会继续累加。这种"先标记后验证"的模式,让 missedPings 成为一个连续的计数器,可以用于更复杂的策略(比如"允许丢 2 个包,第 3 个才断开")。在我们的当前实现中,虽然主要依赖绝对时间判断,但 missedPings 为日志和监控提供了额外信息------你可以打印 missedPings 来观察网络质量。
为什么跳过未认证用户? 未认证用户还没有进入聊天流程,他们只是在连接后发送昵称的阶段。如果此时对他们进行心跳检测,会增加不必要的复杂度。更重要的是,未认证连接可能是恶意扫描或端口探测,我们不想为这些连接浪费心跳资源。通常的做法是:如果用户在连接后 10 秒内没有发送 AUTH 消息,直接断开------这是另一种保护机制,可以在 handleConnection 中通过 setTimeout 实现。
typescript
private handleConnection(socket: Socket): void {
let buffer = '';
const user = new User(socket);
this.users.set(socket, user);
socket.on('data', (chunk) => {
buffer += chunk.toString();
const messages = buffer.split('');
buffer = messages.pop() || '';
for (const msgStr of messages) {
if (!msgStr.trim()) continue;
try {
const msg = JSON.parse(msgStr) as BaseMessage;
this.handleMessage(user, msg);
} catch (e) {
user.sendSystemMessage('格式错误!');
}
}
});
socket.on('close', () => this.handleDisconnect(socket));
socket.on('error', () => this.handleDisconnect(socket));
}
粘包处理(核心技巧)
TCP 是流式协议 ,socket.on('data') 收到的 chunk 不保证是一条完整消息------可能半条,也可能多条粘在一起。
我们的解决方案是按行分割:
- 把新收到的数据追加到
buffer - 用
split(' ')按换行符分割 pop()取出最后一段(可能是不完整的半条消息),留到下次处理- 前面的都是完整消息,逐条
JSON.parse
这是实现 JSON 行协议最经典的方式。
TCP 流式特性的深层理解: 这是网络编程中最容易让初学者困惑的概念。TCP 协议是"面向字节流"的,它只保证数据按顺序到达,不保证数据的边界。当你连续发送两条消息 msg1 和 msg2 时,接收方可能一次收到 msg1 msg2 ,也可能分三次收到 msg1、_half、_msg2 。这种现象称为"粘包"和"半包"。它的根本原因是 TCP 的 Nagle 算法和内核缓冲区机制:操作系统为了提高网络效率,会把小的数据包合并发送,也会把收到的数据暂存后批量交给应用层。
为什么用换行符分割而不是固定长度头? 网络协议通常有两种分包策略:一是"长度前缀"(Length-Prefixed),即在每条消息前加上 4 字节的长度字段;二是"分隔符"(Delimiter-Based),如我们的换行符方案。长度前缀更通用,可以传输二进制数据(包括换行符本身),但实现稍微复杂,需要处理字节序(Endianness)和缓冲区长度计算。换行符方案简单、人类可读(你可以直接用 telnet 或 nc 命令测试服务端),且 JSON 文本本身不会包含裸换行符(JSON 字符串中的换行会被转义为 )。对于入门学习来说,换行符方案是最佳选择。
buffer 变量的生命周期: 注意 buffer 是在 handleConnection 作用域内定义的局部变量,每个 Socket 连接都有自己独立的 buffer。这是关键------如果用一个全局 buffer 来处理所有连接的数据,不同连接的数据会混在一起,导致解析失败。Node.js 的闭包机制让每个连接的回调函数都能访问自己专属的 buffer,这是事件驱动编程的天然优势。
typescript
private handleMessage(user: User, msg: BaseMessage): void {
if (!user.isAuthenticated) {
if (isAuthMessage(msg)) {
this.handleAuth(user, msg as AuthMessage);
} else {
user.sendSystemMessage('请先认证!');
}
return;
}
if (isChatMessage(msg)) {
this.handleChat(user, msg as ChatMessage);
} else if (isJoinMessage(msg)) {
this.handleJoin(user, msg as JoinMessage);
} else if (isPongMessage(msg)) {
user.recordPong();
} else if (isWhisperMessage(msg)) {
this.handleWhisper(user, msg as WhisperMessage);
} else {
user.sendSystemMessage('未知消息类型!');
}
}
消息路由
handleMessage 是整个服务端的消息分发中心:
- 如果用户未认证,只允许
AUTH消息,其他一律拒绝 - 已认证用户,根据消息类型分发到对应的处理器
- 这里大量使用了前面协议层定义的类型守卫(
isChatMessage、isJoinMessage等)
认证状态机的设计: 聊天系统本质上是一个状态机。每个连接从"未认证(Unauthenticated)"状态开始,只有在收到有效的 AUTH 消息后,才转移到"已认证(Authenticated)"状态。在状态机设计中,"状态"决定了"允许的行为"。handleMessage 开头的 if (!user.isAuthenticated) 检查就是状态机的守卫条件(Guard Condition)。这种设计防止了未认证用户发送垃圾消息或尝试加入房间,是安全的第一道防线。
类型守卫的实战价值: 注意 handleMessage 的参数类型是 BaseMessage,这意味着 TypeScript 编译器只知道 msg 有 type、payload(unknown)、timestamp、sender、id 这几个字段。如果我们直接写 msg.payload.content,编译器会报错,因为 unknown 类型没有 content 属性。通过 isChatMessage(msg) 判断后,在 if 分支内部,TypeScript 自动把 msg 收窄为 ChatMessage,此时 msg.payload.content 是合法的。这种"先证明,后使用"的模式,让代码在重构时非常安全------比如如果你把 ChatMessage 的 payload 字段从 content 改成 text,所有使用了 isChatMessage 的地方,TypeScript 都能帮你找到并报错,而不会因为 as any 或 as ChatMessage 的强制转换而遗漏。
typescript
private handleAuth(user: User, msg: AuthMessage): void {
const nickname = msg.payload.nickname.trim();
if (!nickname || nickname.length > 20) {
user.sendError('INVALID_NICKNAME', '昵称长度 1-20 字符');
return;
}
if (this.nicknames.has(nickname)) {
user.sendError('NICKNAME_TAKEN', '昵称已被占用');
return;
}
user.nickname = nickname;
user.isAuthenticated = true;
this.nicknames.set(nickname, user);
const lobby = this.rooms.get('Lobby')!;
if (!lobby.addUser(user)) {
user.sendError('ROOM_FULL', '大厅已满,无法加入');
return;
}
user.currentRoom = 'Lobby';
user.sendMessage({
type: MessageType.AUTH_OK,
payload: { nickname, room: 'Lobby' },
timestamp: new Date().toISOString(),
sender: 'system',
id: uuidv4()
} as BaseMessage);
this.broadcast('Lobby', {
type: MessageType.PRESENCE,
payload: { nickname, action: 'join', room: 'Lobby' },
timestamp: new Date().toISOString(),
sender: 'system',
id: uuidv4()
} as BaseMessage, user);
}
认证流程
- 检查昵称合法性(1-20 字符)
- 检查昵称是否已被占用(查
nicknamesMap) - 把用户标记为已认证,加入
nicknames索引 - 把用户加入 Lobby 房间
- 给用户发送
AUTH_OK,告诉它认证成功、当前在 Lobby - 向 Lobby 内其他用户广播
PRESENCE消息,通知大家"有人上线了"
并发安全的隐式保证: 你可能担心:如果两个客户端同时发送相同的昵称,会不会都通过检查?在 Node.js 中,这是不可能的。Node.js 是单线程事件循环模型,所有 JavaScript 代码都在同一个线程上执行,不存在真正的并行。this.nicknames.has(nickname) 和 this.nicknames.set(nickname, user) 是原子操作------在它们之间不会有其他事件插入。这与多线程语言(如 Java、C++)形成鲜明对比,在那些语言中,这段代码需要用锁(Lock)或同步块(Synchronized)来保护。Node.js 的异步 I/O 虽然并发量高,但业务逻辑的执行是顺序的,这让很多并发问题天然消失。
认证后的资源分配顺序: 注意代码的执行顺序:先设置 user.nickname 和 isAuthenticated,再添加到 nicknames Map,最后加入 Lobby。这个顺序是精心设计的。如果先加入 Lobby 再设置 nickname,Room.addUser 会添加一个空字符串到 users Set 中。如果先加入 nicknames 再设置 isAuthenticated,在极端情况下(虽然 Node.js 单线程下不会发生,但逻辑上)可能导致一个"半认证"状态的用户存在于索引中。
广播的排除参数: this.broadcast('Lobby', ..., user) 的最后一个参数是 exclude,表示"不给这个用户发送"。为什么认证成功的用户不需要收到自己的 PRESENCE 消息?因为客户端在收到 AUTH_OK 时已经知道自己上线了,再收到 "Alice 进入了 Lobby" 是冗余信息。这种排除机制让通知更精确,减少客户端的处理负担。
typescript
private handleChat(user: User, msg: ChatMessage): void {
if (user.isMuted()) {
user.sendError('MUTED', '你已被禁言');
return;
}
const roomName = msg.payload.room || user.currentRoom;
const room = this.rooms.get(roomName);
if (!room) {
user.sendError('ROOM_NOT_FOUND', '房间不存在');
return;
}
const fullMsg = {
type: msg.type,
payload: msg.payload,
sender: user.nickname,
timestamp: new Date().toISOString(),
id: uuidv4()
} as ChatMessage;
room.addMessage(fullMsg as BaseMessage);
this.broadcast(roomName, fullMsg as BaseMessage, user);
user.metadata.messageCount++;
}
聊天广播
- 检查用户是否被禁言
- 确定目标房间(用户指定,或默认当前房间)
- 构造完整消息(补上 sender、timestamp、id)
- 把消息存入房间的内存历史
- 调用
broadcast()发送给房间内除自己以外的所有人
为什么不信任客户端发送的 sender 和 timestamp? 这是一个关键的安全原则。客户端发送的 CHAT 消息中,虽然 BaseMessage 接口要求有 sender 和 timestamp,但我们在服务端重新构造 fullMsg 时,用 user.nickname 覆盖了客户端提供的 sender,用 new Date().toISOString() 覆盖了客户端的时间戳。为什么?因为客户端是不可信的。恶意客户端可以伪造发送者(冒充别人说话)或伪造时间戳(让消息显示为未来或过去的时间)。服务端作为权威源(Source of Truth),必须重新验证和补全所有元信息。只有 payload 中的业务数据(如 content)是客户端提供的,但即使如此,你也应该对 content 做长度限制和敏感词过滤(本篇未实现,但生产环境必须做)。
消息存储与广播的顺序: 代码先执行 room.addMessage(),再执行 this.broadcast()。这个顺序重要吗?在大多数情况下不重要,因为两者都是内存操作,速度极快。但在极端高并发下,如果先广播后存储,可能出现"用户收到了消息,但查询历史时找不到"的短暂不一致。先存储后广播保证了"数据先落地,再通知",符合事务处理的基本逻辑(虽然这里没有真正的事务)。
typescript
private handleWhisper(user: User, msg: WhisperMessage): void {
const target = this.nicknames.get(msg.payload.target);
if (!target) {
user.sendError('USER_NOT_FOUND', '目标用户不在线');
return;
}
const fullMsg = {
type: msg.type,
payload: msg.payload,
sender: user.nickname,
timestamp: new Date().toISOString(),
id: uuidv4()
} as WhisperMessage;
this.sendTo(target, fullMsg as BaseMessage);
user.sendSystemMessage(`私聊已发送给 ${msg.payload.target}`);
}
私聊路由
私聊的本质是点对点路由:
- 通过
nicknamesMap 查找目标用户 - 如果不在线,返回错误
- 如果在线,直接调用
sendTo(target, msg)发送给对方
注意:私聊消息不经过房间广播,也不存入房间历史。
私聊的架构意义: 私聊是聊天室从"多播(Multicast)"到"单播(Unicast)"的演进。在房间广播中,消息被发送给房间内的所有用户(排除发送者),这是一种一对多的通信模式。而私聊是一对一。这种差异在架构上体现为:广播需要遍历 Room.users 集合,而私聊直接通过 nicknames 索引查找。不经过房间广播意味着私聊消息不会打扰房间内的其他用户,也不会污染房间的历史记录------私聊是用户之间的独立对话,与房间上下文无关。
发送者确认机制: 注意我们给发送者回了一条系统消息"私聊已发送给 xxx"。这在 UX 上很重要,因为私聊消息不像房间广播那样会回显在发送者自己的屏幕上(房间广播排除发送者,但发送者知道自己说了什么;私聊则完全不会出现在任何公共界面)。如果没有这条确认,发送者会不确定消息是否真的发出去了。在更完善的实现中,可以引入"消息回执(Acknowledgment)"机制:接收者收到私聊后回复一个 ACK,发送者看到 ACK 后显示"已送达"或"已读"。
typescript
private handleJoin(user: User, msg: JoinMessage): void {
const newRoomName = msg.payload.room;
const oldRoomName = user.currentRoom;
if (newRoomName === oldRoomName) return;
let newRoom = this.rooms.get(newRoomName);
if (!newRoom) {
newRoom = new Room(newRoomName, { password: msg.payload.password });
this.rooms.set(newRoomName, newRoom);
}
if (!newRoom.validatePassword(msg.payload.password || '')) {
user.sendError('WRONG_PASSWORD', '房间密码错误');
return;
}
if (oldRoomName) {
const oldRoom = this.rooms.get(oldRoomName);
if (oldRoom) {
oldRoom.removeUser(user);
this.broadcast(oldRoomName, {
type: MessageType.PRESENCE,
payload: { nickname: user.nickname, action: 'leave', room: oldRoomName },
timestamp: new Date().toISOString(),
sender: 'system',
id: uuidv4()
} as BaseMessage);
}
}
if (!newRoom.addUser(user)) {
user.sendError('ROOM_FULL', '房间已满,无法加入');
return;
}
user.currentRoom = newRoomName;
const history = newRoom.getMessageHistory(20);
history.forEach(h => user.sendMessage(h));
this.broadcast(newRoomName, {
type: MessageType.PRESENCE,
payload: { nickname: user.nickname, action: 'join', room: newRoomName },
timestamp: new Date().toISOString(),
sender: 'system',
id: uuidv4()
} as BaseMessage, user);
}
房间切换
- 如果房间不存在,自动创建 (这就是用户能通过
/join 任意房间名创建房间的机制) - 检查密码
- 从旧房间移除用户,向旧房间广播"离开"通知
- 向新房间添加用户,把最近 20 条历史消息推送给该用户(让他看到上下文)
- 向新房间广播"进入"通知
原子性与一致性考量: 房间切换涉及多个状态变更:从旧房间移除、向新房间添加、更新 user.currentRoom。这些操作不是原子性的------如果代码在旧房间移除后、新房间添加前崩溃(虽然 Node.js 单线程下几乎不可能,但逻辑上要考虑),用户会处于"无家可归"的状态。在我们的实现中,这种风险很低,因为所有操作都是同步的内存操作。但在分布式系统或数据库持久化的场景中,这需要用事务来保证。当前实现通过操作顺序来最小化不一致窗口:先清理旧房间,再设置新房间,最后广播通知。这样即使在最坏情况下,用户也只是短暂地不在任何房间,而不会同时出现在两个房间。
历史消息推送的 UX 设计: 用户加入房间后立即收到最近 20 条消息,这是一种"上下文恢复"机制。在 IRC、Discord 等真实聊天系统中,历史消息的加载策略有多种:一种是"推送"(如我们这样,服务端主动推),另一种是"拉取"(客户端滚动到顶部时请求更多)。推送模式适合小量历史(20-50 条),让用户立即有参与感;拉取模式适合大量历史,避免首次加入时的流量 burst。我们的 history.forEach(h => user.sendMessage(h)) 会连续发送 20 条消息,这在本地网络中很快,但在广域网中可能需要考虑批量打包或延迟发送,以避免瞬间占用大量带宽。
房间自动创建的利弊: 允许 /join 任意房间名 自动创建房间,极大地降低了用户创建房间的门槛------不需要专门的 /create 命令。但这种设计也有问题:如果用户输入 /join 后面跟了一个打字错误(如 /join gamr),系统会默默创建一个名为 gamr 的新房间,而不是提示"房间不存在"。在更严格的系统中,你可能希望区分"创建"和"加入",或者要求房间名符合某种规范(如最小长度、禁止特殊字符)。
typescript
private handleDisconnect(socket: Socket): void {
const user = this.users.get(socket);
if (!user || !user.isAuthenticated) {
this.users.delete(socket);
socket.destroy();
return;
}
if (user.currentRoom) {
const room = this.rooms.get(user.currentRoom);
if (room) {
room.removeUser(user);
this.broadcast(user.currentRoom, {
type: MessageType.PRESENCE,
payload: {
nickname: user.nickname,
action: 'leave',
room: user.currentRoom
},
timestamp: new Date().toISOString(),
sender: 'system',
id: uuidv4()
} as BaseMessage);
}
}
this.nicknames.delete(user.nickname);
this.users.delete(socket);
socket.destroy();
}
public broadcast(roomName: string, msg: BaseMessage, exclude?: User): void {
const room = this.rooms.get(roomName);
if (!room) return;
for (const nickname of room.getUserList()) {
const target = this.nicknames.get(nickname);
if (!target || target === exclude) continue;
target.sendMessage(msg);
}
}
public sendTo(user: User, msg: BaseMessage): void {
user.sendMessage(msg);
}
}
断开连接与广播
handleDisconnect:用户断开时,从房间移除、广播离开通知、清理users和nicknames索引broadcast:遍历房间内的所有昵称,通过nicknamesMap 找到对应的User对象,逐个发送消息。exclude参数用于排除发送者自己sendTo:单播,直接调用目标用户的sendMessage
资源清理的顺序艺术: 在 handleDisconnect 中,清理顺序是:先从房间移除并广播,再从 nicknames 删除,最后从 users 删除并销毁 socket。这个顺序很关键。如果我们先 this.users.delete(socket),那么在后续的广播操作中,如果需要通过 socket 查找 user(虽然这里不需要,但逻辑上),就会失败。更重要的是,我们先广播离开通知,此时 user 对象和 nicknames 索引都还在,广播函数能正常工作。如果先清理 nicknames,broadcast 内部通过 this.nicknames.get(nickname) 查找用户时,发送者自己已经被删除,但其他用户还在------这实际上不影响其他用户的通知,但逻辑上显得混乱。保持"先通知,后清理"的顺序,让系统在任何时刻都处于相对一致的状态。
socket.destroy() 的终极意义: 在 handleDisconnect 的最后,我们调用 socket.destroy()。这与 socket.end() 不同:socket.end() 是优雅关闭,发送 FIN 包,等待对端确认;socket.destroy() 是强制销毁,立即释放所有资源。在断开处理中,我们不需要优雅,因为连接已经死了(close 事件已经触发)。destroy() 确保没有残留的句柄或缓冲区占用内存。注意我们在 handleConnection 的 error 事件中也调用了 handleDisconnect,因为 TCP 错误(如连接重置)也会触发 error 事件,然后可能不触发 close 事件,所以我们需要统一处理。
broadcast 的防御性遍历: 在 broadcast 中,我们通过 room.getUserList() 拿到昵称数组,然后用 this.nicknames.get(nickname) 查找对应的 User。这里有一个防御性检查:if (!target || target === exclude) continue。!target 的检查是为了处理一种边界情况:如果某个用户已经断开连接,但 Room 中的 users Set 还没有及时清理(比如 handleDisconnect 和 broadcast 并发执行,虽然单线程下不太可能),nicknames.get() 会返回 undefined。这个检查让广播函数更加健壮,不会因为一个"幽灵用户"而崩溃。
七、客户端:ChatClient
客户端的职责比服务端简单很多:连接服务器、接收并展示消息、读取用户输入并发送。
typescript
// src/client/client.ts
import { createConnection, Socket } from 'net';
import * as readline from 'readline';
import {
BaseMessage, MessageType, AuthMessage, ChatMessage, WhisperMessage, JoinMessage
} from '../shared/protocol';
import { v4 as uuidv4 } from 'uuid';
export class ChatClient {
private socket: Socket;
private rl: readline.Interface;
private nickname: string = '';
private currentRoom: string = 'Lobby';
private buffer: string = '';
private chatStarted: boolean = false;
constructor(host: string, port: number) {
this.socket = createConnection({ host, port });
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
this.setupSocket();
}
private setupSocket(): void {
this.socket.on('connect', () => {
console.log('已连接到服务器');
this.promptNickname();
});
this.socket.on('data', (chunk: Buffer) => {
this.buffer += chunk.toString();
const lines = this.buffer.split('');
this.buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const msg = JSON.parse(line) as BaseMessage;
this.handleMessage(msg);
} catch {
// 忽略解析失败
}
}
});
this.socket.on('close', () => {
console.log('连接已断开');
this.rl.close();
process.exit(0);
});
this.socket.on('error', (err) => {
console.log('连接错误:', err.message);
process.exit(1);
});
}
Socket 事件处理
和服务端类似,客户端也使用 buffer + split(' ') 来处理粘包。
客户端与服务端粘包处理的对称性: 客户端的 data 事件处理逻辑与服务端几乎完全一致。这体现了协议的对称性------既然双方约定用 分隔 JSON 消息,那么无论哪一方接收数据,都需要同样的解码逻辑。这种对称性让代码更容易维护:你只需要理解一次粘包处理,就能同时理解两端。lines.pop() || '' 的处理确保了如果最后一段是空字符串(刚好收到完整消息,末尾的 导致 split 产生一个空尾元素),buffer 会被正确重置为空,而不是保留一个 undefined。
错误处理策略: 客户端的 error 事件直接调用 process.exit(1),而服务端的 error 事件调用 handleDisconnect。为什么不对称?因为客户端是"末端节点"------如果连接出错,客户端程序没有继续运行的意义(它不能提供服务给其他节点)。而服务端需要保持运行,不能因为一个客户端的错误而崩溃。process.exit(1) 中的 1 是 Unix 退出码,表示"异常退出",这对脚本化和自动化测试很重要。
typescript
private promptNickname(): void {
if ((this.rl as any).closed) return;
this.rl.question('请输入昵称: ', (name) => {
const trimmed = name.trim();
if (!trimmed) {
console.log('昵称不能为空');
this.promptNickname();
return;
}
this.nickname = trimmed;
const auth: AuthMessage = {
type: MessageType.AUTH,
payload: { nickname: trimmed },
timestamp: new Date().toISOString(),
sender: '',
id: uuidv4()
};
this.send(auth);
});
}
连接成功后,客户端先提示用户输入昵称,然后发送 AUTH 消息。如果服务端回复 AUTH_FAIL,客户端会重新提示输入。
递归式重试模式: promptNickname 使用了递归调用(自己调用自己)来实现输入验证。如果用户输入为空,打印提示后再次调用 promptNickname。这在命令行交互中是一种简洁的重试模式。注意递归深度在这里是安全的,因为用户每次输入都会触发新的异步回调,不会导致调用栈溢出。if ((this.rl as any).closed) return 是一个防御性检查:如果 readline 接口已经关闭(比如用户按了 Ctrl+C),不再尝试提问。
为什么 sender 为空字符串? 在 AuthMessage 中,sender 字段被设为空字符串。这是因为客户端在认证前还没有被服务端承认,严格来说还没有"发送者身份"。服务端在 handleAuth 中会忽略客户端提供的 sender,使用 msg.payload.nickname 作为身份标识。这种"占位符"设计让消息结构保持一致,同时表明"此消息尚未获得权威身份"。
typescript
private startChat(): void {
if (this.chatStarted) return;
this.chatStarted = true;
this.rl.setPrompt(` ${this.nickname}[${this.currentRoom}]> `);
this.rl.prompt();
this.rl.on('line', (input) => {
const line = input.trim();
if (!line) {
this.rl.prompt();
return;
}
if (line.startsWith('/')) {
const needPrompt = this.handleCommand(line);
if (needPrompt) this.rl.prompt();
} else {
const msg: ChatMessage = {
type: MessageType.CHAT,
payload: {
content: line,
room: this.currentRoom
},
timestamp: new Date().toISOString(),
sender: this.nickname,
id: uuidv4()
};
this.send(msg);
this.rl.prompt();
}
});
}
startChat() 是客户端的核心交互循环:
- 设置命令行提示符(如
Alice[Lobby]>) - 进入
readline的line事件监听 - 如果输入以
/开头,交给handleCommand处理 - 否则视为普通聊天消息,发送
CHAT
chatStarted 标志的必要性: startChat 只应在认证成功后调用一次。但 handleMessage 中收到 AUTH_OK 时可能会因为网络重连或其他边界情况被多次触发。chatStarted 标志是一个简单的"一次性开关",防止重复注册 rl.on('line') 事件监听器。如果重复注册,每次用户输入会触发多个处理函数,导致消息被发送多次或提示符混乱。这种"幂等性"设计在事件驱动编程中非常常见。
提示符的 UX 设计: this.rl.setPrompt() 设置了命令行左侧的提示文本。Alice[Lobby]> 这种格式让用户随时知道"我是谁"和"我在哪"。注意提示符末尾有一个空格,这是为了让用户输入的内容与提示符有视觉分隔。如果没有空格,输入会紧贴 ],影响可读性。this.rl.prompt() 在设置提示符后立即显示它,并将光标定位在提示符之后。
普通消息与命令的分流: 客户端通过 line.startsWith('/') 来区分命令和普通聊天消息。这是一种约定俗成的 CLI 设计(类似 IRC、Minecraft 控制台等)。所有命令以 / 开头,其他内容视为聊天。这种分流发生在客户端,意味着命令的解析逻辑分布在客户端和服务端两端:客户端负责识别命令并构造对应的消息类型(如 JOIN、WHISPER),服务端负责执行命令的业务逻辑(如切换房间、查找目标用户)。这种分工让客户端更"智能",减少了服务端的解析负担。
typescript
private handleCommand(line: string): boolean {
const parts = line.slice(1).split(' ');
const cmd = parts[0].toLowerCase();
switch (cmd) {
case 'quit':
case 'q':
this.socket.end();
return false;
case 'w':
case 'whisper':
if (parts.length < 3) {
console.log('用法: /w <昵称> <内容>');
break;
}
const target = parts[1];
const content = parts.slice(2).join(' ');
const whisper: WhisperMessage = {
type: MessageType.WHISPER,
payload: { target, content },
timestamp: new Date().toISOString(),
sender: this.nickname,
id: uuidv4()
};
this.send(whisper);
break;
case 'join':
if (parts.length < 2) {
console.log('用法: /join <房间名>');
break;
}
const roomName = parts[1];
const join: JoinMessage = {
type: MessageType.JOIN,
payload: { room: roomName },
timestamp: new Date().toISOString(),
sender: this.nickname,
id: uuidv4()
};
this.send(join);
break;
default:
console.log('未知命令:', cmd);
}
return true;
}
命令解析
/quit或/q:关闭 socket,退出程序/w <昵称> <内容>:构造WHISPER消息发送/join <房间名>:构造JOIN消息发送
命令解析的字符串处理: line.slice(1) 去掉开头的 /,然后用 split(' ') 按空格分割。这里有一个细节:split(' ') 会把多个连续空格也当成分隔符,产生空字符串元素。这在大多数情况下没问题,因为 parts[0] 是命令,parts[1] 是第一个参数。但对于 /w 命令,内容部分可能包含空格,所以我们用 parts.slice(2).join(' ') 来重组内容。这种"前两个词是命令和参数,后面全是内容"的假设,是命令行解析中最简单实用的策略。如果你需要更复杂的解析(如支持引号包裹的参数),就需要引入正则表达式或专门的命令解析库(如 commander.js)。
命令别名的设计: quit 和 q 都映射到同一个操作,w 和 whisper 也是。这是为了提升用户体验:常用命令提供短别名,减少打字量。在 switch 中,我们让多个 case 共享同一个代码块(通过不 break 直接 fall-through),这是 JavaScript switch 语句的一个经典技巧。
返回值语义: handleCommand 返回 boolean,表示"是否需要重新显示提示符"。quit 命令返回 false,因为程序即将退出,不需要再显示 >。其他命令返回 true,表示处理完后继续交互。这种设计让 startChat 中的事件循环更清晰:它不需要知道每个命令的具体逻辑,只需要知道"命令处理完后是否继续"。
typescript
private handleMessage(msg: BaseMessage): void {
switch (msg.type) {
case MessageType.AUTH_OK:
console.log('🎉 认证成功!');
this.startChat();
break;
case MessageType.AUTH_FAIL:
console.log('❌ 认证失败:', (msg as any).payload?.reason || '未知原因');
this.promptNickname();
break;
case MessageType.CHAT:
const chatPayload = (msg as ChatMessage).payload;
console.log(`
[${msg.sender}] ${chatPayload.content}`);
break;
case MessageType.WHISPER:
const whisperPayload = (msg as WhisperMessage).payload;
console.log(`
[私聊 ${msg.sender}→你] ${whisperPayload.content}`);
break;
case MessageType.SYSTEM:
console.log(`
[System] ${(msg as any).payload?.content || ''}`);
break;
case MessageType.PRESENCE:
const p = (msg as any).payload;
console.log(`
👤 ${p.nickname} ${p.action === 'join' ? '进入' : '离开'}了 ${p.room}`);
break;
case MessageType.PING:
this.send({
type: MessageType.PONG,
payload: { timestamp: Date.now() },
timestamp: new Date().toISOString(),
sender: this.nickname,
id: uuidv4()
});
break;
case MessageType.ERROR:
console.log(`
[Error] ${(msg as any).payload?.message || ''}`);
break;
default:
break;
}
}
private send(msg: BaseMessage): void {
this.socket.write(JSON.stringify(msg) + '
');
}
}
handleMessage 根据消息类型打印不同的格式:
CHAT:显示发送者和内容WHISPER:标注为私聊SYSTEM:标注为系统消息PRESENCE:显示用户上下线动作PING:自动回复PONG,用户无感知ERROR:显示错误提示
消息展示的 UX 层次: 客户端用不同的前缀和格式来区分消息类型,让用户一眼就能识别信息的重要性和来源。[Alice] 你好 是普通聊天,[私聊 Bob→你] 秘密 是私聊,[System] 欢迎 是系统通知,👤 Alice 进入了 Lobby 是状态变更。 前缀确保消息不会粘在当前提示符后面,而是另起一行显示。显示完消息后,readline 会自动重新绘制提示符(因为 line 事件处理完后会调用 rl.prompt()),但注意对于 PING、PRESENCE 等异步到达的消息,客户端在 handleMessage 中没有调用 rl.prompt()------这意味着消息会显示在屏幕上,但提示符不会立即重绘。这在 Node.js readline 中是可以接受的,因为用户下一次输入时提示符会自然出现。
PING 的无感知处理: 当客户端收到 PING 时,它立即回复 PONG,但不在屏幕上显示任何内容。这是心跳机制的设计意图:用户不应该感知到心跳的存在。如果每次心跳都在客户端打印 "收到 PING,发送 PONG",屏幕会被垃圾信息填满。Date.now() 被用于 PONG 的 payload.timestamp,让服务端可以计算往返时间(RTT),虽然当前服务端没有利用这个信息,但为未来的性能监控预留了数据。
send 方法的封装: 客户端的 send 方法与服务端的 User.sendMessage 非常相似,都是 JSON.stringify 加 。但客户端不需要检查 socket.destroyed,因为客户端在 socket.on('close') 中已经处理了断开情况,且客户端是单连接的,如果 socket 已死,程序应该已经退出了。这种简化是合理的,因为客户端和服务端的职责不同。
八、入口与运行方式
服务端入口
typescript
// src/server/index.ts
import * as dotenv from 'dotenv';
dotenv.config();
import { ChatServer } from './ChatServer';
const PORT = parseInt(process.env.CHAT_SERVER_PORT || '3000', 10);
const server = new ChatServer(PORT);
server.start();
客户端入口
typescript
// src/client/index.ts
import { ChatClient } from './client';
const HOST = process.env.CHAT_CLIENT_HOST || '127.0.0.1';
const PORT = parseInt(process.env.CHAT_CLIENT_PORT || '3000', 10);
try {
new ChatClient(HOST, PORT);
} catch (err) {
console.error('启动失败:', err);
}
运行步骤
bash
# 1. 安装依赖
npm install
# 2. 编译 TypeScript
npm run build
# 3. 启动服务端
npm run start:server
# 4. 新开一个终端,启动客户端
npm run start:client
九、总结与下篇预告
本篇回顾
通过这篇博客,我们实现了一个完整的命令行 TCP 聊天室,涵盖了网络编程最核心的知识点:
| 知识点 | 说明 |
|---|---|
| TCP 长连接 | net 模块创建服务端与客户端,基于 Socket 收发数据 |
| 粘包处理 | 使用 `buffer + split(' |
| ')` 解决 TCP 流式传输的边界问题 | |
| JSON 行协议 | 简单、可读、易调试的应用层协议设计 |
| 类型守卫 | TypeScript 收窄技巧,安全处理多种消息类型 |
| 心跳保活 | 定时 Ping + 超时检测,及时清理死连接 |
| 房间广播 | 基于 Map + Set 的内存索引,实现高效的定向消息推送 |
| 私聊路由 | 点对点消息分发,不经过房间广播 |
当前局限
本篇为了聚焦网络通信,刻意简化了几个地方:
- 数据全在内存:服务端重启后,用户、房间、消息历史全部丢失
- 没有真正的用户体系:昵称就是唯一标识,没有密码、没有注册登录
- 消息历史有限:每个房间只保留最近 100 条内存消息
- 密码明文存储:房间的密码直接比对明文,没有哈希处理