Qt网络会议室二次开发实战----疯狂踩坑 + 技术复盘
Qt网络会议室二次开发
本文为Qt6网络会议室二次开发的复盘和总结,希望一些内容能帮助到读者
项目地址:
gitee
github
项目背景
目的:
- 为了能在简历上有好的项目经历,这次对第一版做了升级,当然可能还会有三次开发
- 目标用户和使用场景:小团队内部沟通
简单介绍:QtChat 是一个基于 Qt6 的 C/S 架构网络会议室,支持多客户端并发、实时聊天、消息私发、心跳检测、服务端踢人、共享/私发文件传输。服务端使用 thread-per-client 模型,自研应用层协议,SQLite 持久化聊天记录。
具体的协议,模型图在项目文档中,列在这里太占空间了,就不列了
技术选型分析
为什么 thread-per-client 而不是线程池?
初期目标是快速实现功能,线程池需要任务队列+线程管理,复杂度高。实际在线人数 < 100 时,thread-per-client 完全够用,性能问题少。预留了 Worker 的扩展接口,后续可平滑迁移到线程池。
为什么自研协议?
1是学习目的,2是自研协议可以精确控制每字节的含义。
协议迭代分析
v1 协议:所有 flag 平铺,
INTERUPT是\n,协议之间冲突很多,极易出现Bugv2 协议:总体格式: 协议族 + 子类型 + {自定义协议结构}
修改原因:v1协议是边开发边写的,也就是需要使用协议的时候临时开发,导致协议越堆越多,冲突也就越来越多,这导致后面的进度无法进行推进,需要对协议进行重构
对比样例(以客户端的首包为例)
v1: {用户名} ----------------- 服务端readAll 中间过渡 {nameSize} + INTERUPT + {name} 发现这样还是有冲突风险,故改成下面的 v2: 首包协议族 + {nameSize} + INTERUPT + {name}
关键代码片段
协议解析核心循环:
receiveMsg里while(bytesAvailable > 0)的粘包处理逻辑(以客户端为例)解决沾包的手段:
- 根据字节大小读取
- 分割符
/* serverTar:目标服务端 */ void Client::receiveMsg() { // 上锁 QMutexLocker locker(socketMutex); // 循环处理粘包:每次读一个完整消息,直到缓冲区空 // 先通过读取首字节获取协议族 while (serverTar->bytesAvailable() > 0) { char flag; if (!serverTar->getChar(&flag)) break; // 然后根据不同的协议族,进入不同的分支进行处理 // 服务器关闭 / 被踢出:flag + size + INTERUPT + body if (flag == SERVER_CLOSE[0] || flag == SERVER_KICK[0]) { ... } else if (flag == CHAT_INFO[0]) { // 读子类型(3字节) ... } else if (subType == CHAT_PRIVATE_FWD) { ... } ... } else if (flag == SEARCH_SUCCESS[0]) { ... } else if (flag == SEARCH_FAILED[0]) { // 私信发送失败回执 QByteArray content; char ch; while (serverTar->getChar(&ch) && ch != INTERUPT[0]) content.append(ch); writeLog("私信发送失败,用户名填写错误"); } else if (flag == FILE_TRANSFER_RESULT[0]) { // 文件传输协议 —— 读子类型(3字节) QByteArray subType = serverTar->read(3); if (subType == FT_SHARED_NOTIFY) { ... } } else if (...) { ... } ... } ... // 是其他字符,可能是FILE_TRANSFER_END等,考虑安全性 else continue; } }锁的使用模式:本次项目有多种使用锁的方式,包括
标准范式:持锁,读写,释放
子线程 peek + 释放 + emit(如果 emit 持锁,可能死锁(QueuedConnection 主线程槽函数要加锁)
持锁读写 + 解锁后 emit/广播 (用的最多的,在服务端)
客户端持锁 + emit → DirectConnection → 再次加锁(递归)这里详解第四条为什么用递归锁,客户端为什么必须用 QRecursiveMutex,代码如下:
// Client/Client/client.cpp void Client::receiveMsg() { QMutexLocker locker(socketMutex); // 第一次加锁 while (serverTar->bytesAvailable() > 0) { // ... 解析各种协议 ... // 当 flag == FILE_TRANSFER_RESULT 时:(文件协议响应族) } else if (flag == FILE_TRANSFER_RESULT[0]) { // ...读 子类型 if (subType == FT_QUERY_SUCCESS) { // 这里是可下载文件查询成功子类型 emit getFiles(); // DirectConnection → FilesReceiver::receiveAvailableFiles } } } }该槽函数里面:QMutexLocker locker(socketMutex);
此时同一线程、同一锁再次 lock → 非递归锁死锁!如果强制解锁,让
receiveAvailableFiles上锁:
首先别的协议也会一起解锁(这个函数处理了客户端接收消息的所有协议),别的协议处理会有对socket临界区的竞态
其次,receiveAvailableFiles上锁后,需要等待文件名传输完才解锁,但如果有很多文件,这里主线程也会在等待锁释放中卡住一段时间,如果这个方法还用在下载文件上,那么下载大文件的过程中,客户端无法进行其他交互
线程创建代码:
moveToThread+started信号启动 worker 的写法/* 以客户端为例 */ // 创建文件传输线程和传输工人(发送和接收) TransferWorker = new FilesTransFerer(nullptr, this, serverTar, socketMutex); filesReceiver = new FilesReceiver(nullptr, serverTar, this, socketMutex); // 一个客户端有一个自己的文件模块线程 fileTransferThread = new QThread(this); TransferWorker->moveToThread(fileTransferThread); // 启动子线程 fileTransferThread->start();文件分片传输:64KB 分片的 while 循环
...... // 以客户端下载文件为例 QFile file(downloadDir); file.open(QIODevice::WriteOnly); qint64 received = 0; while (received < fileSize) { // 这里用min是为了防止越界,当文件剩余大小不够时,直接读完剩余内容 QByteArray chunk = targetServer->read(qMin(fileSize - received, 65536LL)); // 64KB读取 file.write(chunk); received += chunk.size(); } file.close(); ......
印象深刻的bug排查
本次开发中,最深刻的是下面这个bug
初步现象: 客户端在打开文件模块后,点击刷新,但是没有任何响应,此后看不到任何消息了,包括自己发的 但是别的客户端能看到该客户端发来的消息,而且服务器也确实完成了消息的广播和私发 首次猜想:客户端可能出问题了,没有读取数据,在调试的时候也证明了这一点,客户端确实无法读取数据,无法进入readyread状态 继续猜想:服务端向该客户端发送的时候出问题了,但是调试的时候发现,代码执行到向对应客户端write的时候没有返回错误码,这证明服务端确实是按照逻辑正确执行的 继续猜想:客户端的读socket功能损坏,而服务器的写对应客户端socket功能损坏,可能是跨线程读写socket带来的竞态问题,我尝试在服务端读取客户端发来的请求,并写入日志,却发现是正确的 行为:但是这也有可能只是巧合,以防万一,还是为每个socket读写都上了锁 然后这里观察了好久,最终在广播接口中发现了flush接口,这给了我新的思路 猜想:我只是进行了write,但没有flush,网络流堵在缓冲区没有发送,因此客户端也无法read 调试后,发现该猜想正确,解决了问题第二个案例,也是经典的案例:死锁
/* 初步现象:客户端在正确上传文件后,服务端崩溃(卡死) 猜测:服务端对文件的处理有问题,断点调试,寻找bug 最终发现服务端在传输文件的时候,拿了两次锁 */ QString FileTransformer::saveFile(QTcpSocket* cli, const QString& originalName, qint64 fileSize) { /* 这里本来有个加锁的代码,现在删了 */ // 处理文件 .... return savedName; } void FileTransformer::doReceiveFile(QTcpSocket* cli) { // 预处理代码 ... QMutexLocker locker(socketMutex); // 读取子类型 /* ============== 文件上传模块 =============== */ if (subType == FT_SHARED_UPLOAD) { // 预处理 ... QString savedName = saveFile(client, fileName, fileSize); ...其次,还有像首包bug(有些用户名服务端无法读取),文件下载后未找到文件的bug等
方案取舍表格
决策 1:socket 跨线程保护方案
| 方案 A:QMutex 加锁 + flush | 方案 B:单线程消费模型 | |
|---|---|---|
| 思路 | 不改架构,每个 socket 读写点加锁保护 | 单独开一个消费线程,所有协议读写集中处理 |
| 改动量 | 集中在 socket 读写点(~15 处) | 需要新增线程 + 大量信号槽桥接所有模块 |
| 是否改架构 | 不改,继续 thread-per-client | 大改,消费逻辑重新组织 |
| 风险 | 容易漏加锁、漏 flush | 信号槽数量暴增,复杂度高 |
| 结论 | 选了 | “这要修改的东西特别多,能不用就不用,这是最坏的打算” |
决策 2:服务端锁类型
| QMutex(非递归) | QRecursiveMutex(递归) | |
|---|---|---|
| 重入场景 | 不允许同一线程重复加锁 | 允许,内部有计数 |
| 性能 | 略快 | 略慢(多一个计数检查) |
| 为什么服务端可以不用递归 | 精心设计:持锁只做 socket 读写,emit/广播前必定解锁 | / |
| 选型依据 | 代码审查可以验证:看作用域就知道锁什么时候释放 | 如果用了递归锁,可能掩盖"持锁 emit"的隐藏 bug |
决策 3:文件信号参数类型
| QFileInfo | QString | |
|---|---|---|
| 能否 QueuedConnection | 不行,未注册元类型,静默丢失信号 | Qt 原生支持 |
| 改动 | 需要qRegisterMetaType<QFileInfo>()虽然我并不了解这个元类型注册 | 槽内QFileInfo(path)构造一下即可,改动小 |
| Day6 记录 | “QFileInfo 对象没有在 Qt 元对象系统中注册,无法用于跨线程的队列连接” | “改参数为 QString 来表示地址,解决成功” |
决策 4:P2P 文件传输
| C/S 中转(本项目) | P2P 直连 | |
|---|---|---|
| 原理 | 文件先上传服务端,下载方从服务端拉 | 客户端之间直连传输 |
| 优势 | 实现简单,服务端统一管理,支持离线文件 | 不占服务端带宽,速度更快 |
| 劣势 | 服务端带宽瓶颈 | 需要 NAT 穿透,实现复杂 |
| Day2 结论 | 选择 | “发现要自己写内网穿透,而且要改大量代码,已超出现有技术” |
经验汇总
一、协议设计
在开发前需要预先梳理好相关协议,本次项目由于一开始未设置新协议,而是遇到一个加一个,这使得后面出现很多的协议冲突,不得不进行协议的重构和项目的重构
协议之间要保证独立性,这能保证跨线程读协议时不会读到其他的协议
使用锁只能保证socket读写串行,但如果协议出现bug还是会出现误消费的情况,这里给个本次项目具体的例子在客户端向服务端连接后,客户端要向服务端发送第一个数据包,告诉服务端自己的name 我一开始沿用的新协议:首包格式:{nameSize} + INTERUPT + {name} 而客户端连接后还会在子线程发送心跳包:格式:PUMPING 结果 2 字节的名字(如 "AA")size 前缀首字节 '2' 恰等于 PUNPING_INFO,被心跳分支误消费 导致客户端名字被吞的Bug 因此,后面我把首包格式的协议改成:首包协议族 + {nameSize} + INTERUPT + {name}永远不要让数据恰好落在控制字符的取值空间里,协议族应该自上而下统一分配,避免数值碰撞。
{size} + INTERUPT + {body}是表达变长字段的最优解这保证了尽可能少的调用系统函数,只需用O(n)复杂度读取{size}(小数据量),再用O(1)复杂度读取{body}(大数据量);
比纯分隔符解析更可靠(不会因 body 中出现分隔符而截断),比固定长度更灵活。这是一个经过实战验证的协议设计范式。
二、对Qt 多线程的理解
QTcpSocket 线程安全性
- Qt 的
QTcpSocket不保证线程安全 - QTcpSocket使用write实际上是放入缓冲区,需要用flush刷新(发送),read是消费(消费缓冲区的内容),peek只是简单的访问,但不会消费
- 跨线程对同一个socket操作需要加锁,以防止socket混乱
- Qt 的
锁类型的选择有讲究
服务端用
QMutex(非递归),客户端用QRecursiveMutex(递归)。
原因在于客户端的receiveMsg持锁期间 emit 信号,而FilesReceiver的槽是 DirectConnection(同在主线程),槽内又需要加锁——必须可重入。锁的类型选择不是随意的,取决于调用链中是否存在"持锁→emit→槽→再加锁"的路径。QueuedConnection 参数类型
QFileInfo作为信号参数,QueuedConnection 无法序列化 → 信号静默失败。改成QString传路径,槽内再构造QFileInfo跨线程信号参数必须用 Qt 元对象系统原生支持的类型,否则 QueuedConnection 静默丢失。
三、工程经验总结
尽可能不要修改架构
项目期间遇到 socket 线程安全问题时,我想到两个方案:
A) 改架构,单线程消费所有协议
B) 所有socket操作加锁保护
尽管A的话模型会更简单,但我还是选择了 B 方案。改动集中在 socket 读写处问题在哪发现的?
当客户端点击刷新后,无法读取服务端信息,但可以给服务端发送信息,且确认了write和read正常执行改动架构/重构需要消耗大量的时间和精力,不到万不得已尽可能不要改架构
但是,在经验
1这里我选择了协议重构,然后把整个项目进行重构,这是因为如果不进行重构,会存在大量的协议冲突,且难以修改,后面的进度会很难推进,所以选择了重构性能优化要考虑实际情况
二次开发的项目日志中:Day1 记录了关于
stringList用哈希/红黑树优化的讨论,最终放弃。我的理由是:聊天室的性能瓶颈在 I/O(尤其是文件传输),不在用户列表的查找复杂度。人数规模受物理限制(一个聊天室不可能几千人同时说话),O(n) 的线性查找完全够用。
因此没有必要再使用红黑树或哈希表来进行优化,而且这里用这两个数据结构优化也会消耗大量时间并非每一处性能优化都要做,需要根据实际的业务逻辑和处理优先级
模块并非"解耦越彻底越好"
适度耦合有助于模块复制。完全解耦会使模块粒度太细、碎片化,不利于理解整体逻辑。
需要找到一个很好的平衡点——知道模块的主要功能后,保留一定的耦合性,便于复制其中的碎片逻辑迁移到其他场景。人做决策,AI 做体力活
协议重构的时候,我是用AI来辅助,因为这需要修改每一处的read/write/peek的格式,逻辑简单但量大,交给人做难免有失误,用Ai的效率高很多
“我打算交给 AI 去重构,不然实在是太耗时间了,协议由我来设计” “这里让 AI 帮忙改了,全是体力活”
但同时也要记录了 AI 的缺陷:注释缺失、宏不一致、逻辑遗漏。模式总结:人做协议设计、架构决策、代码审查;AI 做机械性的批量改动。最终每一行改动都要经过 review。
学习过程中也并非完全抵制AI编程
写项目日志
day1~day6 的开发日志是这个项目最宝贵的资产之一。
记录了**“做了什么”,记录了"当时为什么这么想"和"踩了什么坑"**。
Day2 放弃了 P2P 方案,
Day3 尝试高性能网盘设计(高性能文件传输),
Day4 设计size+INTERUPT+body——这些决策现在回头看得清清楚楚。
这些都是宝贵的经验
四、安全意识储备
需要一定的安全意识
本次项目中,存在一定的安全性问题
- 明文 TCP 传输,窃听风险
- 下载路径无安全检测
- 私信文件可被同名+随机数刷取
- 无并发连接上限,可能被 DoS
这些也是后续优化的方向,面试中可能也会作为面试点来询问
五、杂项(开发随想)
"工程是一个从底开发,逐步向上的过程,因为有模块的复用" "开发还是需要学会 cv,复制逻辑碎片模块" "代码量多的项目工程不一定复杂,主要还是看逻辑量和逻辑复杂度" "所有放到项目上的优化都要基于业务的实际出发,没有脱离业务的优化"如果重新做
一开始就把协议设计好(协议族 + 子类型 分层)
直接用
QRecursiveMutex而不是先上QMutex再换文件传输从一开始就预留
long long,支持超大文件传输(未考虑速率)日志模块统一用信号,不在子线程直接操作 UI(Qt不支持子线程操作主UI)
FileTransformer 在子线程直接调
ui->logInfo->addItem()是 Qt 禁止的行为,能跑是运气画出模块图和流程图,写更好的文档
如果你也有写Qt网络项目的打算
我给出以下建议
- 协议先列出来,不要上来就写代码
- QTcpSocket 可以用单线程操作,也可以用多线程+锁,但是要记得flush,不要堵在缓冲区了
QFileInfo不能跨线程传参- 写日志是很好的调试工具,调试要先定位模块,再精准定位问题,可以用日志,可以断点追踪变量
- 最好画出流程图
最后
看下本次项目的效果图
