大文件切片上传、断点续传

因为前几天在做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
posted @ 2021-03-29 14:28  千昭。  阅读(546)  评论(0编辑  收藏  举报