简介:在Qt 5.0及以上版本中,官方不再提供QFtp类,不少开发者移植旧项目时常常受阻。这份资源针对该痛点,从Qt 4.8.7中提取QFtp完整源码,整理为QFtp.h与QFtp.cpp两个核心文件,并配套基于Qt的测试程序,覆盖登录FTP服务器及上传文件、删除文件、创建文件、重命名文件等常用操作,基本囊括FTP客户端常见功能,适合需要在Qt 5/6中快速集成FTP能力的开发者参考使用。资源包共17个文件,主要由cpp/h源文件、ui界面文件、qrc资源文件及pro工程文件组成,整体约35KB,结构精简,便于直接移植到现有工程。其中ui文件对应测试程序的对话框界面,png图标用于辅助操作提示,pro文件配置了构建所需项,分类清晰。目前已有1318人学习下载,说明该方案具有一定参考价值。读者可获得可直接编译的QFtp封装源码和完整示例,免去自行从旧版提取、适配的繁琐过程,同时也能通过示例代码理解FTP交互流程与Qt事件驱动写法。
1. 为什么要自己做一版QFtp:从Qt 4老代码到Qt 5时代的救火现场
某公司内部那条生产线文件同步工具,是四五年前用Qt 4写的,核心功能全靠QFtp类库撑着走FTP协议。后来开发环境从Qt 4一步跳到Qt 5,一编译就报一堆错——QFtp被正式移除了,整个工具直接趴窝。接手的人翻遍了手册,发现QNetworkAccessManager虽然也能传文件,但QFtp里那些stateChanged、listInfo之类的接口语义完全不同,硬改等于推翻重写。这个项目就是在这种背景下诞生的:把QFtp的常用操作全部用自己的代码重新实现,不依赖任何废弃模块,基于QTcpSocket和信号槽手搓一套完整的FTP客户端,同时保留一个与QFtp旧接口风格相近的封装层,让老项目能以最小代价迁移过来。
2. 架构总览:命令队列、状态机与控制/数据连接双通道设计
2.1 目录结构与模块划分
一个能进生产环境的FTP客户端,不能把代码全塞进一个类里。我按职责拆成六个模块,每个模块只干一件事,方便单独测试和替换:
FtpClient/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── MainWindow.h / MainWindow.cpp │ ├── FtpClient.h / FtpClient.cpp │ ├── FtpCommand.h / FtpCommand.cpp │ ├── FtpResponseParser.h / FtpResponseParser.cpp │ ├── FtpDirectoryParser.h / FtpDirectoryParser.cpp │ └── FileTransfer.h / FileTransfer.cpp └── README.mdMainWindow负责界面交互和文件列表展示,本身不碰任何FTP协议逻辑;FtpClient是核心,管理连接生命周期、命令收发和状态流转;FtpCommand把一次操作封装成可以排队的数据结构;FtpResponseParser专门解析服务器返回的状态码和多行响应;FtpDirectoryParser处理LIST命令的返回值;FileTransfer抽象上传下载时的文件流读写。这样的分层有一个好处:协议解析和UI彻底解耦,就算你以后想把界面换成QML或其他框架,核心的FtpClient这一层可以原封不动搬走。
2.2 用信号槽驱动FTP会话状态机
FTP协议本质上是一个显式的状态机:还没连接时服务器不会接受你的USER命令,没登录时你发LIST只会收到530。为了防止乱序调用,我给FtpClient定义了一组状态枚举,任何命令的发出都必须检查当前状态。
enum FtpState { Disconnected, // 已断开或未连接 Connecting, // TCP控制连接建立中 AwaitingGreeting, // 等待220欢迎消息 AwaitingLogin, // 等待USER/PASS的响应 LoggedIn, // 登录完成,可以执行普通命令 Transferring, // 数据通道传输中 Quitting // 正在断开控制连接 };每个状态对应一套合法操作:在AwaitingGreeting时只能继续等或者发QUIT,在LoggedIn时才允许入队LIST、RETR、STOR这类命令。这里用到一个我比较坚持的设计——命令队列。FTP控制连接是串行协议,同一时间只能有一个命令在飞,如果你连续发两条,服务器只能回第一条,第二条可能被当成垃圾数据丢掉。所以FtpClient内部维护一个QQueue<FtpCommand*>,每次从队头取出一个命令发送,等服务器返回最终状态码之后再出队下一个。
登录流程串起来大概是这样的:connectToHost触发TCP连接,connected信号到达后进入AwaitingGreeting,收到220就把USER命令压入队列;队列处理到USER时发出,收到331(需要密码)再入队PASS,收到230就切到LoggedIn。整个过程全部靠信号槽接力,没有任何阻塞等待,UI线程永远不会卡死。
2.3 控制连接与数据连接为什么要分开处理
FTP和HTTP最大的区别就是它有两条独立的TCP连接:控制连接跑文本命令,数据连接跑文件字节流。控制连接默认连服务器的21端口,数据连接的建立时机和方式取决于主动模式还是被动模式。
// 被动模式:客户端发PASV,服务器回应227并告知数据端口 void FtpClient::enterPassiveMode() { sendRawCommand("PASV"); } // 主动模式:客户端开放监听,服务器回连客户端 void FtpClient::enterActiveMode() { dataServer = new QTcpServer(this); dataServer->listen(QHostAddress::Any, 0); quint16 port = dataServer->serverPort(); // 通过PORT命令告诉服务器 127.0.0.1,port/256,port%256 }我建议默认用被动模式,因为主动模式需要客户端开放入站端口,在NAT和防火墙后面十次有九次要踩坑。两条连接用独立的QTcpSocket实例管理,控制连接的生命周期贯穿整个会话,数据连接则在每次传输开始时建立、传输结束时关闭。FtpClient内部用dataSocket == nullptr来判断当前是否有活跃的数据通道,这样能避免在一条连接上同时收发命令和文件数据的混乱局面。
3. 核心模块拆解:一个文件上传从点击到完成的完整链路
从用户点下“上传”按钮,到文件完整落到服务器上,这中间经过的所有环节,我把它们拆开讲清楚。
3.1 FtpCommand:把每个FTP动作封装成可排队的数据结构
FTP命令多,参数类型杂,如果每个方法单独传参,队列存储会很别扭。我设计了一个轻量的FtpCommand类,把命令类型、参数和原始数据都装在一个对象里:
class FtpCommand : public QObject { Q_OBJECT public: enum Type { Noop, Connect, // 建立控制连接 Login, // 发送USER/PASS List, // 列出目录 Retr, // 下载文件 Stor, // 上传文件 Dele, // 删除远程文件 Rename, // 重命名远程文件 Mkdir, Rmdir, Pwd, Cwd, Quit // 优雅断开 }; FtpCommand(Type type, QObject *parent = nullptr); Type type = Noop; QString arg1; // 参数1,例如远程路径 QString arg2; // 参数2,例如本地路径 QByteArray rawData; // 透传原始命令用 };type决定命令的语义,arg1和arg2在不同命令下含义不同。比如Stor命令里arg1是本地文件路径,arg2是远程文件名;Mkdir命令只需要arg1。调用方不需要关心协议文本长什么样,只要按下拉菜单选类型、填参数,FtpClient会在执行时自动拼出STOR filename或MKD /path这样的原始行。
3.2 控制连接解析器:从原始字节流到结构化状态码
FTP服务器的响应格式是三码后跟文本,比如220 Welcome。完整响应可能是多行的——第一行220-开头,中间若干行,最后一行220结束。解析器最怕的就是把多行响应拆成两次信号,因此必须有一个累积缓冲和“待确认状态码”的临时变量:
void FtpClient::onControlReadyRead() { while (controlSocket->canReadLine()) { QByteArray line = controlSocket->readLine().trimmed(); if (line.size() < 4) { continue; // 不合法行,直接丢弃 } int code = line.left(3).toInt(); // 第4个字符是空格表示响应结束,是'-'表示还有后续行 bool finalLine = (line.size() >= 4 && line.at(3) == ' '); if (!finalLine) { multilineBuffer += line.mid(4) + '\n'; pendingCode = code; // 记住多行响应所属的状态码 continue; } QString message; if (pendingCode != 0 && code == pendingCode) { message = multilineBuffer + line.mid(4); // 拼接完整多行文本 } else { message = QString::fromUtf8(line.mid(4)); } pendingCode = 0; multilineBuffer.clear(); handleResponse(code, message); } }解析的关键在于判断finalLine:状态码第4个字符是空格就是末行,是减号就继续累积。pendingCode保证了只有遇到相同状态码的末行才拼接多行缓冲区,避免上一个多行响应还没收尾、下一个单行响应就插进来的错乱情况。handleResponse里再根据状态码做分支:2xx表示成功,3xx表示需要附加信息,4xx/5xx走错误分支,227单独走被动模式解析。这个设计基本把FTP协议的响应形态都覆盖了。
3.3 数据通道传输:上传下载的缓冲读写与进度上报
以被动模式上传为例,完整流程是:发PASV拿端口,连接数据通道,发STOR,然后零拷贝地把本地文件数据往数据socket里写。下面这段是PASV响应解析:
void FtpClient::parsePasvResponse(const QString &message) { // 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2) int left = message.indexOf('('); int right = message.indexOf(')'); if (left < 0 || right < 0) { emit errorOccurred(tr("无法解析PASV响应: %1").arg(message)); return; } QStringList parts = message.mid(left + 1, right - left - 1).split(','); if (parts.size() != 6) { emit errorOccurred(tr("PASV响应格式不正确")); return; } QString host = QString("%1.%2.%3.%4") .arg(parts.at(0), parts.at(1), parts.at(2), parts.at(3)); quint16 port = parts.at(4).toInt() * 256 + parts.at(5).toInt(); dataSocket = new QTcpSocket(this); connect(dataSocket, &QTcpSocket::connected, this, &FtpClient::handleDataConnected); dataSocket->connectToHost(host, port); }PASV响应的6个数字前四个是IP地址,后两个是端口的高字节和低字节,合起来p1*256+p2才是真正的数据端口。这里有个常见的误区:括号里的IP不一定是FTP服务器自己的地址,有些做了端口映射的服务器会返回内网IP,这时要跟配置文件里的公网出口地址做替换。解析失败时要么数字个数不对,要么端口算成了零,都要走独立错误分支,不能继续往下执行。
数据通道建立后,上传的核心逻辑在读写缓冲上。我坚持分块读写而不是一次性读入内存:
void FtpClient::startUpload() { QFile *file = new QFile(currentCommand->arg1); if (!file->open(QIODevice::ReadOnly)) { emit errorOccurred(tr("无法打开本地文件: %1").arg(currentCommand->arg1)); return; } sendRawCommand("STOR " + currentCommand->arg2.toUtf8()); // 服务器返回150后,开始从数据socket写入 } void FtpClient::handleDataBytesWritten(qint64 bytes) { Q_UNUSED(bytes); QByteArray chunk = uploadFile->read(64 * 1024); if (!chunk.isEmpty()) { dataSocket->write(chunk); uploadedBytes += chunk.size(); emit transferProgress(uploadedBytes, totalBytesForProgress); } else { dataSocket->disconnectFromHost(); // 文件读完了,关闭数据连接 socketClosedByClient = true; } }为什么用64KB而不是8KB或者4MB?8KB太小,网络往返频繁,CPU占用高;4MB又太大,内存压力大,而且很多路由器默认缓冲没有那么大,写出去反而要等流量控制。64KB是TCP分段大小和内存占用之间的一个折中值,通常能跑出不错的吞吐量。进度计算的细节要注意:本地文件大小不等于远程文件大小,为了上报准确,我在打开本地文件时就把size记下来,transferProgress(uploadedBytes, localFileSize)里第二个参数是总字节数,不是实时计算出的临时值。
下载方向逻辑对称:发送RETR后,服务器通过数据通道推字节流,在onDataReadyRead里用readAll()或分块读取,然后写入本地QFile。下载时要额外处理一件事——不知道总大小的情况下,进度条无法显示百分比。我一般先尝试从LIST或响应里的Content-Length类字段拿大小,拿不到就用transferProgress(0, 0)表示未知进度,UI端显示跑马灯而不是百分比。
3.4 目录列表解析器:兼容Unix长格式与Windows短格式
FTP服务器五花八门,LIST返回的格式没法统一。主流的有两类:Unix风格类似drwxr-xr-x 2 user group 4096 Jan 1 12:00 folder,Windows风格类似01-01-25 10:00AM <DIR> folder。解析器必须同时兼容:
struct Entry { QString name; bool isDirectory = false; qint64 size = 0; QDateTime modifiedTime; }; QList<Entry> parseDirectoryList(const QByteArray &rawData) { QList<Entry> result; const QString text = QString::fromUtf8(rawData); const QStringList lines = text.split('\n'); for (const QString &line : lines) { QString trimmed = line.trimmed(); if (trimmed.isEmpty()) { continue; } Entry entry; // Windows风格的目录标记 if (trimmed.contains("<DIR>")) { QStringList tokens = trimmed.split(QRegularExpression("\\s+")); entry.isDirectory = true; entry.name = tokens.last(); entry.modifiedTime = QDateTime::fromString( tokens.at(0) + " " + tokens.at(1), "MM-dd-yy hh:mmAP"); } else if (trimmed.startsWith('d') || trimmed.startsWith('-')) { // Unix长格式:先拆前9个字符权限位,再按空格拆字段 QStringList tokens = trimmed.split(QRegularExpression("\\s+")); if (tokens.size() < 9) { continue; } entry.isDirectory = (trimmed.at(0) == 'd'); entry.size = tokens.at(4).toLongLong(); entry.modifiedTime = QDateTime::fromString( tokens.at(5) + " " + tokens.at(6) + " " + tokens.at(7), "MMM d yyyy"); if (entry.modifiedTime.isNull()) { entry.modifiedTime = QDateTime::fromString( tokens.at(5) + " " + tokens.at(6) + " " + tokens.at(7), "MMM d hh:mm"); } entry.name = tokens.at(8); } else { // 未知格式,整行当文件名兜底 entry.name = trimmed; } if (!entry.name.isEmpty() && entry.name != "." && entry.name != "..") { result.append(entry); } } return result; }Unix格式的字段顺序是固定的:权限位、硬链接数、所有者、组、大小、月份、日期、时间或年份、文件名。修改时间可能有两种形态,一种是带年份的Jan 1 2024,一种是不带年份的Jan 1 12:00,解析时要用两个格式依次尝试。Windows格式里<DIR>标记是关键,后面的字段是文件名。兜底分支很重要——某些专有FTP服务器返回的格式既不是Unix也不是Windows,直接把整行当作文件名,保证在最坏情况下用户至少能看到列表里有这个文件,而不是整个列表解析失败一片空白。
4. 运行部署:从克隆代码到跑通一次完整文件传输
4.1 构建环境与依赖准备
这个项目只依赖Qt的基础模块和Network模块,不需要任何第三方库。我建议的构建环境:Qt 5.15及以上(Qt 6.x也能编),CMake 3.16以上,编译器支持C++17。因为这是纯客户端项目,不涉及平台特有API,Windows、Linux、macOS都能直接编过。
cmake_minimum_required(VERSION 3.16) project(FtpClient LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt6 COMPONENTS Widgets Network REQUIRED) add_executable(FtpClient src/main.cpp src/MainWindow.cpp src/FtpClient.cpp src/FtpCommand.cpp src/FtpResponseParser.cpp src/FtpDirectoryParser.cpp src/FileTransfer.cpp ) target_link_libraries(FtpClient PRIVATE Qt6::Widgets Qt6::Network )如果你还在用Qt 5.15,把find_package(Qt6 ...)改成find_package(Qt5 ...),同理链接库改成Qt5::Widgets和Qt5::Network。构建命令在Linux/macOS下是cmake -S . -B build && cmake --build build,Windows上如果装了Qt官方安装包,记得在CMake配置时指定CMAKE_PREFIX_PATH指向你的Qt安装目录,否则会报找不到Qt6Config.cmake。
4.2 配置FTP服务器连接参数
界面上的连接参数有五个:主机地址、端口、用户名、密码、是否启用被动模式。默认端口21,用户名和密码允许为空(匿名FTP)。本地调试时我建议直接装FileZilla Server或任何一个轻量FTP服务端,把根目录指到一个临时文件夹,这样不会误伤生产数据。
| 参数 | 默认值 | 说明 |
|---|---|---|
| host | 127.0.0.1 | FTP服务器地址,支持域名或IP |
| port | 21 | 控制连接端口 |
| username | ftpuser | 匿名登录时留空 |
| password | 空 | 明文传输,仅限测试环境 |
| passive mode | 开启 | 防火墙严格环境建议开启 |
| local root | ./downloads | 本地文件默认存放目录 |
被动模式是默认选项,但对网络环境有要求:服务器必须能对外通信数据端口。如果你发现连上了、列目录成功了,一传输就卡死,先尝试切换成主动模式试试。
4.3 验证一次端到端的上传下载流程
搭好环境后按下面几步跑通一条完整链路,每步都对应FtpClient里的一次信号发射,方便边跑边打印日志调试:
- 填好服务器信息,点“连接”,状态栏应依次出现“正在连接”“已连接”“登录成功”三行状态变化;如果停在“正在连接”超过5秒,检查主机地址和防火墙。
- 目录列表区应显示服务器根目录下的文件和文件夹,大小和日期列有值;如果是空目录,至少能看到列表刷新事件触发。
- 在本地准备一个10MB的随机文件
dd if=/dev/urandom of=/tmp/test.bin bs=1M count=10,上传到服务器,观察进度条从0匀速走到100。 - 在服务器上把该文件复制一份改名,从远程列表里选中它做下载,保存到本地另一个目录。
- 本地执行
sha256sum对比两个文件——上传前后和下载前后的哈希应该完全一致,这一步能同时验证上传和下载方向上的数据完整性。
如果在第2步列表是空的,但服务器那边确实有文件,多半是目录解析器没匹配上服务器返回的格式。这时候打开调试输出,把原始LIST响应打印出来看眼,对照第3章的解析逻辑调整。
5. 避坑指南:QFtp重新实现路上最容易翻车的六个现场
5.1 控制连接握手超时,卡在220欢迎消息之前
现象:connectToHost显示TCP已连上,但FtpClient永远停在AwaitingGreeting,不报错也不继续。原因:服务器TCP握手成功但应用层迟迟不发220,或之前一次异常断开让服务器认为连接还是忙的。解决:在控制连接建立后启动一个5秒的QTimer,超时没收到220就主动断开并重试,重试间隔按1秒、2秒、4秒递增,最多三次后报错。
5.2 被动模式被防火墙和NAT掐断
现象:列目录正常,一传大文件就卡住,数据连接一直连不上。原因:服务器PASV响应里的IP是内网地址,客户端拿着内网IP直连当然连不上;或者服务器只是开放了21端口,数据端口全被防火墙拦截。解决:响应里的IP和当前连接的服务器IP不一致时,优先用当前连接的IP替换括号里的IP;如果还不行,检查服务器防火墙是否放行了PASV端口范围,必要时切主动模式试一次。
5.3 多行响应拼接错位导致后续命令全乱
现象:登录时收到的欢迎语是多行文本,后面的QUIT或PWD响应的状态码出现了错乱的重复数字。原因:多行缓冲区的清空时机不对,响应解析器把两段不同状态码的文本拼到一起了。解决:严格按第3章的规则,只有pendingCode != 0 && code == pendingCode时才拼缓冲区,否则直接丢弃遗留的multilineBuffer,并用当前响应的状态码重新开始累积。
5.4 大文件上传内存暴涨到崩溃
现象:传500MB大文件时,程序内存占用涨到1GB才被系统杀掉。原因:上传实现里把整个文件readAll()进了QByteArray再写入数据socket,一次大文件就把内存吃满了。解决:改成循环分块读,块大小设为64KB,每次读一小段写入socket,全部写完之后再断开数据连接。这个方案同时也要改信号槽逻辑:在bytesWritten信号里取下一块数据,而不是在循环里一次写完。
5.5 中文文件名在目录列表里显示成乱码
现象:Windows下远程中文文件名显示成一堆问号或乱码,下载保存到本地同样乱。原因:很多老牌FTP服务器LIST响应用的不是UTF-8,而是系统的本地编码,客户端按UTF-8解析就全废了。解决:解析时先尝试UTF-8,乱码就回退到QString::fromLocal8Bit,在Windows上这通常就是GBK;如果还有问题,可以把原始字节打印出来人工确认编码,再用QTextCodec精确解码。
5.6 命令排队没做串行导致服务器吞命令
现象:快速依次点两次“上传”,第二个文件一直等不到开始,界面卡在下一条命令永远不执行。原因:FTP控制连接是严格的一问一答协议,我第一版把命令发出去就不管了,第二条命令直接怼上去,服务器回的是错误状态码,或者干脆把多余的数据行当成垃圾吞掉。解决:所有命令必须入队,processNextCommand只在当前命令收到最终状态码后才执行,这个在FtpClient里已经实现,改代码时不要破坏这条串行约束。
6. 二次开发:从QFtp API到现代Qt架构的迁移改造思路
6.1 做一个兼容层把旧QFtp代码骗过来
老项目里到处都是ftp->connectToHost()、ftp->list()、ftp->get()这种调用,如果要求业务代码改动最小,我建议在FtpClient之上再包一层LegacyFtpCompat。这个类把QFtp时期的信号和槽函数名重新暴露一遍,内部转调自己的新实现,比如:
class LegacyFtpCompat : public QObject { Q_OBJECT public: void connectToHost(const QString &host, quint16 port) { client->connectToServer(host, port); } void list(const QString &dir = QString()) { client->listDirectory(dir); } signals: void listInfo(const QList<Entry> &entries); void stateChanged(int state); };这样老代码里那些connect(ftp, &QFtp::listInfo, ...)的连接不用改绑点,只要把原本的QFtp对象替换成LegacyFtpCompat对象,编译就能跑起来。这个兼容层别做太厚,状态枚举和信号参数尽量贴近QFtp文档,只覆盖老业务里实际用到的功能,其他不用的忽略即可。
6.2 用QNetworkAccessManager做混合对照验证
Qt 5.15之后QNetworkAccessManager对FTP的支持也被标记为废弃,但它在测试环境里仍然能用。我会在调试面板里加一个选项:切到QNetworkAccessManager实现,用同样一批文件做上传下载。两个实现的结果用哈希对比,能快速验证自己手搓的FTP逻辑有没有字节错位之类的低级问题。生产环境不建议依赖它,毕竟官方已经明确不给长期维护。
6.3 扩展自定义命令的透传通道
QFtp只能覆盖20来条标准FTP操作,但实际项目里的私有协议五花八门:有的服务器需要SITE INDEX重建索引,有的要用MDTM修改文件时间戳,有的得用OPTS UTF-8 ON切换编码。我建议在FtpCommand里增加一个Raw类型,直接接收原始命令行的QByteArray,这样业务方可以自己组装任意指令,FtpClient只负责透传和把状态码回传给调用方。这个扩展点几乎零成本,但能把FTP客户端的适用范围从标准协议延伸到各种定制服务器。
给老系统做迁移的时候,我就是在这些方向上反复踩了一遍,最后把QFtp这套实现沉淀成了一个独立模块。从那以后,我每次接“升级Qt版本导致老代码翻车”的活,都会强制先做一步:把项目里所有废弃类库的调用点列成一张清单,逐个确认替代方案,而不是等编译器报错了再翻文档。希望帮到你。
本文还有配套的精品资源,点击获取