基于QTcpSocket实现QFtp兼容的FTP双连接客户端 简介面向 Qt5 及以上版本开发者的 QFtp 移植与使用示例针对 Qt5 起官方移除 QFtp 类、无法直接调用 FTP 接口的问题从 Qt4.8.7 源码中提取出 QFtp.h 与 QFtp.cpp 两个文件并配以一套可运行的测试工程。示例覆盖登录 FTP 服务器以及上传文件、删除文件、创建文件、重命名文件等常用操作适合正在开发桌面端文件传输功能、需要快速补齐 FTP 能力的初中级 Qt 开发者参考。压缩包共 17 个文件约 35KB包含 5 个 cpp 与 4 个 h 源文件承载 QFtp 核心实现及各对话框逻辑、3 个 ui 界面文件主窗口、重命名与新建文件夹对话框、qrc 资源文件与 pro 工程文件另附 png 图标素材导入 Qt Creator 即可编译调试。已有 1320 人学习下载。读者可直接获得一份可复用的 QFtp 封装源码与完整调用范例省去自行移植与调试的时间也能借助界面与对话框的实现理解 FTP 操作的交互组织方式。1. 从一行编译不过的老代码说起Qt 实现 QFtp 到底要补哪些轮子接手一台工控上位机的维护第一天就卡住了QFtp *ftp new QFtp(this);这行在老代码里安稳跑了好几年换到新版 Qt 编译直接报未声明。QFtp 早被移出 QtNetwork新的网络模块里没有它可现场需求一点没变——定时把 CSV 日志推到内网 FTP 服务器、按目录取回配置、掉线重连。这个项目干的就是补轮子基于 QTcpSocket 从零做一套 QFtp 风格的客户端让 Qt 实现 QFtp 这件事重新成立把 connectToHost / login / list / get / put / remove 这套接口和 commandStarted / commandFinished / listInfo / dataTransferProgress 这套信号重新长出来上层业务几乎不用改代码。手里有老工程要迁、或者在板子上不想再引第三方库的都能直接拿去用。2. 架构拆解控制连接与数据连接为什么必须分成两条FTP 和 HTTP 最大的差别不在命令长什么样而在它天生是双连接的。搞不清这一点后面所有 bug 都会绕回来。这一章先把协议模型和类结构定死再谈代码。2.1 双连接模型一条发号施令一条搬字节控制连接是一条长活的 TCP从头到尾只有它在说话客户端发一行RETR config.ini服务端回150 Opening BINARY mode data connection数据从另一条连接上流完服务端再在控制连接上补一句226 Transfer complete。也就是说一条命令的生命周期横跨两条 socket而且判定“传输成功”的依据不是数据收完了是控制连接上收到了 226。我见过不少自己写的 FTP 客户端栽在这里——数据收完就把命令标成功结果服务端回了 426文件是半个。数据连接的建立有两种模式。主动模式PORT是客户端告诉服务端“你来连我”在 NAT 和防火墙后面基本废掉被动模式PASV是客户端说“给我个端口”服务端回227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)客户端主动去连。工程上没得选一律 PASV。提示PASV 返回的六个数字里前四个是 IP后两个拼端口p1*256p2这个公式记牢后面解析要用。顺带说一句为什么不用 QNetworkAccessManager。新版 Qt 的 QNAM 早就不带 FTP 后端了只认 HTTP 一族就算能绕QNAM 那套 reply 抽象也喂不出 listInfo 这种逐行回调。所以自研 QTcpSocket 是唯一现实路径代价是协议细节全得自己扛。2.2 类结构FtpClient、FtpCommand、FtpReply 三件套整个项目就三个核心类型。FtpClient 是门面持有两条 socket、一个命令队列和一个当前命令FtpCommand 是不可变的命令描述包含动词、参数、是否需要数据通道、绑定的 IO 设备FtpReply 只做一件事——把控制连接上的原始行翻译成三位码加文本。状态机只四个态够用且好排查状态含义进入条件退出条件Idle空转队列为空上条命令收尾有新命令入队Connecting控制连接握手中connectToHost收到 220 欢迎语Waiting命令已发出等终结应答write 完成收到 2xx/4xx/5xxTransfer数据通道打开中227 解析成功数据 socket 断开且收到 226把状态收敛成四个好处是出问题时看一眼stateChanged日志就知道卡在哪一段。早期的版本我塞了七八个状态结果自己都记不清 Transfer 和 DataClosing 的区别血泪经验。2.3 目录结构一份能直接编译的骨架工程按“协议层 / 解析层 / 界面层”切协议层不依赖任何 Widget方便后面做单元测试和搬到无界面的嵌入式环境。qt-ftp-client/ ├── src/ │ ├── ftp/ │ │ ├── ftpclient.h/.cpp # 门面队列、状态机、两条 socket │ │ ├── ftpcommand.h # 命令描述结构体 │ │ ├── ftpreply.h/.cpp # 三位码解析、多行应答拼装 │ │ └── ftplistparser.h/.cpp # ls -l / MLSD 行转 QUrlInfo │ ├── ui/ │ │ └── mainwindow.h/.cpp # 只做展示不碰协议 │ └── main.cpp ├── tests/ │ └── tst_ftpclient.cpp # 用本地假服务器跑协议回归 └── ftpclient.proftpclient.pro里只需要QT core network界面部分再补 widgets。协议层保持可裁剪是我做这类工具库的固定习惯——只要它能单独编成一个静态库后面往哪儿搬都不疼。3. 控制通道实现命令队列、应答解析与登录时序控制连接是整个客户端的大脑它错了数据通道再怎么优化都没意义。这一章把队列、解析、时序三块拆开讲。3.1 命令队列一条 in-flight 是硬约束FTP 控制连接是严格串行的。你连着发PASV和RETR服务端的应答会一前一后挤在同一次 readAll 里回来客户端就再也分不清哪个 227 对应哪条命令了紧接着就是 425、500 一串玄学错误。所以队列必须是“同一时刻只有一条命令在飞”。// ftpcommand.h struct FtpCommand { int id -1; QString verb; // USER / PASS / TYPE / PASV / LIST / RETR / STOR QString arg; // 参数没有就留空 QStringList preamble; // 主命令之前要串行发掉的准备工作如 TYPE I、REST N bool needsData false; // 是否需要先建数据通道 QIODevice *device nullptr; // RETR 的写出目标 / STOR 的读取源 bool isTransfer false; // 是否走数据通道 };preamble 这个字段是整个设计里最省事的一笔。像TYPE I必须在RETR之前发REST N必须在RETR之前发但它们各自都要等一个应答还不能打乱对外暴露的“一条命令一个 id”的语义。用 preamble 把它们串进同一条命令的生命周期里外面只看到一个 id里面顺序执行干净。入队和推进的代码int FtpClient::enqueue(const FtpCommand cmd) { FtpCommand c cmd; c.id m_lastId; m_queue.enqueue(c); emit commandStarted(c.id); if (m_current.id -1 m_state ! Connecting) sendNext(); // 空闲才推否则等上一条收尾 return c.id; } void FtpClient::sendNext() { if (m_current.id ! -1) return; // 还有一条没收到终结应答直接返回 if (m_queue.isEmpty()) { setState(Idle); return; } m_current m_queue.dequeue(); setState(Waiting); advance(); // 先发 preamble发完才发主命令 } void FtpClient::advance() { if (!m_current.preamble.isEmpty()) { writeControl(m_current.preamble.takeFirst()); return; } QString line m_current.verb; if (!m_current.arg.isEmpty()) line QLatin1Char( ) m_current.arg; writeControl(line); } void FtpClient::writeControl(const QString line) { m_ctrl-write(line.toUtf8() \r\n); // FTP 规定 CRLF别只写 \n m_ctrl-flush(); }enqueue返回的 id 就是给业务层做回调匹配用的跟老 QFtp 的语义完全一致。sendNext里那个m_current.id ! -1的判断是命门少了它队列就形同虚设。writeControl末尾的\r\n也别偷懒某些服务端对纯\n容忍度极低会直接挂住不回话。3.2 应答解析三位码、多行应答与分行缓冲控制连接的读法要小心两件事TCP 会粘包一次 readAll 可能拿到半行也可能拿到三行多行应答比如FEAT、STAT以123-开头直到出现123才算结束。void FtpClient::onControlReadyRead() { m_ctrlBuf.append(m_ctrl-readAll()); int idx; while ((idx m_ctrlBuf.indexOf(\r\n)) 0) { const QByteArray line m_ctrlBuf.left(idx); m_ctrlBuf.remove(0, idx 2); if (line.size() 3) continue; bool ok false; const int code line.left(3).toInt(ok); if (!ok) { // 不是应答行多半是服务端多嘴 continue; } const bool isMultiStart line.size() 3 line.at(3) -; if (isMultiStart || !m_multi.isEmpty()) { m_multi.append(line); const QByteArray tail QByteArray::number(code) ; if (!isMultiStart line.startsWith(tail)) { const QByteArray whole m_multi.join(\r\n); m_multi.clear(); handleReply(code, whole); // 多行整块交给业务 } continue; } handleReply(code, line.mid(4)); } }handleReply里按“当前是否还有 preamble”和“当前命令是否需要数据通道”分流preamble 非空就继续advance()碰到 PASV 的 227 就解析地址开数据 socket碰到 1xx150/125只更新状态不结束命令2xx 才发commandFinished(id, false)。这套分流写对了后面加命令就是往枚举里添一个词几乎不用动框架。3.3 登录时序别把 USER 和 PASS 当成普通命令登录有两个坑。第一USER anonymous有些服务端直接回 230表示免密有些回 331 要密码代码必须两种都认。第二登录没完成之前不能发任何业务命令否则会收到 530。我的处理是把 login 拆成两条命令入队并在handleReply里对 230 与 331 分别处理——331 就自动追加一条PASS。int FtpClient::login(const QString user, const QString password) { FtpCommand c; c.verb QStringLiteral(USER); c.arg user.isEmpty() ? QStringLiteral(anonymous) : user; const int id enqueue(c); m_pendingPassword password; return id; } // handleReply 中 case 331: if (m_current.verb QLatin1String(USER)) { FtpCommand p; p.verb QStringLiteral(PASS); p.arg m_pendingPassword; // 直接推进当前命令不重新入队保证 id 连续 m_current.preamble.prepend(p.verb QLatin1Char( ) p.arg); advance(); return; } break;密码走明文是协议本身的限制别指望在客户端侧加密如果链路不可信正规做法是换 FTPSControl 通道套 TLS后面第 6 章会给出改造入口。4. 数据通道实现PASV 解析、LIST 目录与 RETR/STOR 的收尾握手数据通道的代码量比控制通道多一倍但逻辑其实更线性。三个难点地址怎么算、目录行怎么拆、传输结束怎么判。4.1 PASV 应答解析与数据 socket 的建立时机服务端回的是227 Entering Passive Mode (192,168,1,10,19,137).括号里六个十进制数逗号分隔前四段 IP后两段拼端口19*256137 5001。static bool parsePasv(const QByteArray line, QHostAddress *addr, quint16 *port) { const int l line.indexOf((); const int r line.lastIndexOf()); if (l 0 || r l) return false; const QListQByteArray parts line.mid(l 1, r - l - 1).split(,); if (parts.size() ! 6) return false; bool ok true; quint8 ip[4] {0}; for (int i 0; i 4; i) { ip[i] static_castquint8(parts.at(i).trimmed().toInt(ok)); if (!ok) return false; } const int p1 parts.at(4).trimmed().toInt(ok); const int p2 parts.at(5).trimmed().toInt(ok); if (!ok) return false; addr-setAddress(QStringLiteral(%1.%2.%3.%4) .arg(ip[0]).arg(ip[1]).arg(ip[2]).arg(ip[3])); *port static_castquint16(p1 * 256 p2); return true; }拿到地址之后新建 QTcpSocket 去连connected之后再发主命令LIST/RETR/STOR。顺序反了就是死锁服务端等着你连数据口你等着服务端回 150。注意应答里的 IP 在服务器位于 NAT 后面时是内网地址直连必超时。稳妥做法是忽略这四段 IP强制用控制连接的对端地址m_ctrl-peerAddress()只在端口上信服务端。这个坑我在两台不同的服务器上都撞过。4.2 LIST 的目录解析把 ls -l 一行拆成 QUrlInfoLIST的返回是纯文本没有标准格式只能按 Unixls -l的常见形态做容错解析。别想一口气吃完所有服务端的变体能覆盖ls -l和MLSD就够 90% 场景了。bool FtpListParser::parse(const QString raw, QUrlInfo *info) { // 典型行-rw-r--r-- 1 ftp ftp 1024 Jan 05 10:22 config.ini const QStringList f raw.split(QRegularExpression(QStringLiteral(\\s)), Qt::SkipEmptyParts); if (f.size() 9) return false; const QString perm f.at(0); if (perm.isEmpty()) return false; const QChar type perm.at(0); if (type ! QLatin1Char(-) type ! QLatin1Char(d) type ! QLatin1Char(l)) return false; // 不是 ls -l 风格丢掉 bool ok false; const qint64 size f.at(4).toLongLong(ok); if (!ok) return false; const QString name f.mid(8).join(QLatin1Char( )); // 文件名里可能有空格 if (name QLatin1String(.) || name QLatin1String(..)) return false; info-setName(name); info-setSize(size); info-setDir(type QLatin1Char(d)); info-setSymLink(type QLatin1Char(l)); info-setPermissions(toPermissions(perm)); // rwxr-xr-x - QFileDevice::Permissions return true; }注意f.mid(8).join( )这一句很多实现直接用f.at(8)遇到my report 2024.csv这种带空格的文件名就只剩my二次开发时排查起来非常费时间。目录大小在LIST里没有意义别拿它当真真要文件大小和精确时间让服务端跑MLSD更靠谱。4.3 RETR/STOR读写循环、进度信号与 226 收尾下载侧简单数据 socket 的readyRead里把字节写给目标 QIODevice同时累加计数发进度。上传侧要复杂一点——必须做背压不能一次性把整个文件怼进 socket 缓冲区几百兆的文件能把内存吃穿。void FtpClient::pumpUpload() { // bytesToWrite 超过水位就停下来等 bytesWritten 再继续 while (m_data-bytesToWrite() kHighWater) { const QByteArray chunk m_current.device-read(kChunkSize); if (chunk.isEmpty()) { m_data-disconnectFromHost(); // 半关闭服务端读到 EOF 才会回 226 return; } m_data-write(chunk); m_done chunk.size(); emit dataTransferProgress(m_done, m_total); } } void FtpClient::onDataBytesWritten(qint64) { if (m_current.verb QLatin1String(STOR)) pumpUpload(); }kHighWater取 256 KB、kChunkSize取 64 KB在百兆网和本地回环上都跑得挺稳。关键在disconnectFromHost()它是半关闭发 FIN 但还能收服务端读到 EOF 才判定上传结束才会回 226。要是直接close()或者abort()服务端多半回 426文件残缺。收尾的判定必须落在控制连接上onDataDisconnected只把数据 socket 置空真正的commandFinished(id, false)要等handleReply收到 226 才发。这条纪律守住了“文件传完了但客户端报错”这类问题基本绝迹。5. 避坑与排查五个真机上翻车的现场协议实现最怕的不是不会写是写完了在真机上时好时坏。下面五条都是我踩过的按“现象 → 原因 → 解决”记。第一条上一条命令还没收尾就发下一条服务端回 425。现象是偶尔成功、偶尔报425 Cant open data connection重试又好了。原因是数据 socket 还没关、或者 226 还没到队列就推进了下一条两条命令的数据通道撞在一起。解决就是把m_current.id ! -1这个门闩焊死并且要求数据 socket 的disconnected与 226 应答两个条件同时满足才允许出队。我后来在sendNext里加了一行日志打印当前状态这类问题一眼就能定位。第二条能登录但 LIST 永远超时。现象是 227 拿到了、数据 socket 连不上卡满 30 秒报超时。原因是 PASV 应答里的 IP 是服务器的内网地址客户端所在网段根本路由不过去。解决办法是丢弃应答里的 IP 段改用m_ctrl-peerAddress()端口仍按p1*256p2算。这个改动只有三行但省了我两个下午。第三条上传大文件后大小对不上末尾总少几 KB。现象是服务端显示文件比本地小而且越小越多。原因是程序在bytesToWrite还没清零时就close()了 socket内核缓冲区里的数据直接丢掉。解决是走半关闭流程先disconnectFromHost()在disconnected里再判断是否还有残留实在不放心可以在关闭前waitForBytesWritten(3000)兜一次。从那以后我传完文件都会主动比一次 SIZE这个习惯救过好几次。第四条中文文件名全变问号。现象是LIST回来的中文名是乱码或问号下载也按错误名字找不到文件。原因是控制连接里传的是字节流客户端按 UTF-8 解、服务端按 GBK 发。解决分两步登录后先发OPTS UTF8 ON试试服务端支持 200 就统一走 UTF-8回 5xx 就退回本地编码用QStringDecoder指定 GBK 解一遍。注意这一步要放在TYPE之前别塞进每条命令的 preamble 里。第五条传了几个大文件之后连接突然被重置。现象是前几个文件正常后面开始随机断。原因是控制连接在长时间数据传输期间完全空闲中间的网络设备把这条“没动静”的连接回收了。解决办法是心跳传输过程中每 30 秒往控制连接塞一个NOOP它不影响当前命令服务端回 200直接在handleReply里忽略即可。别在传输中发PWD之类的业务命令那会真的打乱时序。排查手段上我在writeControl和handleReply里各留了一个qCDebug分类把发出的命令、收到的原始行、当前状态一起打出来。FTP 这东西一旦出问题就是黑匣子没有这份日志只能靠猜。6. 二次开发把 QFtp 兼容层补齐顺带把断点续传做进去迁移老工程最省事的路径是做一个薄的兼容层让QFtp这个名字重新可用业务代码一行不改。做法是把 FtpClient 包一层接口签名对齐老类信号做转发必要时用Q_INVOKABLE把原来靠commandFinished(int, bool)匹配 id 的逻辑保留下来。老接口兼容层方法说明connectToHost(host, port)转发到 FtpClient::connectToHost返回命令 id语义一致login(user, pass)同上内部处理 230/331免密服务端也能走通list(dir)同上逐行发 listInfo目录行解析挂在 parser 上get(remote, dev)同上dev 由调用方持有进度信号透传put(dev, remote)同上注意 dev 生命周期传完前别 deleteremove / mkdir / rename直接映射无数据通道rawCommand(cmd)走 enqueueverb 用原串兜底口子排查时很好用断点续传靠 preamble 的机制白捡要续传一个已有 512 KB 的本地文件把REST 524288和TYPE I一起塞进 preamble主命令仍写RETR服务端回 350 就继续回 5xx 就退回整传。int FtpClient::getResume(const QString remote, QFile *out) { FtpCommand c; c.verb QStringLiteral(RETR); c.arg remote; c.preamble QStringLiteral(TYPE I) QStringLiteral(REST ) QString::number(out-size()); c.needsData true; c.isTransfer true; c.device out; // 调用方保证 out 已 open(WriteOnly|Append) return enqueue(c); }超时重连我一般做成两级控制连接的QTimer只管握手阶段15 秒传输阶段交给心跳探活重连成功之后不自动重放业务命令而是发reconnected信号让上层决定——自动重放很容易把“上传一半”变成“传了两遍”这个后悔药不好吃。再往下扩就是 FTPS把QTcpSocket *m_ctrl和m_data换成QSslSocket在connected之后调startClientEncryption()encrypted信号到了再发USER。改动点收敛在writeControl、connectDataChannel、onControlConnected三个函数里协议逻辑一行不用碰——这也是当初坚持把 socket 操作全收口的原因。验证方法别只靠手点。我在tests/里写了个极简的假服务器QTcpServer 监听本地端口按脚本回220/331/230/227数据口随便吐两行目录配合 QSignalSpy 断言commandFinished(id, false)和listInfo的条数。协议这种东西只要有一次没跑回归就敢改早晚出事。从那以后我每次动handleReply都强制走一遍这套用例哪怕只改了一个 case 分支希望帮到你。本文还有配套的精品资源点击获取