这个局域网聊天室最初是一次 C++ 网络编程练习,后来逐步加入了图形界面、账号系统、私聊、文件传输和管理员功能。功能增加以后,真正困难的部分不再是“怎样调用 send”,而是怎样让协议、线程、状态和界面保持一致。
本文记录当前桌面版本的真实设计,也说明其中为了学习和可控规模做出的取舍。
整体架构
项目分为三个可执行部分:
单文件启动器 Chatroom.exe
│
├─ 启动并检查 TCP 服务端(默认端口 8888)
│ ├─ 连接与登录状态机
│ ├─ 消息和文件路由
│ └─ SQLite 持久化
│
└─ 打开 Win32 图形客户端
├─ 登录与注册
├─ 会话和成员列表
└─ 消息、状态与文件交互
启动器把服务端、客户端和默认配置作为资源嵌入自身。运行时先创建 runtime 目录,再检查 127.0.0.1:8888 是否已经有服务端监听。若没有,则释放服务端并等待最多约三秒;确认端口可用后才启动客户端。
这个设计把多个程序的启动顺序藏在一个入口后面,同时允许后续启动的客户端复用现有服务端。
用文本行协议建立边界
通信层采用 UTF-8 文本行协议:一条消息占一行,字段使用 | 分隔。
REG|账号|密码|昵称
LOGIN|账号|密码
MSG|大家好
PM|目标昵称|这是一条私聊消息
STATUS|away
服务端返回的消息也有明确类型,例如 WELCOME、ULIST、PUB、PM、SYS 和 ERR。消息正文固定为最后一个字段,因此正文中的 | 可以被保留,不会被无限切分。
文本协议便于调试,但 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 再发送,既保证对象生命周期,也把慢操作移出了临界区。
群聊、私聊和在线状态
公共消息写入数据库后广播给除发送者外的在线用户,发送者由客户端本地回显。私聊先在在线表中查找目标,只向对应套接字转发,并保存发送者和接收者信息。
用户可以切换在线、离开、忙碌和隐身状态。隐身状态不会出现在普通成员列表里;在进入或离开隐身状态时,服务端只重新推送成员列表,不广播“某人切换了隐身”的提示,避免提示本身暴露用户。
管理员能力和普通消息使用同一套协议,但服务端始终重新校验权限。即使普通客户端手工构造 MUTE 或 BAN 命令,也会收到“需要管理员权限”的错误。
文件传输如何复用消息通道
文件传输被拆成三个阶段:
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 校验,再考虑线程池或事件驱动网络模型。把边界写清楚,比把学习项目包装成“生产可用”更有价值。
这次项目带来的理解
聊天室表面上是消息发送,内部却是一组相互制约的问题:协议必须能分帧,并发代码必须明确锁的范围,持久化不能拖住网络路径,界面线程也不能被后台任务随意修改。
这次实现让我真正理解了一个原则:网络程序首先要设计状态和边界,然后才是增加功能。只要协议、生命周期和数据所有权足够清楚,群聊、私聊、文件和管理功能才有稳定扩展的基础。