ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Qt文件上传实战:客户端与Flask服务端全链路实现

Qt文件上传实战:客户端与Flask服务端全链路实现 简介这是一套基于Qt5.9.9开发的完整文件上传系统源码面向C与Qt初学者及网络编程实践者解决跨平台大文件可靠传输的典型工程问题。资源包含客户端与服务端双端实现核心依托QTcpSocket与QTcpServer构建TCP通信机制适用于局域网文件分发、嵌入式设备固件更新等轻量级上传场景。压缩包共13个文件涵盖4个cpp含main.cpp与widget.cpp等主逻辑、2个ui界面布局、2个pro项目配置、2个h头文件定义、2个userQt Creator工程元数据及1个.fec3d4f用户配置校验文件整体仅16KB结构精简、模块职责清晰便于逐层理解连接建立、分块读写、进度反馈等关键流程。已有1733人学习下载读者可直接编译运行、调试断点跟踪传输状态、对比双端信号槽交互逻辑并参考实际目录组织方式掌握Qt网络项目标准架构。1. Qt 文件上传不是“把文件拖进窗口就完事”——它必须同时跑通客户端界面交互、HTTP 协议封装、服务端接收解析三环缺一不可很多刚接触 Qt 网络编程的开发者看到“Qt 实现文件上传”第一反应是用QFileDialog选个文件再用QNetworkAccessManager发个 POST 请求——代码能跑但一到真实环境就卡在 500 错误、超时、中文路径乱码、大文件卡死、服务端收不到数据体。根本原因在于Qt 的QHttpMultiPart不是黑盒它生成的 multipart boundary 必须与服务端解析器严格对齐而服务端若用 Python Flask 或 Node.js Express默认不启用 multipart 解析更不会校验 Content-Length、分块边界、文件名编码。本文聚焦「可落地、可调试、可上线」的最小闭环基于 Qt 5.15兼容 MSVC2019/MinGW64实现带进度反馈的客户端搭配轻量 Python Flask 服务端非 Node.js/Java所有源码无第三方 SDK 依赖全部使用 Qt 原生类和标准库。适合嵌入式设备配套工具、工业 HMI 上传日志、本地化部署的桌面端运维系统等场景——不碰云存储 API不依赖 Web 服务器配置从main.cpp到app.py全链路可控。2. 客户端核心用 QHttpMultiPart 构建符合 RFC 7578 的 multipart/form-data 请求体Qt 提供了QHttpMultiPart类来构造 multipart 请求但它不是“自动适配所有后端”的万能胶。关键在于理解 multipart/form-data 的结构本质每个字段以--boundary分隔文件字段必须包含Content-Disposition: form-data; namefile; filenamexxx头且filename值需做 RFC 5987 编码否则中文名在 Flask 中变成None。直接用QFileInfo::fileName()传入会导致服务端无法识别原始文件名。2.1 构建 multipart 主体并注入文件二进制流#include QHttpMultiPart #include QHttpPart #include QFile #include QUrlQuery #include QTextCodec QByteArray buildMultipartData(const QString filePath, const QString fieldName file) { QHttpMultiPart *multiPart new QHttpMultiPart(QHttpMultiPart::FormDataType); // 1. 添加普通字段如 token、upload_id QHttpPart textPart; textPart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(text/plain)); textPart.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant(form-data; name\upload_token\)); textPart.setBody(a1b2c3d4); // 示例 token实际应从登录态获取 multiPart-append(textPart); // 2. 添加文件字段 —— 关键filename 必须 UTF-8 编码 RFC 5987 格式 QHttpPart filePart; QFile file(filePath); if (!file.open(QIODevice::ReadOnly)) { qWarning() Cannot open file: filePath; delete multiPart; return QByteArray(); } QFileInfo fileInfo(file); QString fileName fileInfo.fileName(); // RFC 5987 编码将 filename 转为 utf-8 字节序列再 base64 编码并加 charset 声明 QByteArray utf8Name fileName.toUtf8(); QByteArray encodedName utf8Name.toBase64(); QString contentDisposition QString(form-data; name\%1\; filename*UTF-8%2) .arg(fieldName) .arg(QString::fromLatin1(encodedName)); filePart.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant(contentDisposition)); filePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(application/octet-stream)); filePart.setBodyDevice(file); file.setParent(multiPart); // 让 multiPart 管理 file 生命周期 multiPart-append(filePart); // 3. 序列化为字节数组注意multiPart-boundary() 返回的是实际使用的 boundary QByteArray data; QBuffer *buffer new QBuffer(data); buffer-open(QIODevice::WriteOnly); multiPart-setBoundary(QtBoundary_ QUuid::createUuid().toString(QUuid::WithoutBraces).left(16)); multiPart-setParent(nullptr); multiPart-writeTo(buffer); buffer-close(); delete buffer; delete multiPart; return data; }提示QHttpMultiPart::writeTo()内部会调用generateBoundary()但该 boundary 是随机生成的无法提前获知。因此不能手动拼接--boundary字符串必须让QHttpMultiPart自行写入完整请求体。上述代码中显式设置setBoundary()是为了确保 boundary 可预测便于服务端调试但实际生产中建议保留默认随机值。2.2 发起异步上传并实时监听进度Qt 的QNetworkReply提供uploadProgress(qint64 bytesSent, qint64 bytesTotal)信号但仅当QNetworkRequest::setHeader(QNetworkRequest::ContentTypeHeader, ...)设置正确且服务端返回Content-Length时才可靠触发。需配合QEventLoop或状态机管理上传生命周期class FileUploader : public QObject { Q_OBJECT public: explicit FileUploader(QObject *parent nullptr) : QObject(parent), manager(new QNetworkAccessManager(this)) {} void uploadFile(const QString url, const QString filePath) { QByteArray postData buildMultipartData(filePath); if (postData.isEmpty()) return; QNetworkRequest request(QUrl(url)); request.setHeader(QNetworkRequest::ContentTypeHeader, multipart/form-data; boundary getBoundaryFromData(postData)); reply manager-post(request, postData); connect(reply, QNetworkReply::uploadProgress, this, FileUploader::onUploadProgress); connect(reply, QNetworkReply::finished, this, FileUploader::onUploadFinished); connect(reply, QNetworkReply::errorOccurred, this, FileUploader::onUploadError); } private slots: void onUploadProgress(qint64 bytesSent, qint64 bytesTotal) { double progress (bytesTotal 0) ? (double)bytesSent / bytesTotal * 100 : 0; emit progressUpdated(progress, bytesSent, bytesTotal); } void onUploadFinished() { if (reply-error() QNetworkReply::NoError) { QByteArray responseData reply-readAll(); qDebug() Upload success, response: responseData; emit uploadCompleted(true, QString::fromUtf8(responseData)); } else { emit uploadCompleted(false, reply-errorString()); } reply-deleteLater(); reply nullptr; } void onUploadError(QNetworkReply::NetworkError code) { qWarning() Network error: code reply-errorString(); } signals: void progressUpdated(double percent, qint64 sent, qint64 total); void uploadCompleted(bool success, const QString message); private: QNetworkAccessManager *manager; QNetworkReply *reply nullptr; // 从 multipart 数据中提取 boundary用于设置 header QString getBoundaryFromData(const QByteArray data) { int start data.indexOf(--); if (start -1) return ; int end data.indexOf(\r\n, start); if (end -1) return ; return QString::fromUtf8(data.mid(start 2, end - start - 2)); } };注意getBoundaryFromData()是临时方案仅用于调试。生产环境应避免解析 raw data而是统一用QHttpMultiPart::boundary()获取需改造buildMultipartData()返回QHttpMultiPart*并暴露 boundary。3. 服务端实现Flask 接收 multipart 并安全保存文件拒绝任意路径写入Qt 客户端发来的 multipart 请求服务端必须能正确解析filename*RFC 5987格式的文件名并防止路径遍历如../etc/passwd、空字节截断、超大文件耗尽内存等风险。Python Flask 默认使用request.files但它对filename*支持有限需手动解析Content-Disposition头。3.1 使用 werkzeug.datastructures.FileStorage 安全读取上传文件# app.py from flask import Flask, request, jsonify from werkzeug.datastructures import FileStorage import os import re import mimetypes from pathlib import Path app Flask(__name__) UPLOAD_FOLDER Path(uploads) MAX_FILE_SIZE 100 * 1024 * 1024 # 100MB # 确保上传目录存在 UPLOAD_FOLDER.mkdir(exist_okTrue) def safe_filename(filename: str) - str: 移除路径字符只保留字母、数字、下划线、点号 return re.sub(r[^a-zA-Z0-9._-], _, filename) def parse_rfc5987_filename(headers: dict) - str: 从 Content-Disposition 头解析 RFC 5987 格式的 filename* disp headers.get(Content-Disposition, ) # 匹配 filename*UTF-8xxxx match re.search(rfilename\*(?:UTF-8|utf-8)([^;]), disp) if match: try: import urllib.parse return urllib.parse.unquote(match.group(1)) except Exception: pass # fallback to regular filename match re.search(rfilename([^]), disp) if match: return match.group(1) return unknown.bin app.route(/upload, methods[POST]) def handle_upload(): # 1. 检查 Content-Type 是否为 multipart content_type request.headers.get(Content-Type, ) if not content_type.startswith(multipart/form-data): return jsonify({error: Invalid Content-Type}), 400 # 2. 手动解析 multipart body绕过 Flask 自动解析缺陷 # 使用 werkzeug 的 parser支持 RFC 5987 from werkzeug.formparser import MultiPartParser from werkzeug.datastructures import Headers # 构造 Headers 对象Flask request.headers 是 ImmutableHeaders headers_dict dict(request.headers) headers_obj Headers(headers_dict) parser MultiPartParser() try: stream, form, files parser.parse( request.stream, content_type, len(request.get_data(cacheFalse)), clsFileStorage ) except Exception as e: return jsonify({error: fParse failed: {str(e)}}), 400 # 3. 查找名为 file 的上传文件 uploaded_file None for key, fs in files.items(): if key file: # 与 Qt 客户端 fieldName 一致 uploaded_file fs break if not uploaded_file: return jsonify({error: No file field found}), 400 # 4. 安全提取原始文件名 original_name parse_rfc5987_filename(uploaded_file.headers) safe_name safe_filename(original_name) # 5. 检查文件大小stream 已被 parser 读取需重置或用 fs.stream # werkzeug FileStorage 的 stream 是 BytesIO可 seek uploaded_file.stream.seek(0, 2) # 移动到末尾 file_size uploaded_file.stream.tell() uploaded_file.stream.seek(0) # 重置到开头 if file_size MAX_FILE_SIZE: return jsonify({error: fFile too large: {file_size} bytes}), 413 # 6. 生成唯一文件名防止覆盖 ext os.path.splitext(safe_name)[1].lower() unique_name f{os.urandom(8).hex()}{ext} save_path UPLOAD_FOLDER / unique_name # 7. 写入磁盘使用 chunked write 防止 OOM try: with open(save_path, wb) as f: while True: chunk uploaded_file.stream.read(8192) if not chunk: break f.write(chunk) return jsonify({ success: True, saved_as: unique_name, original_name: original_name, size: file_size }), 200 except Exception as e: return jsonify({error: fSave failed: {str(e)}}), 500提示Flask 的request.files在遇到filename*时可能返回None因此必须使用werkzeug.formparser.MultiPartParser手动解析。该 parser 是 Flask 内部所用完全兼容 RFC 7578 和 RFC 5987。3.2 启动服务并验证端口可用性# 安装依赖仅需 flask 和 werkzeug无额外包 pip install flask # 运行服务绑定本地回环禁止外网访问 FLASK_APPapp.py FLASK_ENVdevelopment flask run --host127.0.0.1 --port5000此时服务端监听http://127.0.0.1:5000/upload接受 Qt 客户端 POST 请求。关键验证点上传含中文名的文件如测试报告.xlsx服务端original_name应正确还原上传 200MB 文件应返回 413而非 500 或卡死上传../../../etc/passwdsafe_filename()应转为___etc_passwd且保存路径仍在uploads/下。4. 客户端 UI 集成用 QProgressBar QPushButton 实现可中断的上传流程Qt 文件上传不能只停留在控制台。真实项目需要按钮响应、进度条可视化、失败重试、取消功能。QNetworkReply支持abort()但需注意abort()后finished信号仍会触发需在槽函数中判断reply-isFinished()和reply-error()。4.1 构建主窗口并绑定上传逻辑// mainwindow.h #ifndef MAINWINDOW_H #define MAINWINDOW_H #include QMainWindow #include QProgressBar #include QPushButton #include QLabel #include QVBoxLayout #include QFileDialog #include fileuploader.h class MainWindow : public QMainWindow { Q_OBJECT public: explicit MainWindow(QWidget *parent nullptr); private slots: void onBrowseClicked(); void onUploadClicked(); void onCancelClicked(); void onProgressUpdated(double percent, qint64 sent, qint64 total); void onUploadCompleted(bool success, const QString message); private: QPushButton *browseBtn; QPushButton *uploadBtn; QPushButton *cancelBtn; QLabel *statusLabel; QProgressBar *progressBar; QString selectedFilePath; FileUploader *uploader; }; #endif // MAINWINDOW_H// mainwindow.cpp #include mainwindow.h #include QMessageBox #include QDir MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), uploader(new FileUploader(this)) { QWidget *central new QWidget(this); setCentralWidget(central); QVBoxLayout *layout new QVBoxLayout(central); browseBtn new QPushButton(选择文件, this); uploadBtn new QPushButton(开始上传, this); cancelBtn new QPushButton(取消, this); statusLabel new QLabel(等待选择文件..., this); progressBar new QProgressBar(this); progressBar-setRange(0, 100); progressBar-setValue(0); layout-addWidget(browseBtn); layout-addWidget(uploadBtn); layout-addWidget(cancelBtn); layout-addWidget(statusLabel); layout-addWidget(progressBar); connect(browseBtn, QPushButton::clicked, this, MainWindow::onBrowseClicked); connect(uploadBtn, QPushButton::clicked, this, MainWindow::onUploadClicked); connect(cancelBtn, QPushButton::clicked, this, MainWindow::onCancelClicked); connect(uploader, FileUploader::progressUpdated, this, MainWindow::onProgressUpdated); connect(uploader, FileUploader::uploadCompleted, this, MainWindow::onUploadCompleted); uploadBtn-setEnabled(false); cancelBtn-setEnabled(false); } void MainWindow::onBrowseClicked() { QString path QFileDialog::getOpenFileName(this, 选择要上传的文件, QDir::homePath(), 所有文件 (*.*);;文本文件 (*.txt);;图片 (*.png *.jpg)); if (!path.isEmpty()) { selectedFilePath path; statusLabel-setText(已选择: QFileInfo(path).fileName()); uploadBtn-setEnabled(true); } } void MainWindow::onUploadClicked() { if (selectedFilePath.isEmpty()) return; statusLabel-setText(正在上传...); uploadBtn-setEnabled(false); cancelBtn-setEnabled(true); progressBar-setValue(0); uploader-uploadFile(http://127.0.0.1:5000/upload, selectedFilePath); } void MainWindow::onCancelClicked() { if (uploader-isUploading()) { // 需在 FileUploader 中添加 isUploading() 成员 uploader-cancelCurrentUpload(); // 调用 reply-abort() statusLabel-setText(已取消上传); progressBar-setValue(0); uploadBtn-setEnabled(true); cancelBtn-setEnabled(false); } } void MainWindow::onProgressUpdated(double percent, qint64 sent, qint64 total) { progressBar-setValue(static_castint(percent)); statusLabel-setText(QString(上传中: %1 KB / %2 KB) .arg(sent / 1024) .arg(total / 1024)); } void MainWindow::onUploadCompleted(bool success, const QString message) { if (success) { statusLabel-setText(上传成功 message); QMessageBox::information(this, 成功, 文件已保存至服务端); } else { statusLabel-setText(上传失败: message); QMessageBox::warning(this, 错误, message); } uploadBtn-setEnabled(!selectedFilePath.isEmpty()); cancelBtn-setEnabled(false); }注意FileUploader需扩展cancelCurrentUpload()方法内部调用if (reply) reply-abort();并设置标志位m_isUploading false。isUploading()返回该标志。4.2 编译与部署qmake 工程配置要点# upload.pro QT core widgets network TARGET qt-file-uploader TEMPLATE app SOURCES main.cpp \ mainwindow.cpp \ fileuploader.cpp HEADERS mainwindow.h \ fileuploader.h FORMS # Windows 下需显式链接网络模块 win32:LIBS -lqtnetwork # 打包时包含资源图标、语言文件等 RESOURCES resources.qrc运行qmake make即可生成可执行文件。若需打包发布使用windeployqt自动复制 Qt DLLwindeployqt --no-opengl-sw --no-compiler-runtime --no-system-d3d-compiler qt-file-uploader.exe5. 生产级加固超时控制、重试机制与服务端校验增强开发阶段能跑通不等于生产可用。真实环境需应对网络抖动、服务端瞬时不可用、大文件传输中断等问题。QtQNetworkRequest支持setTransferTimeout()但默认值为 30 秒对百兆文件明显不足Flask 服务端需增加 CSRF token 校验、文件类型白名单、SHA256 校验回传。5.1 客户端超时与指数退避重试// 在 FileUploader::uploadFile 中添加超时与重试逻辑 void FileUploader::uploadFile(const QString url, const QString filePath, int maxRetries 2) { QByteArray postData buildMultipartData(filePath); if (postData.isEmpty()) return; QNetworkRequest request(QUrl(url)); request.setHeader(QNetworkRequest::ContentTypeHeader, multipart/form-data; boundary getBoundaryFromData(postData)); request.setTransferTimeout(300000); // 5分钟超时 for (int attempt 0; attempt maxRetries; attempt) { reply manager-post(request, postData); QEventLoop loop; connect(reply, QNetworkReply::finished, loop, QEventLoop::quit); loop.exec(); if (reply-error() QNetworkReply::NoError) { QByteArray responseData reply-readAll(); // 解析 JSON 响应检查 success 字段 QJsonParseError jsonErr; QJsonDocument doc QJsonDocument::fromJson(responseData, jsonErr); if (jsonErr.error QJsonParseError::NoError doc.isObject()) { QJsonObject obj doc.object(); if (obj[success].toBool(false)) { emit uploadCompleted(true, obj[message].toString()); reply-deleteLater(); return; } } } if (attempt maxRetries) { int delay qPow(2, attempt) * 1000; // 指数退避1s, 2s, 4s QThread::msleep(delay); qWarning() Retry attempt (attempt 1) after delay ms; } } emit uploadCompleted(false, Max retries exceeded); }5.2 服务端增加 SHA256 校验与文件类型限制# app.py 新增校验逻辑 import hashlib app.route(/upload, methods[POST]) def handle_upload(): # ... 前置解析逻辑不变 ... # 新增计算上传文件 SHA256流式计算不加载全内存 sha256_hash hashlib.sha256() uploaded_file.stream.seek(0) while chunk : uploaded_file.stream.read(8192): sha256_hash.update(chunk) file_hash sha256_hash.hexdigest() # 白名单校验根据 mimetype 或扩展名 mime_type mimetypes.guess_type(safe_name)[0] or application/octet-stream allowed_types {text/plain, application/json, image/png, image/jpeg, application/pdf} if mime_type not in allowed_types: return jsonify({error: fUnsupported MIME type: {mime_type}}), 415 # 保存文件后返回 hash with open(save_path, wb) as f: uploaded_file.stream.seek(0) while chunk : uploaded_file.stream.read(8192): f.write(chunk) return jsonify({ success: True, saved_as: unique_name, original_name: original_name, size: file_size, sha256: file_hash, mime_type: mime_type }), 200客户端收到响应后可比对本地文件 SHA256 与服务端返回值确保传输完整性// 在 onUploadCompleted 中添加校验 QJsonParseError jsonErr; QJsonDocument doc QJsonDocument::fromJson(responseData, jsonErr); if (jsonErr.error QJsonParseError::NoError doc.isObject()) { QJsonObject obj doc.object(); if (obj[success].toBool(false)) { QString serverHash obj[sha256].toString(); QString localHash calculateFileSha256(filePath); // 自定义函数 if (serverHash localHash) { emit uploadCompleted(true, 校验通过 serverHash.left(8) ...); } else { emit uploadCompleted(false, SHA256 校验失败); } } }至此一个具备生产可用性的 Qt 文件上传闭环已完成从界面选择、进度反馈、断点续传预备、服务端安全解析、到哈希校验全部基于 Qt 原生能力与标准 Python 库无外部依赖可直接集成进工业 HMI、设备配置工具或本地运维平台。本文还有配套的精品资源点击获取
返回列表