Qt网络会议室二次开发本文为Qt6网络会议室二次开发的复盘和总结希望一些内容能帮助到读者项目地址giteegithub项目背景目的为了能在简历上有好的项目经历这次对第一版做了升级当然可能还会有三次开发目标用户和使用场景小团队内部沟通简单介绍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上锁后需要等待文件名传输完才解锁但如果有很多文件这里主线程也会在等待锁释放中卡住一段时间如果这个方法还用在下载文件上那么下载大文件的过程中客户端无法进行其他交互线程创建代码moveToThreadstarted信号启动 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等方案取舍表格决策 1socket 跨线程保护方案方案 AQMutex 加锁 flush方案 B单线程消费模型思路不改架构每个 socket 读写点加锁保护单独开一个消费线程所有协议读写集中处理改动量集中在 socket 读写点~15 处需要新增线程 大量信号槽桥接所有模块是否改架构不改继续 thread-per-client大改消费逻辑重新组织风险容易漏加锁、漏 flush信号槽数量暴增复杂度高结论选了“这要修改的东西特别多能不用就不用这是最坏的打算”决策 2服务端锁类型QMutex非递归QRecursiveMutex递归重入场景不允许同一线程重复加锁允许内部有计数性能略快略慢多一个计数检查为什么服务端可以不用递归精心设计持锁只做 socket 读写emit/广播前必定解锁/选型依据代码审查可以验证看作用域就知道锁什么时候释放如果用了递归锁可能掩盖持锁 emit的隐藏 bug决策 3文件信号参数类型QFileInfoQString能否 QueuedConnection不行未注册元类型静默丢失信号Qt 原生支持改动需要qRegisterMetaTypeQFileInfo()虽然我并不了解这个元类型注册槽内QFileInfo(path)构造一下即可改动小Day6 记录“QFileInfo 对象没有在 Qt 元对象系统中注册无法用于跨线程的队列连接”“改参数为 QString 来表示地址解决成功”决策 4P2P 文件传输C/S 中转本项目P2P 直连原理文件先上传服务端下载方从服务端拉客户端之间直连传输优势实现简单服务端统一管理支持离线文件不占服务端带宽速度更快劣势服务端带宽瓶颈需要 NAT 穿透实现复杂Day2 结论选择“发现要自己写内网穿透而且要改大量代码已超出现有技术”经验汇总一、协议设计在开发前需要预先梳理好相关协议本次项目由于一开始未设置新协议而是遇到一个加一个这使得后面出现很多的协议冲突不得不进行协议的重构和项目的重构协议之间要保证独立性这能保证跨线程读协议时不会读到其他的协议使用锁只能保证socket读写串行但如果协议出现bug还是会出现误消费的情况这里给个本次项目具体的例子在客户端向服务端连接后客户端要向服务端发送第一个数据包告诉服务端自己的name 我一开始沿用的新协议首包格式{nameSize} INTERUPT {name} 而客户端连接后还会在子线程发送心跳包格式PUMPING 结果 2 字节的名字如 AAsize 前缀首字节 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混乱锁类型的选择有讲究服务端用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 设计sizeINTERUPTbody——这些决策现在回头看得清清楚楚。这些都是宝贵的经验四、安全意识储备需要一定的安全意识本次项目中存在一定的安全性问题明文 TCP 传输窃听风险下载路径无安全检测私信文件可被同名随机数刷取无并发连接上限可能被 DoS这些也是后续优化的方向面试中可能也会作为面试点来询问五、杂项开发随想工程是一个从底开发逐步向上的过程因为有模块的复用 开发还是需要学会 cv复制逻辑碎片模块 代码量多的项目工程不一定复杂主要还是看逻辑量和逻辑复杂度 所有放到项目上的优化都要基于业务的实际出发没有脱离业务的优化如果重新做一开始就把协议设计好协议族 子类型 分层直接用QRecursiveMutex而不是先上QMutex再换文件传输从一开始就预留long long支持超大文件传输(未考虑速率)日志模块统一用信号不在子线程直接操作 UIQt不支持子线程操作主UIFileTransformer 在子线程直接调ui-logInfo-addItem()是 Qt 禁止的行为能跑是运气画出模块图和流程图写更好的文档如果你也有写Qt网络项目的打算我给出以下建议协议先列出来不要上来就写代码QTcpSocket 可以用单线程操作也可以用多线程锁但是要记得flush不要堵在缓冲区了QFileInfo不能跨线程传参写日志是很好的调试工具调试要先定位模块再精准定位问题可以用日志可以断点追踪变量最好画出流程图最后看下本次项目的效果图