Qt FTP客户端开发:从协议层到断点续传与多任务实战
简介:基于QT框架开发的FTP客户端完整源码工程,面向学习Qt网络编程与文件传输的初中级开发者。整体压缩包约10.82MB,共148个文件,包含81张界面截图、9个cpp源文件、6个头文件以及ui界面文件、qss样式表、qm翻译文件和qch/qhc辅助文档,编译运行即可查看完整功能界面。已有216人学习下载。代码基于QFtp类实现服务器连接、身份认证、目录切换与内容列举、文件上传下载等完整FTP交互流程,覆盖主动与被动两种数据连接模式;主窗口、设置对话框与FTP文件树控件等模块分工清晰,借助信号与槽机制反馈命令完成状态并及时处理异常。整体结构明了,适合Qt网络编程入门实战,便于进一步扩展断点续传、多线程传输等功能。 一个 QT FTP 客户端需求,落到你手上时通常不是缺界面,而是卡在传输层:文件能列出来,一传大文件就崩溃,或者界面假死十几秒。我翻过几个 ftp 源码包,毛病高度一致——控制连接和数据连接没分清楚,被动模式端口解析随手写,TYPE没切二进制就开始传,中文文件名乱成一锅粥。这里不从界面讲起,先把协议层拆明白,再给一个能在 Qt 里跑通的最小客户端,最后把断点续传、多任务和发布环境这些坑一个个填上。适合要自己维护 FTP 客户端,或者拿到源码包想改出可用版本的人。
2. 拆开 QT FTP 客户端:控制连接、数据连接与 Qt 侧三条实现路线
2.1 控制连接与数据连接:为什么被动模式才是客户端的默认选择
FTP 和 HTTP 最大的区别是它有两根 TCP 连接。控制连接固定连服务器的 21 端口,用来发USER、PASS、CWD、RETR这类命令并读取服务器的文本应答;数据连接则是临时的,传目录列表和文件内容时才会建立。这个设计在上世纪 70 年代没问题,放到今天 NAT 和防火墙环境下就成了灾难——尤其是主动模式。
主动模式(PORT)里,客户端在控制连接上告诉服务器“我这边开了一个端口等你来连”,服务器就去连客户端的那个端口。家用路由、云主机安全组、企业防火墙基本都会掐掉这种“从外向内”的连接,表现在客户端上就是:列目录卡在150之后,超时,然后整条控制连接被服务器断开。被动模式(PASV)正好反过来:客户端在控制连接上发PASV,服务器返回227 Entering Passive Mode (h1,h2,h3,h4,p1,p2),把数据连接的 IP 和端口告诉客户端,由客户端主动去连。所以我一般直接把客户端锁死在被动模式,不做主动模式开关,省掉一整个维度的排查工作量。
另一个容易被忽略的是应答格式。FTP 应答分成单行和多行两种,多行应答的格式是220-开头、同一状态码的220结尾,中间可以夹好几行文本。很多简单实现只readLine一次,拿到220-就当结束了,结果把后续行当作下一条命令的响应去解析,状态机就乱了。下面这段是我常用的读取函数,先处理多行再返回状态码。
2.2 Qt 侧三条路:QFtp、QNetworkAccessManager 与自研 QTcpSocket 的取舍
拿到一个 QT FTP 客户端需求,技术上其实有三条路可选,先看对比再决定走哪条:
| 方案 | 维护状态 | 支持断点续传 | 自定义命令扩展 | 典型问题 |
|---|---|---|---|---|
| QFtp 模块 | 已移出 Qt 主线 | 部分 | 一般 | Qt 5 以上需自行编译 qtftp,老接口与新信号风格不一致 |
| QNetworkAccessManager + ftp:// URL | 随 Qt 网络模块 | 不支持 | 差 | 底层 FTP 支持受编译选项影响,GET/PUT 之外的命令无从下手 |
| QTcpSocket 手写命令层 | 自己维护 | 完全可控 | 能加任意命令 | 要处理多行响应、超时和数据连接,代码量略大 |
QNetworkAccessManager 的版本适合“下载单个文件、测试连通性”这种轻量场景,网上很多 Demo 也是这么写的:构造一个ftp://user:pass@host/file的 URL 丢给get(),然后等readyRead把数据写进本地文件。但注意两点:第一,这个 FTP 支持是 QtNetwork 编译时的可选项,预编译包里未必开启;第二,它不暴露 PASV、REST、APPE 这些命令,想断点续传只能换路。源码包里那种带目录递归、断点续传、任务队列的客户端,我基本都是用 QTcpSocket 自研命令层,控制力足够,核心代码也就两三百行。
先说最核心的应答读取函数。它要能处理多行应答,同时给出状态码,后续任何命令交互都要复用它:
bool FtpClient::readReply(QByteArray &buffer, int *code, int timeoutMs) { buffer.clear(); if (!ctrlSocket->canReadLine() && !ctrlSocket->waitForReadyRead(timeoutMs)) { return false; } QByteArray line = ctrlSocket->readLine(); if (line.isEmpty()) { return false; } buffer = line; // 多行应答形如 "220-...",结束行形如 "220 ..." bool multi = (line.size() > 3 && line.at(3) == '-'); while (multi) { if (!ctrlSocket->canReadLine() && !ctrlSocket->waitForReadyRead(timeoutMs)) { return false; } QByteArray next = ctrlSocket->readLine(); buffer += next; if (next.size() > 3 && next.at(3) == ' ' && next.left(3) == line.left(3)) { break; } } if (code) { *code = line.left(3).toInt(); } return true; }说明几处关键参数:waitForReadyRead(timeoutMs)是阻塞式读取,只能用在工作线程里,不能在 UI 线程直接调,否则界面会卡死;readLine读到的是控制连接上的文本行,末尾包含\r\n,而状态码判断只看前三字节,因为 226 后面可能是任意描述文本;多行结束的判断条件是“下一行前三个字符与起始行相同,且第四个字符是空格”,这覆盖了大多数服务器实现。我在这个函数上吃了不少亏,早期版本只判断空格不判断相同状态码,遇到425和散落的多行文本就对不上了。
有了这个函数,命令层就能统一发命令、读应答、判状态码:sendCommand("USER admin")之后readReply(...)期望 331,再发PASS期望 230。这套模式贯穿登录、列目录、下载、续传所有动作。
3. 最小可用客户端:登录、列目录、下载三个动作如何落地
3.1 连接与登录命令序列:220、331、230 三个状态码走通
登录的过程本质是在控制连接上做三次握手式的问答。客户端连上 21 端口后,服务器先发220 Service ready for new user;客户端回USER 用户名,服务器如果认了这个用户名会回331 Password required,然后客户端再发PASS 密码,服务器回230 User logged in。整个过程不需要数据连接参与,是纯控制连接交互。
SFTP 是另一个协议,很多新人把它和 FTP 混在一起,这里提一句:本方案只讲标准 FTP,听到“密钥登录”“22 端口”那是 SSH File Transfer Protocol,路由完全不同。下面是登录实现:
bool FtpClient::login(const QString &host, quint16 port, const QString &user, const QString &pass) { ctrlSocket->connectToHost(host, port); if (!ctrlSocket->waitForConnected(5000)) { lastError = QStringLiteral("connect failed: %1") .arg(ctrlSocket->errorString()); return false; } QByteArray reply; int code = 0; if (!readReply(reply, &code, 5000) || code != 220) { lastError = QStringLiteral("expected 220, got %1").arg(code); return false; } sendCommand("USER " + user.toUtf8()); if (!readReply(reply, &code, 5000) || code != 331) { lastError = QStringLiteral("expected 331, got %1 : %2") .arg(code).arg(QString::fromUtf8(reply)); return false; } sendCommand("PASS " + pass.toUtf8()); if (!readReply(reply, &code, 5000) || code != 230) { lastError = QStringLiteral("login rejected, got %1").arg(code); return false; } return true; }几个参数别照抄就完事:waitForConnected(5000)的单位是毫秒,5 秒是内网和公网都能接受的折中值,实际项目里我会把它提出来放到配置项里;USER和PASS后面的值用toUtf8()编码是安全做法,用户名密码一般只含 ASCII,但少数服务器允许带扩展字符,用 UTF-8 至少不会在客户端本地这一侧先变异。登录失败时不要只记一个code,把完整应答文本也存进lastError,方便复现问题——我见过有人只存 code=530,结果查了一天也没搞清到底是密码错还是被限 IP。
sendCommand的实现就是ctrlSocket->write(cmd + "\r\n")加一个flush,不必单独解释。要注意的是 FTP 命令一律以\r\n结尾,只写\n的服务器也可能收下,但严格按标准来最好。
3.2 PASV + LIST/RETR:列目录与下载走同一条数据连接通道
登录之后,列目录和下载文件在协议层面几乎是一模一样的流程:先发PASV拿到数据端口,再发LIST或RETR,随后在数据连接上收字节,收完后控制连接返回226 Transfer complete。区别只在于 LIST 的数据是流式文本,RETR 的数据是文件字节流,处理方式完全一致。
PASV 的应答格式是227 Entering Passive Mode (202,96,80,10,192,13),括号里有 6 个数字:前四个是 IP,后两个是端口的高位和低位。注意有些服务器在 NAT 环境下会返回内网 IP,比如192.168.x.x,这种情况按着应答去连必然失败,属于服务器部署问题,客户端能做的最多是把它记录到日志里提示用户检查服务器配置。
bool FtpClient::startPassiveDataConnection() { QByteArray reply; int code = 0; sendCommand("PASV"); if (!readReply(reply, &code, 5000) || code != 227) { lastError = QStringLiteral("PASV failed: %1").arg(code); return false; } QRegularExpression re("\\((\\d+),(\\d+),(\\d+),(\\d+),(\\d+),(\\d+)\\)"); auto m = re.match(QString::fromLatin1(reply)); if (!m.hasMatch()) { lastError = QStringLiteral("bad PASV reply: %1") .arg(QString::fromLatin1(reply)); return false; } QString dataHost = QStringLiteral("%1.%2.%3.%4") .arg(m.captured(1), m.captured(2), m.captured(3), m.captured(4)); quint16 dataPort = m.captured(5).toUShort() * 256 + m.captured(6).toUShort(); dataSocket.reset(new QTcpSocket(this)); dataSocket->connectToHost(dataHost, dataPort); return dataSocket->waitForConnected(5000); } bool FtpClient::listDir(const QString &remotePath, QByteArray *output) { if (!startPassiveDataConnection()) return false; sendCommand("CWD " + remotePath.toUtf8()); QByteArray reply; int code = 0; if (!readReply(reply, &code, 5000) || code != 250) { lastError = QStringLiteral("CWD failed: %1").arg(code); return false; } sendCommand("LIST"); if (!readReply(reply, &code, 5000) || code != 150) { lastError = QStringLiteral("LIST failed: %1").arg(code); return false; } output->clear(); while (dataSocket->waitForReadyRead(5000)) { output->append(dataSocket->readAll()); } dataSocket->disconnectFromHost(); if (!readReply(reply, &code, 5000) || code != 226) { lastError = QStringLiteral("LIST incomplete: %1").arg(code); return false; } return true; }这里要敲两个细节。第一,CWD不是必须的:LIST和RETR可以直接带相对或绝对路径,但先 CWD 有一个好处,后续所有命令都可以用相对路径,日志里的语义也更清楚,所以我在列目录前先 CWD 一次。第二,数据连接的读取循环,我用的waitForReadyRead(5000)在读到 EOF 时返回 false,但要注意正常结束和异常断开都表现为等待超时,所以收完字节后必须再读一次控制连接确认226——这是判断“传输真的完整”的唯一依据。只盯着数据连接看不出半截断,控制连接那个226才是闭环。
3.3 别把界面卡死:把 FTP 操作放进工作线程
刚才的代码全是用waitForReadyRead写的阻塞式调用,如果直接在按钮的点击槽里跑download,UI 线程就会卡在网络上,窗口转圈、无响应,Windows 甚至会提示你“强制关闭”。qt 多线程的常见做法是做一个 worker 对象,把 FTP 客户端扔到子线程里,界面线程只负责收信号刷新。
习惯的写法是QObject子类加moveToThread:把 FtpClient 和 worker 都搬到子线程,由信号槽驱动任务的开始和结束。子线程里阻塞等待没问题,信号槽跨线程会自动排队,UI 线程不会停。
class FtpWorker : public QObject { Q_OBJECT public slots: void startDownload(const QString &host, quint16 port, const QString &user, const QString &pass, const QString &remote, const QString &local) { bool ok = false; QString msg; FtpClient client; if (client.login(host, port, user, pass)) { ok = client.download(remote, local, 0); msg = ok ? QStringLiteral("done") : client.lastErrorString(); } else { msg = client.lastErrorString(); } emit finished(ok, msg); } signals: void finished(bool ok, const QString &msg); };启动方式是:创建线程和 worker,把 workermoveToThread到线程,线程start后用Qt::QueuedConnection连接界面发来的下载请求与 worker 的槽,worker 的finished再连回界面线程。waitForReadyRead在子线程里放心用,它阻塞的是 worker 线程,界面该刷新刷新。另一个常见做法是用QThreadPool加QRunnable,适合下载任务很多、需要并发的情况,这个在第 4 章展开。这里提醒一句:千万别在 worker 里用QMessageBox::information之类的弹窗去同步提示结果,跨线程的模态窗口会卡住事件循环,日志信号或者通知信号才是干净的路子。
4. 把客户端做成能用的产品:进度、断点续传与多文件并发
4.1 大文件传输进度与内存控制:不要一把梭 readAll
网上不少 Demo 下载文件是这么写的:QNetworkReply的finished里readAll(),然后整个写进本地文件。小文件没问题,到了几百 MB 的视频或镜像,轻则内存暴涨,重则 32 位程序直接崩。qt 大文件网络传输的正确姿势是边收边写,给readyRead挂槽函数,数据来一批写一批。
自研的 socket 版本也同理,把数据连接的readyRead交给槽函数处理,每次只读bytesAvailable()或限制最大的缓冲块,写完马上推进度。Progress 更新的频率也要控制:每来一个包就发信号,界面刷新的开销会超过传输本身,我一般按“超过 1 MB 或间隔超过 100 毫秒”才发一次,视觉上进度条流畅,CPU 占用也低。
void DownloadTask::onDataReady() { const qint64 chunk = dataSocket->bytesAvailable(); if (chunk <= 0) return; QByteArray block = dataSocket->readAll(); localFile->write(block); receivedBytes += block.size(); const qint64 total = remoteSize; if (total > 0 && (receivedBytes - lastEmitted) >= 1024 * 1024) { lastEmitted = receivedBytes; emit progressChanged(receivedBytes, total); } }这里有几个参数要调:remoteSize是哪来的?在点下载之前我会先发一次SIZE <远程文件路径>,大部分服务器会回213 文件大小,这个是进度条分母的主力。拿不到 SIZE 时进度条的total就传-1,界面显示“传输中”而不是百分比。chunk的判断不要只看> 0,有些服务器会在消息边界发空数据,直接跳过。localFile的打开方式要跟断点续传配合,见下一节。
4.2 断点续传:REST 命令与追加写文件的配合
断点续传在 FTP 里的核心命令是REST,它告诉服务器“从偏移量 N 开始发送”。要注意的是REST不是一个独立动作,它必须紧跟在RETR前面才生效,而且很多服务器要求先TYPE I切到二进制模式再REST,否则按文本模式传,\r\n会被转换,续传下来的文件前段后段拼接处直接损坏。
顺序我建议这么走:TYPE I→PASV→REST offset→RETR remotePath。有服务器对REST的位置敏感,把 REST 放在 PASV 前面会返回 503 顺序错误,把 REST 放在 PASV 后面是目前兼容性最好的选择。本地文件侧,续传时不能简单地WriteOnly覆盖,而是在文件末尾继续写。
QFile localFile(localPath); QIODevice::OpenMode openMode = QIODevice::WriteOnly; if (resumeOffset > 0) { openMode |= QIODevice::Append; } if (!localFile.open(openMode)) { lastError = QStringLiteral("open local file failed: %1") .arg(localPath); return false; } sendCommand("TYPE I"); readReply(reply, &code, 5000); // 期望 200 sendCommand("PASV"); // 这里建立数据连接 sendCommand(QString("REST %1").arg(resumeOffset).toUtf8()); if (!readReply(reply, &code, 5000) || code != 350) { lastError = QStringLiteral("server does not support REST: %1") .arg(code); return false; } sendCommand("RETR " + remotePath.toUtf8());踩坑点在于“REST 被拒绝”和“REST 假成功”。被拒绝的情况,响应码通常是502 Command not implemented或504,这时应该自动降级成从头下载,并给用户一个明确的提示,而不是把错误抛到脸上。假成功的情况更隐蔽:服务器回350,但最终传下来的文件大小和本地已有文件加起来的字节数对不上,这是服务器对 REST 支持不完整的表现,所有续传函数返回后必须校验总字节数和文件大小。我现在统一用一个校验函数:传完读一下本地QFileInfo::size(),跟“续传前大小 + 本次收到的字节数”做比较,对不上就标记失败。
4.3 多文件任务队列与并发数:QThreadPool 的正确用法
一次下载几十个文件的场景,不能给每个文件开一个QThread。线程创建销毁的成本、同时打开的 socket 数、服务器的并发连接限制都会让你翻车。习惯做法是维护一个任务队列,配合QThreadPool::globalInstance(),并发数控制在 3 到 5。
class DownloadTask : public QRunnable { public: void run() override { FtpClient client; if (!client.login(host, port, user, pass)) { emitError(taskId, client.lastErrorString()); return; } bool ok = client.download(remotePath, localPath, resumeOffset); if (!ok && retryTimes < maxRetry) { retryTimes++; QThreadPool::globalInstance()->start(this); return; } emitFinished(taskId, ok); } };这个写法里有个具体的坑:QRunnable本身不是QObject,不能直接emit信号。我一般让DownloadTask持有一个QObject子类的回传指针,或者回调一个全局信号转发器,也可以用QtConcurrent::run加QFutureWatcher拿结果。另外注意QRunnable默认会在run()返回后被QThreadPool自动删除,如果你设置setAutoDelete(false)就得自己管生命周期,否则内存泄漏和悬垂指针二选一。生产者和消费者的关系在这里很直观:界面线程往队列里“生产”任务,线程池里的 worker 是“消费”方,重试逻辑写在run()内部而不是外部循环,否则排队的语义会乱。
并发数不是越大越好。很多 FTP 服务器对单 IP 的连接数有限制,超过 5 个直接拒绝或踢掉旧连接,我一般把QThreadPool的maxThreadCount设成 4,再额外做一层“同一时间单服务器最多 4 个任务”的信号量控制。批量任务里的失败重试最多两次,再多就要进死信队列等人处理了。
5. 避坑排查:Qt FTP 客户端最常见的 5 个翻车现场
5.1 编译期:qt 5.15.2 报 dependent 头文件不存在
现象:拿到源码包用 qt 5.15.2 msvc2019_64 打开.pro,编译直接报错,形如:-1: error: dependent '..\..\..\..\..\..\Qt\5.15.2\msvc2019_64\include\QtWidgets\qtwidgetsversion.h' does not exist。
原因:源码包里.pro手写了INCLUDEPATH和DEPENDPATH,用的是一长串..相对路径,压缩包解开到不同目录层级后路径全断了。这种问题最常见于网上分享的源码包,作者在自己机器上能编,换目录就废。
解决:先删掉.pro里手写的INCLUDEPATH和DEPENDPATH,改成QT += widgets network这种模块声明;然后用 Qt Creator 重新打开.pro,让 kit 自动配置 msvc2019_64 的 Qt 路径。如果必须保留自定义 include,用$$PWD做绝对路径拼装,不要用..逐级往上爬。
# 手动清理后,通常这样检查 qmake 是否拿到正确 Qt 版本 qmake -v qmake 你的项目.pro -o Makefile5.2 发布后:qt.qpa.plugin 找不到 windows 平台插件
现象:开发机里跑得好好的,把 exe 拷到别的机器上双击,弹窗qt.qpa.plugin: could not find the Qt platform plugin "windows" in "",程序直接崩溃,还有些机器连弹窗都没有,Process Explorer 能看到进程起来立刻死掉。
原因:exe 依赖 Qt 的 platform 插件,路径是 exe 旁边的platforms/qwindows.dll。开发环境能跑是因为 PATH 里能找到 Qt 的 bin 和 plugins,目标机器上没有。这是 qt 发布软件最经典的翻车点,不是代码问题。
解决:用windeployqt打发布包,这是 Qt 自带的部署工具,会拷贝全部依赖。发布目录里必须出现platforms\qwindows.dll,否则都是白搭。
# 在 Qt 的命令行环境下执行 windeployqt --release --no-translations 你的程序.exe # 验证目录结构 dir platforms如果某些环境必须绿色免安装,也可以手动设环境变量QT_QPA_PLATFORM_PLUGIN_PATH指向 platforms 所在目录,但部署脚本里配置这个变量容易忘,不如直接按标准目录结构放。
5.3 中文文件名乱码:UTF-8 和 GBK 的拉锯战
现象:连 Linux 的 vsftpd 一切正常,连 Windows 服务器的 FileZilla Server 或 IIS 时,列目录中文名全是乱码,CWD 中文目录失败或进入错误目录。
原因:FTP 协议本身没规定目录列表的字符编码。vsftpd 默认 UTF-8;Windows 上不少 FTP Server 按本地代码页 GBK 输出。客户端这边 Qt 的QString::fromUtf8把 GBK 字节按 UTF-8 解,自然乱。
解决:登录后先发一条OPTS UTF8 ON,看服务器是否回200或202。如果服务器不支持 UTF-8,就用QTextCodec::codecForName("GBK")解码列表的原始字节。
QTextCodec *codec = QTextCodec::codecForName("GBK"); if (codec) { QString decoded = codec->toUnicode(rawListBytes); }这个方案在 Qt 5 下直接可用,Qt 6 里QTextCodec移到了 Core5Compat 模块,需要额外引入。列目录的原始字节先存QByteArray,再统一按检测到的编码解码,不要在字节层面断章取义。路径这块,CWD 命令的参数编码也要跟列表一致,否则目录名“软件包”会在服务器端对不上。
5.4 传一半就断:数据连接超时和内存暴涨
现象:下载 1 GB 文件,传到 70% 左右连接断开;或者程序内存占用直线飙到几个 GB,界面失去响应,最后以 qt 崩溃收场。
原因:两个问题同时存在。一个是用readAll()把整个文件读进了内存;另一个是数据连接上长时间没数据、waitForReadyRead抛了超时,但控制连接还没收到226,代码直接把 socket 关了,导致服务器侧无法确认,回传的文件字节数和本地文件大小对不上。
解决:第一,readyRead里分批写文件,绝不readAll整个响应,代码见 4.1。第二,给数据接收做独立超时,比如 30 秒没有任何新字节就主动断开并标记失败;断开之后不要直接报错,把已接收字节数记下来,下次用REST续传。第三,写完文件后做一个大小校验:QFileInfo的size()要和 FTP 的SIZE命令返回值一致。断点续传不是“附加功能”,它同时就是大文件传输的兜底方案。
5.5 搭测试服务器时:本地 FTP 行为与生产环境不一致
现象:本地用 FileZilla Server 搭的测试环境一切正常,一上生产环境就超时、乱码、断点续传失败,而且问题千奇百怪。
原因:不同 FTP 服务器对的 REST 顺序要求不同,被动模式端口范围差异大,还有的服务器返回的 PASV 地址是内网 IP。本地测出来的“正常”不代表协议层面的兼容性好。
解决:客户端这边做三件事——把REST的位置做成可配置(PASV前或PASV后各试一次);把OPTS UTF8 ON不支持的场景自动降级到 GBK;PASV 返回的内网 IP 记录下来给用户一条明确提示。至于 ftp 服务器怎么搭建,本地建议直接装 FileZilla Server 或 Docker 起一个 vsftpd,别用 Windows 自带的 IIS FTP,行为差太多,测不出你要的边界。
6. 进阶验证:命令行模式、传输日志与哈希校验
客户端做到能下载、能续传、能并发,离“可交付”还差最后一步:可验证。我习惯给客户端加一个命令行入口,跑通之后再挂界面,这样自动化测试和运维排查都能复用同一套逻辑。
QCommandLineParser parser; parser.addOption(QCommandLineOption({"h", "host"}, "FTP host")); parser.addOption(QCommandLineOption({"P", "port"}, "FTP port", "21")); parser.addOption(QCommandLineOption({"u", "user"}, "username")); parser.addOption(QCommandLineOption({"p", "pass"}, "password")); parser.addOption(QCommandLineOption({"r", "remote"}, "remote path")); parser.addOption(QCommandLineOption({"o", "local"}, "local path")); parser.addOption(QCommandLineOption({"R", "resume"}, "resume offset", "0"));命令行模式跑完之后,如果下载的是文件,用QCryptographicHash算一个 MD5 或 SHA256,跟服务器或源文件比对。这一步能一次性暴露文本/二进制模式搞错、拼接偏移算错、尾部多收字节这三类问题。生产环境里,我见过太多“能下完但无法解压”的场景,最终还是哈希校验兜住了。
传输日志也要结构化。控制连接上一来一回,每行都记:方向、状态码、命令或响应文本。排错时最关键的证据是“最后一条命令是什么、服务器的响应是什么”。比如断线发生在RETR之后,控制连接回的是426 Connection closed; transfer aborted,而不是226,那答案已经很清楚了——数据连接异常,不是服务器主动断开,续传方向是对的。CRC 和哈希不一致时,看日志里有没有TYPE A的残留,基本能找到根因。
我自己验收客户端时有个习惯:先跑一遍命令行模式,看日志最后几十行,确认控制连接收尾是 226,再跑哈希校验,最后才打开界面做人工测试。界面容易掩盖协议层的问题,这套流程会强制你面对协议本身。qt 崩溃的大多数场景都不是界面代码的问题,而是网络层没有兜底。按这个顺序排查,能省掉很多来回折腾的时间,希望帮到你。
本文还有配套的精品资源,点击获取