大文件切片上传、断点续传
因为前几天在做h5音乐播放器的时候需要上传MP3文件,就想到之前面试有问过大文件的上传,所以就着手实现了一个,演示地址:https://www.zsp.cool/ls
gitee仓库:https://gitee.com/zhangshengpengBXH/vue-musec
手机扫码:
一、前后端大致工作:
1.1 前端部分:
- 首先前端计算出读取到的大文件的md5,然后对文件进行切片,使用md5+切片序号来命名,然后将切片上传至后端,由于大文件计算md5相当耗时,在主线程操作可能会造成页面假死,所以将md5的计算放到WebWorker线程;
- 而断点续传的实现依赖于切片的上传进度,如果上传在中途中断,则已上传成功的切片无需再次上传,所以需在每次切片上传前,检查后端是否已存在该切片,若存在则跳过;
- 当所有切片上传完毕后,通知后端合并切片;
1.2 后端部分:
- 需要实现根据切片路径及名称查找该切片是否存在的接口;
- 根据md5动态生成文件夹,将该文件的所有切片保存在该目录下;
- 收到切片全部上传完毕的通知后,读取切片生成原文件,删除切片及文件夹;
下面开始具体实现,先说说一下前后端用到的相关工具吧
前端:vue+axios,spark-md5
后端:node+express,multer
二、具体实现
2.1前端部分:
关于webWorker的创建,暂时没找到什么好方法,这里我直接将初始化webWorker的方法挂载到了window上,html模板文件里:
<script> window.worker = null window.createWorker = () => { window.worker = new Worker('./js/webWorker.js') } </script>
spark-md5的使用可以去掘金搜索
把spark-md5.min.js拷贝到了public/js目录下,并在该目录创建webWorker.js:
self.importScripts('./spark-md5.min.js') self.addEventListener('message', ({ data }) => { const spark = new self.SparkMD5.ArrayBuffer() const getMd5 = files => { const reader = new FileReader() reader.readAsArrayBuffer(files.shift().file) reader.onload = e => { spark.append(e.target.result) if (files.length) { getMd5(files) } else { self.postMessage({ hash: spark.end() }) } } } getMd5(data) })
其中data为传入的文件切片数组,循环调用getMd5直至遍历完所有切片,向主线程发送计算得出的hash
界面部分,先获取文件,对文件进行切片,将切片传递给webWorker,切片利用file.slice实现:
<input id="file" value="file" type="file" @change="handlefiles" v-show="false" accept="audio/mp3" multiple/> data () { return { file: null, fileList: null, chunckList: [] } }, methods: { createChunkList (file) { const fileChunkList = [] const chunkSize = 1024 * 1024 * 40 // 切片大小为40MB,大家随意 let cur = 0 while (cur < file.size) { fileChunkList.push({ file: file.slice(cur, cur + chunkSize), percent: 0 }) cur += chunkSize } return fileChunkList } handlefiles (e) { window.createWorker() this.fileList = Array.from(e.target.files) this.file = this.fileList[0] this.chunckList = this.createChunkList(this.fileList.shift()) window.worker.postMessage(this.chunckList) } }
handleFiles中还需要在接收到webWorler线程的md5之后,验证后端是否已有该切片,以及将未发送的切片和md5信息上传至后端:
window.worker.onmessage = ({ data }) => { const promiseList = [] this.chunckList.forEach((item, index) => { promiseList.push(new Promise((resolve, reject) => { this.$axios.post('/chunckAlready', { name: data.hash, hash: `${data.hash}-${index}` }).then(res => { if (res.data.already === false) { // 后端不存在该切片,上传切片 const formData = new FormData() formData.append('hash', `${data.hash}-${index}`) formData.append('name', `${data.hash}`) formData.append(this.fileName, item.file) this.$upload.post(this.baseUrl, formData, { onUploadProgress: progressEvent => { this.chunckList[index].percent = (progressEvent.loaded / progressEvent.total * 100 | 0) } }).then(res => { resolve(res) }) } else { this.chunckList[index].percent = 100 resolve() } }) })) }) Promise.all(promiseList).then(() => { // 全部切片上传完毕,通知后端合并切片 this.$axios.post('/merge', { hash: data.hash, fileName: `${data.hash}-${this.file.name}` }).then((res) => { this.$emit('finish', res.data) if (this.fileList.length) { // 如果选中多个文件,重复切片、上传操作 this.file = this.fileList[0] this.chunckList = this.createChunkList(this.fileList.shift()) window.worker.postMessage(this.chunckList) } else { this.showPercent = false this.$nextTick(() => { this.chunckList = [] window.worker.terminate() }) } }) }) }
2.2 后端部分
后端处理上传文件使用的multer插件,插件的使用不再介绍,下面是我的app.js,multer相关配
var Router = require('./route') var multer = require('multer') var storage = multer.diskStorage({ destination: function (req, file, cb) { if (req.url === '/upload-chunck') { fs.stat(`./public/music/chunck/${req.body.name}`, (err, stats) => { if(err) { // 文件夹不曾存在,创建文件夹 fs.mkdir(`./public/music/chunck/${req.body.name}/`, () => { cb(null, `./public/music/chunck/${req.body.name}`) }) } else if (stats.isDirectory()) { // 存在,直接使用 cb(null, `./public/music/chunck/${req.body.name}`) } }) } }, filename: function (req, file, cb) { if (req.url === '/upload-chunck') { cb(null, req.body.hash) } } }) var upload = multer({ storage: storage }); app.post('/upload-chunck', upload.single('music'), Router.uploadFile) // 上传切片
检测切片是否已存在:
app.js:
app.post('/chunckAlready', Router.cheackChunck)
Router.js
exports.cheackChunck = (req, res) => { fs.stat(`./public/music/chunck/${req.body.name}/${req.body.hash}`, (err, stats) => { if(err) { console.log('查找失败',err) res.send({ already: false }) } else if (stats.isFile()) { res.send({ already: true }) } }) }
通知合并:
app.js
app.post('/merge', Router.mergeChunck) // 合并切片
Router..js
function del(path) { // 第一步读取文件内部的文件 let arr = fs.readdirSync(path) // 遍历数组 for (let i = 0; i < arr.length; i++) { // 获取文件的状态 let stat = fs.statSync(path + '/' + arr[i]); // 判断是文件还是文件夹 if (stat.isDirectory()) { // 说明是文件夹 递归调用 del(path + '/' + arr[i]); } else { // 说明是文件 fs.unlinkSync(path + '/' + arr[i]); } } } exports.mergeChunck = (req, res) => { fs.readdir( `./public/music/chunck/${req.body.hash}/`,async function(err, files) { files.sort((a, b) => { // 对切片排序 a = a.split('-')[1] b = b.split('-')[1] return Number(a) - Number(b) }) let w = fs.createWriteStream(`./public/music/${req.body.fileName}`) // 创建用于写入的管道流 w.on('close', () => { // 关闭时删除切片文件及文件夹 del(`./public/music/chunck/${req.body.hash}`) fs.rmdir(`./public/music/chunck/${req.body.hash}`, () => {}) }) function merge(chunck, finish) { return new Promise((resolve, rej) => { let r = fs.createReadStream(`./public/music/chunck/${req.body.hash}/${chunck}`) r.pipe(w, { end: finish }) r.on('end', resolve) }) } for (let i = 0; i < files.length; i++) { await merge(files[i], i === files.length -1) } res.send({ name: req.body.fileName, src: `https://www.zsp.cool/music/${req.body.fileName}` }) })
参考文章: https://mp.weixin.qq.com/s/xabsRAsBDoPfbRytAPikGA
欢迎联系交流: 1612977540@qq.com