从零构建 Node.js TCP 多人聊天室 - 完整实战教程

使用 Node.js net 模块打造实时多人聊天系统,深入理解 Socket 编程、事件驱动和状态管理

📖 目录


🎯 项目演示

功能特性

多人实时聊天 - 支持多个客户端同时连接
昵称系统 - 用户可自定义昵称
消息广播 - 消息自动发送给所有在线用户
命令系统 - /quit/users/help 等实用命令
优雅退出 - Ctrl+C 或输入命令均可正常断开
局域网支持 - 其他设备可通过 IP 地址连接

运行效果

# 启动服务器
$ npx ts-node 聊天室.ts
========================================
   聊天室服务器已启动
========================================
本地访问: localhost:3500
局域网访问: 192.168.1.100:3500
========================================

# 客户端 1
$ npx ts-node 客户端.ts
正在连接到 localhost:3500...
已连接到服务器!

=== 欢迎进入聊天室 ===
请输入你的昵称:
> 艾魅
你的昵称是: 艾魅
现在可以开始聊天了!
> 你好

# 客户端 2(看到的消息)
[艾魅] 你好

🛠️ 技术栈

技术 用途 说明
Node.js 运行时环境 v18+ 推荐
net 模块 TCP 网络通信 Node.js 内置模块
TypeScript 类型安全 严格模式开发
readline 模块 命令行交互 处理用户输入
os 模块 获取本地 IP 显示局域网地址
Map 数据结构 客户端管理 O(1) 查找和删除

💡 核心概念解析

在深入代码之前,让我们先理解几个核心概念。我会用生活中的例子来解释,让你更容易理解。

1. Socket(套接字)是什么?

想象一下打电话的场景:

  • Socket = 电话听筒 📞
    • 你和对方各有一个听筒
    • 通过听筒说话(发送数据)和听话(接收数据)
    • 通话建立后,双方可以随时交流

在代码中:

// 服务器创建一个"总机"
const server = net.createServer((socket) => {
    // 每个客户端连接进来,就分配一个"听筒"(socket)
    socket.write('你好!');  // 通过听筒说话
    socket.on('data', (msg) => {
        console.log(msg);    // 通过听筒听话
    });
});

2. 端口(Port)是什么?

继续用电话举例:

  • IP 地址 = 大楼地址 🏢(如 192.168.1.100)
  • 端口 = 房间号 🚪(如 3500)
// 服务器监听 3500 端口
server.listen(3500, '0.0.0.0', () => {
    console.log('服务器在 3500 房间等待连接');
});

// 客户端连接到 3500 端口
const client = net.createConnection({ 
    host: '192.168.1.100',  // 大楼地址
    port: 3500               // 房间号
});

为什么需要端口?

  • 一台服务器可以同时运行多个服务(Web、FTP、邮件等)
  • 端口用来区分不同的服务
  • 常见端口:HTTP(80)、HTTPS(443)、SSH(22)

3. 事件驱动模型

Node.js 的核心思想:不是"我问你有没有数据",而是"有数据时你通知我"

传统方式(阻塞式):

// ❌ 一直问:"有数据吗?有数据吗?..."
while (true) {
    const data = socket.read();
    if (data) {
        process(data);
    }
}

事件驱动(非阻塞式):

// ✅ 注册监听器,有数据时自动触发
socket.on('data', (data) => {
    process(data);  // 有数据时才执行
});

// 程序可以继续做其他事,不会被卡住
console.log('我可以继续执行其他代码');

类比

  • 阻塞式 = 站在门口等快递,什么都不干
  • 事件驱动 = 去忙别的,快递员到了按门铃通知你

4. Map 数据结构

为什么用 Map 而不是普通对象 {}

// ❌ 普通对象 - 键只能是字符串
const obj = {};
obj['socket1'] = '艾魅';  // 需要把 socket 转成字符串

// ✅ Map - 键可以是任何类型(包括对象)
const map = new Map();
map.set(socket, '艾魅');  // 直接用 socket 对象作为键

优势对比

特性 Object Map
键类型 仅字符串/符号 任意类型
性能 O(n) 遍历 O(1) 查找
大小查询 手动计数 map.size
迭代顺序 不确定 插入顺序

🖥️ 服务器端实现

整体架构

聊天室服务器 (聊天室.ts)
├── 初始化
│   ├── 创建 TCP 服务器
│   ├── 获取本地 IP
│   └── 监听端口 3500
│
├── 客户端管理
│   ├── clientMap: Map<Socket, string>  // 存储 socket -> 昵称
│   ├── 新连接处理
│   ├── 昵称设置
│   └── 断开连接清理
│
├── 消息处理
│   ├── 接收消息 (on 'data')
│   ├── 命令解析 (/quit, /users, /help)
│   └── 消息广播
│
└── 优雅关闭
    ├── 捕获 SIGINT (Ctrl+C)
    ├── 通知所有客户端
    ├── 主动断开连接
    └── 关闭服务器

核心代码详解

1. 服务器初始化
import * as net from 'net';
import * as os from 'os';

// 存储客户端连接:Socket -> 昵称
const clientMap: Map<net.Socket, string> = new Map();
let isShuttingDown = false; // 防止重复关闭

// 获取本地局域网 IP
function getLocalIP(): string {
    const interfaces = os.networkInterfaces();
    for (const name of Object.keys(interfaces)) {
        for (const iface of interfaces[name]!) {
            // IPv4 且非内部地址(127.0.0.1)
            if (iface.family === 'IPv4' && !iface.internal) {
                return iface.address;
            }
        }
    }
    return '127.0.0.1';
}

// 创建 TCP 服务器
const server = net.createServer((socket) => {
    // 每个新连接都会执行这里的代码
    const clientInfo = `${socket.remoteAddress}:${socket.remotePort}`;
    console.log(`新客户端加入: ${clientInfo}`);
    
    // ... 后续处理
});

// 监听所有网络接口(0.0.0.0 表示接受任何 IP 的连接)
const PORT = 3500;
const localIP = getLocalIP();

server.listen(PORT, '0.0.0.0', () => {
    console.log(`本地访问: localhost:${PORT}`);
    console.log(`局域网访问: ${localIP}:${PORT}`);
});

关键点解释

  • 0.0.0.0 vs localhost

    • localhost (127.0.0.1) - 只允许本机连接
    • 0.0.0.0 - 允许任何 IP 连接(包括局域网其他设备)
  • os.networkInterfaces()

    • 返回所有网络接口信息
    • 我们需要找到非内部的 IPv4 地址(即局域网 IP)
2. 客户端连接与昵称设置
server = net.createServer((socket) => {
    const clientInfo = `${socket.remoteAddress}:${socket.remotePort}`;
    
    socket.write('=== 欢迎进入聊天室 ===\n');
    socket.write('请输入你的昵称:\n');
    
    let nickname = '';              // 当前客户端的昵称
    let isSettingNickname = true;   // 是否正在设置昵称
    let hasLeftChat = false;        // 是否已主动离开(防止重复广播)
    
    socket.on('data', (data) => {
        const msg = data.toString().trim();  // 转为字符串并去除空白
        
        // === 阶段 1:设置昵称 ===
        if (isSettingNickname) {
            nickname = msg || `用户${Date.now()}`;  // 空昵称使用时间戳
            clientMap.set(socket, nickname);         // 存入 Map
            isSettingNickname = false;               // 标记设置完成
            
            // 欢迎消息
            socket.write(`你的昵称是: ${nickname}\n`);
            socket.write('现在可以开始聊天了!\n> ');
            
            // 通知其他用户
            clientMap.forEach((name, client) => {
                if (client !== socket && client.writable) {
                    client.write(`[系统] ${nickname} 加入了聊天室\n`);
                }
            });
            return;
        }
        
        // === 阶段 2:正常聊天 ===
        if (!msg) return;  // 空消息忽略
        
        // ... 命令处理和消息广播
    });
});

闭包的作用

每个客户端连接都有独立的 nicknameisSettingNicknamehasLeftChat 变量,这是因为它们定义在 createServer 的回调函数内部,形成了闭包

// 客户端 A 连接
socket_A -> nickname = "艾魅", isSettingNickname = false

// 客户端 B 连接  
socket_B -> nickname = "斯派克", isSettingNickname = false

// 互不干扰!
3. 命令处理与消息广播
socket.on('data', (data) => {
    const msg = data.toString().trim();
    
    if (isSettingNickname) {
        // ... 昵称设置逻辑
        return;
    }
    
    if (!msg) return;
    
    // === 命令处理 ===
    
    // 退出聊天室
    if (msg === '/quit' || msg === '/exit') {
        hasLeftChat = true;  // 标记已主动离开
        const leaveName = clientMap.get(socket) || nickname;
        
        // 广播离开消息
        clientMap.forEach((name, client) => {
            if (client !== socket && client.writable) {
                client.write(`[系统] ${leaveName} 离开了聊天室\n`);
            }
        });
        
        console.log(`${leaveName} 退出了聊天室`);
        clientMap.delete(socket);  // 从 Map 移除
        
        socket.write('[系统] 再见!\n');
        socket.end();  // 关闭连接
        return;
    }
    
    // 查看在线用户
    if (msg === '/users') {
        const users = Array.from(clientMap.values());
        socket.write(`[系统] 在线用户 (${users.length}人): ${users.join(', ')}\n`);
        return;
    }
    
    // 帮助信息
    if (msg === '/help') {
        socket.write('[系统] 可用命令:\n');
        socket.write('  /quit 或 /exit - 退出聊天室\n');
        socket.write('  /users - 查看在线用户\n');
        socket.write('  /help - 显示帮助信息\n');
        return;
    }
    
    // === 普通消息广播 ===
    console.log(`[${nickname}] ${msg}`);
    
    const broadcastMsg = `[${nickname}] ${msg}\n`;
    clientMap.forEach((name, client) => {
        if (client.writable) {  // 检查连接是否仍然有效
            client.write(broadcastMsg);
        }
    });
});

为什么要检查 writable

当客户端断开连接时,socket 可能还没有完全关闭,此时向其写入数据会抛出错误。writable 属性可以判断连接是否仍然可用。

if (client.writable) {
    client.write(message);  // 安全写入
} else {
    // 连接已断开,跳过
}
4. 断开连接处理
socket.on('close', () => {
    // 如果已经主动离开(通过 /quit),就不需要再处理
    if (hasLeftChat) {
        return;
    }
    
    const name = clientMap.get(socket);
    const displayName = name || nickname || clientInfo;
    
    if (name) {
        console.log(`客户端已断开: ${name}`);
        clientMap.delete(socket);
        
        // 向其他在线用户广播离开消息
        clientMap.forEach((_, client) => {
            if (client.writable) {
                client.write(`[系统] ${displayName} 离开了聊天室\n`);
            }
        });
    } else {
        // 没有昵称,说明可能是在设置昵称前就断开了
        console.log(`客户端已断开: ${clientInfo}`);
    }
});

socket.on('error', (err) => {
    console.error(`Socket错误: ${err.message}`);
    clientMap.delete(socket);
});

hasLeftChat 标志的重要性

这是解决"重复广播离开消息"的关键!

场景 1:用户输入 /quit
1. 设置 hasLeftChat = true
2. 广播离开消息
3. 调用 socket.end()
4. 触发 close 事件
5. 检查 hasLeftChat,发现为 true,直接返回 ✅

场景 2:用户直接关闭终端(网络断开)
1. hasLeftChat 仍为 false
2. 触发 close 事件
3. 检查 hasLeftChat,发现为 false,执行正常流程 ✅
5. 服务器优雅关闭
process.on('SIGINT', () => {
    // Windows 上 Ctrl+C 可能触发多次,防止重复处理
    if (isShuttingDown) {
        console.log('\n正在强制关闭...');
        process.exit(1);
        return;
    }
    
    isShuttingDown = true;
    console.log('\n服务器正在关闭...');
    
    // 1. 通知所有客户端服务器即将关闭
    clientMap.forEach((name, client) => {
        if (client.writable) {
            client.write('[系统] 服务器正在关闭,请保存您的工作...\n');
        }
    });
    
    // 2. 主动关闭所有客户端连接
    clientMap.forEach((_, client) => {
        try {
            client.end();
        } catch (err) {
            console.error(`关闭客户端连接时出错: ${err}`);
        }
    });
    
    // 3. 清空客户端列表
    clientMap.clear();
    
    // 4. 设置超时保护(5秒后强制退出)
    const forceExitTimeout = setTimeout(() => {
        console.log('\n超时,强制退出...');
        process.exit(1);
    }, 5000);
    
    // 5. 关闭服务器
    server.close(() => {
        clearTimeout(forceExitTimeout);
        console.log('服务器已关闭');
        process.exit(0);
    });
});

为什么需要这么复杂?

  1. 防止重复触发:Windows 上按 Ctrl+C 可能触发多次 SIGINT
  2. 通知客户端:让用户知道服务器要关闭了
  3. 主动断开server.close() 只停止接受新连接,不会断开已有连接
  4. 超时保护:防止某些客户端无法正常断开导致服务器无法退出

💻 客户端实现

整体架构

聊天室客户端 (客户端.ts)
├── 连接建立
│   ├── 解析命令行参数(IP、端口)
│   ├── 创建 TCP 连接
│   └── 连接成功提示
│
├── 消息接收
│   ├── 监听 'data' 事件
│   ├── 清除当前行(避免显示混乱)
│   └── 重新显示提示符
│
├── 消息发送
│   ├── readline 模块处理输入
│   ├── 发送用户输入到服务器
│   └── 支持 /quit 命令
│
└── 优雅退出
    ├── 捕获 SIGINT (Ctrl+C)
    ├── 发送 /quit 命令
    └── 关闭连接并退出

核心代码详解

1. 连接建立
import * as net from 'net';
import * as readline from 'readline';

// 从命令行参数获取服务器地址
const HOST = process.argv[2] || 'localhost';
const PORT = parseInt(process.argv[3] || '3500');

console.log(`正在连接到 ${HOST}:${PORT}...`);

// 创建 TCP 连接
const client = net.createConnection({ host: HOST, port: PORT }, () => {
    console.log('已连接到服务器!\n');
});

// 连接错误处理
client.on('error', (err) => {
    console.error(`连接错误: ${err.message}`);
    process.exit(1);
});

// 连接关闭处理
client.on('close', () => {
    console.log('\n与服务器断开连接');
    process.exit(0);
});

process.argv 解析

# 运行命令
$ npx ts-node 客户端.ts 192.168.1.100 3500

# process.argv 数组
[
  'C:\\Program Files\\nodejs\\node.exe',  // argv[0] - Node.js 路径
  'C:\\project\\客户端.ts',                 // argv[1] - 脚本路径
  '192.168.1.100',                          // argv[2] - 第一个参数(IP)
  '3500'                                    // argv[3] - 第二个参数(端口)
]

类型安全处理

// ❌ 不安全 - process.argv[3] 可能是 undefined
const PORT = parseInt(process.argv[3]) || 3500;

// ✅ 安全 - 先提供默认值,再转换
const PORT = parseInt(process.argv[3] || '3500');
2. 命令行交互优化
// 创建交互式命令行
const rl = readline.createInterface({
    input: process.stdin,    // 从标准输入读取
    output: process.stdout,  // 向标准输出写入
    prompt: '> '             // 输入提示符
});

// 显示初始提示符
rl.prompt();

为什么需要 readline?

如果只用简单的 process.stdin,会出现这个问题:

用户输入:你好
服务器消息:[艾魅] 你好
显示结果:
> 你好[艾魅] 你好   ← 混乱!

使用 readline 后:

用户输入:你好
服务器消息:[艾魅] 你好
显示结果:
[艾魅] 你好
>                  ← 清晰!
3. 消息接收与显示
client.on('data', (data: Buffer) => {
    const message = data.toString();
    
    // 清除当前行(输入提示符所在行)
    process.stdout.write('\r' + ' '.repeat(80) + '\r');
    
    // 显示服务器消息
    process.stdout.write(message);
    
    // 确保消息后有换行
    if (!message.endsWith('\n')) {
        process.stdout.write('\n');
    }
    
    // 重新显示输入提示符
    rl.prompt(true);
});

逐行解释

  1. '\r' + ' '.repeat(80) + '\r'

    • \r - 回车符,光标回到行首
    • ' '.repeat(80) - 80 个空格,覆盖当前行内容
    • \r - 再次回车,准备写入

    效果:清除用户正在输入的内容

  2. process.stdout.write(message)

    • 显示服务器发来的消息
    • 不使用 console.log,因为会自动添加换行
  3. rl.prompt(true)

    • 重新显示 > 提示符
    • true 参数表示保留当前行的输入(如果有)
4. 消息发送
rl.on('line', (line: string) => {
    const input = line.trim();
    if (input) {
        client.write(input + '\n');  // 发送到服务器
    }
});

rl.on('close', () => {
    client.end();
    console.log('再见!');
    process.exit(0);
});

为什么要加 \n

服务器使用 .trim() 去除消息两端的空白,包括换行符。如果不加 \n,服务器可能无法正确识别消息边界。

5. 优雅退出
// 捕获 Ctrl+C
process.on('SIGINT', () => {
    client.write('/quit\n');  // 发送退出命令
});

这样服务器会收到 /quit 命令,执行正常的退出流程(广播离开消息等)。


🔧 关键问题与解决方案

问题 1:客户端断开时显示端口而非昵称

现象

[系统] 192.168.1.100:54321 离开了聊天室  ← 显示端口

原因

  • /quit 处理中,先从 clientMap 删除了 socket
  • socket.end() 触发 close 事件
  • close 事件中 clientMap.get(socket) 返回 undefined
  • 使用备用值 clientInfo(IP:端口)

解决方案
添加 hasLeftChat 标志,在 close 事件中检查:

socket.on('close', () => {
    if (hasLeftChat) {
        return;  // 已主动离开,不再处理
    }
    // ... 正常处理
});

问题 2:重复广播离开消息

现象

[系统] 艾魅 离开了聊天室
[系统] 艾魅 离开了聊天室  ← 重复!

原因

  • /quit 命令处理中广播了一次
  • socket.end() 触发 close 事件又广播了一次

解决方案
同上,使用 hasLeftChat 标志防止重复处理。

问题 3:Windows 上 Ctrl+C 触发多次 SIGINT

现象
按一次 Ctrl+C,关闭逻辑执行多次

原因
Windows 系统的信号处理特性

解决方案
添加 isShuttingDown 标志:

let isShuttingDown = false;

process.on('SIGINT', () => {
    if (isShuttingDown) {
        process.exit(1);  // 第二次按 Ctrl+C 强制退出
        return;
    }
    isShuttingDown = true;
    // ... 正常关闭流程
});

问题 4:输入提示符与接收消息冲突

现象

> 你好[艾魅] 你们好  ← 混乱

原因
用户正在输入时,收到服务器消息,直接追加到当前行

解决方案
使用 readline 模块,收到消息时清除当前行并重新显示提示符:

client.on('data', (data) => {
    process.stdout.write('\r' + ' '.repeat(80) + '\r');  // 清除当前行
    process.stdout.write(message);                        // 显示消息
    rl.prompt(true);                                      // 重新显示提示符
});

⚙️ TypeScript 配置要点

常见问题

很多初学者会遇到这些 TypeScript 错误:

❌ 找不到名称"net"。是否需要安装 Node.js 的类型定义?
❌ 参数"socket"隐式具有"any"类型。
❌ 找不到名称"process"。

解决方案

1. 安装类型定义
npm install --save-dev @types/node
2. 配置 tsconfig.json
{
  "compilerOptions": {
    "rootDir": ".",           // ✅ 源码在根目录
    "outDir": "./dist",
    "module": "nodenext",
    "target": "esnext",
    "types": ["node"],        // ✅ 启用 Node.js 类型
    "strict": true,
    "esModuleInterop": true,
    "isolatedModules": true,
    "skipLibCheck": true
  },
  "include": ["**/*.ts"],     // ✅ 包含所有 .ts 文件
  "exclude": ["node_modules", "dist"]
}

常见错误配置

{
  "compilerOptions": {
    "rootDir": "./src",       // ❌ 源码不在 src 目录
    "verbatimModuleSyntax": true  // ❌ 与 CommonJS 冲突
  },
  "include": ["src/**/*"],    // ❌ 不包含根目录文件
  "exclude": ["*.ts"]         // ❌ 排除了所有 .ts 文件
}
3. 重启 TypeScript 服务

如果配置正确但仍报错,重启 VSCode 的 TypeScript 服务:

  1. Ctrl + Shift + P
  2. 输入 TypeScript: Restart TS Server
  3. 回车执行

🚀 扩展功能建议

基础功能

  • 私聊功能 - @用户名 消息 只发送给指定用户
  • 聊天记录 - 保存历史消息到文件
  • 表情包支持 - 支持 emoji 或自定义表情
  • 多房间 - 用户可以加入不同聊天室
  • 管理员权限 - 踢人、禁言、查看 IP

进阶功能

  • WebSocket 支持 - 浏览器客户端
  • 用户认证 - 登录/注册系统
  • 消息加密 - TLS/SSL 加密通信
  • 文件传输 - 发送图片、文档等
  • 语音聊天 - WebRTC 集成

性能优化

  • 消息长度限制 - 防止超长消息
  • 频率限制 - 防止刷屏(如每秒最多 5 条)
  • 心跳检测 - 定期发送心跳包,检测死连接
  • 连接数限制 - 限制最大客户端数量
  • Buffer 池 - 减少内存分配开销

安全加固

  • 内容过滤 - 过滤敏感词、XSS 攻击
  • 昵称规范 - 禁止特殊字符、保留字
  • IP 白名单 - 只允许特定 IP 连接
  • 速率限制 - 防止 DDoS 攻击
  • 日志记录 - 记录所有操作,便于审计

📝 总结

通过这个实战项目,我学习了:

核心技术

  1. Socket 编程 - 理解 TCP 连接、端口、IP 地址
  2. 事件驱动 - Node.js 的非阻塞 I/O 模型
  3. 状态管理 - 使用闭包和 Map 管理客户端状态
  4. 流处理 - Socket 作为双工流的读写操作
  5. 优雅关闭 - 正确处理资源释放和连接断开

工程实践

  1. TypeScript 类型安全 - 严格模式下的开发技巧
  2. 错误处理 - 各种异常情况的容错处理
  3. 代码组织 - 清晰的模块划分和职责分离
  4. 用户体验 - 友好的命令行交互设计
  5. 健壮性 - 防止重复操作、超时保护等

Happy Coding! 🎉

更多推荐