简介:面向Python Web开发者的分片上传与下载演示项目,后端基于Flask框架,前端采用WebUploader组件,可实现大文件分段上传、服务器端合并以及低内存流式下载,适合学习文件传输处理、内存控制和并发场景的工程师。压缩包共26个文件,包含两处Python源码(服务入口与上传模块)、两个HTML模板页面,以及Bootstrap、WebUploader所需的CSS、JavaScript、字体和映射文件,整体仅427KB,目录结构清晰,能快速定位前后端代码和静态资源。该项目已有651人浏览学习,示例代码完整展示了分片上传、分片合并、流式下载等关键逻辑,还处理了多用户同时上传互不干扰的可靠机制;说明文档还特别指出未来可优化方向,即下载时按序直接重新合并分片回传,而不先在服务端拼接全部分片,从而进一步降低内存占用。对研究大文件传输场景、希望复用成熟前后端交互方案的开发者有直接参考价值。
1. 为什么大文件上传总翻车:分片不是可选项,是必选项
折腾过文件上传的人应该都有过这种血压拉满的时刻:一个 2GB 的压缩包,进度条稳如老狗地爬到 97%,然后浏览器给你弹一个“网络错误”,后端日志里连个影子都没有。一怒之下重传,服务器又开始新一轮的磁盘和带宽消耗。这不是网络玄学,而是单次 HTTP 请求承载大文件时,稳定性天然脆弱——连接超时、代理断开、内存溢出,任何一个环节抖动,整趟上传就白搭。分片上传(fragmentation / chunked upload)就是冲着这个痛点去的:把大文件切成若干个 2MB~5MB 的小块,失败时只重传失败的那片,而不是从头再来。这份upload-demo做的事情很纯粹——用 Flask 做后端接收分片,用 WebUploader 做前端切片和并发控制,再用 Bootstrap 套一层能直接点着玩的前端页面。适合正在做私有网盘、内部系统、后台管理项目里需要接收大文件的人,也适合想搞懂“分段上传前后端到底怎么对齐”的人。下面我会把它的工作流、三个核心接口、前端参数权衡和常见坑拆开讲清楚。
2. 选型与工作流:WebUploader 如何与 Flask 配合处理分段上传
2.1 为什么是 Flask + WebUploader,而不是其他组合
我没选 Django 系或者 Spring 系来拆这个 Demo,不是它们不行,而是 Flask 在处理分片上传这类“路由少、逻辑集中”的场景里,性价比高得离谱。你不需要 ORM、不需要 Admin 后台、不需要中间件体系,只要几个路由接收上传请求、读写文件目录就够了。Flask 的request.files和request.form天然就能解析 WebUploader 提交的 multipart 表单,几乎没有磨合成本。
WebUploader 这边,它的价值在于把前端分片这件事封装成了“配置项”而不是“算法”。你不用自己写 FileReader 切片的逻辑,也不用自己管理 Blob 的合并,它默认就支持:
- 按
chunkSize自动切割文件 - 多个分片并发上传(
threads控制并发数) - 失败分片单独重试
- 通过
formData追加自定义参数(后端要的标识符就靠它传递)
Bootstrap 只承担最外层的页面样式,让你打开 demo 页面时不至于对着裸 HTML 怀疑人生。它的存在不参与上传逻辑,但能让整个 demo 看起来像个能交付的东西。这是这类“演示项目”里很实用的取舍——把精力放在上传链路上,UI 用现成框架搭个壳。
2.2 一次完整的分片上传,数据是怎么流的
我习惯先把数据流向梳理清楚,再去看代码。整个流程是这样一条链路:
- 用户在页面选中文件,WebUploader 读取文件基本信息(文件名、大小、类型),计算总大小。
- 前端按
chunkSize把文件切成chunk个数个分片,每个分片就是一个独立的 Blob。 - 第一个分片开始上传时,通过
formData携带自定义参数identifier(我习惯用文件名的 MD5 值或者时间戳加随机串),以及 WebUploader 自动带上来的chunk(当前分片序号)和chunks(总分片数)。 - 后端收到每个分片后,不直接写进目标文件,而是先落到以
identifier命名的临时目录里,存成chunk_0、chunk_1这样的分片文件。 - 当前端最后一个分片上传完成后,触发
merge接口,后端读取该identifier对应临时目录下的所有分片,按顺序拼接成完整文件。 - 拼接完成后,校验文件大小是否等于前端上报的
size,通过则删除临时分片,返回成功。
我拆过不少上传组件,这里最容易翻车的点是第 4 步和第 5 步:后端怎么命名分片文件、合并时怎么排序,前后端必须达成默契。WebUploader 的formData里不会给你排序用的fileIndex,只有chunk字段,所以后端必须用chunk作为文件名的一部分,合并时按它的数值排序,而不是按字典序。这个细节我在第 5 章会单独当坑讲。
3. 后端三个接口与合并逻辑:路由设计、参数含义和状态机
3.1 接收分片的 upload 路由:临时目录与分片落盘
后端是整个分片上传的核心承重墙,前端切得再漂亮,后端接不住全白搭。我拆这份 demo 时,把后端逻辑拆成了三个独立的接口:/upload接收分片、/merge合并文件、/check查询状态。先看接收分片的实现:
import os import uuid from flask import Flask, request, jsonify app = Flask(__name__) UPLOAD_DIR = os.path.join(os.getcwd(), "uploads") CHUNK_DIR_BASE = os.path.join(os.getcwd(), "chunks") os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(CHUNK_DIR_BASE, exist_ok=True) @app.route("/upload", methods=["POST"]) def upload_chunk(): # WebUploader 分片上传时,文件字段名默认是 file,可通过 fileVal 修改 file = request.files.get("file") if file is None: return jsonify({"status": "error", "message": "no file part"}), 400 # identifier 是自定义上传标识,我习惯用 uuid 或前端算出的 md5 identifier = request.form.get("identifier", str(uuid.uuid4())) chunk_index = int(request.form.get("chunk", 0)) total_chunks = int(request.form.get("chunks", 1)) # 每个 identifier 独立临时目录,避免不同文件的切片互相污染 chunk_dir = os.path.join(CHUNK_DIR_BASE, identifier) os.makedirs(chunk_dir, exist_ok=True) # chunk 字段是当前分片的序号,必须按它来命名,合并时才能正确排序 chunk_path = os.path.join(chunk_dir, f"chunk_{chunk_index}") file.save(chunk_path) return jsonify({ "status": "success", "identifier": identifier, "chunk_index": chunk_index, "total_chunks": total_chunks }), 200这段代码里有两个点值得关注。第一是identifier的生成策略:demo 里默认用uuid.uuid4()生成,但真实场景我一般会让前端把文件名哈希或用户 ID 传过来,方便后续做“秒传”,后面进阶部分会展开。第二是分片文件的命名,chunk_{chunk_index}看起来简单,但它是合并环节排序正确与否的关键。
分片存储用file.save(chunk_path)这个 Flask 自带的保存方式,它会把上传流完整写到磁盘,不会出现只写一半的情况。这里不需要你手动做read()和write(),因为file.save内部已经处理了流式读取,对内存占用很友好。
3.2 合并文件的 merge 路由:排序与拼接的正确姿势
分片全部到齐后,前端会调用/merge接口触发合并。这个接口决定最终文件能不能用,我见过太多在这里把sorted()字典序排序导致文件损坏的例子。正确实现:
@app.route("/merge", methods=["POST"]) def merge_chunks(): identifier = request.form.get("identifier") if not identifier: return jsonify({"status": "error", "message": "identifier missing"}), 400 original_name = request.form.get("original_name", "download.bin") chunk_dir = os.path.join(CHUNK_DIR_BASE, identifier) if not os.path.isdir(chunk_dir): return jsonify({"status": "error", "message": "chunk dir not found"}), 404 chunk_files = os.listdir(chunk_dir) # 按 chunk_ 后的数字排序,而不是按文件名排序! # sorted(["chunk_0", "chunk_10", "chunk_2"]) 默认结果 0/10/2,顺序是错的 def chunk_sort_key(name): return int(name.split("_")[1]) chunk_files.sort(key=chunk_sort_key) # 拼接所有分片到目标文件,使用二进制追加模式 final_path = os.path.join(UPLOAD_DIR, f"{identifier}_{original_name}") with open(final_path, "wb") as final_file: for chunk_name in chunk_files: chunk_path = os.path.join(chunk_dir, chunk_name) with open(chunk_path, "rb") as chunk_file: final_file.write(chunk_file.read()) chunk_file.close() # 合并完成后清理临时分片目录,释放磁盘空间 shutil.rmtree(chunk_dir) return jsonify({ "status": "success", "file_path": final_path, "size": os.path.getsize(final_path) }), 200排序这块是血泪经验。os.listdir返回的文件名是字符串,"chunk_10"排在"chunk_2"前面,如果直接按字符串排,合并出来的文件大概率是尾部数据跑到前面,解压直接报错。chunk_sort_key这个函数把chunk_10转成数字10,再按数字排序,才能保证顺序正确。
合并时用chunk_file.read()把整个分片读进内存再写入,对单分片 5MB 以下没问题;但如果你的分片调大到了 50MB,建议换成shutil.copyfileobj(chunk_file, final_file, length=1024*1024)或者手动while True: data = chunk_file.read(8192)去循环读写,避免内存被大分片撑爆。
3.3 状态查询路由 check:再传一个完整文件时怎么识别
/check路由不是 WebUploader 默认会调用的,但我在实际项目里一定会加它,用途是让前端在开始上传之前先问一句:“这个文件我是不是传过了?”实现逻辑:
@app.route("/check", methods=["POST"]) def check_file(): identifier = request.form.get("identifier") if not identifier: return jsonify({"status": "error", "message": "identifier missing"}), 400 # 在目标目录里查找是否有这个标识符对应的完整文件 for filename in os.listdir(UPLOAD_DIR): # 文件名格式是 {identifier}_{original_name},这里用前缀匹配 if filename.startswith(identifier): target_file = os.path.join(UPLOAD_DIR, filename) return jsonify({ "status": "success", "existing": True, "file_name": filename, "size": os.path.getsize(target_file) }), 200 return jsonify({"status": "success", "existing": False}), 200这个路由的价值在于:当前端在formData里把identifier固定为文件内容的哈希值时,同一个文件再次被选中,前端可以先请求/check,后端发现已经存在同名文件,直接告诉前端“无需上传”,前端就假装上传完成。这就是大文件上传组件里常见的“秒传”逻辑。demo 里如果没有这一步,你可以在上面代码的基础上自己补上,成本非常低。
4. 前端接入与并发控制:chunkSize、threads 和重试的权衡
4.1 初始化 WebUploader:核心参数逐一拆解
后端三个接口准备好之后,前端的关键是把参数对齐。WebUploader 的初始化代码里,chunkSize、threads、fileVal这三个参数决定了上传行为,我逐个拆给你看:
var uploader = WebUploader.create({ // 上传接口地址 server: '/upload', // 选择文件的按钮 pick: '#picker', // 文件上传表单名,后端用 request.files.get('file') 取的字段 fileVal: 'file', // 是否开启分片上传,大文件必开 chunked: true, // 每个分片的大小,单位是字节,这里设为 5MB chunkSize: 5 * 1024 * 1024, // 并发上传线程数,根据服务器承受能力调整 threads: 3, // 开启文件去重,同一文件不重复上传 duplicate: true, // 客户端内存只保留分片数据,不整个文件驻留 formData: { 'identifier': '' } });参数层面,chunkSize是最值得斟酌的。5MB 是我在多数部署环境下的默认值,太大了(比如 50MB)分片上传退化成单文件上传,一遇到网络抖动整个分片重传;太小了(比如 512KB)会导致 HTTP 请求数量暴增,对后端文件读写压力反而更大。我的经验是:局域网内部系统可以调到 10MB,公网环境建议 2MB~5MB。threads设置成 3 是 WebUploader 的默认值,并发意味着同时会有 3 个分片在飞,对 Flask 这类同步框架来说已经有一定压力,不要一上来就调成 10。
formData里的identifier在这里是个占位符,真正的值需要在文件被添加之后动态灌入。这就是下一部分要处理的事件逻辑。
4.2 事件回调里的秘密:identifier 赋值与分片进度
WebUploader 的事件机制是你理解它的第二把钥匙。fileQueued在文件进入队列时触发,这时候我们能拿到文件对象,算好identifier并塞进formData。uploadProgress控制进度条,uploadAccept判断后端是否接受当前分片:
uploader.on('fileQueued', function (file) { // 用文件的名称加时间戳生成一个唯一标识 // 如果想要秒传,可以在这里改用 SparkMD5 计算文件哈希 var identifier = file.name + '_' + new Date().getTime(); uploader.options.formData.identifier = identifier; // 把这个 identifier 塞给当前文件,后续所有分片请求都会携带 file.identifier = identifier; // 显示文件信息到页面上 $('#file-name').text(file.name); $('#file-size').text(file.size); }); uploader.on('uploadProgress', function (file, percentage) { // 这个接口的值范围是 0 到 1,直接赋给 Bootstrap 进度条 $('#progress-bar').css('width', percentage * 100 + '%'); }); uploader.on('uploadAccept', function (file, response) { // 后端返回 status 不是 success 时,返回 false 让该分片进入重试队列 if (response.status !== 'success') { return false; } }); uploader.on('uploadComplete', function (file) { // 所有分片传完后,手动触发合并请求 $.ajax({ url: '/merge', method: 'POST', data: { identifier: file.identifier, original_name: file.name }, success: function (res) { $('#status').text('上传成功,合并文件大小:' + res.size); } }); });注意uploadAccept这个回调的返回值,是 WebUploader 用来判断当前分片要不要重试的关键。回调里return false会让该分片进入失败队列并自动重试;返回true才认为这片上传成功。不少开发者忽略了这个回调,后端返回 500 时前端还一脸茫然地继续传下一个分片,等全部传完去 merge,才发现分片缺胳膊少腿。
uploadComplete触发的时机是“所有分片都上传成功”,注意这里是前端视角,后端的分片可能还没完全落盘。稳妥做法是在uploadComplete里加个setTimeout延时 500ms 再调/merge,给后端一点处理时间。虽然我这个写法直接调,但实际部署时建议加延时。
4.3 Bootstrap 页面的拼装:静态资源位置别放错
demo 的页面结构是三件套:Bootstrap 的 CSS、WebUploader 的 JS 和 CSS、自己写的上传业务逻辑。目录结构我一般这样摆:
upload-demo/ ├── app.py # Flask 后端,上面三个路由 ├── uploads/ # 合并完成的文件目录 ├── chunks/ # 分片临时目录 ├── static/ │ ├── bootstrap/ │ │ ├── css/bootstrap.min.css │ │ └── js/bootstrap.min.js │ ├── webuploader/ │ │ ├── webuploader.css │ │ ├── webuploader.js │ │ └── Uploader.swf # 兼容老浏览器的 Flash 模式,可忽略 │ └── jquery/ │ └── jquery.min.js ├── templates/ │ └── index.htmlHTML 里引用静态资源时,WebUploader要求swf路径单独配置,如果你不在 HTML5 模式下工作,必须把它指向正确的Uploader.swf位置。我拆过很多类似项目,最常见的问题是:开发者把webuploader.css漏了,导致选择按钮的样式完全错乱,明明功能正常但界面看起来像没加载成功。
Bootstrap 在这里只负责撑场面,把按钮、进度条、状态文字这些元素摆得整齐一些。真正干活的是webuploader.js和 jQuery。如果后期你想要文件列表、上传历史、拖拽上传,Bootstrap 的组件生态也能顺手补齐,但它不是分片上传的必要条件——WebUploader 本身不依赖 Bootstrap。
5. 避坑清单:WebUploader + Flask 组合的六个常见翻车点
5.1 合并后的文件损坏:祸根是分片文件名排序
现象:所有分片上传都提示成功,合并接口也返回了 200,但下载下来的文件打不开,图片只显示上半截,压缩包提示“文件已损坏”。
原因:合并时用了sorted()对文件名做字典序排序,chunk_10排在chunk_2前面,分片被错误拼接。
解决:按数字值排序。代码里用int(name.split("_")[1])作为排序 key。这是我在第 3.2 节里反复强调的点,也是我见过翻车率最高的一处。
5.2 同一个文件上传两次,第二次全是脏数据
现象:上传同一个文件,第一次成功,第二次选中后还没传完,后端就报文件已存在或者合并出来的文件大小不对。
原因:没有动态生成identifier,第二次用了相同的标识,临时目录里残留了上次的分片,新分片和旧分片混在一起。
解决:前端在fileQueued里用“文件名 + 时间戳”生成identifier,或者更好的是用文件内容的哈希值,结合后端/check路由实现秒传。临时目录在合并完成后要立即删除(shutil.rmtree),避免磁盘被废弃分片堆满。
5.3 前端页面和 Flask 不在一个端口,跨域把你卡死在“待上传”
现象:页面跑在 127.0.0.1:8080,后端是 127.0.0.1:5000,点完上传按钮后,浏览器控制台一堆 CORS 错误,WebUploader 的队列状态变成 error。
原因:Flask 默认不允许跨域资源共享,浏览器的同源策略拦截了分片上传请求。
解决:后端加一个简单的 CORS 中间件,我一般用flask-cors扩展,两三行搞定:
from flask_cors import CORS CORS(app, supports_credentials=True)如果不想引入扩展,也可以用 Flask 的after_request钩子手动添加三个响应头。这个问题的陷阱在于,前端请求能发出去,后端能收到,但浏览器不把响应交给 JS,所以uploadAccept收不到response.status,你会看到一种“请求成功但回调无效”的诡异现象——绝大多数是 CORS。
5.4 所有分片上传成功,但服务器上一个分片都没存下来
现象:页面显示上传进度到了 100%,后端/upload接口也打到了 200,但chunks/目录是空的,或者只看到几个 0 字节的文件。
原因:request.files.get("file")取不到文件,说明前端传的字段名和后端期望的不一致。WebUploader 默认fileVal是'file',如果后端写成了request.files.get('upload_file'),分片数据被丢弃。
解决:把后端的字段名改成'file',或者在前端初始化时把fileVal改成和后端一样的名字。我每次排查这类问题时,会先在后端加一行print(request.files.keys()),看一眼实际收到的字段名是什么,而不是凭记忆猜。
5.5 合并时后端内存飙升,文件一大就崩溃
现象:上传 4GB 的文件,合并到一半,后端进程内存占用冲到 2GB 以上,程序直接 OOM 崩溃。
原因:合并逻辑用了chunk_file.read()把整个分片读进内存,分片如果设置得太大(比如 50MB),加上 Flask 的调试模式缓存,内存瞬间吃满。
解决:改用流式复制。Python 标准库的shutil.copyfileobj就是干这个的:
with open(final_path, "wb") as final_file: for chunk_name in chunk_files: with open(os.path.join(chunk_dir, chunk_name), "rb") as cf: shutil.copyfileobj(cf, final_file, length=1024 * 1024)每次只读 1MB、写 1MB,内存占用会稳定在极低水平。这条在任何处理大文件的系统里都适用。
5.6 分片全传完了,Nginx 却报 413 Request Entity Too Large
现象:后端直连的时候上传一切正常,一放到 Nginx 反代后面,分片上传第一次就失败,错误码 413。
原因:Nginx 默认client_max_body_size是 1MB,单个分片超过这个值就会被拦截。
解决:在 Nginx 配置里调大这个参数:
server { client_max_body_size 20m; }注意这里设置的是单个分片的最大体积,不是整个文件的最大体积。如果chunkSize是 5MB,把client_max_body_size设成 20MB 已经留足余量。
6. 进阶技巧:用 MD5 指纹替换随机 identifier,顺带实现秒传
这份 demo 的核心链路已经完整,但如果你要把它用到真实项目中,还缺一个重要拼图:文件唯一标识目前用的是“文件名 + 时间戳”,它只能保证每次上传的 identifier 不同,却没法识别同一个文件重复上传。要让系统具备秒传能力,需要把 identifier 换成文件内容的哈希值(通常是 MD5),让相同内容的文件在首次上传后直接返回“已存在”。
WebUploader 官方对 MD5 计算是这样做:开启calculateHash后,组件会逐块读取文件内容算 MD5,在md5回调里拿到值。代码层面:
uploader.on('md5', function (file, md5) { // 这个回调在所有分片上传前触发,拿到的 md5 就是文件内容指纹 uploader.options.formData.identifier = md5; file.identifier = md5; }); uploader.on('fileQueued', function (file) { // 先请求后端 /check 接口 $.ajax({ url: '/check', method: 'POST', data: { identifier: file.name + '_' + file.size }, success: function (res) { if (res.existing) { // 后端已存在相同 identifier 的文件,直接标记为完成 uploader.skipFile(file); $('#status').text('文件已存在,秒传完成'); } else { uploader.options.formData.identifier = file.name + '_' + file.size; } } }); });这里有个权衡要讲清楚:计算一个 2GB 文件的 MD5,需要完整读取一遍磁盘,耗时可能在十几秒到一分钟不等。这是“秒传”付出的前置成本——省去了上传时间,但增加了计算时间。我的习惯是:文件小于 200MB 直接算 MD5,大于 200MB 时退化为“文件名 + 大小”作为 identifier,秒传命中率略降,但避免了用户选完文件还要等一分钟才看到进度条。
后端/check路由里,我第 3.3 节给的是“按 identifier 前缀匹配目标文件名”,在秒传场景下需要再确认一点:合并后的文件名规则必须是{identifier}_{original_name},这样当前端传来的 identifier 是 MD5 时,后端能直接通过文件名定位到已存在的文件,然后返回existing: true。
这套补全逻辑做完后,整个 upload-demo 才算从一个“能跑的分片上传界面”变成一个“具备断点续传和秒传能力的大文件传输系统”。回头看我拆过的那些项目,凡是出现“目标文件损坏”又找不到原因的,九成
本文还有配套的精品资源,点击获取