文件分片上传设计

shigen日更文章的博客写手,擅长Java、python、vue、shell等编程语言和各种应用程序、脚本的开发。记录成长,分享认知,留住感动。

现在是接近凌晨了,突然有伙伴给我提到了文件分片上传的事情,我一想,这个我熟悉呀。因为在若干月前,我想亲手写了这部分的代码,还给自己整理出了飞书文档。对,一看文件,原来是遥远的2023年6月20日

其实说分片上传,原理很简单,就是前端分片、上传,后端的解析合并。其实半句话就可以讲清楚,但是代码实现起来要花很大的功夫。

今天的代码案例shigen选取的是node.js作为后端服务写的文件上传。

我们先来看一下实现的效果:

整体的传输效果很快,会在文件夹里存储分片,在所有的分片上传完毕之后,整合成一个文件。我可以直接的打开和预览。

那代码怎么设计的呢?这是个核心的问题。一起来和shigen看看吧。

代码设计

前端

文件名为index.html

xml 复制代码
 <html lang="en">
 ​
 <head>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <title>file-upload</title>
 </head>
 ​
 <body>
     <input type="file" onchange="selFile(event)" />
     <script src="https://cdn.bootcdn.net/ajax/libs/axios/1.3.4/axios.min.js"></script>
 ​
     <script>
         // default size: 0.5MB
         function createThunk(file, size = 1024 * 1024 * 0.5) {
             const res = [];
             let cur = 0
             while (cur < file.size) {
                 res.push({
                     tempFile: file.slice(cur, cur + size),
                 });
                 cur += size;
             }
             return res;
         }
 ​
         function selFile(event) {
             const file = event.currentTarget.files[0];
             const fileList = createThunk(file);
             console.log(file);
             // console.log(fileList);
             // 发送请求, uuid作为文件名
             // const uuid = crypto.randomUUID(); // Uncaught TypeError: crypto.randomUUID is not a function
             const uuid = file.name;
             const uploadList = fileList.map((item, index) => {
                 const formData = new FormData();
                 // formData includes chunk,name, filename
                 formData.append('chunk', item.tempFile);
                 formData.append('name', uuid + "_" + index);
                 formData.append('filename', uuid);
                 return axios.post('/upload_file_thunk', formData);
             });
             // after all files are uploaded
             Promise.all(uploadList).then((res) => {
                 console.log('upload success');
                 axios.post('/upload_thunk_end', {
                     filename: uuid,
                     extname: file.name.split('.').slice(-1)[0],
                 }).then((res) => {
                     console.log(res.data);
                 });
             });
         }    
     </script>
 </body>
 ​
 </html>

前端部分的代码分析如下:

  1. 异步的网络请求-上传文件选取的是axios作为工具,很符合promise风格,写起来也丝滑友好;
  2. 采用了输入框的失去焦点事件,失去焦点即上传文件。文件根据规定的大小0.5MB分块,用UUID+文件分片序号作为新的文件标识,异步的调用分片上传文件的接口
  3. 当所有的分片上传完毕之后,调用合并文件的接口,实现文件的合并。

是不是顿时感觉so easy了。我们再来看看后端的代码。

后端

文件名为:app.js

ini 复制代码
 const express = require('express');
 const multiparty = require('multiparty');
 const fs = require('fs');
 const path = require('path');
 ​
 const app = express();
 ​
 app.use(express.json());
 app.use('/', express.static('./public'));
 ​
 app.post('/upload_file_thunk', (req, res) => {
     const form = new multiparty.Form();
     form.parse(req, (err, fields, files) => {
         if (err) {
             res.json({
                 code: 0,
                 data: {},
             });
         } else {
             // save chunk files
             console.log(fields);
             fs.mkdirSync('./public/uploads/thunk/' + fields['filename'][0], {
                 recursive: true
             });
             // move
             console.log('files', files);
             fs.renameSync(files['chunk'][0].path, './public/uploads/thunk/' + fields['filename'][0] + '/' + fields['name'][0]);
             res.json({
                 code: 1,
                 data: '分片上传成功',
             });
         }
     });
 });
 ​
 /**
  * 文件合并
  * @param {*} sourceFiles 源文件
  * @param {*} targetFile  目标文件
  */
 function thunkStreamMerge(sourceFiles, targetFile) {
     const thunkFilesDir = sourceFiles;
     const list = fs.readdirSync(thunkFilesDir); // 读取目录中的文件
 ​
     const fileList = list
         .sort((a, b) => a.split('_')[1] * 1 - b.split('_')[1] * 1)
         .map((name) => ({
             name,
             filePath: path.resolve(thunkFilesDir, name),
         }));
     const fileWriteStream = fs.createWriteStream(targetFile);
     thunkStreamMergeProgress(fileList, fileWriteStream, sourceFiles);
 }
 ​
 /**
  * 合并每一个切片
  * @param {*} fileList        文件数据
  * @param {*} fileWriteStream 最终的写入结果
  * @param {*} sourceFiles     文件路径
  */
 function thunkStreamMergeProgress(fileList, fileWriteStream, sourceFiles) {
     if (!fileList.length) {
         // thunkStreamMergeProgress(fileList)
         fileWriteStream.end('完成了');
         // 删除临时目录
         // if (sourceFiles)
         //     fs.rmdirSync(sourceFiles, { recursive: true, force: true });
         return;
     }
     const data = fileList.shift(); // 取第一个数据
     const { filePath: chunkFilePath } = data;
     const currentReadStream = fs.createReadStream(chunkFilePath); // 读取文件
     // 把结果往最终的生成文件上进行拼接
     currentReadStream.pipe(fileWriteStream, { end: false });
     currentReadStream.on('end', () => {
         // console.log(chunkFilePath);
         // 拼接完之后进入下一次循环
         thunkStreamMergeProgress(fileList, fileWriteStream, sourceFiles);
     });
 }
 ​
 // 合并切片
 app.post('/upload_thunk_end', (req, res) => {
     const fileName = req.body.filename;
     const extName = req.body.extname;
     const targetFile = './public/uploads/' + fileName + '.' + extName;
     thunkStreamMerge('./public/uploads/thunk/' + fileName, targetFile);
     res.json({
         code: 1,
         data: targetFile,
     });
 });
 ​
 ​
 function getLocalIP() {
     const os = require('os');
     //获取本机ip
     var interfaces = os.networkInterfaces();
     for (var devName in interfaces) {
         var iface = interfaces[devName];
         for (var i = 0; i < iface.length; i++) {
             var alias = iface[i];
             if (alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal) {
                 return alias.address;
             }
         }
     }
 }
 ​
 ​
 port = 9000;
 ​
 const ip = getLocalIP();
 console.log('ip', ip);
 app.listen(port, () => console.log(`server running on ${getLocalIP()}:${port}......`));
 ​

这个代码就有点多了,115行,但是都是对应着后端的操作,并提供http服务。

shigen从分析每一个接口开始:

  1. /:主要是代理到public文件夹下,展示index.html,即我们上边的代码;
  2. upload_file_thunk:主要就是上传分片,并把分片从系统的某个空间转移到我们约定的目录之下
  3. upload_thunk_end: 主要就是合并我所有的分片了。它会调用我上边定义的方法,递归的拼接文件
  4. 最后的getLocalIP是我调用锡荣的工具类实现获得局域网下我的电脑IP地址,实现内网的相互访问和文件共享。岂不是很nice、smart!

那我启动起来就是一个命令即可:

复制代码
 node app.js

浏览器访问输出的IP+端口即可。

后记

最近突然有了一种偏见,这些设计完全都是没用的。因为仙子云服务这么成熟的了,对象存储这么成熟了,谁还成天研究这些东西。我们以腾讯云的对象存储COS为例子,我们看看腾讯云COS操作文档

作为云服务提供厂商,它已经帮我们想好了遇到的各种情况,甚至把相应的API设计好了。我们再去想破头实现,显得是那么的无意义。因为在云时代,我们更关注的是效率的提升和业务的增长。作为云服务厂商,它给我们提供了广大的平台,我们只需要拿来即用即可。

也希望每个企业,无论是国企、还是小公司、外包,拥抱云时代,别再花心思自研一些虚无的东西。业务的增长才是硬实力。


以上就是今天分享的全部内容了,觉得不错的话,记得点赞 在看 关注支持一下哈,您的鼓励和支持将是shigen坚持日更的动力。同时,shigen在多个平台都有文章的同步,也可以同步的浏览和订阅:

平台 账号 链接
CSDN shigen01 shigen的CSDN主页
知乎 gen-2019 shigen的知乎主页
掘金 shigen01 shigen的掘金主页
腾讯云开发者社区 shigen shigen的腾讯云开发者社区主页
微信公众平台 shigen 公众号名:shigen

shigen一起,每天不一样!

相关推荐
程序员爱钓鱼32 分钟前
Go语言实战案例-创建模型并自动迁移
后端·google·go
javachen__37 分钟前
SpringBoot整合P6Spy实现全链路SQL监控
spring boot·后端·sql
uzong6 小时前
技术故障复盘模版
后端
GetcharZp7 小时前
基于 Dify + 通义千问的多模态大模型 搭建发票识别 Agent
后端·llm·agent
桦说编程7 小时前
Java 中如何创建不可变类型
java·后端·函数式编程
IT毕设实战小研7 小时前
基于Spring Boot 4s店车辆管理系统 租车管理系统 停车位管理系统 智慧车辆管理系统
java·开发语言·spring boot·后端·spring·毕业设计·课程设计
wyiyiyi7 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
阿华的代码王国8 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
Jimmy8 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
AntBlack9 小时前
不当韭菜V1.1 :增强能力 ,辅助构建自己的交易规则
后端·python·pyqt