当前位置: 首页 > news >正文

Qt网络会议室二次开发实战----疯狂踩坑 + 技术复盘

Qt网络会议室二次开发

本文为Qt6网络会议室二次开发的复盘和总结,希望一些内容能帮助到读者

项目地址:

gitee
github

项目背景

目的:

  1. 为了能在简历上有好的项目经历,这次对第一版做了升级,当然可能还会有三次开发
  2. 目标用户和使用场景:小团队内部沟通

简单介绍:QtChat 是一个基于 Qt6 的 C/S 架构网络会议室,支持多客户端并发、实时聊天、消息私发、心跳检测、服务端踢人、共享/私发文件传输。服务端使用 thread-per-client 模型,自研应用层协议,SQLite 持久化聊天记录。

具体的协议,模型图在项目文档中,列在这里太占空间了,就不列了

技术选型分析

  • 为什么 thread-per-client 而不是线程池?

    初期目标是快速实现功能,线程池需要任务队列+线程管理,复杂度高。实际在线人数 < 100 时,thread-per-client 完全够用,性能问题少。预留了 Worker 的扩展接口,后续可平滑迁移到线程池。

  • 为什么自研协议?

    1是学习目的,2是自研协议可以精确控制每字节的含义。

协议迭代分析

  • v1 协议:所有 flag 平铺,INTERUPT\n,协议之间冲突很多,极易出现Bug

  • v2 协议:总体格式: 协议族 + 子类型 + {自定义协议结构}

  • 修改原因:v1协议是边开发边写的,也就是需要使用协议的时候临时开发,导致协议越堆越多,冲突也就越来越多,这导致后面的进度无法进行推进,需要对协议进行重构

  • 对比样例(以客户端的首包为例)

    v1: {用户名} ----------------- 服务端readAll 中间过渡 {nameSize} + INTERUPT + {name} 发现这样还是有冲突风险,故改成下面的 v2: 首包协议族 + {nameSize} + INTERUPT + {name}

关键代码片段

  • 协议解析核心循环receiveMsgwhile(bytesAvailable > 0)的粘包处理逻辑(以客户端为例)

  • 解决沾包的手段:

    1. 根据字节大小读取
    2. 分割符
    /* 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:文件信号参数类型

QFileInfoQString
能否 QueuedConnection不行,未注册元类型,静默丢失信号Qt 原生支持
改动需要qRegisterMetaType<QFileInfo>()
虽然我并不了解这个元类型注册
槽内QFileInfo(path)构造一下即可,改动小
Day6 记录“QFileInfo 对象没有在 Qt 元对象系统中注册,无法用于跨线程的队列连接”“改参数为 QString 来表示地址,解决成功”

决策 4:P2P 文件传输

C/S 中转(本项目)P2P 直连
原理文件先上传服务端,下载方从服务端拉客户端之间直连传输
优势实现简单,服务端统一管理,支持离线文件不占服务端带宽,速度更快
劣势服务端带宽瓶颈需要 NAT 穿透,实现复杂
Day2 结论选择“发现要自己写内网穿透,而且要改大量代码,已超出现有技术”

经验汇总

一、协议设计

  1. 在开发前需要预先梳理好相关协议,本次项目由于一开始未设置新协议,而是遇到一个加一个,这使得后面出现很多的协议冲突,不得不进行协议的重构和项目的重构

  2. 协议之间要保证独立性,这能保证跨线程读协议时不会读到其他的协议
    使用锁只能保证socket读写串行,但如果协议出现bug还是会出现误消费的情况,这里给个本次项目具体的例子

    在客户端向服务端连接后,客户端要向服务端发送第一个数据包,告诉服务端自己的name 我一开始沿用的新协议:首包格式:{nameSize} + INTERUPT + {name} 而客户端连接后还会在子线程发送心跳包:格式:PUMPING 结果 2 字节的名字(如 "AA")size 前缀首字节 '2' 恰等于 PUNPING_INFO,被心跳分支误消费 导致客户端名字被吞的Bug 因此,后面我把首包格式的协议改成:首包协议族 + {nameSize} + INTERUPT + {name}

    永远不要让数据恰好落在控制字符的取值空间里,协议族应该自上而下统一分配,避免数值碰撞。

  3. {size} + INTERUPT + {body}是表达变长字段的最优解

    这保证了尽可能少的调用系统函数,只需用O(n)复杂度读取{size}(小数据量),再用O(1)复杂度读取{body}(大数据量);

    比纯分隔符解析更可靠(不会因 body 中出现分隔符而截断),比固定长度更灵活。这是一个经过实战验证的协议设计范式。

二、对Qt 多线程的理解

  1. QTcpSocket 线程安全性

    • Qt 的QTcpSocket不保证线程安全
    • QTcpSocket使用write实际上是放入缓冲区,需要用flush刷新(发送),read是消费(消费缓冲区的内容),peek只是简单的访问,但不会消费
    • 跨线程对同一个socket操作需要加锁,以防止socket混乱
  2. 锁类型的选择有讲究

    服务端用QMutex(非递归),客户端用QRecursiveMutex(递归)。
    原因在于客户端的receiveMsg持锁期间 emit 信号,而FilesReceiver的槽是 DirectConnection(同在主线程),槽内又需要加锁——必须可重入。锁的类型选择不是随意的,取决于调用链中是否存在"持锁→emit→槽→再加锁"的路径。

  3. QueuedConnection 参数类型

    QFileInfo作为信号参数,QueuedConnection 无法序列化 → 信号静默失败。改成QString传路径,槽内再构造QFileInfo

    跨线程信号参数必须用 Qt 元对象系统原生支持的类型,否则 QueuedConnection 静默丢失。

三、工程经验总结

  1. 尽可能不要修改架构

    项目期间遇到 socket 线程安全问题时,我想到两个方案:
    A) 改架构,单线程消费所有协议
    B) 所有socket操作加锁保护
    尽管A的话模型会更简单,但我还是选择了 B 方案。改动集中在 socket 读写处

    问题在哪发现的?
    当客户端点击刷新后,无法读取服务端信息,但可以给服务端发送信息,且确认了write和read正常执行

    改动架构/重构需要消耗大量的时间和精力,不到万不得已尽可能不要改架构

    但是,在经验1这里我选择了协议重构,然后把整个项目进行重构,这是因为如果不进行重构,会存在大量的协议冲突,且难以修改,后面的进度会很难推进,所以选择了重构

  2. 性能优化要考虑实际情况

    二次开发的项目日志中:Day1 记录了关于stringList用哈希/红黑树优化的讨论,最终放弃。我的理由是:

    聊天室的性能瓶颈在 I/O(尤其是文件传输),不在用户列表的查找复杂度。人数规模受物理限制(一个聊天室不可能几千人同时说话),O(n) 的线性查找完全够用。
    因此没有必要再使用红黑树或哈希表来进行优化,而且这里用这两个数据结构优化也会消耗大量时间

    并非每一处性能优化都要做,需要根据实际的业务逻辑和处理优先级

  3. 模块并非"解耦越彻底越好"

    适度耦合有助于模块复制。完全解耦会使模块粒度太细、碎片化,不利于理解整体逻辑。
    需要找到一个很好的平衡点——知道模块的主要功能后,保留一定的耦合性,便于复制其中的碎片逻辑迁移到其他场景。

  4. 人做决策,AI 做体力活

    协议重构的时候,我是用AI来辅助,因为这需要修改每一处的read/write/peek的格式,逻辑简单但量大,交给人做难免有失误,用Ai的效率高很多

    “我打算交给 AI 去重构,不然实在是太耗时间了,协议由我来设计” “这里让 AI 帮忙改了,全是体力活”

    但同时也要记录了 AI 的缺陷:注释缺失、宏不一致、逻辑遗漏。模式总结:人做协议设计、架构决策、代码审查;AI 做机械性的批量改动。最终每一行改动都要经过 review。

    学习过程中也并非完全抵制AI编程

  5. 写项目日志

    day1~day6 的开发日志是这个项目最宝贵的资产之一。
    记录了**“做了什么”,记录了"当时为什么这么想"和"踩了什么坑"**。
    Day2 放弃了 P2P 方案,
    Day3 尝试高性能网盘设计(高性能文件传输),
    Day4 设计size+INTERUPT+body——这些决策现在回头看得清清楚楚。
    这些都是宝贵的经验

四、安全意识储备

  1. 需要一定的安全意识

    本次项目中,存在一定的安全性问题

    • 明文 TCP 传输,窃听风险
    • 下载路径无安全检测
    • 私信文件可被同名+随机数刷取
    • 无并发连接上限,可能被 DoS

    这些也是后续优化的方向,面试中可能也会作为面试点来询问

五、杂项(开发随想)

"工程是一个从底开发,逐步向上的过程,因为有模块的复用" "开发还是需要学会 cv,复制逻辑碎片模块" "代码量多的项目工程不一定复杂,主要还是看逻辑量和逻辑复杂度" "所有放到项目上的优化都要基于业务的实际出发,没有脱离业务的优化"

如果重新做

  • 一开始就把协议设计好(协议族 + 子类型 分层)

  • 直接用QRecursiveMutex而不是先上QMutex再换

  • 文件传输从一开始就预留long long,支持超大文件传输(未考虑速率)

  • 日志模块统一用信号,不在子线程直接操作 UI(Qt不支持子线程操作主UI)

    FileTransformer 在子线程直接调ui->logInfo->addItem()是 Qt 禁止的行为,能跑是运气

  • 画出模块图和流程图,写更好的文档

如果你也有写Qt网络项目的打算

我给出以下建议

  • 协议先列出来,不要上来就写代码
  • QTcpSocket 可以用单线程操作,也可以用多线程+锁,但是要记得flush,不要堵在缓冲区了
  • QFileInfo不能跨线程传参
  • 写日志是很好的调试工具,调试要先定位模块,再精准定位问题,可以用日志,可以断点追踪变量
  • 最好画出流程图

最后

看下本次项目的效果图

http://www.jsqmd.com/news/1165448/

相关文章:

  • 2026年7月最新泰州雅典官方售后客户服务电话及线下网点地址 - 亨得利官方服务中心
  • S7和OPC UA,你的项目该用谁?
  • 2026年7月最新沈阳美度官方售后客服电话及服务网点地址查询 - 亨得利钟表维修中心
  • 高精度ADC ADS122U04与PIC32MX695F512L的工业测量方案
  • ngx_http_gzip_header_filter
  • Boson Netsim 思科路由器 OSPF 多区域配置:3 区域划分与 LSA 类型解析
  • 可灵AI NEXTGEN技术解析:多模态生成与推理优化实践指南
  • ATmega8 分立元件 125kHz RFID 阅读器:3 大核心电路模块与 15cm 读距实测
  • Cocos2d-x 4.0 Windows开发环境搭建与CocosUI入门实战
  • 华为视角C++实战:从语法到工程,构建高性能系统开发能力
  • 点对点接口开发6大核心痛点与集成平台式解决方案
  • Codex 桌面端第三方 API 暂无 GPT-5.6 选项?手动修改新旧会话模型的方法(附详细教程)
  • 男子在无空调室内干一天活,肾坏了!夏天干活的兄弟,别再拿命硬
  • STM32F407与ADS127L11高精度ADC信号采集方案
  • 2026年电钢琴选购指南:十款高性价比推荐(含三款派德拉)
  • 2026年7月最新徐州宝珀官方售后维修服务网点地址与客服电话 - 宝珀官方售后服务中心
  • 【小白也能轻松玩转龙虾】虾壳云一键部署文档 AI,表格自动处理 OpenClaw(附最新安装包)
  • 卡地亚中国官方售后服务中心|服务热线及门店详细地址权威信息通告(2026年7月更新) - 卡地亚官方售后中心
  • 2026 年当下,丽水比较好的耐候钢板树篦子批发厂家选哪家,别再用普通材料!这玩意儿如何让你的树篦子寿命翻倍? - 领域鉴赏官
  • 劳力士回收价格查询及各大回收平台实测排行(2026年7月最新) - 尊奢回收二奢平台
  • Kling AI视频生成模型:物理模拟与细节表现的技术解析
  • 2026年7月最新杭州雷达官方售后联系电话与客户服务中心网点地址 - 亨得利钟表维修中心
  • Hermes Desktop 安装与 DeepSeek 模型配置:新手一篇跑通
  • 3种 PWM 生成方案对比:MCU 硬件定时器 vs 软件模拟 vs 专用 IC
  • Gemma 4移动端部署实战:Android原生跑大模型全链路指南
  • 高精度数据采集系统设计与实现:基于ADS122U04与PIC18F46K20
  • 深海采矿 ROV 技术 2024:3类矿产(结核/SMS/结壳)的采掘系统与效率对比
  • Linux使用结构化命令
  • 2026年国内语音芯片行业供应商调研:3家长期合作靠谱语音芯片厂家推荐
  • 激光器长导通 1ms 驱动电路设计:电容储能方案解决 5V 电源 0.8m 线压降