在项目上遇到需要上传大文件到服务器的需求,本着好记性不如烂笔头记录(现学现卖😂)的精神,这里就记录一下哈哈哈。
一、上传文件的前端流程
我们在上传大文件中,常用方案是将一个大文件切片成多个小文件,并行请求接口进行上传,上传完成之后,再通知服务器合并。具体来说,操作流程如下图所示。
1.1 从文件分片到md5加密
我们可以通过Md5把文件加密,给定一个唯一的标识,那么这样就可以利用这个标识来查询文件的上传状态了。在前端,有一个流传甚广的md5加密插件,spark-md5,它可以根据文件的内容生成md5值,这样有一个好处,可以通过md5值判断文件是否唯一,防止用户重复上传文件,增加服务器空间压力。
不过,在加密之前,我们首先要把文件进行切割,然后再分片读取进行加密。由文档可知,Blob 对象中的 slice
方法可以对文件进行切割。而File 对象是继承 Blob 对象的,因此 File 对象也有 slice 方法,这个我们会在后面用到的。
好了,我们可以写一个加密的方法了,这里的代码格式参考了spark-md5
的官方文档,感兴趣的可以去阅读一下。我们在这里使用JavaScript的用原生方法,当然,使用vue的话也可以。
在vue中,甚至有一个更好的插件,叫 vue-uploader ,也支持断点续传,秒传等,感兴趣也可以去读读官方Readme.
下面是代码展示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
|
function md5File(file) { return new Promise((resolve, reject) => { var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice; var chunkSize = file.size / 100, chunks = 100, currentChunk = 0, spark = new SparkMD5.ArrayBuffer(), fileReader = new FileReader();
fileReader.onload = function (e) { spark.append(e.target.result); currentChunk++; if (currentChunk < chunks) { loadNext(); } else { console.log("finished loading"); let result = spark.end(); resolve(result); } }; fileReader.onerror = function (e) { console.warn("oops, something went wrong."); reject(e.target.result); }; function loadNext() { var start = currentChunk * chunkSize, end = start + chunkSize >= file.size ? file.size : start + chunkSize;
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end)); } loadNext(); });
|
1.2 与服务器文件进行比对
我们在本地对文件都做了md5加密处理,文件都有了唯一的标识,那么接下来,就对服务器上的文件进行比对,看看是否都有,有的话就不需要上传了,直接给用户一个秒传,当然,缺少的,就需要上传了。
前端代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| function checkFileMD5(fileName, fileMd5Value) { return new Promise((resolve, reject) => { let url = baseUrl + "/check/file?fileName=" + fileName + "&fileMd5Value=" + fileMd5Value; $.getJSON(url, function (data) { resolve(data); }); }); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
|
1 2 3 4 5 6
| let result = await checkFileMD5(file.name, fileMd5Value); if (result.file) { alert("文件已秒传"); return; }
|
1.3 上传分片至服务器
当完成第一步把文件加密,第二步文件进行比对之后,没有出现特别的情况,我们就可以把文件上传至服务器了,在上传之前,我们检查一下返回过来的chunklist
,从而判断是否需要断点续传
。如果不需要,我们进行下一步。
这里就要用到前面我们提到的Blob 对象中silce
方法对文件进行切割。然后分片上传至服务器。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| async function checkAndUploadChunk(fileMd5Value, chunkList) { var chunks = Math.ceil(fileSize / chunkSize); var hasUploaded = chunkList.length; for (let i = 0; i < chunks; i++) { let exit = chunkList.indexOf(i + "") > -1; if (!exit) { let index = await upload(i, fileMd5Value, chunks); hasUploaded++; } } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| { "stat": 1, "chunkList": [ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9" ], "desc": "folder list" }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| function upload(i, fileMd5Value, chunks) { return new Promise((resolve, reject) => { let end =(i + 1) * chunkSize >= file.size ? file.size : (i + 1) * chunkSize; let form = new FormData(); form.append("data", file.slice(i * chunkSize, end)); form.append("total", chunks); form.append("index", i); form.append("fileMd5Value", fileMd5Value); $.ajax({ url: baseUrl + "/upload", type: "POST", data: form, async: true, processData: false, contentType: false, success: function (data) { resolve(data.desc); }, }); }); }
|
1.5 给一个上传进度
对前端来说,有必要把视觉效果搞得好好的,把无交互搞得少少的。我们可监听hasUploaded
和chunks
的值,从而制作上传进度的数值显示条。
1 2 3 4
| let radio = Math.ceil((hasUploaded / chunks) * 100);
|
1. 6 通知服务器进行文件合并
当上传完毕之后,给服务器发送一个合并请求。同时,服务器也会返回来的合并成功通知。
1 2 3 4 5 6 7 8 9 10 11 12 13
| function notifyServer(fileMd5Value) { let url = baseUrl + "/merge?md5=" + fileMd5Value + "&fileName=" + file.name + "&size=" + file.size; $.getJSON(url, function (data) { alert("上传成功"); }); }
|
二、上传文件的后端流程
后端需要对接收到的数据进行处理,例如当时空数据的时候,需要返回相对应的值,从而让前端方便处理。
这里通过node.js
进行演示,大部分是伪码
。
首先,我们要处理前端上传的发送的请求,分别是:
检查服务端是否有该文件
接收发送的文件
合并发送的文件
那么,作为服务器,我们需要按照各个接口的要求,对数据进行处理
- 检查需要上传文件的MD5,看看是否在服务器中存在
- 检查分块,是不是都齐全
- 新上传的文件用新路径存储,并创建文件夹
- 设立缓存目录,将新上传的文件暂存于此
- 排序,准备合并新上传的文件
- 合并文件
- 将合并好的新上传的文件移动到固定的文件路径,删除缓存,告知浏览器上传成功
这里附上部分代码:
响应函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| app.get("/check/file", (req, resp) => { let query = req.query; let fileName = query.fileName; let fileMd5Value = query.fileMd5Value; getChunkList( path.join(uploadDir, fileName), path.join(uploadDir, fileMd5Value), (data) => { resp.send(data); } ); });
|
1 2 3 4 5 6 7 8 9 10 11
| app.all("/merge", (req, resp) => { let query = req.query; let md5 = query.md5; let size = query.size; let fileName = query.fileName; mergeFiles(path.join(uploadDir, md5), uploadDir, fileName, size); resp.send({ stat: 1, }); });
|
1 2 3 4 5
| app.get("/", function (req, resp) { let query = req.query; resp.send("success!!"); });
|
1 2
| app.use(express.static(path.join(__dirname)));
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| app.get("/check/chunk", (req, resp) => { let query = req.query; let chunkIndex = query.index; let md5 = query.md5;
fs.stat(path.join(uploadDir, md5, chunkIndex), (err, stats) => { if (stats) { resp.send({ stat: 1, exit: true, desc: "Exit 1", }); } else { resp.send({ stat: 1, exit: false, desc: "Exit 0", }); } }); });
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| app.all("/upload", (req, resp) => { var form = new formidable.IncomingForm({ uploadDir: "nodeServer/tmp", }); form.parse(req, function (err, fields, file) { let index = fields.index; let total = fields.total; let fileMd5Value = fields.fileMd5Value; let folder = path.resolve(__dirname, "nodeServer/uploads", fileMd5Value); folderIsExit(folder).then((val) => { let destFile = path.resolve(folder, fields.index); copyFile(file.data.path, destFile).then( (successLog) => { resp.send({ stat: 1, desc: index, }); }, (errorLog) => { resp.send({ stat: 0, desc: "Error", }); } ); }); }); });
|
处理函数
1 2 3 4 5 6 7 8 9 10 11 12 13
| function isExist(filePath) { return new Promise((resolve, reject) => { fs.stat(filePath, (err, stats) => { if (err && err.code === "ENOENT") { resolve(false); } else { resolve(true); } }); }); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| async function getChunkList(filePath, folderPath, callback) { let isFileExit = await isExist(filePath); let result = {}; if (isFileExit) { result = { stat: 1, file: { isExist: true, name: filePath, }, desc: "文件已存在", }; } else { let isFolderExist = await isExist(folderPath); console.log(folderPath); let fileList = []; if (isFolderExist) { fileList = await listDir(folderPath); } result = { stat: 1, chunkList: fileList, desc: "文件块已存在", }; } callback(result); }
|
1 2 3 4 5 6 7 8 9 10 11 12
| function listDir(path) { return new Promise((resolve, reject) => { fs.readdir(path, (err, data) => { if (err) { reject(err); return; } resolve(data); }); }); }
|
1 2 3 4 5 6 7 8 9 10 11
| function folderIsExit(folder) { console.log("folderIsExit", folder); return new Promise(async (resolve, reject) => { let result = await fs.ensureDirSync(path.join(folder)); console.log("result----", result); resolve(true); }); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| function copyFile(src, dest) { let promise = new Promise((resolve, reject) => { fs.rename(src, dest, (err) => { if (err) { reject(err); } else { resolve(`复制文件${dest} 成功!`); } }); }); return promise; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| async function mergeFiles(srcDir, targetDir, newFileName, size) { console.log(...arguments); let targetStream = fs.createWriteStream(path.join(targetDir,newFileName)); let fileArr = await listDir(srcDir); fileArr.sort((x, y) => { return x - y; }); for (let i = 0; i < fileArr.length; i++) { fileArr[i] = srcDir + "/" + fileArr[i]; } concat(fileArr, path.join(targetDir, newFileName), () => { console.log("合并成功。"); }); }
|
总结
- Blob.slice 将文件切片,并发上传多个切片,所有切片上传后告知服务器合并,实现大文件分片上传;
- 通过spark-md5 根据文件内容算出文件 MD5,得到文件唯一标识,与文件上传状态绑定;
- 分片上传前通过文件 MD5 查询已上传切片列表,上传时只上传未上传过的切片,实现断点续传。
最后,附上前后端代码地址
https://github.com/Chuyuxuan0v0/bigFileUpload ,
需要的小伙伴可自行研究,代码稀烂,大佬们轻点喷