QFTP源码接入:Qt FTP客户端双平台编译与异步避坑
简介:QFTP源码是一份面向Qt/C++开发者与网络编程学习者的跨平台FTP客户端实现参考,核心围绕FTP连接建立、命令收发、数据传输及URL信息解析展开,可用于在Linux与Windows环境下理解文件上传下载的底层流程,也便于将FTP能力集成到自有工具或项目中。压缩包共4个文件,以2个C++源文件与2个头文件为主,分别承载客户端功能实现、URL信息处理及对外接口声明,整体仅24KB,体量轻便,适合直接阅读与二次开发。已有297人学习,说明其在网络协议实现与跨平台适配方面具有一定参考价值。读者可从中梳理FTP客户端的分层结构、命令响应处理与错误处理思路,并借鉴同一份源码在两类操作系统上完成测试的适配经验,为自定义传输功能或集成插件式模块提供可复用的代码基础。
1. 拿到 QFTP 源码先别急着编译:它补的是 Qt 网络模块里那个空位
写上位机或数据采集程序的人多半碰过这个场景:采集端跑得好好的,交付前要求把每天生成的报表自动推到一台 FTP 服务器上,你打开 Qt 文档翻网络模块,Tcp、Udp、Http 一个不少,唯独没有 FTP 客户端类。这不是你记错,FTP 的实现很早之前就被移出核心网络模块,后来干脆独立成模块,新装的 Qt 里自然找不到。这份 QFTP 源码就是那套被移走的实现单独打包出来的版本,已在 Linux 与 Windows 平台完成测试,可以编成静态库直接接进现有工程。它适合两类人:需要在程序内部静默完成上传下载、不想外挂命令行进程的;以及交付环境受限、不能再引入第三方库的。
2. 源码解剖与双平台编译:从 .pro 到 libqftp.a 的四步
2.1 四个文件各自管什么
这份源码的职责拆得很干净。qftp.h 与 qftp.cpp 是客户端本体,负责命令队列、控制连接、数据连接的建立与回收;qurlinfo.h 与 qurlinfo.cpp 是目录列表的载体,LIST 命令返回的每一行都会被解析成一个 QUrlInfo 对象,文件名、大小、是否目录、权限位、最后修改时间都挂在这个对象上。
之所以要单独带一份 qurlinfo,是因为它原本也属于"被挪走"的部分。新版 Qt 里直接 include 会报未定义类型,报错位置往往在你自己的业务代码里,看起来像是用法错了,实际是头文件来源不对。
把这两个单元的分工记住,后面排错能省一大半时间:控制连接走 21 端口,只负责发命令收应答;数据连接是每次 get、put、list 时临时建一条,用完即关。凡是"登录成功、列目录也正常,一到传文件就卡住"的现象,问题基本都在第二条连接上,跟账号密码没关系。
接入方式有两种。编成静态库再链接,多个项目共用一份,版本干净;或者把四个文件直接拖进现有工程,省事且改源码方便。以我的经验,后一种更实际——中文编码和被动模式这两处你大概率是要动的,源码放在自己工程里改起来没有心理负担。
2.2 写一个能编过的 .pro
这块最容易翻车。很多人拿到源码直接qmake -project生成一个工程文件,编出来一堆未定义符号,就开始怀疑源码有问题。原因是自动生成的 .pro 既没声明模块依赖,也没把 qftp.h 放进 HEADERS,导致 moc 根本没跑。
QT += core network QT -= gui TARGET = qftp TEMPLATE = lib CONFIG += staticlib CONFIG -= app_bundle # 头文件必须写进 HEADERS,moc 才会为带 Q_OBJECT 的类生成元对象代码 HEADERS += qftp.h \ qurlinfo.h SOURCES += qftp.cpp \ qurlinfo.cpp # MSVC 下源码里的中文注释会触发 C4819,不加这行编译告警刷屏 win32-msvc*: QMAKE_CXXFLAGS += /utf-8QT += network不能少,QFtp 内部依赖 QTcpSocket 和 QTcpServer 建立数据通道;QT -= gui是给无界面程序用的,如果你的业务本身是带窗体的应用,这行可以留着不写。CONFIG += staticlib决定产物是 .a 还是 .so/.dll,交付给甲方时静态库更省事,不用担心运行时找不到库文件。
HEADERS那一行是重点。QFtp 类声明里有 Q_OBJECT,缺了 moc 生成的代码,链接阶段会报undefined reference to QFtp::staticMetaObject,这个报错看起来像源码不完整,实际只是构建脚本没写对。
2.3 Linux 下 qmake + make
Linux 侧基本没有意外,注意别在源码目录里直接编译,中间文件会把目录搞乱。
mkdir -p build && cd build qmake ../qftp.pro CONFIG+=release make -j$(nproc) # 产物是静态库,先确认符号里真的带 QFtp 的实现 ar t libqftp.a nm -C libqftp.a | grep -c "QFtp::"make -j$(nproc)按核数并行编译,源码体量不大,几秒就完。后面两步是验证环节,比"看到编译成功"可靠得多:ar t列出归档成员,正常能看到 qftp.o、qurlinfo.o、moc_qftp.o 三个;nm -C过滤出的符号数量应该是两位数,如果只 grep 到个位数甚至 0,说明目标文件没打进去,通常是 .pro 里 SOURCES 路径写错了。
交叉编译到 ARM 板子上也是同样流程,只在 qmake 前指定对应的 mkspec 就行,源码本身没有平台相关依赖。唯一的差别是板子上的字节序和文件系统权限,跟编译无关。
2.4 Windows 下 MinGW 与 MSVC 的差异
Windows 侧两种工具链我都编过,行为差异比想象中大,主要集中在编码和构建工具上。
# MinGW qmake ..\qftp.pro -spec win32-g++ CONFIG+=release mingw32-make -j4 # MSVC(Qt 自带的命令行环境里执行) qmake ..\qftp.pro -spec win32-msvc "CONFIG+=release" jom -j8MinGW 这条链路通常一次过,源码里的 UTF-8 中文注释 MinGW 能识别。MSVC 就不一定了:没加/utf-8时会刷一屏 C4819 警告,某些区域设置下甚至把注释里的汉字当成非法字符报错。如果机器上没装 jom,把jom -j8换成nmake即可,速度慢一些但结果一致。
还有一个隐性问题:MSVC 下生成的库只有 release 版和 debug 版匹配才能链接,你的主工程是 debug 配置、库是 release 编的,链接阶段会报一串LNK2038运行时库不匹配。省事的做法是两种配置各编一份,用CONFIG+=debug_and_release一次出两个目录。
3. 命令队列与信号槽:QFtp 的异步模型怎么用才不踩空
3.1 每个调用都只是入队,返回的 int 才是凭据
QFtp 不走同步阻塞那套。connectToHost、login、put、get、list这些函数调用时立刻返回,返回值是一个命令编号,动作真正执行要等事件循环把队列里的命令一条条喂给控制连接。这一点如果不清楚,写出来的代码会非常反直觉。
配套的信号有四组最常用:stateChanged(int)反映连接状态迁移,从 Unconnected 到 HostLookup、Connecting、Connected、LoggedIn;commandStarted(int)与commandFinished(int, bool)成对出现,参数就是那个命令编号和是否成功;done(bool)表示队列里的一批命令全处理完了;error(int)只在出错时发出,配合errorString()取文本。
"一批命令"这个概念很关键。你连着调用connectToHost、login、put、close四条,它们会被视为同一批,commandFinished触发四次,最后再来一次done。所以判断"上传结束"应该等commandFinished里编号等于那次 put 的返回值、且 bool 为 true,而不是等done——done只说明这一批都过了,中间某条失败也会触发它。
3.2 一个能跑的最小上传器
下面这段是完整可编译的例子,用的是 Qt4 的 SIGNAL/SLOT 宏写法,因为这份源码本身就是那个年代的产物,混用新语法容易在 moc 上出岔子。
#include <QCoreApplication> #include <QFile> #include <QDebug> #include "qftp.h" class Uploader : public QObject { Q_OBJECT public: explicit Uploader(QObject *parent = 0) : QObject(parent) { // 被动模式适配绝大多数 NAT 场景,除非服务端强制主动模式 ftp.setTransferMode(QFtp::Passive); connect(&ftp, SIGNAL(commandFinished(int,bool)), this, SLOT(onCmdFinished(int,bool))); connect(&ftp, SIGNAL(done(bool)), this, SLOT(onDone(bool))); connect(&ftp, SIGNAL(error(int)), this, SLOT(onError(int))); connect(&ftp, SIGNAL(dataTransferProgress(qint64,qint64)), this, SLOT(onProgress(qint64,qint64))); } void start(const QString &host, const QString &user, const QString &pwd, const QString &local, const QString &remote) { m_file = new QFile(local, this); // 生命周期必须覆盖整个传输 if (!m_file->open(QIODevice::ReadOnly)) { qWarning() << "local open failed" << local; return; } m_putId = -1; ftp.connectToHost(host, 21); // 只是入队,函数立刻返回 ftp.login(user, pwd); m_putId = ftp.put(m_file, remote); // 记下编号,后面靠它认领结果 ftp.close(); // 排在 put 之后,队列执行完才关 } private slots: void onCmdFinished(int id, bool ok) { if (id == m_putId) { if (ok) qDebug() << "upload ok"; else qWarning() << "upload failed" << ftp.errorString(); m_file->close(); // 到这里才可以释放设备 } } void onDone(bool err) { if (err) qWarning() << "batch error"; } void onError(int code) { qWarning() << "ftp error" << code << ftp.errorString(); } void onProgress(qint64 done, qint64 total) { qDebug() << done << "/" << total; } private: QFtp ftp; QFile *m_file = 0; int m_putId = -1; }; #include "main.moc" // 单文件编译时必须有,否则链接不到元对象几个参数说明一下。connectToHost(host, 21)第二个参数省略时默认就是 21,写出来是为了显式;login(user, pwd)传空密码时给空字符串,别传 NULL。put(QIODevice*, remote, type)的第三个参数默认 Binary,文本文件若要服务端做换行转换再传QFtp::Ascii,但跨平台场景我建议一律 Binary,避免 Windows 的 CRLF 被二次转换。
m_file用new挂到 this 上而不是栈变量,是这段代码里最要紧的一处。QFtp 的 put 是异步读设备的,函数返回时数据一个字节都还没发,栈上的 QFile 早析构了,传输必然失败。这是新手最常踩的坑,前面章节还会单独拎出来讲。
#include "main.moc"这行很多人没见过。单文件里写带 Q_OBJECT 的类,moc 的输出需要显式包含进来,否则链接时报undefined reference to vtable for Uploader。工程里拆成 .h/.cpp 就没有这个问题。
3.3 下载与目录遍历:readyRead 与 listInfo 的用法
下载路径和上传对称,区别在数据是被推过来的,得靠readyRead接。
// 成员:QFile m_out; bool m_outReady = false; connect(&ftp, SIGNAL(readyRead()), this, SLOT(onReadyRead())); connect(&ftp, SIGNAL(listInfo(const QUrlInfo &)), this, SLOT(onListInfo(const QUrlInfo &))); // 列目录:结果通过 listInfo 一条条吐出来,不是一次性返回列表 ftp.list("/data/2024"); // 下载:第二个参数给 QIODevice* 时数据写进设备,给 0 时走 readyRead ftp.get("/data/2024/report.csv", &m_out);list()的行为值得单独说。它不是"返回一个 QList",而是在数据连接收完后,为每一行发出一次listInfo(const QUrlInfo&),这一批发完才轮到commandFinished。所以想收集完整列表,得在槽里往自己的容器里追加,然后在下一次commandFinished里做后续处理。这个顺序反了会导致列表永远是空的。
get()第二个参数传 QIODevice* 时,QFtp 内部直接把收到的字节写进设备,readyRead就不再发数据;传 0 或者不传,数据通过readyRead出来,需要自己ftp.readAll()。两种方式别混用,混用的结果是一部分数据进了文件、一部分被你忽略,最后文件大小对不上还找不到原因。
dataTransferProgress(qint64, qint64)是免费送的进度条数据源,两个参数分别是已传字节和总字节,总字节在部分服务端不返回时会等于 -1,界面要做兜底。
4. 中文路径与编码:让 cd 和 put 不在文件名上翻车
4.1 控制连接的字节序列由谁决定
FTP 协议本身是七位 ASCII 时代的设计,文件名怎么编码协议里没规定死,后来补了一个 UTF8 能力协商,服务端支持与否要看它的配置。这就导致同一个程序在 Linux 上跑得好好的,换到 Windows 上文件名全变问号。
根因在字节序列的转换链上:QString 内部是 UTF-16,发命令前要转成字节;服务端按它自己的 locale 解释这串字节。Linux 服务器通常是 UTF-8,Windows 上如果你的程序在 GBK 环境里把 QString 转成了本地编码的字节,服务端按 UTF-8 解,就是乱码;反过来列目录时服务端回 UTF-8,你按本地编码解,也是乱码。
排查这类问题有个简单的判定方法:如果纯英文文件名一切正常、只有中文出问题,那百分之百是编码,不用怀疑网络和权限。如果连英文名都传不上去,去看 550 应答的具体文本,那是权限或路径不存在。
4.2 改源码里的三处解码点
这份源码是按年代较早的写法实现的,内部大量使用QString::fromLatin1和toLatin1做字节与字符串的互转。Latin1 是单字节直通,能保证不丢字节,但中文就会变成一堆问号。改造方式是把这些点统一换成一个可配置的 codec。
// 在 qftp.cpp 顶部加一个文件级编码入口,别到处 new QTextCodec static QTextCodec *g_ftpCodec = 0; static inline QTextCodec *ftpCodec() { if (!g_ftpCodec) g_ftpCodec = QTextCodec::codecForName("UTF-8"); // 按服务端实际编码改 return g_ftpCodec; } // 原来的:QString::fromLatin1(buf) // 改成: ftpCodec()->toUnicode(buf) // 原来的:str.toLatin1() // 改成: ftpCodec()->fromUnicode(str)改完别急着庆祝,先确认三件事。一是g_ftpCodec为空时的兜底,codecForName找不到会返回 0,直接解引用就是崩溃;二是改的范围要覆盖"命令参数编码"和"应答解析"两个方向,只改一边会出现能 cd 进去但列不出文件名的情况;三是这段代码在多线程里用要注意,静态指针初始化不是线程安全的,实际工程里我一般把它做成 QFtp 的一个成员,随对象走。
4.3 服务端能力探测:FEAT 与 OPTS UTF8 ON
改客户端是下策,先探一下服务端支持什么更省事。
// 登录成功后立刻发,rawCommand 的应答走 rawCommandReply 信号 ftp.rawCommand("FEAT"); ftp.rawCommand("OPTS UTF8 ON");FEAT的应答里如果出现UTF8一行,说明服务端支持 UTF-8 文件名,这时发OPTS UTF8 ON把会话切到 UTF-8,客户端只要保证按 UTF-8 编解码就万事大吉。如果应答里没有这一行,那就只能退回到服务端的本地编码,Linux 上多半还是 UTF-8,Windows 服务端上常见的是 GBK,把上面那个 codec 名字改掉即可。
注意rawCommandReply(int, const QString&)的信号里,第一个参数是应答码,第二个是文本。别把它跟commandFinished搞混,rawCommand 同样会触发commandFinished,但应答内容只在rawCommandReply里。
还有一个细节:OPTS UTF8 ON必须在login之后发,在登录前发会被服务端以 530 拒绝;同时它要排在所有涉及文件名的命令之前,队列是先进先出,位置放错了等于没发。
5. 跨平台避坑清单:Linux 跑通不等于 Windows 跑通
5.1 传输层的三个坑
现象一:上传成功,服务端上的文件是 0 字节。原因基本是 QIODevice 的生命周期没管住——要么 QFile 是栈对象,put返回后立刻析构;要么在commandFinished到达前就delete了它。QFtp 是在数据连接建立后才去读这个设备的,设备没了自然读到 0 字节,而命令本身仍然"成功",因为服务端确实收到了一个空文件。解决方式是把设备挂到长生命周期的对象上(new QFile(local, this)),在对应的commandFinished(id, ok)里判 id 匹配后再 close 和释放。这条我在两个项目里各踩过一次,第二次是因为改代码时顺手把它挪成了局部变量。
现象二:登录、列目录都正常,一 get 就卡住,几十秒后超时。这是被动模式在 NAT 后面的典型表现。PASV 命令的应答里带的是服务端自己认为的 IP 和端口,服务端如果在一层地址转换后面,它返回的是内网地址,客户端拿着这个地址去连,必然连不上。解决方式是在源码里解析 227 应答时丢掉 IP 部分,只用端口,然后用控制连接的对端地址去建数据连接——这也是很多成熟客户端的做法。改的位置通常在 qftp.cpp 里处理 PASV 应答的那个函数,把QHostAddress的构造参数换成QTcpSocket::peerAddress()即可。如果服务端支持,另一个选择是切主动模式setTransferMode(QFtp::Active),但要求客户端所在机器能被服务端反向连回来,在有防火墙的环境里多半不行。
现象三:中文文件名的 resume 或者重命名偶发失败,错误码 550。除了编码,还有一种情况是路径拼接时多了一个斜杠或者少了斜杠,服务端把//当成非法路径。跨平台时尤其明显:Windows 侧的路径习惯用反斜杠,业务代码里如果直接把本地路径当远端路径传进去,服务端看到的是\,直接拒绝。解决方式是远端路径单独维护,用/拼接,并且在上传前做一次规范化,把连续斜杠压掉。
5.2 编译与生命周期管理的两个坑
现象四:MSVC 链接报LNK2019 unresolved external symbol "public: static struct QMetaObject const QFtp::staticMetaObject"。前面提过一次,但值得再强调:这不是源码缺文件,而是 moc 没跑。触发条件是 qftp.h 没出现在 .pro 的 HEADERS 里。有些工程图省事,把 .h 和 .cpp 一起塞进 SOURCES,编译能过,moc 不认。解决方式是把 qftp.h、qurlinfo.h 补进 HEADERS,重新执行一次 qmake 让构建系统重新扫描;如果用的是 CMake,对应的是set(CMAKE_AUTOMOC ON)加上把 qftp.h 列进 target 的源文件。
现象五:程序跑一夜之后,服务端拒绝新连接,返回 421 too many connections。原因是 QFtp 对象被反复创建销毁,每次都没走close(),控制连接停在半开状态,服务端那边还占着会话名额。QFtp 析构时会 abort,但 abort 只是本地断开,服务端要等超时才发现。正确做法是整个进程复用一个 QFtp 实例,需要重连时先abort()清空队列再connectToHost,退出前调close()并等done信号。如果业务上确实需要多连接并发,那就在服务端侧调大会话上限,同时保证每个实例都有明确的销毁时机。
6. 移植到新版 Qt 与回归验证:六条用例跑完再交付
6.1 从老语法迁到 Qt5/Qt6 的改动点
把这份源码接进新工程,编译期会撞上几处固定报错,对着表改就行,不用逐行读代码。
| 报错或现象 | 根因 | 改法 |
|---|---|---|
QFtp: No such file or directory | 新版 Qt 的 network 模块里没有这个类 | 源码编成静态库,工程里加INCLUDEPATH与LIBS += -lqftp |
QUrlInfo does not name a type | 该类同样不在新模块里 | include 源码包自带的qurlinfo.h |
QTextCodec未声明 | Qt6 把 QTextCodec 挪进了兼容模块 | 工程里加QT += core5compat,或改用 QStringConverter |
| 信号连接编译不过 | 老代码用 SIGNAL/SLOT 宏,参数类型带空格 | 改成函数指针语法,qint64这类类型要写全 |
Q_FOREACH警告 | 容器遍历宏被弃用 | 换 range-for,注意遍历时别改容器 |
迁移时有个顺序上的建议:先只改编译错误,让工程能 link 通过,再动语法糖。一次性把所有Q_FOREACH换成 range-for,同时又在改编码逻辑,出了问题根本分不清是哪一处引入的。我一般是编译通过后先跑一遍原有的传输流程,确认行为与老版本一致,再做清理。
6.2 六条回归用例与超时看门狗
源码"已测试"只代表作者的环境跑通了,接进你的工程后还得自己验一遍。下面这六条是我每次必跑的,覆盖了绝大部分线上会出问题的路径。
| 编号 | 用例 | 观察点 | 判失败条件 |
|---|---|---|---|
| 1 | 上传 1KB 小文件 | 服务端文件大小、内容一致 | 大小对不上或 0 字节 |
| 2 | 上传 500MB 大文件 | 内存占用、进度回调 | 内存持续增长或进度不动 |
| 3 | 下载同名文件并比对 | 本地文件 MD5 | 内容不一致 |
| 4 | 中文目录 cd + 中文文件名 put | 服务端实际文件名 | 乱码或 550 |
| 5 | 传输中断开服务端 | error()是否触发、状态是否回 Unconnected | 界面卡死无回调 |
| 6 | 连续 200 次上传 | 服务端会话数、客户端句柄数 | 会话堆积或句柄泄漏 |
第 5、6 条最容易被跳过,也最容易在交付后出事。QFtp 自身不带任何超时机制,服务端半死不活的时候,命令会永远挂在队列里,界面看起来就是"转圈不动"。补一个看门狗是成本最低的兜底。
// 每条命令开始即重新计时,命令结束即停表,超时就 abort 清队列 class FtpGuard : public QObject { Q_OBJECT public: FtpGuard(QFtp *ftp, int timeoutMs = 30000) : m_ftp(ftp) { m_timer.setSingleShot(true); m_timer.setInterval(timeoutMs); connect(&m_timer, SIGNAL(timeout()), this, SLOT(onTimeout())); connect(ftp, SIGNAL(commandStarted(int)), this, SLOT(onStarted(int))); connect(ftp, SIGNAL(commandFinished(int,bool)), this, SLOT(onFinished(int,bool))); } private slots: void onStarted(int) { m_timer.start(); } void onFinished(int, bool) { m_timer.stop(); } void onTimeout() { m_ftp->abort(); } // abort 后 done(false) 会来,可在此重连 private: QFtp *m_ftp; QTimer m_timer; };超时时间别设太短。列一个大目录、传一个大文件都可能超过 30 秒,我一般按业务里最大单次传输耗时的一点五倍来定,并且只对控制类命令(connect、login、cd、list)启用短超时,传输类命令单独放宽。另外abort()之后一定要重置业务状态,很多人只 abort 不重置,下一次操作还接着用旧的命令编号去比对,回调永远匹配不上,看上去就像"重连之后再也不工作了"。
断线重连这块也有个习惯动作:重连前先确认state()已经回到 Unconnected,没回到就再等一个stateChanged,别连着调两次connectToHost,队列里会堆两条连接命令,第二次必然报错。至于重连的退避,简单点用固定间隔就行,别在客户端里搞复杂的指数退避,那玩意儿在弱网下反而把恢复时间拉长。
从那以后,我每次把这份 QFTP 源码接进一个新工程,第一步都不是写业务代码,而是先把上面六条用例跑一遍,跑完再动业务逻辑。血泪经验是:编码问题和生命周期问题一旦混进业务代码里,排查成本会翻好几倍,而它们本来可以在半小时内被这六条用例挡在门外。希望帮到你。
本文还有配套的精品资源,点击获取