Qt/C++自建个人网盘:从传输协议到断点续传的实战指南

Qt/C++自建个人网盘:从传输协议到断点续传的实战指南 简介一份基于QT与C实现的个人网盘系统完整源码同时提供服务端与客户端适用于计算机相关专业的学生完成课程设计、毕业设计也可作为开发者学习Qt网络编程的练手项目。压缩包共94个文件整体大小约593KB以C代码为主体包含34个头文件、30个cpp源文件、8个Qt的ui界面文件、6个qrc资源文件及4个pro工程文件另有SQL数据库脚本、txt与md说明文档目录结构清晰方便按模块检索。项目完整实现了用户注册登录、好友管理、文件上传下载与共享、在线状态展示等功能覆盖网络通信、数据库操作、GUI交互等核心环节对理解客户端/服务端架构很有帮助。目前已有202人学习使用源码经过验证可稳定运行既可支撑课程报告与毕设答辩也方便在此之上扩展即时通信、文件预览等更多功能。1. 用 Qt/C 自建个人网盘先想清楚这三件事再动手标题里的“个人网盘系统”听起来像是个大工程但拆开看无非是文件上传下载、目录管理、用户隔离和断点续传这几条主线。很多人一上来就写界面、拖控件结果服务端接口一联调就崩传输大文件直接把内存打满。真正靠谱的做法是先把协议定死、把存储路径规划好、把传输边界想清楚再谈 Qt 的 UI 怎么画。这套方案适合两类人一是 C 后端工程师想补 Qt 客户端技能树二是桌面应用开发者想把本地工具改造成 C/S 架构。读完你应该能跑通一个最小可用的网盘并知道哪些参数必须在服务端和客户端各调一遍。2. 个人网盘的传输协议与请求模型用 JSON over TCP 还是 HTTP2.1 选型Qt 生态里最顺手的传输方案是 QNetworkAccessManager 配合自定义协议常见做法是让 Qt 客户端通过 HTTP 与服务端通信因为 Qt 的QNetworkAccessManager对 HTTP 支持非常成熟POST、GET、PUT 都有现成封装还天然支持 HTTPS。相比裸 TCP 自己拼包头HTTP 的 Content-Length 和 chunked 编码能直接解决粘包拆包问题省掉大半个协议栈的活儿。数据格式用 JSON服务端和客户端都是 C 的话服务端用nlohmann/json或 Qt 的QJsonDocument解析都行客户端统一走QJsonDocument两边只约定字段名和嵌套层级。2.2 核心接口定义ping、上传、下载、目录列举、元信息服务端至少要暴露五个接口连接测试、上传文件块、下载文件块、列举目录、删除文件。每个接口的请求和响应都走 JSON 包一层元信息文件名、大小、偏移量、校验值放在 header 里文件二进制数据单独跟在 JSON 头后面。上传时客户端先发一个 JSON 头里面包含token、path、total_size、offset服务端解析完 JSON 后从 socket 里继续读原始字节流写入磁盘。这里不要偷懒把文件 base64 塞进 JSON会多出 33% 的网络开销Qt 的QByteArray直接以二进制方式追加到请求体里性能更好。// 客户端构造上传请求JSON 元信息 原始二进制 QHttpMultiPart *multiPart new QHttpMultiPart(QHttpMultiPart::FormDataType); QHttpPart jsonPart; jsonPart.setHeader(QNetworkRequest::ContentTypeHeader, QString(application/json)); jsonPart.setBody(QJsonDocument(metaObj).toJson(QJsonDocument::Compact)); QHttpPart filePart; filePart.setHeader(QNetworkRequest::ContentTypeHeader, QString(application/octet-stream)); filePart.setBodyDevice(fileDevice); // fileDevice 是已打开的 QFile* multiPart-append(jsonPart); multiPart-append(filePart); QNetworkRequest request; request.setUrl(QUrl(http://127.0.0.1:8080/upload)); request.setRawHeader(Authorization, token.toUtf8()); QNetworkReply *reply manager.post(request, multiPart);这段代码的关键在于setBodyDevice而不是setBody前者会以流式方式读取文件不会一次性把整个文件载入内存后者是QByteArray拷贝500MB 文件就可能导致客户端内存占用瞬间破 2GB。multiPart的 ownership 要交给 QNetworkReply否则请求还没发送完对象就被析构这是 Qt 网络模块最常见的坑之一。total_size和offset字段要在上传前用QFileInfo::size()和已写入字节数填充服务端用它们做分片校验。2.3 服务端最小可复现框架QTcpServer 接收 JSON 头 二进制体很多开源网盘的服务端直接用QTcpServer因为 Qt 的事件循环天然适合长连接场景。每个客户端连接用一个QTcpSocket*承载在readyRead信号里先尝试读一行 JSON以\n结尾解析出content_length之后再把剩余字节读够。这里有个容易踩的坑一次readyRead不一定能把 JSON 头和文件体都读完需要维护一个状态机用 QBuffer 暂存不完整的包。// 服务端接收循环的骨架 // m_buffer 是 QByteArraym_expectLength 是待读取的总字节数 void ServerHandler::onReadyRead() { m_buffer.append(m_socket-readAll()); while (m_buffer.size() (int)sizeof(int)) { // 前4字节存包体长度 int payloadLen 0; memcpy(payloadLen, m_buffer.constData(), sizeof(int)); if (m_buffer.size() payloadLen (int)sizeof(int)) break; QByteArray payload m_buffer.mid(sizeof(int), payloadLen); m_buffer.remove(0, payloadLen sizeof(int)); QJsonDocument doc QJsonDocument::fromJson(payload); handleRequest(doc); // 根据 cmd 字段路由到上传/下载处理 } }这里的包结构是4 字节长度 JSON 元信息 二进制文件体长度字段只覆盖 JSON 元信息部分文件体通过QIODevice::write边读边写避免QByteArray拼接超大文件。服务端每个连接分配独立的工作状态cmd字段支持login、upload_meta、upload_data、download、list、delete。如果是多用户网盘还应引入 token 鉴权每次请求都校验 token 后再操作文件系统。token 用QCryptographicHash::hash(用户名时间戳盐, QCryptographicHash::Sha256)生成存到服务端内存哈希表里客户端每次请求带上防止明文密码反复传输。3. 用 QFileSystemWatcher 与 QDir 构建服务端存储引擎和断点续传3.1 目录规划根目录按用户隔离、跨平台路径用 QDir::separator服务端落盘路径不能写死D:\\netdisk\\或/home/netdisk/要用QDir::currentPath() /storage拼出来然后对每个用户建独立目录。Qt 的QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)也能拿到合适的用户数据目录但部署在 Linux 服务器上时AppDataLocation可能落在/root/.local/share/权限和备份都要留意。最简单可靠的做法是在配置文件里显式指定storage_root启动时用QDir().mkpath(storageRoot)确保目录存在。每个用户目录下再按日期或文件类型建子目录会影响文件查找效率反而直接平铺存储、把相对路径记录到 SQLite 数据库里更灵活。服务端启动时扫描一次磁盘对比数据库里的记录缺失的记录标记为异常文件。这样做的好处是客户端只关心逻辑路径物理路径对客户端不可见后续做去重、迁移存储节点都不用改客户端代码。3.2 断点续传客户端记录偏移服务端只追加断点续传的核心是记录已成功写入的字节偏移。客户端在每次分片上传前发送 JSON 元信息包含offset字段服务端检查目标文件当前大小忽略offset小于实际大小的数据块直接从offset处 seek 并写入。Qt 的QFile在追加模式QIODevice::Append下会自动 seek 到文件末尾但分片上传需要精确控制写入位置必须用QFile::seek(offset)。// 服务端处理下载请求时支持 Range 头断点下载 QFile file(filePath); if (!file.open(QIODevice::ReadOnly)) { sendError(FILE_NOT_FOUND); return; } qint64 startPos 0; if (requestMeta.contains(range_start)) { startPos requestMeta[range_start].toVariant().toLongLong(); } if (startPos 0) { file.seek(startPos); } QByteArray chunk file.read(64 * 1024); // 64KB 分片发送 n-write(chunk);range_start由客户端从本地已下载字节数读取存入一个.download临时文件旁的.meta文件里里面放 JSON{path:/docs/prd.pdf, total:1048576, done:524288}。客户端每次启动扫描.meta发现有未完成任务就自动续传。这里不建议把断点信息写进数据库桌面端场景下 SQLite 数据可能因非正常退出损坏一个临时 JSON 文件反而更容易自愈。传输完成后把临时文件还名为目标文件名QFile::rename在 Windows 下如果目标已存在会失败需要先QFile::remove再重命名。3.3 秒传功能的取舍用文件哈希判断重复存储秒传是网盘用户感知最强的功能实现思路是客户端计算文件 SHA-256 或 MD5上传前先请求check_hash接口服务端在哈希索引表里查重如果已存在相同哈希文件直接返回“上传成功”客户端不传文件体。Qt 端计算大文件哈希要用流式读取QCryptographicHash的addData支持分块喂数据// 客户端分块计算文件哈希 QCryptographicHash hash(QCryptographicHash::Sha256); QFile f(filePath); if (!f.open(QIODevice::ReadOnly)) return; QByteArray buf; const qint64 chunkSize 1 20; // 1MB while ((buf f.read(chunkSize)) ! nullptr) { hash.addData(buf); } QString fileHash hash.result().toHex();服务端查询哈希表时如果哈希相同但文件名不同要返回deduptrue同时把已有文件的引用计数加一。真正落盘时做硬链接或软链接Windows 上QFile::link对 NTFS 支持有限稳妥做法是直接复制一份到新用户目录虽然多占地盘但跨平台兼容性最好。哈希索引表用 QMap 内存缓存就行文件量上万后再换 SQLite 也不迟。4. Qt 客户端界面与上传下载队列的并发模型4.1 界面线程与网络线程QThreadPool 跑文件 IOQNetworkAccessManager 跑网络 IOQt 客户端最忌把所有操作塞进 GUI 线程上传一个 10GB 文件时界面会直接卡死。正确分工是文件磁盘读取放工作线程网络发送用QNetworkAccessManager它在内部使用异步 IO不阻塞调用线程界面更新通过信号槽切回 GUI 线程。上传文件列表用QTreeView配合QFileSystemModel展示本地目录上传任务用QListWidget显示进度条。每个上传任务建一个 worker 对象继承QObject用QThreadPool::start启动任务。worker 里读取文件块通过信号把QByteArray传回主线程由主线程里的QNetworkAccessManager发送。这种方式的优点是保证QNetworkAccessManager只在主线程使用避免跨线程调用网络模块引发的崩溃。上传进度用QProgressBar的setRange(0, total)和setValue(bytesSent)更新更新频率不要超过每 100ms 一次否则 UI 刷新开销反而拖慢传输。// 上传任务的 Worker 签名 class UploadWorker : public QObject { Q_OBJECT public: UploadWorker(const QString path, qint64 offset, qint64 length); public slots: void process(); // 在 QThreadPool 线程中执行 signals: void chunkReady(qint64 offset, const QByteArray data); void finished(bool ok); };process()里打开文件、seek(offset)、读length字节然后 emitchunkReady。主线程槽函数收到chunkReady后调用manager-post(request, data)服务端返回响应后再启动下一个 chunk 的 worker。这里用latch或者简单的QAtomicInt记录当前活跃并发数限制同时上传 3 个 chunk避免小带宽场景下网络缓冲区溢出。下载同理只是数据流向相反。4.2 多任务并发上传的调度参数并发数、超时、重试并发数和超时是两个最容易拍脑袋的参数实际经验值是内网或本机部署时并发 3~4 个 chunk 可以占满带宽公网受 TCP 拥塞控制限制并发 2 个就够每个 chunk 的超时建议设为 30 秒超过 30 秒未收到服务端响应就判定分片失败从上次成功写入的 offset 重试。重试次数最多 3 次超过后把任务标记为失败等待用户手动重试。这里不要自己写超时判断用QNetworkRequest的setTransferTimeoutAttribute属性Qt 5.15 起原生支持设置传输超时。// 客户端设置超时和重试策略 QNetworkRequest request; request.setUrl(url); request.setRawHeader(X-Upload-Offset, QByteArray::number(offset)); request.setAttribute(QNetworkRequest::HttpPipeliningAllowedAttribute, true); request.setTransferTimeoutAttribute(30000); // 30秒传输超时需要注意setTransferTimeoutAttribute对整个请求生效如果文件块只有 64KB在慢速网络上传输本身也可能超过 30 秒这时要区分是连接假死还是传输慢可以把超时设成 60 秒并额外用QTimer做应用层心跳检测。4.3 失败重试与文件锁防止两个客户端同时操作同一文件多客户端同时拖拽同一个文件到文件夹里重名的处理和服务端写冲突是必现问题。服务端在每个上传请求的元信息里检查conflict字段若文件已存在返回CONFLICT并带上时间戳后缀客户端提示用户“另存为新文件”还是“覆盖”。覆盖必须加锁服务端用QFile::open(QIODevice::WriteOnly | QIODevice::Truncate)会截断原文件如果另一个下载请求正在读这个文件会读到半截数据。最保险的做法是服务端把文件写到.tmp后缀的临时文件写完后再原子重命名覆盖目标这样下载请求永远只看到完整文件。客户端本地也可以加一个.lock文件写入当前进程 PID 和操作类型其他客户端实例启动时检测到锁就只读不写。Windows 上QFile::open不会自动加锁需要调用系统 API 或者干脆用QLockFileQt 5.1 内置它的tryLock(1000)能在.lock文件存在时返回失败适合做单实例检测。5. 登录鉴权与 token 过期处理基于 QSettings 和安全存储5.1 会话保持登录成功后把 token 写入 QSettings 加密存储个人网盘系统的鉴权不能每次启动都让用户输密码要把 token 持久化。Qt 的QSettings在 Windows 上写注册表在 Linux 上写~/.config/目录直接明文存 token 有安全隐患。更好的是用系统密钥环但 Qt 没有跨平台的统一 API常用做法是 token 存入QSettings时做一层简单的 XOR 混淆再加QCryptographicHash校验。专业一点可以接qtkeychain库不过对个人网盘这种低价值场景混淆存储已经够用。// 加密 token 写入配置 QSettings settings(MyCompany, NetDisk); QByteArray token eyJhbGciOiJIUzI1NiI...; QByteArray key QCryptographicHash::hash(netdisk-salt, QCryptographicHash::Sha256); QByteArray encrypted token.toBase64(); for (int i 0; i encrypted.size(); i) { encrypted[i] encrypted[i] ^ key[i % key.size()]; } settings.setValue(auth/token, encrypted.toHex());读取时按同样方式反转 XOR 即可。服务端每个 token 设置 24 小时有效期客户端在每次请求返回 401 时刷新 token弹窗提示输入密码用旧的 refresh_token 换新的 access_token。refresh_token的存储位置与服务端要严格分离refresh_token 永远只在服务端数据库里客户端只能持有一次换取结果。5.2 接口错误码约定客户端如何区分网络错误、业务错误和鉴权错误联调时不约定错误码客户端排错会非常痛苦。统一返回HTTP 200 业务码模式网络层错误连接失败、超时由 Qt 的QNetworkReply::error()判断业务错误文件已存在、路径非法、token 过期放在 JSON 响应的code字段里。code0是成功code401token 过期code403权限不足code404文件不存在code500服务端内部错误。客户端收到code401时不直接弹错误框而是进入静默刷新 token 流程刷新成功重放原请求刷新失败才弹“登录已过期”提示。void NetDiskClient::handleReply(QNetworkReply *reply) { QJsonObject obj QJsonDocument::fromJson(reply-readAll()).object(); int code obj[code].toInt(); if (code 0) { emit success(obj); } else if (code 401) { this-retryWithRefreshToken(reply-request(), obj[refresh_token].toString()); } else { emit error(obj[message].toString()); } }retryWithRefreshToken里用一个QSetQString记录已重放的请求唯一 ID防止同一请求被无限重放。5.3 防暴力破解登录失败次数限制与验证码策略个人网盘暴露在公网后必然会被扫描服务端登录接口要做限速。常见做法是每个 IP 一小时内允许 5 次失败登录超过后返回code429并附带锁定时间。Qt 服务端实现这个不需要引入 Redis直接用一个QHashQString, QListQDateTime记录失败时间戳数组即可每次登录前清理过期记录。客户端侧在 429 响应后触发图形验证码验证码可以在客户端本地生成也可以让服务端返回一个数学题 JSON客户端显示计算题降低破解自动化程度。这里不建议做短信验证码个人网盘没有运营资源一个合理的延迟退让算法就能挡住大多数暴力破解。6. 客户端多语言与 Qt 国际化配置的坑6.1 用 Qt 的 tr() 包住所有用户可见字符串启用翻译文件很多个人网盘的客户端界面字符串直接硬编码在代码里后期要加英文版或日语版时只能全文搜索替换。正确做法是所有控件文本、工具栏提示、错误提示统一走tr(...)然后在.pro文件里配置TRANSLATIONS netdisk_zh_CN.ts netdisk_en_US.ts。使用lupdate生成更新.ts文件再用 Qt Linguist 完成翻译最后lrelease发布成.qm文件。运行时加载翻译文件用QTranslator安装到QApplication::installTranslator注意在 MainWindow 构造之前安装否则部分控件初始化时已经取走了英文文本。// 启动时动态加载翻译文件 QTranslator translator; QString lang settings.value(app/language, zh_CN).toString(); QString qmPath QString(%1/translations/netdisk_%2.qm) .arg(QCoreApplication::applicationDirPath()) .arg(lang); if (translator.load(qmPath)) { qApp-installTranslator(translator); }切换语言时要做两步先卸载旧 translator再安装新的然后对顶层 MainWindow 调用retranslateUi刷新所有控件的文本。如果是用 Qt Designer 做的界面retranslateUi已经自动生成手动创建的控件要记得在切换槽里重新setText。6.2 数字和文件大小的本地化QLocale 设置注意时区与千分位Qt 默认的QLocale::c()会用英文小数点中文环境里用户看到1,024.5 MB会不习惯。文件大小格式化建议用QLocale(QLocale::Chinese, QLocale::China).toString(size)拿到带千分位的字符串。时区上服务端存储时间统一用QDateTime::currentDateTimeUtc()客户端展示时用toLocalTime()转换。这里踩过坑Qt 的QDateTime::toString(yyyy-MM-dd hh:mm:ss)用的是 12 小时制hh想要 24 小时制必须用HH否则下午文件的时间全部错乱。时间戳字段在 JSON 里传quint64毫秒数最安全不传 ISO 字符串避免时区歧义。6.3 高清屏适配QT 5.15 高 DPI 缩放的属性开关高分辨率屏幕下 Qt 默认缩放模糊是高频问题。5.15 之前的版本需要在main函数里设置QApplication::setAttribute(Qt::AA_EnableHighDpiScaling)5.15 起该属性默认启用但仍有边界情况。个人网盘客户端涉及列表和进度条布局要允许拉伸不能写死宽度。设置字体时用相对大小避免使用setPixelSize而是setPointSizeF。如果混合了 C 和 QML需要额外在.pro里定义QT_QPA_PLATFORM的高分屏策略Windows 上常见配置为QT_QPA_PLATFORMwindows:dpiawareness0,1,2但会与是否生效产生兼容问题。更稳的方案是让测试机覆盖 100%、125%、150%、200% 四档缩放逐一检查布局错位改动量通常集中在表格列宽和图标资源上。7. 用 WireShark 抓包验证客户端与服务端交互完整性的办法7.1 本地回环抓包实验过滤端口 8080 的完整 HTTP 请求部署完成后第一步验证不要用业务界面直接开抓包工具以 loopback 接口为目标过滤条件设为tcp.port 8080启动客户端上传一个小文件观察网盘中是否出现了POST /upload请求请求体里有没有完整的 JSON 元信息和二进制文件体。WireShark 的 Follow HTTP Stream 功能可以直接看到请求头和响应体的对应关系。如果抓到多个分片请求但服务端未生成对应文件优先检查offset字段是否传对服务端QFile::seek是否返回了true。# 分析 http 请求耗时分布 tshark -r capture.pcap -Y http and ip.addr127.0.0.1 -T fields \ -e http.request.method -e http.request.uri -e http.response.code7.2 记录服务端 Debug 日志曲线的姿势Qt 服务端建议用qInstallMessageHandler把日志同时输出到控制台和文件文件名按日期轮转。日志里除了打印错误还要打印每次请求的耗时、上传的字节数和当前文件的偏移量。用QElapsedTimer在请求入口和出口记录耗时超过 3 秒的打标记然后对照客户端所在网络环境判断瓶颈在磁盘还是网络。传输大文件时打开.tmp文件的增长曲线如果文件大小停在某个值不动多半是服务端读事件没有触发需要在QTcpServer上层加定时 flush 迫使缓冲写入磁盘。7.3 配置 c 环境变量与 Qt 调试器勾选 —— 客户端定位崩溃的储备客户端崩溃最常见的是跨线程使用QNetworkAccessManager或QFile。开发阶段用 Qt Creator 调试时在断点命中后打开“线程”视图确认当前代码执行线程与对象创建线程是否一致。应对show()崩溃、deleteLater悬挂这类问题优先检查QObject的 parent 链是否在堆栈对象上建立了 parent 关系。测试时把QT_DEBUG_PLUGINS1加到系统环境变量运行后会打印所有加载的插件路径可以快速确定平台插件缺失导致的启动失败。遇到崩溃信息里有qt_qpa_platform_plugin_path类似的路径错位提示时重新检查 Qt 版本与编译器版本的匹配关系MSVC2019 的库就不要放到 MinGW 程序里用这个错误在个人网盘跨平台分发时特别常见。本文还有配套的精品资源点击获取