
简介这份资源面向使用 Qt5 及以上版本开发桌面应用的开发者解决高版本 Qt 移除 QFtp 类后无法直接进行 FTP 操作的问题。作者从 Qt4.8.7 源码中提取出 QFtp.h 与 QFtp.cpp 两个文件并配套编写了完整测试示例覆盖登录 FTP 服务器、上传文件、删除文件、创建文件夹、重命名文件等常用功能适合需要为项目补回 FTP 能力或学习 Qt 网络编程的中级开发者参考。压缩包共 17 个文件约 35KB包含 5 个 cpp 与 4 个 h 源文件承载核心逻辑和窗口类3 个 ui 文件描述主界面及重命名、新建文件夹对话框另有 qrc 资源、pro 工程文件及图标图片结构紧凑、便于直接编译运行。目前已有 1319 人学习下载读者可据此快速理解 QFtp 的移植方式与调用流程并在此基础上扩展自己的 FTP 客户端功能。1. 从一份“断头”的 FTP 客户端说起Qt 里那个被移除的 QFtp 到底怎么补回来接手一个老工控上位机项目时我遇到一个很典型的需求设备日志要定时从一台内网 FTP 服务器上拉下来界面里还得能手动上传配置包。项目是 Qt 写的翻文档才发现从 Qt 5 开始 QFtp 这个类就被从网络模块里拿掉了官方只留了 QNetworkAccessManager 那套 HTTP 味很重的接口对 FTP 的支持只剩个“只读下载”的残废能力。于是这份“Qt 实现 QFtp”的资源就有了明确的使用场景——它把当年 Qt 4 里那套基于命令队列的 FTP 客户端重新实现了一遍封装成可以直接塞进 Qt 5/Qt 6 工程的类。适合谁手上有 Qt 项目、需要 FTP 上传下载、又不想为了一个协议去引第三方大库的开发者。下面我按“它是什么、怎么跑、怎么改”的顺序把这份代码拆开讲。2. 架构拆解QFtp 的命令队列与状态机是怎么转起来的2.1 为什么 FTP 客户端不能写成“一个函数调到底”FTP 和 HTTP 最大的区别在于它是有状态的长连接协议控制连接上一条命令一条响应登录、切目录、被动模式协商、数据传输每一步都得等上一条的应答码回来才能继续。如果你用同步阻塞的方式写界面直接卡死如果用“发一条命令开一个 socket”的写法又会把控制连接和数据连接搞混。所以这类客户端的合格做法只有一个命令队列 状态机。这份实现的核心思路是维护一个QQueueQFtpCommand每次只取队首命令发出去收到完整应答行后解析三位应答码根据码值决定是继续下一条、还是进入数据传输阶段、还是报错清队列。整个类对外暴露的是connectToHost、login、cd、get、put、list、close这些和 Qt 4 原版一致的接口内部全部转成命令对象入队。这样调用方写起来是“连续调用一串方法”实际执行是严格串行的不会出现两条命令抢一个 socket 的翻车现场。2.2 目录结构一个类拆成几个文件才合理拿到源码包先别急着编译把文件摊开看结构。这类实现通常不会只有一个.cpp合理的拆分是这样的qftp/ ├── qftp.h // 对外主类命令队列与状态机 ├── qftp.cpp ├── qftpiodevice.h // 数据连接抽象get/put/list 共用 ├── qftpiodevice.cpp ├── qftpcommand.h // 单条命令的封装命令字、参数、期望应答 ├── qftpcommand.cpp └── qftp.pro // qmake 工程也可换成 CMakeLists.txtQFtp只管控制连接和调度QFtpIoDevice继承QIODevice把数据连接包装成可读可写的设备这样get就能直接对接QFile做流式落盘put也能从文件读一块发一块不用把整个文件读进内存。QFtpCommand是个轻量结构体记录命令文本、参数列表和“这条命令期待什么应答”。这种拆法的好处是你要加一个新命令比如SIZE、MDTM只需要在命令枚举和解析分支里各加一处不用动主状态机。2.3 控制连接的建立与应答解析先看连接和登录这段这是所有后续操作的前提// qftp.cpp 片段建立控制连接并进入命令循环 void QFtp::connectToHost(const QString host, quint16 port) { d-state QFtpPrivate::Connecting; d-controlSocket-connectToHost(host, port); // 控制连接默认 21 } void QFtp::onControlReadyRead() { d-buffer.append(d-controlSocket-readAll()); // FTP 应答以 \r\n 结尾多行应答形如 230-...\r\n230 ...\r\n while (d-buffer.contains(\r\n)) { int idx d-buffer.indexOf(\r\n); QString line QString::fromLatin1(d-buffer.left(idx)); d-buffer.remove(0, idx 2); if (line.size() 3 line.at(3) QLatin1Char(-)) { d-multiLineCode line.left(3); // 多行应答记住码值继续收 continue; } if (!d-multiLineCode.isEmpty()) { if (line.left(3) d-multiLineCode) d-multiLineCode.clear(); // 多行结束 continue; } d-parseReply(line.left(3).toInt(), line.mid(4)); } }这段的关键在多行应答的处理。FTP 服务器返回230-开头表示后面还有若干行直到出现230注意是空格不是横杠才算结束。很多自己手写 FTP 客户端的人在这里翻车只按行解析结果把多行欢迎信息当成多条应答状态机直接错位。代码里用multiLineCode记住当前多行块的码值遇到同码值加空格的行才清空逻辑是对的。parseReply拿到三位码后按 1xx/2xx/3xx/4xx/5xx 分类2xx 表示命令成功推进队列3xx 表示需要后续数据连接比如150后跟2264xx/5xx 直接触发error信号并清空队列。2.4 被动模式下的数据连接get 和 put 共用一条链路数据连接这块是最容易写乱的地方。主动模式PORT要服务器回连客户端内网和防火墙环境下基本不可用所以合格实现默认走被动模式PASV先发PASV从应答里解析出服务器开放的 IP 和端口再主动连过去。// qftpiodevice.cpp 片段解析 PASV 应答并建立数据连接 bool QFtpIoDevice::openDataConnection(const QString pasvReply) { // 应答形如227 Entering Passive Mode (192,168,1,10,195,80) QRegExp rx(\\((\\d),(\\d),(\\d),(\\d),(\\d),(\\d)\\)); if (rx.indexIn(pasvReply) -1) return false; QString ip QString(%1.%2.%3.%4) .arg(rx.cap(1)).arg(rx.cap(2)) .arg(rx.cap(3)).arg(rx.cap(4)); quint16 port rx.cap(5).toUShort() * 256 rx.cap(6).toUShort(); dataSocket-connectToHost(ip, port); return dataSocket-waitForConnected(5000); }参数说明PASV 应答里的端口是两个字节拼出来的高字节乘 256 加低字节这是 FTP 协议规定的写成cap(5) * 256 cap(6)才对直接当十进制端口用会连不上。waitForConnected给 5 秒超时内网够用跨网段可以调到 10 秒。数据连接建好后get走“读 socket 写 QFile”put走“读 QFile 写 socket”list走“读 socket 拼字符串”三者共用QFtpIoDevice的读写缓冲区别只在方向。传输结束以数据 socket 的disconnected为准此时控制连接上还会来一条226 Transfer complete状态机收到它才把命令出队。3. 把工程跑起来qmake 与 CMake 两条路外加一个最小验证程序3.1 编译前先确认 Qt 版本和模块依赖这份代码只依赖QtCore和QtNetwork不涉及 GUI所以理论上 Qt 5.9 到 Qt 6.x 都能编。但有两个版本坑要提前说Qt 6 里QRegExp被移到了Qt5Compat模块如果源码里用了QRegExp上面 PASV 解析就是要么在.pro里加QT core5compat要么把正则换成QRegularExpression。另外 Qt 6 的QString::fromLatin1行为没变但QTextCodec相关调用会编译失败源码里如果有编码转换统一换成QStringDecoder或直接按 Latin1 处理。用 qmake 的话.pro里至少要有QT core network CONFIG c11 # Qt6 且源码用了 QRegExp 时追加 # QT core5compat用 CMake 的话对应写find_package(Qt6 REQUIRED COMPONENTS Core Network) # 若需兼容 QRegExp # find_package(Qt6 REQUIRED COMPONENTS Core5Compat) add_library(qftp STATIC qftp.cpp qftpiodevice.cpp qftpcommand.cpp) target_link_libraries(qftp PUBLIC Qt6::Core Qt6::Network)3.2 写一个最小可运行程序验证连通性别一上来就往业务工程里塞先单独建个控制台程序跑通“连接—登录—列目录—下载”这条链路确认代码本身没问题// main.cpp最小验证连接内网 FTP 并下载一个文件 #include QCoreApplication #include QFile #include QDebug #include qftp.h int main(int argc, char *argv[]) { QCoreApplication app(argc, argv); QFtp ftp; QObject::connect(ftp, QFtp::listInfo, [](const QUrlInfo i) { qDebug() entry: i.name() i.size(); }); QObject::connect(ftp, QFtp::commandFinished, [](int, bool err) { if (err) { qWarning() command failed; app.quit(); } }); QObject::connect(ftp, QFtp::done, [](bool err) { qDebug() all done, err err; app.quit(); }); ftp.connectToHost(192.168.1.10, 21); ftp.login(user, pass); // 匿名服务器可用 anonymous ftp.list(/logs); // 列目录触发 listInfo ftp.get(/logs/app.log, nullptr); // 第二参数传 QIODevice* 可自定义落盘 ftp.close(); return app.exec(); }逻辑说明connectToHost和login是入队不是立即返回所以后面可以连着写list、get它们会按顺序执行。listInfo信号每解析到一个目录项发一次适合往QListWidget里塞。commandFinished带一个bool表示该条命令是否出错done是整个队列清空后的总信号。跑这个程序时把 IP、账号换成你环境里的先确认能list出东西再验证get落盘的文件大小和服务器上一致。3.3 放进 GUI 工程时的事件循环注意点控制台程序里app.exec()一跑信号槽正常转。但放进 GUI 工程后有个常见误区有人在按钮槽函数里写ftp.get(...)之后紧跟一句while (!finished) {}去等结果直接把界面卡死。正确做法是全程异步按钮点下去只负责入队进度和结果通过信号回主线程更新界面。如果确实需要“等下载完再干下一件事”用状态标志加信号驱动或者把后续逻辑挂到done信号的槽里别用忙等。另外QFtp对象建议作为主窗口的成员而不是局部变量局部变量出了作用域被析构正在传输的连接会断日志里就是一堆Remote host closed connection。4. 避坑与排查FTP 客户端最容易栽的五个地方4.1 现象能登录能列目录一 get 就超时原因多半是 PASV 应答解析错了端口或者服务器在 NAT 后面PASV 返回的是内网 IP客户端连过去自然不通。解决先打印pasvReply原文看格式确认端口拼接是高字节*256低字节如果是 NAT 环境很多服务器支持EPSV或者干脆让客户端忽略应答里的 IP、只用控制连接的对端 IP 加解析出的端口去连这个改动在openDataConnection里把ip换成controlSocket-peerAddress().toString()即可。4.2 现象中文文件名列出来是乱码原因是 FTP 协议本身不规定文件名编码服务器可能用 GBK 也可能用 UTF-8。Qt 这边默认按 Latin1 解中文必乱。解决登录后先发OPTS UTF8 ON尝试开启 UTF-8如果服务器不认就在listInfo解析处按QTextCodec::codecForName(GBK)转一道。这块没有银弹血泪经验是先抓包看服务器实际发的是什么字节再决定用哪个编码别凭感觉猜。4.3 现象大文件 put 到一半连接断服务器上留个半截文件原因是数据连接写太快控制连接上的226还没来就把 socket 关了或者本地读文件时没处理QFile::error。解决put结束以数据 socket 的disconnected为准收到后再等控制连接的226才算出队写之前先QFile::open(QIODevice::ReadOnly)检查返回值读一块写一块每块 8KB 到 64KB 之间比较稳太大容易在慢速链路上触发超时。4.4 现象连续调用 cd 再 get路径不对原因是cd是相对当前工作目录的而当前目录由服务器维护客户端这边如果没同步记录第二次cd就可能跑到意料之外的位置。解决在QFtp里维护一个currentPath成员每次cd成功后更新get/put传相对路径时先拼成绝对路径再发。更稳的做法是业务层统一用绝对路径少用相对cd。4.5 现象程序退出时崩溃在 socket 析构原因是QFtp析构时还有命令在队列里、socket 还在读直接 delete 会触发野指针。解决析构函数里先abort()清空队列再disconnectFromHost()并waitForDisconnected(3000)最后才让 socket 随对象销毁。GUI 里关窗口前主动调一次close()别指望析构顺序。5. 二次开发加一个断点续传顺便把命令队列改成可取消5.1 断点续传的改造点在哪FTP 断点续传靠REST命令下载前先发REST offset服务器确认后后续RETR就从该偏移开始传。改造分三步。第一步在命令枚举里加RestparseReply里把350归到“成功推进队列”。第二步get增加一个qint64 offset参数入队时先压一条REST再压RETR。第三步落盘时用QFile::open(QIODevice::Append)而不是WriteOnly否则会把已下载的部分覆盖掉。核心改动大概是这样// 在 QFtp::get 的重载里加入偏移支持 void QFtp::get(const QString file, QIODevice *dev, qint64 offset) { if (offset 0) d-enqueueCommand(QFtpCommand::Rest, QString::number(offset)); d-enqueueCommand(QFtpCommand::Retr, file, dev); }参数说明offset为 0 时行为和原版一致大于 0 时先发REST。注意不是所有服务器都支持REST发之前可以先发FEAT看服务器声明的特性里有没有REST STREAM没有就退回整文件重下。验证方法很直接下到一半手动 kill 进程记录本地文件大小重启后把该大小作为offset传进去比对最终文件 MD5 和服务器上的是否一致。5.2 让命令队列支持取消原版队列只能一路跑到底业务上经常需要“用户点了取消正在传的这条别传了”。做法是给QFtpCommand加一个cancelable标志QFtp::abort()里把队列清空、对当前正在执行的数据传输发ABOR然后等控制连接的226或426回来再复位状态机。这里有个细节ABOR发出后服务器可能回两条应答一条对ABOR一条对被打断的传输状态机要能吃掉多余的那条否则下一条命令的应答会对错位。我一般会在abort后设一个ignoreNextReply计数收到对应条数再恢复正常解析。5.3 一个验证改造是否正确的笨办法改完别只看“能跑”拿一个本地起的 FTP 服务做对照测试准备一个 50MB 的随机文件分别测整传、断点续传、传输中取消三种场景每种跑三遍比对服务器端文件大小和 MD5。断点续传那组要故意在 30% 和 70% 处各中断一次。这套流程跑下来队列状态机有没有错位、REST偏移有没有算错基本一目了然。从那以后我每次动这类有状态协议的代码都强制走一遍“中断—恢复—校验”的闭环不再靠肉眼看日志判断对错。这份 QFtp 实现的骨架清晰照着上面的路子加功能不会伤筋动骨希望帮到你。本文还有配套的精品资源点击获取