简介:一套基于Qt的FTP上传功能演示工程,面向需要快速掌握QNetworkAccessManager网络编程的Qt开发者,帮助解决在桌面应用中集成FTP文件上传的实际需求。压缩包内共13个文件,核心代码由4个cpp源文件与3个h头文件构成,配合2个ui界面文件、工程文件及翻译配置文件,整体仅47KB,结构简洁便于直接阅读与复用。已有446人学习下载,适用于理解FTP上传的基本流程。通过该Demo,读者可重点参考QNetworkRequest构造、QNetworkAccessManager::put调用及finished/uploadProgress信号槽处理等关键环节,并在此基础上扩展多文件上传、错误处理、进度展示等功能。工程代码量小、依赖少,是学习Qt网络通信与FTP协议交互的实用入门范例。
1. Qt实现FTP上传Demo:内网传文件,为什么还得绕回FTP这条路
在设备调试和内部系统对接的现场,你经常面对一台系统停留在老版本的内网工控机,没有Web服务、没有对象存储,唯一的文件通道就是FTP。用Qt实现FTP上传Demo,是这类场景里落地最快的方案:它不依赖服务器端额外开发,只要对方开一个FTP账号就能传。很多刚接触Qt网络编程的开发者会在这里卡住——新版Qt对FTP的支持一改再改,老模块需要自己编译,上传又是异步流程,信号槽套着命令队列,很容易一头雾水。这篇笔记会直接讲清楚怎么做:从模块选型、编译接入,到连接、登录、put上传、进度回调,以及被动模式、中文编码、超时重试这些必调参数,最后把断点续传和上传后校验补齐。读完你可以照着把Demo跑起来,也知道哪些坑必须提前绕开。
2. 从协议到选型:QFtp、QNetworkAccessManager和手写TcpSocket该选谁
2.1 FTP上传的底层逻辑:控制连接与数据连接
FTP和HTTP有个本质区别:它用两条连接。控制连接走21端口,负责传命令和应答码;数据连接另开通道,负责传文件内容。一次完整的上传,从客户端视角看是这么一串命令:CONNECT → USER/PASS 登录 → CWD 切换目录 → STOR 存储文件 → QUIT。服务器在每条命令后都会回一个三位数应答码,比如230表示登录成功、150表示准备接收数据、226表示传输完成。
这个流程天然是异步的。你不可能发一条命令就阻塞等待几十秒,所以Qt里做FTP上传,核心就是对命令队列和信号槽的处理。QFtp这个模块把上面这套状态机封装好了:你只管调用connectToHost、login、cd、put,它在内部维护一个待执行命令队列,每条命令结束后发commandFinished信号,数据传输出进度时发dataTransferProgress信号。理解了这两条连接和一个队列,后面写代码就不容易乱。
2.2 三个可选实现:QFtp、QNetworkAccessManager、QTcpSocket手写
先说结论:Qt 6里QNetworkAccessManager已经把FTP支持移除,还在用QNAM写FTP上传的代码基本是陈年项目。即便在Qt 5里,它对FTP的支持也很有限,没有主动/被动模式控制、没有断点续传接口,对很多实际场景来说不够用。手写QTcpSocket当然可控性最高,但FTP应答码解析、数据连接建立、LIST解析这些工作量大,一个Demo阶段完全没必要。
QFtp是官方老模块,虽然早已不在标准发行包里,但源码仍然以独立形式维护。它保留了完整的FTP命令支持,包括cd、rename、remove、mkdir、rawCommand这些。对做上传Demo来说,QFtp是唯一一个"既有高层封装、又留了底层口子"的选择。下面的对比表应该能帮你快速做决定:
| 实现方式 | 控制力 | 断点续传 | 主动/被动模式 | 代码量 | 结论 |
|---|---|---|---|---|---|
| QFtp 独立模块 | 高 | 可用rawCommand实现 | 固定被动模式 | 少 | 首选 |
| QNetworkAccessManager | 低 | 不支持 | 不可控 | 最少 | Qt 6已移除,放弃 |
| QTcpSocket 手写 | 最高 | 完全可控 | 完全可控 | 非常大 | 仅特殊场景 |
我一般会这样判断:如果你只是往一个固定FTP服务器上传文件,QFtp足够;如果你要处理复杂的主动/被动切换,或者服务器对FTP命令兼容性极差,那再考虑手写协议层,但那时候你写的已经不是Demo了。
2.3 把QFtp编译进Qt工程:最小接入步骤
QFtp不在Qt安装包里,需要额外拿到源码放进自己的工程。这步不复杂,但很多人第一次会栽在moc处理上。常见做法是把qftp的源文件目录拷到工程里,让.qmake自动参与编译。先看目录准备这一步:
# 把qtftp模块源码里的 qftp 目录拷贝到工程第三目录 mkdir -p 3rdparty/qftp cp -r <qtftp源码路径>/src/qftp/* 3rdparty/qftp/然后修改工程文件,把qftp.cpp和qftp.h加进编译列表:
QT += network INCLUDEPATH += $$PWD/3rdparty/qftp SOURCES += \ main.cpp \ ftpuploader.cpp \ $$PWD/3rdparty/qftp/qftp.cpp HEADERS += \ ftpuploader.h \ $$PWD/3rdparty/qftp/qftp.h注意QFtp是个QObject子类,头文件里带了Q_OBJECT宏,所以它必须经过moc。直接放在SOURCES和HEADERS里,qmake会自动为它跑一遍moc,不需要额外处理。如果你的工程是CMake,记得把qftp.cpp加入add_executable的源文件列表,并且包含qftp头文件目录。接入完成后,写一行#include <QFtp>能编译通过,就算成功了。
3. 跑通上传主流程:连接、登录、put与进度回调的槽编排
3.1 建立FTP连接:connectToHost与login的串联
QFtp是异步的,所以不能像普通函数调用那样一行接一行写。我会用一个状态标记m_next来记录"当前命令完成后下一步该做什么",在commandFinished信号里驱动状态机。先看连接部分:
// ftpuploader.h #include <QFtp> #include <QFile> class FtpUploader : public QObject { Q_OBJECT public: enum NextAction { None, Connect, Login, Cd, Put, Close }; void upload(const QString &host, quint16 port, const QString &user, const QString &pass, const QString &remotePath, const QString &localPath); private slots: void onCommandFinished(int id, bool error); void onProgress(qint64 done, qint64 total); private: QFtp m_ftp; // QFtp 作为成员,绝对不能是 upload() 内的局部变量 NextAction m_next = None; QFile *m_file = nullptr; QString m_remoteName; };连接和登录的串联逻辑在commandFinished里完成。每条命令完成后,根据m_next的值决定下一步动作:
void FtpUploader::upload(const QString &host, quint16 port, const QString &user, const QString &pass, const QString &remotePath, const QString &localPath) { connect(&m_ftp, SIGNAL(commandFinished(int,bool)), this, SLOT(onCommandFinished(int,bool))); connect(&m_ftp, SIGNAL(dataTransferProgress(qint64,qint64)), this, SLOT(onProgress(qint64,qint64))); m_next = Connect; m_ftp.connectToHost(host, port); // 命令入队,立刻返回 // 登录参数暂存为成员,等 Connected 状态完成再使用 m_user = user; m_pass = pass; m_remotePath = remotePath; } void FtpUploader::onCommandFinished(int id, bool error) { if (error) { qWarning() << "FTP命令失败:" << m_ftp.errorString(); m_ftp.clearPendingCommands(); // 出错后清空队列,避免连锁失败 return; } switch (m_next) { case Connect: m_next = Login; m_ftp.login(m_user, m_pass); break; case Login: m_next = Cd; m_ftp.cd(m_remotePath); break; case Cd: m_next = Put; m_file = new QFile(m_localPath, this); if (!m_file->open(QIODevice::ReadOnly)) { qWarning() << "本地文件打开失败" << m_file->errorString(); return; } m_ftp.put(m_file, m_remoteName, QFtp::Binary); break; default: break; } }这段代码里最关键的是m_ftp必须是成员变量。如果写成局部变量,upload函数一返回,QFtp对象就被析构,信号槽连接全部失效,你会看到命令发出去了但槽完全不触发。connectToHost返回的是一个int类型的命令id,可以用来精确匹配某一条命令的完成响应,但用m_next这个状态标记在串行上传场景里更直观。
3.2 put上传与进度回调:从本地文件到服务器的数据流
put这一步是体力活,但细节不少。QFtp的put有两个重载:一个接受QByteArray,一个接受QIODevice指针。上传大文件必须用QIODevice版本,否则整个文件被读进内存。文件open方式必须是ReadOnly,并且最好把类型显式传成Binary,避免某些服务器对文本模式做换行符转换:
case Put: { m_file = new QFile(m_localPath, this); if (!m_file->open(QIODevice::ReadOnly)) { qWarning() << "本地文件打开失败:" << m_file->errorString(); return; } m_totalBytes = m_file->size(); m_ftp.put(m_file, m_remoteName, QFtp::Binary); m_next = Close; break; }进度回调连接的是dataTransferProgress信号,这个信号反映的是数据连接上实际传输的字节数。注意有个坑:有些FTP服务器在被动模式下不会预先报告文件总大小,total参数可能是0。这时候进度条会变成不确定状态,我一般自己拿本地文件大小兜底:
void FtpUploader::onProgress(qint64 done, qint64 total) { if (total <= 0) { total = m_totalBytes; // 服务器没给大小,用本地文件大小填充 } int percent = (total > 0) ? (int)(done * 100 / total) : 0; emit uploadProgress(percent); }记住done和total是qint64,计算百分比时先乘再除,避免整数溢出。文件超过2GB时尤其要注意类型,int会直接翻车。
3.3 上传完成后的收尾:关闭文件、切换目录与命名
put命令发出后,服务器在收到完整数据后返回226应答,这时QFtp发出put对应命令id的commandFinished。不要在数据还没传完时就关闭文件。我的习惯是,put命令完成后再做三个收尾动作:关闭本地文件、确认远程文件大小、按需把临时文件名改回正式名。
临时文件名的做法在现场非常实用:先传成目标名.tmp,全部完成后再rename成正式名。这样即使上传中途断掉,服务器上也不会留下一个半截的正式文件。QFtp里rename是现成接口,直接放到命令队列里:
case Put: { m_next = Rename; m_ftp.rename(m_remoteNameTmp, m_remoteName); // 上传用临时名,完成后改名 break; } case Rename: { m_file->close(); m_file->deleteLater(); m_ftp.close(); break; }这个顺序保证了一件事:服务器上永远只有完整文件或空文件名,不会有半截内容被下游任务消费。如果你不需要临时名,那至少要在put完成后关闭文件,否则在Windows上文件句柄不释放,下次上传同名文件会报权限错误。
4. 上传参数的四个必调项:被动模式、中文编码、超时与重试策略
4.1 被动模式不是玄学:QFtp默认只有一条路
QFtp实现的基本是FTP被动模式,也就是客户端主动去连服务器返回的数据端口。对大多数内网环境这没问题,但跨网段时容易出状况:服务器在PASV应答里返回的是它自己视角的内网地址,客户端如果不在同一网段,根本连不上这个地址。现象就是登录正常,一put就卡在"等待数据连接"。
遇到这种情况,先确认服务器配置里有没有"被动模式返回外网地址"这类开关,很多FTP服务端是有的。如果服务器不可调,那Qftp这边没有公开接口切主动模式——这是它的硬边界。真要做主动模式,只能改QFtp源码或者自己用QTcpSocket写协议层。所以选型时要提前问清楚:服务器部署在哪个网段,客户端能不能直连它的数据端口。这个问题越早确认越好,别等功能联调时才发现。
4.2 中文文件名乱码:用QTextCodec把本地编码对齐服务器
QFtp发送命令时,内部把QString转成本地8位编码。在中文Windows上,这意味着命令以GBK发出去。如果服务器端用的是UTF-8,中文文件名就会变成乱码,或者干脆报550找不到路径。反过来也是同理。
解决办法是全局统一编码,让QFtp发出的命令编码和服务器一致。Qt的QTextCodec提供了setCodecForLocale,它会影响QString的toLocal8Bit行为,而QFtp底层用的正是toLocal8Bit:
#include <QTextCodec> // 在main()里、创建任何QFtp对象之前设置 QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8"));如果服务器是GBK,就换成codecForName("GBK")。注意这个设置是进程级的全局影响,改了之后所有本地编码转换都会跟着变,所以要么在程序启动时一次性设好,要么就保持默认再手动转码。我最常用的验证方式,是先传一个带中文名的空文件到服务器,用另一个FTP客户端看一眼文件名是否正常,再决定要不要加这一行。
4.3 超时控制:QFtp没有自带超时,用QTimer加中止
QFtp另一个让人头疼的点是没有超时设置。网络断了、服务器假死,命令队列会一直挂着,界面就像卡死。我一般用单发QTimer给每一条关键命令做超时守卫:
QTimer *timer = new QTimer(this); timer->setSingleShot(true); timer->setInterval(15000); // 15秒没响应就中止 connect(timer, &QTimer::timeout, this, [this, timer]() { m_ftp.abort(); // 中止当前正在执行的命令 m_ftp.clearPendingCommands(); // 清掉后面排队的命令 timer->deleteLater(); }); // 每发一条命令时重启定时器 int id = m_ftp.cd("/upload"); timer->start();然后在commandFinished里把定时器停掉。这样把"单条命令卡死"控制在15秒内,配合重试逻辑,比让用户干等强得多。abort之后还要记得clearPendingCommands,否则队列里后面的命令会继续执行,可能把状态搞乱。
4.4 重试用例设计:哪些错误值得重试
不是所有错误都值得重试。登录失败、目录不存在这类属于永久性错误,重试一百次也没用,直接抛给用户。而超时、数据连接被断开、服务器临时返回4xx,这些属于临时性错误,适合做两到三次重试。我的做法是给命令id加一个重试计数器,超过3次就放弃,并在界面上给出"上传失败,请检查网络和服务器状态"而不是一句冷冰冰的errorString。
重试时要注意命令队列状态。出现错误后队列可能还有残留命令,必须先clearPendingCommands,再重新发起connectToHost之前的流程。这也是为什么很多Demo会把"上传文件"提炼成一个transient操作:从connect开始,状态机从头跑,重试只是把入口重新调用一次。
5. FTP上传Demo避坑与排查:5个典型翻车点
5.1 槽函数全部不触发,命令却正常发出
现象:connectToHost返回了命令id,但onCommandFinished、onProgress这些槽一次都没被调用。
原因:QFtp对象被声明成了upload函数的局部变量,函数返回后对象析构,信号连带着没了接收者。这是Qt里最常见的"黑匣子"问题——代码看着没错,实际对象已经死了。
解决:把QFtp声明成类成员,或在堆上new出来并指定parent为this。用成员时注意upload函数里不要再重新赋值覆盖已有连接,connect一次就够了。
5.2 上传成功但远程出现0字节文件
现象:命令流全部走完,没有任何error,但服务器上的文件大小是0。
原因:QFile没有成功打开,或者打开后没有检查error状态。很多时候是本地路径不存在,但代码里QFile构造函数不报错,直到open失败才返回false,这里被忽略后put就把一个空设备传上去了。
解决:open之后立刻判断返回值并输出errorString;put传入前确认m_file->isOpen()为true。文件很大时还要确认m_file->size()不为0,这两个检查能挡住九成问题。
5.3 put一直卡在"等待数据连接",进度始终为0
现象:登录成功,切换到目录也成功,但put命令发出去后没有任何进度回调,界面上一直转圈。
原因:被动模式下,客户端连接PASV返回的数据端口失败。常见于客户端和FTP服务器跨网段,或者中间防火墙只放行了21端口,没有放行数据端口范围。
解决:先确认服务器是否配置了正确的被动模式地址;然后用同网段一台机器试试能否直连数据端口。QFtp固定走被动模式,如果服务器无法配合,就得考虑换实现方案,而不是继续调Qftp代码。
5.4 上传成功但文件损坏,或Windows和Unix下文件大小不一致
现象:二进制文件上传后无法解压、图片打不开,或者从Windows传到Unix后换行符变了。
原因:put用了默认的文本传输类型,服务器在做ASCII模式转换。同时,put返回后立刻关闭QFile,也可能导致数据没完全写入网络缓冲。
解决:put时显式传QFtp::Binary;put命令的commandFinished触发后再close文件。这两步配合,才能保证文件字节级一致。做Demo时我总会在本地放一个已知MD5的文件,传上去后用服务器端MD5比对,一次性确认传输类型没有坑。
5.5 断点续传后文件多出一块,或两边长度对不上
现象:用REST命令续传后,服务器上的文件比本地大,内容错位。
原因:最常见的是本地文件seek的偏移和REST命令的偏移不一致。REST让服务器从某个字节开始写,但本地文件没同步seek到同一个位置,两边就错开了。
解决:续传前先取本地文件残留大小,同时把REST偏移设置成同一个值,顺序是seek先做,再发REST,最后put。另外,有些服务器对REST的支持并不标准,续传前最好先做一次"传1KB测试文件断点续传"的验证,确认服务端行为正常再正式传。
6. 给Demo装上断点续传:REST命令与上传后的自校验
6.1 用REST命令实现断点续传的最小代码
QFtp没有直接封装断点续传,但它留了rawCommand这个口子。续传时,让文件从本地偏移处继续读,同时发REST告诉服务器从对应偏移开始写:
m_file->seek(localOffset); // 本地文件跳到断点 m_ftp.rawCommand("REST " + QString::number(localOffset)); m_ftp.put(m_file, m_remoteName, QFtp::Binary); // 入队,等REST应答后执行rawCommand和put共用同一个命令队列,所以顺序有保证。你不需要等REST的应答手动放行,QFtp会一条条执行。续传成功后,把localOffset更新成当前进度,下次断线继续用。
6.2 上传完不是结束:用服务端LIST做一次自校验
真正成熟的Demo会给自己留一条验证路径。上传完成后,发一条list命令,在listInfo信号里找到目标文件的size,和本地文件大小比对;如果服务器支持MD5校验,再用原始FTP命令拉取校验值。大小不一致时直接标记为失败,并清理掉远程半截文件,这样下游拿到的一定是完整文件。
我现在做FTP上传功能,都会保留一个只读的测试账号,专门用来跑"传上去、拉回来、比对大小、断点续传、主动取消"这五个用例。这个习惯救过我好几次——有一次就因为服务器默认开了ASCII模式,让一批二进制包全部损坏,靠自校验才在发版前拦下来。把Demo留在能复现、能验证的状态,比多写几个功能更值得,希望帮到你。
本文还有配套的精品资源,点击获取