返回博客

局域网聊天室:从 TCP 连接到桌面交互

结合真实项目源码,复盘协议分帧、多线程广播、SQLite 持久化和 Win32 客户端的实现。

这个局域网聊天室最初是一次 C++ 网络编程练习,后来逐步加入了图形界面、账号系统、私聊、文件传输和管理员功能。功能增加以后,真正困难的部分不再是“怎样调用 send”,而是怎样让协议、线程、状态和界面保持一致。

本文记录当前桌面版本的真实设计,也说明其中为了学习和可控规模做出的取舍。

整体架构

项目分为三个可执行部分:

单文件启动器 Chatroom.exe

        ├─ 启动并检查 TCP 服务端(默认端口 8888)
        │       ├─ 连接与登录状态机
        │       ├─ 消息和文件路由
        │       └─ SQLite 持久化

        └─ 打开 Win32 图形客户端
                ├─ 登录与注册
                ├─ 会话和成员列表
                └─ 消息、状态与文件交互

启动器把服务端、客户端和默认配置作为资源嵌入自身。运行时先创建 runtime 目录,再检查 127.0.0.1:8888 是否已经有服务端监听。若没有,则释放服务端并等待最多约三秒;确认端口可用后才启动客户端。

这个设计把多个程序的启动顺序藏在一个入口后面,同时允许后续启动的客户端复用现有服务端。

用文本行协议建立边界

通信层采用 UTF-8 文本行协议:一条消息占一行,字段使用 | 分隔。

REG|账号|密码|昵称
LOGIN|账号|密码
MSG|大家好
PM|目标昵称|这是一条私聊消息
STATUS|away

服务端返回的消息也有明确类型,例如 WELCOMEULISTPUBPMSYSERR。消息正文固定为最后一个字段,因此正文中的 | 可以被保留,不会被无限切分。

文本协议便于调试,但 TCP 是字节流,不知道哪里是一条完整消息。一次 recv 可能拿到半条消息,也可能同时拿到多条。项目中的接收缓冲区会持续追加字节,只提取已经出现换行符的完整消息:

buffer.append(bytes, count);

std::vector<std::string> lines;
bool tooLong = false;
extractLines(buffer, lines, tooLong);

for (const auto& line : lines) {
  handleLine(line);
}

如果缓冲区持续增长却没有换行符,服务端会把它视为异常输入并断开连接。单行消息也设置了长度上限,避免客户端无限占用内存。

发送同样不能假设一次完成。sendAll 会记录已经发送的字节数,循环调用 send,直到整条消息写完或发生错误。这两个细节解决了最常见的 TCP 粘包、拆包和短写问题。

登录是一段独立的状态机

新连接建立后不会立即进入聊天室,而是先进入最长 60 秒的登录阶段。此时只接受三种命令:注册、账号登录和游客进入。

注册会校验账号、密码和昵称格式,并在同一个临界区内完成查重和插入,防止两个并发请求注册相同账号。游客昵称不能与任何已注册昵称相同,这能避免游客冒充已有用户。

登录成功后,服务端才创建 ClientInfo,记录套接字、IP、昵称、账号、权限和在线状态,并把它加入在线用户表。连接从此切换到聊天阶段,才能发送消息、切换状态或执行管理员命令。

广播不能一直拿着全局锁

服务端为每个客户端启动一个独立线程。这种模型直观,适合当前最大 50 人的局域网场景,但多个线程会同时访问在线用户、禁言表和文件路由表。

项目使用两层锁控制共享状态:

  • 全局互斥锁保护用户表、账号表、封禁表和临时路由状态。
  • 每个 ClientInfo 拥有独立发送锁,保证同一套接字上的消息不会互相穿插。

广播时最重要的处理是缩短全局锁持有时间:

std::vector<std::shared_ptr<ClientInfo>> recipients;
{
  std::lock_guard<std::mutex> lock(globalMutex);
  for (const auto& [name, client] : online) {
    recipients.push_back(client);
  }
}

for (const auto& client : recipients) {
  sendTo(client, message);
}

网络发送可能因为某个客户端缓慢而阻塞。如果把它放在全局锁内,所有登录、退出和状态更新都会一起等待。先复制 shared_ptr 再发送,既保证对象生命周期,也把慢操作移出了临界区。

群聊、私聊和在线状态

公共消息写入数据库后广播给除发送者外的在线用户,发送者由客户端本地回显。私聊先在在线表中查找目标,只向对应套接字转发,并保存发送者和接收者信息。

用户可以切换在线、离开、忙碌和隐身状态。隐身状态不会出现在普通成员列表里;在进入或离开隐身状态时,服务端只重新推送成员列表,不广播“某人切换了隐身”的提示,避免提示本身暴露用户。

管理员能力和普通消息使用同一套协议,但服务端始终重新校验权限。即使普通客户端手工构造 MUTEBAN 命令,也会收到“需要管理员权限”的错误。

文件传输如何复用消息通道

文件传输被拆成三个阶段:

FILE_BEGIN|目标|文件ID|文件名|大小
FILE_CHUNK|文件ID|Base64 数据
FILE_END|文件ID

服务端在 FILE_BEGIN 时记录“发送者 + 文件 ID”对应的路由,后续分块只能沿这个路由发往私聊目标或群聊成员,结束或连接断开时清理临时状态。

客户端每次读取 720 字节,将数据编码为 Base64 后发送。接收端只保留文件名,去掉发送方路径,再把文件写入自己的 downloads 目录,从而避免利用文件名写到任意目录。客户端限制单文件最大 10 MB。

Base64 让二进制内容可以继续走文本协议,代码简单,但体积会增加约三分之一。更完整的版本应改用二进制帧,并增加分块序号、确认、哈希校验、超时和断点续传。

SQLite 统一保存运行数据

早期版本把账号、封禁和聊天记录分别写入文本文件。当前版本改为 SQLite,并建立五类数据表:账号、封禁 IP、消息、管理员事件和服务端事件。

数据库开启 WAL 日志模式、完整互斥线程模式和忙碌超时。注册账号时使用预处理语句与参数绑定,消息和管理操作也通过统一接口写入数据库。这样既减少文件格式之间的不一致,也为后续查询聊天记录和制作管理界面留下空间。

接收线程不直接操作窗口

Win32 控件应该由创建它们的主线程更新。客户端的网络线程只负责 recv、分帧和判断消息类型,然后调用 PostMessageW 把数据指针交给窗口消息循环:

for (const auto& line : lines) {
  PostMessageW(mainWindow, WM_SERVER, 0,
               reinterpret_cast<LPARAM>(new std::string(line)));
}

窗口过程收到 WM_SERVER 后,再更新会话列表、成员列表和消息气泡,并释放字符串。这种方式避免后台线程直接操作界面造成竞态,也让网络层与展示层的职责更加清晰。

目前仍需要补上的安全能力

这个项目适合可信局域网中的学习和演示,并不等于已经满足公网安全要求:

  • 消息是明文 TCP,尚未启用 TLS。
  • 密码只做 SHA-256 摘要,没有随机盐和计算成本。
  • 默认管理员凭据需要在正式使用前移除或强制修改。
  • 文件缺少类型限制、病毒扫描和完整性校验。
  • 每连接一线程适合小规模场景,不适合直接扩大到大量长连接。

如果继续迭代,我会优先加入 TLS、Argon2id 或 bcrypt、挑战式会话认证和文件 SHA-256 校验,再考虑线程池或事件驱动网络模型。把边界写清楚,比把学习项目包装成“生产可用”更有价值。

这次项目带来的理解

聊天室表面上是消息发送,内部却是一组相互制约的问题:协议必须能分帧,并发代码必须明确锁的范围,持久化不能拖住网络路径,界面线程也不能被后台任务随意修改。

这次实现让我真正理解了一个原则:网络程序首先要设计状态和边界,然后才是增加功能。只要协议、生命周期和数据所有权足够清楚,群聊、私聊、文件和管理功能才有稳定扩展的基础。