用 TypeScript 从零写一个 TCP 聊天室(上)—— 网络编程入门实战
用 TypeScript 从零写一个 TCP 聊天室(上)——网络编程入门实战
想入门网络编程?这篇文章适合你。我们用纯 TypeScript + Node.js 实现一个命令行聊天室,支持多房间、私聊、心跳保活。这篇只关注"网络通信"本身,数据全部存在内存里。用户注册、数据持久化、历史消息分页这些,留给下篇。说实话,下篇我也在写,数据持久化这部分确实有点烦。
零、效果预览
开两个终端,一个跑服务端,一个跑客户端:
# 终端 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 模块,监听端口,接受多个客户端连接 |
| 昵称认证 | 客户端连上后先报昵称,服务端检查是否重复,通过后才让进 |
| 大厅与房间 | 默认有个 “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 对象
- 对象末尾加换行符
\n - 接收方按行读取,逐条解析 JSON
比如客户端发一条聊天消息:
{"type":"CHAT","payload":{"content":"你好","room":"Lobby"},"timestamp":"2024-01-01T00:00:00.000Z","sender":"Alice","id":"550e8400-e29b-41d4-a716-446655440000"}
实现简单、人类可读、调试方便,适合入门。
二、核心依赖和关键 API
动手之前,熟悉几个要用到的模块和库。
2.1 net —— Node.js 网络模块
net 是 Node.js 内置模块,提供 TCP socket 通信。
创建服务端
import { createServer, Server, Socket } from 'net';
const server: Server = createServer();
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) |
方法 | 发送数据,可以是字符串或 Buffer |
socket.destroy() |
方法 | 强制销毁连接,释放资源 |
socket.remoteAddress |
属性 | 对端 IP 地址 |
创建客户端连接
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!\n');
});
socket.on('data', (chunk: Buffer) => {
console.log('收到数据:', chunk.toString());
});
2.2 readline —— 命令行交互
readline 是 Node.js 内置模块,读命令行输入。客户端靠它实现"用户打字 → 发送消息"的交互。
import * as readline from 'readline';
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question('请输入昵称: ', (answer) => {
console.log('你输入了:', answer);
});
rl.on('line', (input) => {
console.log('你输入了一行:', input);
});
rl.setPrompt('> ');
rl.prompt();
2.3 uuid —— 生成唯一 ID
uuid 是第三方库,给每条消息生成唯一 id,方便追踪和去重。
import { v4 as uuidv4 } from 'uuid';
const id: string = uuidv4();
// → '550e8400-e29b-41d4-a716-446655440000'
2.4 dotenv —— 环境变量管理(可选)
从 .env 文件加载环境变量,配置端口号、心跳间隔等,不用硬编码。
import * as dotenv from 'dotenv';
dotenv.config();
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,所有具体消息都继承它:
interface BaseMessage {
type: MessageType;
payload: unknown;
timestamp: string;
sender: string;
id: string;
}
然后为每种业务场景定义具体的 MessageType 和对应接口。比如:
- 用户刚连接时发
AUTH消息,带上昵称 - 用户想聊天时发
CHAT消息,带上内容和房间名 - 服务端想通知全员时发
SYSTEM消息
为什么这样设计? BaseMessage 定义了所有消息都必须带的"元信息":timestamp 用于消息排序和日志追溯,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 的类型守卫就是干这个的:
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') 然后强行 as ChatMessage 转换。这有两个问题:第一,as 是你对编译器的"谎言"——判断条件写错了,运行时崩溃;第二,转换代码散落在业务逻辑中,难以维护。类型守卫把"判断逻辑"封装成可复用函数,编译器在 if 分支内自动收窄类型。ChatServer.handleMessage 会收到各种消息,没有类型守卫的话,就得用大量 as 断言,代码会变得脆弱且难以重构。这也是用 TS 的好处——严格规定类型形状,操作合法的话,大部分错误能在编译期被发现。
4.3 代码实现
// 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', // 房间列表响应
PING = 'PING', // 服务端心跳
PONG = 'PONG', // 客户端心跳回复
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 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,强制使用前做类型判断。- 具体消息接口:如
ChatMessage、AuthMessage,extends BaseMessage,把payload收窄为具体结构。 generateUniqueId:封装uuidv4(),方便统一替换。- 类型守卫:如
isChatMessage,检查msg.type === MessageType.CHAT,返回msg is ChatMessage。
关于接口继承:每个具体接口都 extends BaseMessage,但又重新声明了 type 字段。这是 TypeScript 的"可辨识联合(Discriminated Union)“模式。type 字段充当"判别式”,编译器通过它判断联合类型的具体分支。把所有消息类型组合成 type AllMessage = AuthMessage | ChatMessage | ... 时,只要检查 msg.type,编译器就能推断出 msg 对应的具体接口,精确知道 payload 里有哪些字段。这比类继承加 instanceof 轻量得多——接口编译后完全消失,不增加运行时开销。
关于类型守卫:你可能注意到,我们的类型守卫写得比较简单,没有逐个属性做深度对比。这只是个小项目,不需要那么严格,体现类型守卫的思想就够了。
五、用户模型与房间模型
两个服务端的"数据结构",封装业务状态。
5.1 User.ts —— 封装一个在线用户
一个用户对应一个 TCP 连接(Socket)。User 类封装了与用户状态相关的行为:发消息、记录心跳、禁言检查等。
// 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) + '\n');
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,末尾加\n,写入 socket。做了防御性检查:socket 已销毁则返回false,避免异常抛到上层。在 TCP 网络编程中,Socket 状态是异步变化的。用户突然断网、关笔记本、按 Ctrl+C,TCP 连接不会立即通知服务端。服务端可能还在往"半死"的连接写数据,
socket.write()会抛异常,或者数据进内核缓冲区但永远到不了对端。不捕获这些异常,整个 Node.js 进程可能因为一个未处理的Error事件崩溃。检查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 —— 管理一个聊天房间
// 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()调用时自然失效(nicknames.get(nickname)返回undefined,broadcast会跳过)。用
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 │ │
│ │(Map) │ │ (Map) │ │
│ └────┬────┘ └────────┬────────┘ │
│ │ │ │
│ ┌────┴────────────────┴────────┐ │
│ │ handleMessage() │ │
│ │ ┌────────┬────────┬───────┐ │ │
│ │ │handleAuth│handleChat│handleWhisper│handleJoin│...│ │
│ │ └────────┴────────┴───────┘ │ │
│ └───────────────────────────────┘ │
│ │
│ ┌───────────────────────────────┐ │
│ │ heartbeatTimer │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
6.2 代码实现
// 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);
}
public stop(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
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 对象做其他操作。
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 秒),遍历所有在线用户:
- 跳过未认证的用户
- 检查该用户是否已超时——距离上次收到 PONG 超过 60 秒,直接
socket.destroy()断开 - 没超时的话,发
PING消息,missedPings加 1
客户端收到 PING 后立即回 PONG,User.recordPong() 更新 lastPongTime 并清空 missedPings。
心跳状态机的详细解析:每个已认证用户处于两种状态之一:“健康"或"可疑”。刚认证时是健康状态,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 实现。
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('\n');
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('\n')按换行符分割 pop()取出最后一段(可能是不完整的半条消息),留到下次处理- 前面的是完整消息,逐条
JSON.parse
这是实现 JSON 行协议最经典的方式。
TCP 流式特性的深层理解:这是网络编程里最容易让初学者困惑的概念。TCP 协议"面向字节流",只保证数据按顺序到达,不保证数据边界。连续发送两条消息 msg1\n 和 msg2\n,接收方可能一次收到 msg1\nmsg2\n,也可能分三次收到 msg1、_half、_msg2\n。这叫"粘包"和"半包"。根本原因是 TCP 的 Nagle 算法和内核缓冲区机制:操作系统为了提高网络效率,把小数据包合并发送,也把收到的数据暂存后批量交给应用层。
为什么用换行符分割而不是固定长度头? 网络协议通常有两种分包策略:一是"长度前缀"(Length-Prefixed),在每条消息前加 4 字节的长度字段;二是"分隔符"(Delimiter-Based),如我们的换行符方案。长度前缀更通用,可以传二进制数据(包括换行符本身),但实现稍复杂,需要处理字节序和缓冲区长度计算。换行符方案简单、人类可读(你可以直接用 telnet 或 nc 命令测试服务端),且 JSON 文本本身不会包含裸换行符(JSON 字符串中的换行会被转义为 \n)。入门学习来说,换行符方案是最佳选择。
buffer 变量的生命周期:buffer 是在 handleConnection 作用域内定义的局部变量,每个 Socket 连接有自己独立的 buffer。这是关键——如果用全局 buffer 处理所有连接的数据,不同连接的数据会混在一起,导致解析失败。Node.js 的闭包机制让每个连接的回调函数都能访问自己专属的 buffer,这是事件驱动编程的天然优势。
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消息,其他一律拒绝 - 已认证用户,根据消息类型分发到对应处理器
- 大量使用了协议层定义的类型守卫
认证状态机的设计:聊天系统本质上是个状态机。每个连接从"未认证"状态开始,只有收到有效的 AUTH 消息后,才转移到"已认证"状态。“状态"决定了"允许的行为”。handleMessage 开头的 if (!user.isAuthenticated) 检查就是状态机的守卫条件。这防止了未认证用户发垃圾消息或尝试加入房间,是安全的第一道防线。
类型守卫的实战价值: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 的强制转换而遗漏。
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++)形成鲜明对比——那些语言里这段代码需要用锁或同步块来保护。Node.js 的异步 I/O 虽然并发量高,但业务逻辑的执行是顺序的,很多并发问题天然消失。
认证后的资源分配顺序:代码的执行顺序是:先设置 user.nickname 和 isAuthenticated,再添加到 nicknames Map,最后加入 Lobby。这个顺序是精心设计的。如果先加入 Lobby 再设置 nickname,Room.addUser 会添加一个空字符串到 users Set 中。如果先加入 nicknames 再设置 isAuthenticated,逻辑上可能出现"半认证"状态的用户存在于索引中。
广播的排除参数:this.broadcast('Lobby', ..., user) 的最后一个参数 exclude,表示"不给这个用户发送"。认证成功的用户不需要收到自己的 PRESENCE 消息——客户端在收到 AUTH_OK 时已经知道自己上线了,再收到 “Alice 进入了 Lobby” 是冗余信息。排除机制让通知更精确,减少客户端处理负担。
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 消息虽然有 sender 和 timestamp 字段,但服务端重新构造 fullMsg 时,用 user.nickname 覆盖了客户端提供的 sender,用 new Date().toISOString() 覆盖了客户端的时间戳。为什么?因为客户端不可信。恶意客户端可以伪造发送者(冒充别人说话)或伪造时间戳(让消息显示为未来或过去的时间)。服务端作为权威源(Source of Truth),必须重新验证和补全所有元信息。只有 payload 中的业务数据(如 content)来自客户端,但即使如此,生产环境也应该对 content 做长度限制和敏感词过滤(这篇没实现,但你应该知道)。
消息存储与广播的顺序:先执行 room.addMessage(),再执行 this.broadcast()。这个顺序在大多数情况下不重要,因为两者都是内存操作,速度极快。但在极端高并发下,如果先广播后存储,可能出现"用户收到了消息,但查询历史时找不到"的短暂不一致。先存储后广播保证"数据先落地,再通知",符合事务处理的基本逻辑(虽然这里没有真正的事务)。
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)发送
私聊消息不经过房间广播,也不存入房间历史。
私聊的架构意义:私聊是聊天室从"多播"到"单播"的演进。房间广播中消息发给房间内所有用户(排除发送者),是一对多。私聊是一对一。广播需要遍历 Room.users 集合,私聊直接通过 nicknames 索引查找。
发送者确认机制:给发送者回了一条系统消息"私聊已发送给 xxx"。这在 UX 上很重要,因为私聊消息不像房间广播那样会回显在发送者自己的屏幕上。没有这条确认,发送者会不确定消息是否真的发出去了。更完善的实现可以引入"消息回执":接收者收到私聊后回复 ACK,发送者看到 ACK 后显示"已送达"或"已读"。
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 的新房间,而不是提示"房间不存在"。更严格的系统中,你可能希望区分"创建"和"加入",或者要求房间名符合某种规范。
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 索引都还在,广播函数能正常工作。保持"先通知,后清理"的顺序,让系统在任何时刻都处于相对一致的状态。
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 还没及时清理,nicknames.get() 返回 undefined。这个检查让广播函数更健壮,不会因为"幽灵用户"而崩溃。
七、客户端:ChatClient
客户端的职责比服务端简单:连接服务器、接收并展示消息、读取用户输入并发送。
// 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('\n');
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('\n') 处理粘包。
客户端与服务端粘包处理的对称性:两端的 data 事件处理逻辑几乎一致。体现了协议的对称性——既然双方约定用 \n 分隔 JSON 消息,无论哪一方接收数据,都需要同样的解码逻辑。这种对称性让代码更容易维护:你只需要理解一次粘包处理,就能同时理解两端。lines.pop() || '' 确保如果最后一段是空字符串(刚好收到完整消息,末尾的 \n 导致 split 产生空尾元素),buffer 会被正确重置为空,而不是保留 undefined。
错误处理策略:客户端的 error 事件直接 process.exit(1),服务端的 error 事件调用 handleDisconnect。为什么不对称?客户端是"末端节点"——连接出错的话,客户端程序没有继续运行的意义(它不能提供服务给其他节点)。服务端需要保持运行,不能因为一个客户端的错误而崩溃。process.exit(1) 中的 1 是 Unix 退出码,表示"异常退出",对脚本化和自动化测试很重要。
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 作为身份标识。这种"占位符"设计让消息结构保持一致,同时表明"此消息尚未获得权威身份"。
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),服务端负责执行命令的业务逻辑(如切换房间、查找目标用户)。这让客户端更"智能",减少了服务端的解析负担。
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 中的事件循环更清晰:它不需要知道每个命令的具体逻辑,只需要知道"命令处理完后是否继续"。
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(`\n[${msg.sender}] ${chatPayload.content}`);
break;
case MessageType.WHISPER:
const whisperPayload = (msg as WhisperMessage).payload;
console.log(`\n[私聊 ${msg.sender}→你] ${whisperPayload.content}`);
break;
case MessageType.SYSTEM:
console.log(`\n[System] ${(msg as any).payload?.content || ''}`);
break;
case MessageType.PRESENCE:
const p = (msg as any).payload;
console.log(`\n👤 ${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(`\n[Error] ${(msg as any).payload?.message || ''}`);
break;
default:
break;
}
}
private send(msg: BaseMessage): void {
this.socket.write(JSON.stringify(msg) + '\n');
}
}
handleMessage 根据消息类型打印不同格式:
CHAT:显示发送者和内容WHISPER:标注为私聊SYSTEM:标注为系统消息PRESENCE:显示用户上下线动作PING:自动回PONG,用户无感知ERROR:显示错误提示
消息展示的 UX 层次:客户端用不同的前缀和格式区分消息类型,让用户一眼识别信息来源和重要性。 前缀确保消息不会粘在当前提示符后面,而是另起一行显示。显示完消息后,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 加 \n。但客户端不需要检查 socket.destroyed,因为客户端在 socket.on('close') 中已经处理了断开情况,且客户端是单连接的,socket 已死的话程序应该已经退出了。这种简化是合理的,因为客户端和服务端的职责不同。
八、入口与运行方式
服务端入口
// 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();
客户端入口
// 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);
}
运行步骤
# 1. 安装依赖
npm install
# 2. 编译 TypeScript
npm run build
# 3. 启动服务端
npm run start:server
# 4. 新开一个终端,启动客户端
npm run start:client
九、总结与下篇预告
本篇回顾
通过这篇博客,我们实现了一个完整的命令行 TCP 聊天室,覆盖了网络编程最核心的知识点:
| 知识点 | 说明 |
|---|---|
| TCP 长连接 | net 模块创建服务端与客户端,基于 Socket 收发数据 |
| 粘包处理 | 用 buffer + split('\n') 解决 TCP 流式传输的边界问题 |
| JSON 行协议 | 简单、可读、易调试的应用层协议设计 |
| 类型守卫 | TypeScript 收窄技巧,安全处理多种消息类型 |
| 心跳保活 | 定时 Ping + 超时检测,及时清理死连接 |
| 房间广播 | 基于 Map + Set 的内存索引,实现高效定向消息推送 |
| 私聊路由 | 点对点消息分发,不经过房间广播 |
当前局限
这篇为了聚焦网络通信,刻意简化了几处:
- 数据全在内存:服务端重启后,用户、房间、消息历史全丢
- 没有真正的用户体系:昵称就是唯一标识,没有密码、没有注册登录
- 消息历史有限:每个房间只保留最近 100 条内存消息
- 密码明文存储:房间密码直接比对明文,没有哈希
这些会在下篇解决
更多推荐



所有评论(0)