用 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” 大厅,用户可以创建新房间(如 gamemusic),可以设密码,同一时间只能待在一个房间
房间广播 在某个房间里发的消息,只有该房间的人能看到
私聊 /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)chunkBuffer
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 用于全局去重。payloadunknown 而不是 any,是刻意为之——它强制你在访问具体字段之前先做类型判断。如果你错把一个 AuthMessage 当成 ChatMessage 来访问 payload.content,TypeScript 编译器会直接报错,而不是运行时默默返回 undefined

为什么用字符串枚举而非数字枚举? 调试网络程序时,可读性是第一优先级。日志里看到 type: 3,你得去查表才知道是 CHAT;看到 type: "CHAT",一眼就能明白。字符串枚举在跨语言通信时也友好,JSON 直接携带语义信息,不需要额外的协议文档来解释每个数字的含义。传输开销?现代带宽下可以忽略不计。

4.2 什么是类型守卫(Type Guard)?

BaseMessagepayloadunknown,收到消息后需要先判断它到底是什么类型,才能安全访问 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 代码解析

  1. MessageType 枚举:字符串枚举,调试时直接从日志看出消息类型。
  2. BaseMessage:所有消息的"骨架"。payloadunknown,强制使用前做类型判断。
  3. 具体消息接口:如 ChatMessageAuthMessageextends BaseMessage,把 payload 收窄为具体结构。
  4. generateUniqueId:封装 uuidv4(),方便统一替换。
  5. 类型守卫:如 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.destroyedsocket.closed,是"前置防御"——写入前确认通道是否还活着。返回 boolean 而不是 void,让上层调用者(如 ChatServer.broadcast)知道消息是否成功发出,从而决定是否需要记日志或重试。

  • sendSystemMessage / sendError:便捷方法,避免业务代码里重复构造 BaseMessage

    便捷方法的价值不止是少写代码。长期维护中,消息格式可能调整——某天给所有系统消息加个 level 字段,或者把错误消息的 code 改成枚举。如果业务代码里散落着几十处手动构造 BaseMessage 的地方,修改就是噩梦。通过 sendSystemMessagesendError 封装,“如何构造系统消息"的知识集中在 User 类内部。User 类知道如何与用户通信,ChatServer 只需要说"告诉这个用户某事”,不用关心消息的具体格式。

  • 心跳相关recordPong() 在收到 PONG 时调用,重置计时器;isHeartbeatTimedOut() 在服务端定时检查时调用。

    TCP 协议本身有保活机制,为什么还要自己实现心跳?因为操作系统级别的 TCP keep-alive 间隔通常很长(默认 2 小时),聊天室需要及时感知用户离线,等不了那么久。应用层心跳让我们自定义间隔(30 秒)和超时阈值(60 秒),实现秒级故障检测。lastPongTimeDate 对象而非原始时间戳,是因为 Date 提供更丰富的 API(如 toISOString()),方便日志记录。missedPingslastPongTime 是互补指标: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),UserSocket 可能永远无法被 GC 回收,造成内存泄漏。只存 nickname(字符串),Room 对用户对象没有直接引用。ChatServer 通过 nicknames Map 管理 User 对象的生命周期,Room 只关心"谁在房间里"这个名单。断开连接时,只需要从 ChatServer.usersChatServer.nicknames 中删除 UserRoom 中的昵称字符串会在下次 getUserList() 调用时自然失效(nicknames.get(nickname) 返回 undefinedbroadcast 会跳过)。

    Set 而非 ArraySetadddeletehas 都是 O(1),ArrayincludesindexOf 是 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,而不是一个?这是"多索引"设计。usersSocket 为键,在 socket.on('data')socket.on('close') 时快速找到对应的 User——事件回调里拿到的是 Socket 实例。nicknamesnickname 为键,用于业务逻辑中的用户查找:私聊时通过昵称找目标用户,或判断昵称是否已被占用。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 秒),遍历所有在线用户:

  1. 跳过未认证的用户
  2. 检查该用户是否已超时——距离上次收到 PONG 超过 60 秒,直接 socket.destroy() 断开
  3. 没超时的话,发 PING 消息,missedPings 加 1

客户端收到 PING 后立即回 PONGUser.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 不保证是一条完整消息——可能半条,也可能多条粘在一起。

解决方案是按行分割

  1. 把新收到的数据追加到 buffer
  2. split('\n') 按换行符分割
  3. pop() 取出最后一段(可能是不完整的半条消息),留到下次处理
  4. 前面的是完整消息,逐条 JSON.parse

这是实现 JSON 行协议最经典的方式。

TCP 流式特性的深层理解:这是网络编程里最容易让初学者困惑的概念。TCP 协议"面向字节流",只保证数据按顺序到达,不保证数据边界。连续发送两条消息 msg1\nmsg2\n,接收方可能一次收到 msg1\nmsg2\n,也可能分三次收到 msg1_half_msg2\n。这叫"粘包"和"半包"。根本原因是 TCP 的 Nagle 算法和内核缓冲区机制:操作系统为了提高网络效率,把小数据包合并发送,也把收到的数据暂存后批量交给应用层。

为什么用换行符分割而不是固定长度头? 网络协议通常有两种分包策略:一是"长度前缀"(Length-Prefixed),在每条消息前加 4 字节的长度字段;二是"分隔符"(Delimiter-Based),如我们的换行符方案。长度前缀更通用,可以传二进制数据(包括换行符本身),但实现稍复杂,需要处理字节序和缓冲区长度计算。换行符方案简单、人类可读(你可以直接用 telnetnc 命令测试服务端),且 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 编译器只知道 msgtypepayload(unknown)、timestampsenderid。如果直接写 msg.payload.content,编译器会报错,因为 unknown 类型没有 content 属性。通过 isChatMessage(msg) 判断后,在 if 分支内部,TypeScript 自动把 msg 收窄为 ChatMessage,此时 msg.payload.content 是合法的。这种"先证明,后使用"的模式,让代码在重构时很安全——如果你把 ChatMessagepayload 字段从 content 改成 text,所有用了 isChatMessage 的地方,TypeScript 都能帮你找到并报错,不会因为 as anyas 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. 检查昵称合法性(1-20 字符)
  2. 检查昵称是否已被占用(查 nicknames Map)
  3. 标记用户为已认证,加入 nicknames 索引
  4. 把用户加入 Lobby 房间
  5. AUTH_OK,告诉用户认证成功、当前在 Lobby
  6. 向 Lobby 内其他用户广播 PRESENCE 消息,通知"有人上线了"

并发安全的隐式保证:两个客户端同时发相同昵称,会不会都通过检查?在 Node.js 中不可能。Node.js 是单线程事件循环模型,所有 JavaScript 代码在同一个线程上执行,不存在真正的并行。this.nicknames.has(nickname)this.nicknames.set(nickname, user) 之间不会有其他事件插入。这与多线程语言(如 Java、C++)形成鲜明对比——那些语言里这段代码需要用锁或同步块来保护。Node.js 的异步 I/O 虽然并发量高,但业务逻辑的执行是顺序的,很多并发问题天然消失。

认证后的资源分配顺序:代码的执行顺序是:先设置 user.nicknameisAuthenticated,再添加到 nicknames Map,最后加入 Lobby。这个顺序是精心设计的。如果先加入 Lobby 再设置 nicknameRoom.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++;
    }
聊天广播
  1. 检查用户是否被禁言
  2. 确定目标房间(用户指定,或默认当前房间)
  3. 构造完整消息(补上 sender、timestamp、id)
  4. 把消息存入房间的内存历史
  5. 调用 broadcast() 发给房间内除自己以外的所有人

为什么不信任客户端发送的 sender 和 timestamp? 这是安全原则。客户端发来的 CHAT 消息虽然有 sendertimestamp 字段,但服务端重新构造 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}`);
    }
私聊路由

私聊的本质是点对点路由

  1. 通过 nicknames Map 查找目标用户
  2. 不在线则返回错误
  3. 在线则直接调用 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);
    }
房间切换
  1. 房间不存在则自动创建(用户通过 /join 任意房间名 创建房间的机制)
  2. 检查密码
  3. 从旧房间移除用户,向旧房间广播"离开"通知
  4. 向新房间添加用户,推送最近 20 条历史消息(让用户看到上下文)
  5. 向新房间广播"进入"通知

原子性与一致性考量:房间切换涉及多个状态变更:从旧房间移除、向新房间添加、更新 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:用户断开时,从房间移除、广播离开通知、清理 usersnicknames 索引
  • broadcast:遍历房间内所有昵称,通过 nicknames Map 找到对应 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() 确保没有残留的句柄或缓冲区占用内存。handleConnectionerror 事件中也调用了 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 为空字符串? AuthMessagesender 设为空字符串。客户端认证前还没被服务端承认,严格来说没有"发送者身份"。服务端 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() 是客户端的核心交互循环:

  1. 设置命令行提示符(如 Alice[Lobby]>
  2. 进入 readlineline 事件监听
  3. 输入以 / 开头则交给 handleCommand 处理
  4. 否则视为普通聊天消息,发 CHAT

chatStarted 标志的必要性startChat 只应在认证成功后调用一次。但 handleMessage 中收到 AUTH_OK 时,可能因为网络重连或其他边界情况被多次触发。chatStarted 是简单的"一次性开关",防止重复注册 rl.on('line') 事件监听器。重复注册会导致每次用户输入触发多个处理函数,消息被发送多次或提示符混乱。这种"幂等性"设计在事件驱动编程中很常见。

提示符的 UX 设计this.rl.setPrompt() 设置命令行左侧的提示文本。Alice[Lobby]> 这种格式让用户随时知道"我是谁"和"我在哪"。提示符末尾有个空格,让用户输入内容与提示符有视觉分隔。没有空格的话输入会紧贴 ],影响可读性。this.rl.prompt() 在设置提示符后立即显示,并将光标定位在提示符之后。

普通消息与命令的分流:客户端通过 line.startsWith('/') 区分命令和普通聊天消息。这是约定俗成的 CLI 设计(类似 IRC、Minecraft 控制台等)。所有命令以 / 开头,其他内容视为聊天。分流发生在客户端,意味着命令解析逻辑分布在两端:客户端负责识别命令并构造对应的消息类型(如 JOINWHISPER),服务端负责执行命令的业务逻辑(如切换房间、查找目标用户)。这让客户端更"智能",减少了服务端的解析负担。

    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)。

命令别名的设计quitq 都映射到同一个操作,wwhisper 也是。常用命令提供短别名,减少打字量。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())。但对于 PINGPRESENCE 等异步到达的消息,handleMessage 中没有调用 rl.prompt()——消息会显示在屏幕上,但提示符不会立即重绘。这在 Node.js readline 中是可以接受的,因为用户下一次输入时提示符会自然出现。

PING 的无感知处理:客户端收到 PING 时立即回 PONG,但不在屏幕上显示任何内容。这是心跳机制的设计意图:用户不应该感知到心跳的存在。如果每次心跳都打印 “收到 PING,发送 PONG”,屏幕会被垃圾信息填满。Date.now() 被用于 PONGpayload.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 的内存索引,实现高效定向消息推送
私聊路由 点对点消息分发,不经过房间广播

当前局限

这篇为了聚焦网络通信,刻意简化了几处:

  1. 数据全在内存:服务端重启后,用户、房间、消息历史全丢
  2. 没有真正的用户体系:昵称就是唯一标识,没有密码、没有注册登录
  3. 消息历史有限:每个房间只保留最近 100 条内存消息
  4. 密码明文存储:房间密码直接比对明文,没有哈希

这些会在下篇解决

更多推荐