为什么会有大文件上传
前景:
随着信息技术的飞速发展,各行业的数字化程度越来越高,产生的数据量呈爆发式增长。例如医疗领域的医学影像数据,如 CT、MRI 等图像文件,单个文件大小可能达到数百 MB 甚至数 GB;科研领域的实验数据,像大型强子对撞机产生的海量数据,一次实验的数据量就可能高达数 TB。这些大量的、不断增长的数据需要通过大文件上传的方式存储到更安全、更便于管理的服务器或云端。
多媒体内容的创作和传播日益广泛,高清视频、高分辨率图像和大型音频文件等越来越常见。比如一部 4K 高清电影的大小可能在几十 GB 以上,专业摄影师拍摄的 RAW 格式照片单张也可能达到几十 MB。为了在网络上分享、存储这些多媒体内容,大文件上传功能就显得尤为重要。
大文件上传有什么作用
数据共享与传输:方便团队成员共享大文件 无需物理设备,即可快速将大文件发送给不同地区的用户
数据备份与存储:将大文件上传至云端,防止本地设备故障导致的数据丢失 适合需要长期保存的大文件
内容分发:媒体公司可通过上传大文件发布高清视频、音频等内容 开发者可通过上传大文件发布软件更新或补丁
数据分析与处理:上传大文件至云平台,便于进行大数据分析 上传大文件用于训练机器学习模型,提升模型性能
远程访问与管理:支持远程访问和管理大文件,提升工作效率 实现多设备间的文件同步,便于随时随地访问
用户体验:上传大文件可提供高清视频、图片等,提升用户体验 避免压缩导致的质量损失,保持文件完整性
大文件上传步骤
- 文件分块 :按照大小固定分成多个小块 每块都有一个唯一的标识 (哈希值)用于区分不同的块
const createChunks = (file: File): Chunk[] => { let cur: number = 0; let chunks: Chunk[] = []; while (cur < file.size) { const end = Math.min(cur + CHUNK_SIZE, file.size); // 修复越界问题 //将文件分成多个块 const blob: Chunk = file.slice(cur, end); chunks.push(blob); cur = end; // 使用结束位置作为新起点 } return chunks; };
- 计算哈希值:每个文件都有一个唯一的哈希值用于标识不同文件, 哈希可以判断文件是否已上传
const calculateHash = (chunks: Chunk[]): Promise<string> => { return new Promise((resolve) => { const targets: Chunk[] = []; const spark = new sparkMd5.ArrayBuffer(); //读取内容并计算哈希值 const fileReader = new FileReader(); chunks.forEach((chunk: Chunk, index: number) => { if (index === 0 || index === chunks.length - 1) { targets.push(chunk); } else { targets.push(chunk.slice(0, 2)); targets.push(chunk.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2)); // 修正这里的范围,避免超出边界 targets.push(chunk.slice(CHUNK_SIZE - 2, CHUNK_SIZE)); } }); fileReader.readAsArrayBuffer(new Blob(targets)); fileReader.onload = (e: ProgressEvent<FileReader>) => { spark.append(e.target?.result as ArrayBuffer); resolve(spark.end()); }; }); };
- 分块上传:将每块单独上传到服务器 每块上传时携带文件的哈希值和索引(时间不一样按照时间排序)服务器根据这些存储起来
//前端上传文件块 const uploadChunks = async (chunks: Chunk[], existChunks: string[]) => { const data = chunks.map((chunk: Chunk, index: number) => { return { FileHash: FileHash.current, chunkHash: FileHash.current + "_" + index, chunk }; }); //后端 router.post("/upload", async (req, res) => { const form = new multiparty.Form(); form.parse(req, async (err, fields, files) => { if (err) { res.status(401).json({ ok: false, msg: "上传失败" }); return; } const fileHash = fields.FileHash[0]; const chunkHash = fields.chunkHash[0]; const chunkPath = path.resolve(UPLOAD_DIR, fileHash); if (!fse.existsSync(chunkPath)) { await fse.mkdirs(chunkPath); } const oldPath = files.chunk[0].path; await fse.move(oldPath, path.resolve(chunkPath, chunkHash)); res.status(200).json({ ok: true, msg: "上传成功" }); }); });
- 存储块:接收文件后 存储在临时目录中
//用于构建上传的表单 const formDatas = data.filter((item) => !existChunks?.includes(item.chunkHash)).map((item) => { const formData = new FormData(); formData.append('FileHash', item.FileHash); formData.append('chunkHash', item.chunkHash); formData.append('chunk', item.chunk); return formData; }); const max: number = 6; let i: number = 0; const taskPool: Promise<Response>[] = []; while (i < formDatas.length) { const task = fetch("http://localhost:3000/upload", { method: 'POST', body: formDatas[i] }).then(res => { if (!res.ok) throw new Error(`上传失败: ${res.status}`); return res; }); taskPool.push(task); i++; updateProgress(i, formDatas.length); // 更新上传进度 if (taskPool.length === max) { //控制上传的数量 await Promise.race(taskPool).finally(() => { taskPool.splice(0, taskPool.findIndex(t => t === Promise.race(taskPool))); }); } } await Promise.all(taskPool); // 通知后端合并文件 mergeRequest(); };
- 合并文件:所有的块完成后 前端会通知服务器合并 根据索引顺序将所有块合并成和一个完整的文件
// 通知后端合并文件的函数 const mergeRequest = async () => { return fetch("http://localhost:3000/merge", { method: 'POST', headers: { "content-type": "application/json" }, body: JSON.stringify({ //区分不同的文件 FileHash: FileHash.current, FileName: FileName.current, //切片大小 size: CHUNK_SIZE }) }) .then((res) => { alert("合并成功"); return res; }); }; //后端 router.post("/merge", async (req, res) => { const { FileHash, FileName, size } = req.body; // 如果已经存在该文件,就没必要合并了 合并后的文件路径 const filePath = path.resolve(UPLOAD_DIR, FileHash + extraExt(FileName)); if (fse.existsSync(filePath)) { res.status(200).json({ ok: true, msg: "文件已存在" }); return; } // 如果不存在该文件,才去合并 文件存储目录 const chunkDir = path.resolve(UPLOAD_DIR, FileHash); if (!fse.existsSync(chunkDir)) { res.status(401).json({ ok: false, msg: "文件不存在" }); return; } // 合并操作 并排序 let chunkPaths = await fse.readdir(chunkDir); chunkPaths.sort((a, b) => Number(a.split("_")[1]) - Number(b.split("_")[1])); const list = chunkPaths.map((chunkName, index) => { return new Promise((resolve) => { //读取文件并写入到合并后的文件路径中 const chunkPath = path.resolve(chunkDir, chunkName); const readStream = fse.createReadStream(chunkPath); const writeStream = fse.createWriteStream(filePath, { start: index * size, end: (index + 1) * size }); readStream.on("end", async () => { await fse.unlink(chunkPath); resolve(); }); readStream.pipe(writeStream); }); }); await Promise.all(list); //删除临时文件快目录 await fse.remove(chunkDir); res.status(200).json({ ok: true, msg: "上传成功" }); });
- 断点续传:在上传前发送一个验证 检查文件是否已上传或部分上传 服务器返回已上传的列表 前端只上传未完成的块
// 验证文件是否已存在的函数 //返回验证结果 const verify = async (): Promise<VerifyResponse> => { //发送验证 return fetch("http://localhost:3000/verify", { method: 'POST', headers: { "content-type": "application/json" }, body: JSON.stringify({ FileHash: FileHash.current, FileName: FileName.current, }) }) .then((res) => res.json()) .then((res) => res as VerifyResponse); }; //后端 router.post("/verify", async (req, res) => { const { FileHash, FileName } = req.body; //文件是否存在 const filePath = path.resolve(UPLOAD_DIR, FileHash + extraExt(FileName)); //文件快是否存在 const chunkDir = path.resolve(UPLOAD_DIR, FileHash); //获取已上传的文件快列表 let chunkPaths = []; if (fse.existsSync(chunkDir)) { chunkPaths = await fse.readdir(chunkDir); } if (fse.existsSync(filePath)) { res.status(200).json({ ok: true, data: { shouldUpload: false } }); } else { res.status(200).json({ ok: true, data: { shouldUpload: true, existChunks: chunkPaths } }); } });