Qt网盘系统源码剖析:自定义协议、TCP服务器与客户端
简介一份基于QT框架开发的网盘系统完整源码包含TCP服务端与客户端双端实现覆盖网络通信、数据库存储、文件上传下载与共享、私聊及好友管理等功能模块代码经过运行验证功能稳定。适用于计算机相关专业学生作为课程设计、毕业设计或初期项目演示的参考项目也适合具备一定C基础的开发者学习Qt界面开发与Socket编程实战。压缩包内共50个文件以C源码16个cpp、14个h为主体同时包含Qt界面定义文件、资源配置文件、工程管理文件及SQLite数据库文件整体大小仅219KB目录清晰、依赖简单便于快速部署与二次开发。附带的项目说明文档有助于梳理服务端与客户端的交互流程、数据库表设计和核心类职责可帮助读者对照源码理解网盘系统的整体架构与关键实现。目前已有302人学习浏览对于需要完整课设或毕设项目参考、想快速搭建网盘原型的学习者具有较高的借鉴价值。1. 为什么要在 2025 年翻一个 Qt 网盘系统源码2025 年再翻出这份 Qt 网盘系统源码第一反应是这不就是一个毕业设计吗但把 TCPServer 和 TCPClient 两个工程同时打开后我发现它把网盘最核心的流程都串齐了注册登录、在线用户列表、好友私聊、文件上传下载、共享文件全部跑在一个自定义协议上。服务端用 QTcpServer 管连接SQLite 存用户和文件索引客户端是标准 Qt Widgets 界面。没有 Redis没有对象存储甚至连第三方库都没引。对准备课程设计和毕业设计的同学来说这比直接抄一层薄壳的增删改查要有参考价值得多因为它能让你看到网络协议、状态处理与 UI 分离是怎么在 100 多个文件里协作的。这篇我会从协议、服务器、客户端三层拆开讲最后补上三个打包与排错的技巧照着能复现出一套可演示的局域网网盘。2. 自定义协议 protocol.h一条消息怎么从客户端跑到服务器2.1 PDU 的定义与固定字段的作用从文件列表可以看到TCPServer 和 TCPClient 各自引入了一份 protocol.h / protocol.cpp。两边的收发逻辑都依赖这份协议所以任何一端要加新功能都要先在这里加消息类型。很多课程设计喜欢把每条消息单独写一个结构体但这个项目走的是“一个 PDU 走天下”的路线。常见做法是把一个数据包定义成四段4 字节的 uiMsgType4 字节的 uiLength128 字节的 caData 固定业务字段最后是变长的 caMsg 消息体。这样设计的好处是短数据不需要额外分配内存。比如登录时用户名和密码都是几十字节直接塞固定字段文件上传和私聊内容可能很大就放进变长体。enum MsgType { REG_REQUEST 1, REG_RESPOND, LOGIN_REQUEST, LOGIN_RESPOND, FILE_UPLOAD_REQUEST, FILE_UPLOAD_DATA, FILE_UPLOAD_RESPOND, FILE_LIST_REQUEST, FILE_LIST_RESPOND, PRIVATE_CHAT_MSG, HEARTBEAT_REQ, MSG_TYPE_MAX }; struct PDU { quint32 uiMsgType; // 消息类型 quint32 uiLength; // caMsg 的长度 char caData[128]; // 短业务字段 char caMsg[]; // 柔性数组实际消息体长度由 uiLength 决定 };这里有容易算错的地方sizeof(PDU)只包含 8 个字节的头部加 128 字节的caDatacaMsg不占空间。分配一个完整数据包时要手动加uiLength。发送的时候也不能直接用sizeof(PDU)否则柔性数组里的内容发不出去。我一般会在protocol.cpp里提供一个makePDU(quint32 type, const char *data, int len)内部用malloc(sizeof(PDU) len)先取整块内存再分别填充固定字段和消息体调用方用完必须free。消息类型方向用途LOGIN_REQUEST / LOGIN_RESPOND客户端→服务器 / 服务器→客户端登录认证REG_REQUEST / REG_RESPOND客户端→服务器 / 服务器→客户端注册新账号FILE_UPLOAD_DATA客户端→服务器上传文件分块内容FILE_LIST_REQUEST / RESPOND客户端→服务器 / 服务器→客户端拉取当前用户文件列表PRIVATE_CHAT_MSG双向私聊消息转发HEARTBEAT_REQ客户端→服务器保活与在线状态维护每一种消息在服务器端的mytcpsocket.cpp里都会走到一个switch(uiMsgType)分支。实际写过就会知道协议层决定了整个项目能加多少功能后面不管是allonline、sharefile还是privatechat本质上都是在消息类型上做分支。2.2 粘包和半包为什么不能直接 readAll()TCP 是流式协议Qt 的readyRead信号不保证一次收到的正好是一个完整 PDU。客户端一次写入可能被拼到同一个 TCP 段里服务器第一次readAll()可能读出两个 PDU 的一半反过来一个 PDU 可能分多次到达。如果上来就readAll()然后按一个消息解析第二天就要面对“登录偶尔成功偶尔失败”的灵异问题。解决办法是加一个接收缓冲区。每次readyRead到达后先把数据append到成员变量里再循环解析先读前 8 个字节拿到type和len检查缓冲区是否已经足够8 len字节够就取出一条完整 PDU不够就等下一次事件。void MyTcpSocket::onReadyRead() { m_buffer.append(m_socket-readAll()); while (m_buffer.size() 8) { quint32 type 0; quint32 len 0; memcpy(type, m_buffer.constData(), 4); memcpy(len, m_buffer.constData() 4, 4); if (m_buffer.size() static_castint(8 len)) { break; // 数据还没收全等待下一个 readyRead } QByteArray pdu m_buffer.left(8 len); handlePdu(type, pdu.mid(8)); m_buffer.remove(0, 8 len); } }这里m_buffer的remove在最坏情况下有 O(n) 代价但 PDU 通常是几百字节到几 MB不是问题。真正要警惕的是恶意客户端发一个超大len所以后面解析时要加长度上限。如果缓冲区里累积了脏数据一直达不到8 len就说明协议栈已经和对方错位继续等待只会越积越多这时候应该直接abort()断开连接并记录日志。2.3 字符串序列化QDataStream 版本要锁死caData里放的是定长字段但用户名、文件名、私聊内容这些长度不固定适合放到消息体里做序列化。最容易踩的坑是直接std::string或QString::toUtf8().constData()往里塞解码端却不知道字符串到哪结束。项目里常见做法是用QDataStream先写 4 字节长度再写 UTF-8 字节。void writeString(QDataStream out, const QString str) { QByteArray bytes str.toUtf8(); out static_castquint32(bytes.size()); out.writeRawData(bytes.constData(), bytes.size()); } QString readString(QDataStream in) { quint32 len 0; in len; if (len 1024 * 1024) { return QString(); // 防异常长度 } QByteArray bytes; bytes.resize(len); in.readRawData(bytes.data(), len); return QString::fromUtf8(bytes); }QDataStream的版本必须在两端保持一致。out.setVersion(QDataStream::Qt_5_15)这里如果不写默认版本会跟随 Qt 编译版本走。Qt 5.15 客户端连 Qt 6 编译的服务端整数编码可能不同解析出来的长度字段会变成天文数字程序直接卡死在后续resize或memcpy。整数值编码和长度前缀都处理对了中文用户名、带空格的文件名才不会有问题。这也是这套系统里所有消息体统一走同一种序列化方式的原因。3. 服务器端QTcpServer 连接管理 SQLite 落地3.1 用包装类管理连接而不是裸用 QTcpSocketTCPServer.pro里能看到mytcpserver.cpp、mytcpsocket.cpp、operatedb.cpp这三个核心源文件。MyTcpServer负责监听和接受连接MyTcpSocket负责每条连接上的协议处理OperateDB统一访问cloud.db。这样拆比把所有逻辑写在main.cpp里好维护得多一个 socket 对应一个MyTcpSocket对象服务器再维护一个QMapQString, MyTcpSocket*保存在线用户发私聊和刷新在线列表时直接查表。void MyTcpServer::onNewConnection() { QTcpSocket *client nextPendingConnection(); MyTcpSocket *worker new MyTcpSocket(client, this); connect(worker, MyTcpSocket::loginSuccess, this, MyTcpServer::addOnlineUser); connect(worker, MyTcpSocket::clientOffline, this, MyTcpServer::removeOnlineUser); m_workers.append(worker); } void MyTcpServer::addOnlineUser(const QString username, MyTcpSocket *socket) { if (m_onlineUsers.contains(username)) { m_onlineUsers.take(username)-abort(); // 顶掉旧连接 } m_onlineUsers[username] socket; emit onlineUsersChanged(); }顶掉旧连接是处理重复登录的常用策略。登录成功后同一个账号再次登录旧连接直接abort()新连接保留。这个细节在演示时很能体现工程经验如果不顶两个客户端用同一个账号在线在线列表就会显示两次用户名私聊也会不知道该发给谁。onlineUsersChanged信号会被所有连接接收然后客户端刷新allonline窗口。注意在线列表的广播要在addOnlineUser或removeOnlineUser结束后调用不能放在QMap写入之前否则会出现中间状态。3.2 operatedb 封装 SQLite参数绑定与单例连接cloud.db是 SQLite 数据库operatedb.cpp把注册、登录、文件索引都封装成了函数。实际项目中数据库连接建议在一个地方初始化不要每个函数里都调QSqlDatabase::addDatabase。同一连接名重复添加会直接报duplicate connection name。我一般会在main()启动时做一次连接初始化QSqlDatabase db QSqlDatabase::contains(cloud_conn) ? QSqlDatabase::database(cloud_conn) : QSqlDatabase::addDatabase(QSQLITE, cloud_conn); db.setDatabaseName(cloud.db); if (!db.open()) { qCritical() open cloud.db failed: db.lastError().text(); }cloud.db里通常会有三张核心表users存账号user_files存文件索引friends存好友关系。表结构大致如下CREATE TABLE IF NOT EXISTS users( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS user_files( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, filename TEXT NOT NULL, filepath TEXT NOT NULL, filesize INTEGER NOT NULL, shared INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS friends( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, friendname TEXT NOT NULL );登录查询不要拼 SQL 字符串要使用prepare addBindValue。用户输入带单引号时拼字符串要么报错要么被注入。下面的做法是安全且清晰的bool OperateDB::loginUser(const QString username, const QString password) { QSqlQuery query(db); query.prepare(SELECT password FROM users WHERE username ?); query.addBindValue(username); if (query.exec() query.next()) { return query.value(0).toString() password; } return false; }注意这里QSqlQuery query(db)括号里传入连接名对应的QSqlDatabase对象避免在函数内重新选择默认连接。password字段在这份源码里大概率是明文存储课程设计这么做可以理解但如果是正式项目需要换成QCryptographicHash加盐哈希后再比对。文件索引里的filepath不要存绝对路径否则数据库拷到另一台机器路径就失效存成相对存储根目录的相对路径更稳。3.3 文件上传临时文件 分块追加网盘的核心不是登录是文件上传不下丢。源码里netdiskfile.cpp和sharefile.cpp都与文件操作相关。客户端上传的时候把文件切块每块打包成FILE_UPLOAD_DATA发过来服务器端不能每收到一块就open(QIODevice::WriteOnly)一次否则前面写入的内容全部被覆盖。正确做法是维护一个QFile成员变量整个上传期间保持打开只追加写入。void MyTcpSocket::handleFileUploadData(const QByteArray data) { if (!m_uploadFile.isOpen()) { m_uploadFile.setFileName(m_currentTempPath); m_uploadFile.open(QIODevice::WriteOnly); } qint64 written m_uploadFile.write(data); if (written ! data.size()) { qWarning() write failed, disk full?; m_uploadFile.close(); m_uploadFile.remove(); sendUploadRespond(false); return; } m_uploadedBytes written; if (m_uploadedBytes m_expectedBytes) { m_uploadFile.flush(); m_uploadFile.close(); QFile::rename(m_currentTempPath, m_currentFinalPath); sendUploadRespond(true); } }用临时文件的好处是客户端传一半断线服务器只会留下一个没有重命名的临时文件不会污染正式文件索引。m_expectedBytes在上传请求里由客户端带上服务器一开始就检查磁盘剩余空间避免写一会儿才发现磁盘满。每块写完后可以不立即flush()靠操作系统页缓存合并写盘但收到结束标记后一定要先flush()再rename否则rename时数据还没落盘程序一崩文件就是 0 字节。3.4 连接断开后的清理不能只依赖“下线消息”很多课程设计只在收到LOGOUT_REQ时把用户从在线列表移除。实际应用中用户可能直接关电脑、拔网线、切网络客户端来不及发下线消息。只要 TCP 连接断开操作系统的 FIN/RST 最终会触发QTcpSocket::disconnected信号所以这里必须双保险在disconnected里也做一次下线清理。void MyTcpSocket::onDisconnected() { if (!m_username.isEmpty()) { emit clientOffline(m_username); } deleteLater(); }注意deleteLater()不要在主线程直接delete this因为当前可能正在执行这个对象的槽函数直接释放会让后面的栈展开访问野指针。deleteLater()回到事件循环后处理不会影响正在执行的调用链。服务器结构到这里就比较完整了MyTcpServer连接管理、OperateDB数据持久化、MyTcpSocket协议处理。4. 客户端登录、文件列表、私聊如何复用同一个连接4.1 登录请求的组装与心跳保活TCPClient.pro下的工程同样用protocol.cpp所以客户端组包的方式和服务端完全一致。登录时把用户名和密码塞进固定字段caData前面 32 字节放用户名32 字节之后放密码。这是一种紧凑写法读的时候用QString::fromUtf8(pdu-caData, 32).trimmed()去掉末尾空字符。void TcpClient::login(const QString username, const QString password) { PDU *pdu makePDU(LOGIN_REQUEST, nullptr, 0); memset(pdu-caData, 0, sizeof(pdu-caData)); QByteArray user username.toUtf8(); QByteArray pwd password.toUtf8(); memcpy(pdu-caData, user.constData(), qMin(user.size(), 32)); memcpy(pdu-caData 32, pwd.constData(), qMin(pwd.size(), 32)); m_socket-write(reinterpret_castchar*(pdu), sizeof(PDU) pdu-uiLength); free(pdu); }这里有个细节caData不初始化就memcpy剩余字节是随机值。服务端按trimmed()处理倒无所谓但为了日志和调试可读性makePDU内部最好先memset清零。登录响应回来以后客户端根据uiMsgType判断是LOGIN_SUCCESS还是LOGIN_FAILED成功则创建主操作窗口失败则停留在登录页显示错误。这里不要直接在TcpClient类里去改ui发一个loginSuccess()信号由窗口层决定跳转逻辑才能复用。登录成功之后网盘主界面需要长期挂在服务器上等待文件操作和私聊。很多项目不做心跳客户端挂机两个小时后服务器因为 TCP 长时间无数据被运营商或防火墙断开用户却不知道。常见做法是客户端启动一个 15 秒的QTimer每次超时发一个HEARTBEAT_REQ服务器收到后只刷新最后活跃时间不回数据。服务器在 30 秒内没收到任何包就会断开。QTimer *heartbeat new QTimer(this); heartbeat-setInterval(15000); connect(heartbeat, QTimer::timeout, this, [this]() { PDU *pdu makePDU(HEARTBEAT_REQ, nullptr, 0); m_socket-write(reinterpret_castchar*(pdu), sizeof(PDU)); free(pdu); }); heartbeat-start();心跳包过于频繁会无谓占用带宽15 秒一次足够。如果服务器在同一连接上还承担文件上传下载上传过程中网卡一直有数据服务器不会因为“没收到心跳”而断开所以心跳计时器逻辑要在disconnected时停止避免连接已断还继续写数据。4.2 文件列表请求与响应表客户端netdiskfile窗口负责展示当前用户的网盘文件。请求时只发一个FILE_LIST_REQUEST消息体里带上用户名。服务器收到后从数据库user_files表查出记录把文件名、文件大小、是否共享打包成响应。由于响应内容数量不固定适合用QDataStream先写一个总数量再逐条写字符串。void NetdiskFile::sendListRequest(const QString username) { QByteArray body; QDataStream out(body, QIODevice::WriteOnly); out.setVersion(QDataStream::Qt_5_15); out username; PDU *pdu makePDU(FILE_LIST_REQUEST, body.constData(), body.size()); m_socket-write(reinterpret_castchar*(pdu), sizeof(PDU) pdu-uiLength); free(pdu); } void NetdiskFile::parseListRespond(const QByteArray body) { QDataStream in(body); in.setVersion(QDataStream::Qt_5_15); quint32 count 0; in count; ui-fileTable-setRowCount(static_castint(count)); for (quint32 i 0; i count; i) { QString filename, filepath, filesize, shared; in filename filepath filesize shared; // 填充 QTableWidget 的第 i 行 } }QDataStream对QString的序列化自带长度前缀所以这里不需要在字符串中间插入|或\t做分隔文件名里有空格、中文、甚至换行也不会解析错位。表格里filesize可以继续用字符串也可以改成qint64后显示时格式化成 KB/MB。注意客户端不能只写m_socket-write()后立刻ui-fileTable-clear()因为write只进入发送缓冲服务器处理并回包需要时间清理动作应该放到收到响应之后再执行。4.3 上传下载的进度条要放到子线程上传文件如果把while (!file.atEnd())放进 UI 线程文件一大界面就会卡死。移动窗口、点击按钮都没有响应readyRead信号也被阻塞。正确的做法是把文件读写和 socket 写入放到一个FileTransferThread进度通过信号发回主线程更新QProgressBar。这里给一个不上锁的简单版本void FileTransferThread::run() { QFile file(m_filePath); if (!file.open(QIODevice::ReadOnly)) { emit transferError(QStringLiteral(打开文件失败)); return; } const qint64 total file.size(); qint64 sent 0; char buf[4096]; while (!file.atEnd()) { qint64 n file.read(buf, sizeof(buf)); if (n 0) break; QByteArray payload(buf, static_castint(n)); PDU *pdu makePDU(FILE_UPLOAD_DATA, payload.constData(), payload.size()); m_socket-write(reinterpret_castchar*(pdu), sizeof(PDU) pdu-uiLength); free(pdu); sent n; emit progressChanged(sent * 100 / total); } file.close(); emit finished(); }这里注意m_socket不能在新线程里直接写因为QTcpSocket默认属于创建它的线程跨线程写 socket 需要在信号槽里把m_socket的线程亲和性改过去或者在子线程里创建独立的QTcpSocket。我在实际项目里更倾向于让子线程使用自己的 socket和服务端建立第二条数据连接这样控制连接负责心跳和指令数据连接专门传文件断点续传也更好实现。不过这套源码的基础版是单连接课程设计阶段先把进度条不卡死这件事解决就已经能让演示效果好很多。4.4 私聊转发与在线列表的共用逻辑allonline.cpp、privatechat.cpp、sharefile.cpp看起来是三个窗口实际都跑在同一个QTcpSocket上。服务器在MyTcpSocket::handlePdu里根据uiMsgType分流私聊消息从固定字段里取出目标用户名直接在服务器自己的在线QMap中查找对应的MyTcpSocket找到就把源 PDU 原样转发给目标客户端找不到就回一个PRIVATE_CHAT_OFFLINE给发送方。原样转发比重新组包更快而且不需要关心消息体内容。void MyTcpServer::forwardPrivateChat(const PDU *pdu) { QByteArray rawTarget(pdu-caData, 32); QString target QString::fromUtf8(rawTarget.trimmed()); MyTcpSocket *dst m_onlineUsers.value(target); if (dst) { dst-write(reinterpret_castconst char*(pdu), sizeof(PDU) pdu-uiLength); } else { // 给发送方返回对方不在线 } }在线列表的刷新依赖服务器广播ONLINE_USERS消息客户端收到后清空allonline列表再重新填充。这块要防止一点某客户端断开后服务器还没来得及广播其他客户端就按照旧列表发私聊很容易出现“对方不在线但还是发了消息”。课程设计里可以在私聊发送前先查一次本地在线列表如果目标不在就弹提示服务器端也做一次二次校验两端都判断才最稳。5. 上线前收尾windeployqt 打包、协议边界校验与 sqlite3 数据复位5.1 用 windeployqt 打包别只拷一个 exedemo 演示最怕的就是在自己机器上能跑到教室电脑上双击没反应。Windows 下 Qt 程序光拷TCPClient.exe不行还需要Qt5Core.dll、Qt5Widgets.dll、Qt5Network.dll以及platforms/qwindows.dll。最简单的方法是让 Qt 工具自动收集依赖cd build-desktop-Release windeployqt --release TCPClient.exe运行后查看platforms目录是否存在没有qwindows.dll程序会在启动时报 “could not find or load the Qt platform plugin windows”。如果目标机器是精简版 Windows还需要手动把 VC 运行库或者 MinGW 运行库一起带上。打包完成后用一台没装 Qt 的虚拟机试跑才算是真的打包好了。5.2 给协议解析加上边界校验我在调试这个项目的粘包问题时遇到过一个字段错位后服务端把len读成几十 GBmalloc直接返回空指针程序崩溃。给handlePdu入口加一个校验函数能挡住绝大多数脏数据。bool validatePdu(const PDU *pdu) { if (pdu-uiMsgType 0 || pdu-uiMsgType MSG_TYPE_MAX) return false; if (pdu-uiLength MAX_MSG_LEN) return false; return true; }MAX_MSG_LEN我一般设为5 * 1024 * 1024超过这个长度的消息体直接丢弃并断开连接。这个校验对正常业务零影响但能防止异常时把服务器拖崩。加了校验之后再把缓冲区里解析失败的数据清空而不是继续 append排错速度会快很多。5.3 演示前用 sqlite3 快速验证数据库状态课程设计答辩前最尴尬的场面打开程序发现之前的测试账号乱成一片文件列表残留脏数据。演示前手动清一次库再插一个干净的测试账号sqlite3 cloud.db DELETE FROM user_files; sqlite3 cloud.db DELETE FROM users; sqlite3 cloud.db INSERT INTO users(username, password) VALUES(admin, 123456);这样演示时先登录 admin再上传一个文件用sqlite3 cloud.db SELECT * FROM user_files;确认文件索引真的写进去了比截图更有说服力。注意密码是项目自带的明文存储演示数据无所谓但正式环境必须换成哈希。本文还有配套的精品资源点击获取