大文件分片上传,断点续传 node.js+axios+egg.js

1,908 阅读7分钟

之前我写过一篇纯前端页面实现oss分片上传, 其实只是前端去调用阿里云提供的分片上传得接口,当然我们在断点续传上做了一些处理,我一直很想自己去实现分片上传以及断点续传,主要是得花很长得时间测试,也发现很多人的文章只是实现了分片上传,没有做断点续传.

由于我们得大文件一般都会存在阿里云等云存储平台,而这种平台基本会封装好api给我们调,所以实际上手动去实现断点续传和分片上传得机会还是比较少得,但是得自己去实现一下,才知道怎么处理得.

所以我今天结合掘友的一篇文章以及ali-oss源码来实现分片上传+断点续传,采用的是214兆的文件

1667468308416.png

参考资料:

大文件分片上传(NodeJs+Koa)

Ali-oss源码

1.分片上传,断点续传是什么?

对于大文件上传,比如400兆, 是需要等很久的,用户不知道进度,假设我上传文件好不容易上传了一半,但是不小心刷新页面或者网络中断,那我得重新全部上传

分片上传采用得是将一个文件,分割成多个,文件得size是固定得,只要定义要每片得大小,就能拿到最多得片数, 所以每次只上传一小片,等上传得片数和总片数一致时,然后将所有的片数进行合并

分片上传可能会加快上传得速度,这个其实取决于设置得每片大小得合理性(我认为是要测试,得到一个总时间比较短得兆数),而且能够给用户展示上传得进度(已传片数/总片数),上传中中断得问题,就是保留已经上传的,继续传没有上传的部分,那么需要做的就是区分总共需要上传得片数(每片得有标记) - 已经上传得片数,剩下的就是需要上传得.

定义:

分片上传就是将待上传的文件分成多个碎片(Part)分别上传,全部上传之后合成一个完整的文件
断点续传就是如果出现网络异常或程序崩溃导致文件上传失败时,将从断点记录处继续上传未上传完成的部分。

优点:

1.能够提升上传速度
2.中断可以进行续传
3.能够显示上传的进度

2.必备的知识点以及一些思考
2.1关于file的切片功能

BLOB (binary large object),二进制大对象,是一个可以存储二进制文件的容器.

File 对象是特殊类型的 Blob,File 接口没有定义任何方法,但是它从 Blob 接口继承了以下方法: Blob.slice([start[, end[, contentType]]])

Blob.slice(start,end)方法用于创建一个包含源 Blob的指定字节范围内的数据的新 Blob 对象。

意思就是上传后的到file,包含了slice方法,能够进行切片,得到一个包含原来的file里面指定范围的新file

2.2 因为上传的片数的兆数是一样的,是不是每个都是按照顺序进行上传的呢?

10兆切成每3兆一片: slice(0, 3),slice(3, 6),slice(6,9),slice(9, 10),如果顺序切片上传,每个都是3兆,会不会每个都是顺序成功上传到了服务端呢?答案是不是,测试可得

2.3 合成是不是要按照片数的顺序进行合成

是的

2.4 node以及fs-extra包用到的方法

fse.existsSync判断文件是否存在
fse.mkdirs创建文件
fse.move移动文件
fse.readdir读目录底下的所有文件
fse.appendFileSync用于将给定数据同步追加到文件中

3.分片上传代码

思路:确定file要切割成多少兆一片,然后记录每个片段的index值,给片做标记,const start = index * chunkSize进行递归上传,全部上传之后,对所有的片根据起初的index值进行从小到大的排序,然后进行合成

完全参考大文件分片上传(NodeJs+Koa)除了代码优化已经框架上一点点调整.也很详细的写了注释

前端:

<html lang="zh-CN">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
  <!-- 引入 Koa -->
  <script src="./axios.min.js"></script>
</head>

<body>
  <input type="file" id="btnFile">
  <input type="button" value="上传" onclick="upload()">
  <script>
    /********************** 参数定义start****************/
    let btnFile = document.querySelector('#btnFile')
    const chunkSize = 3
    const multipleSize = 50
    const mergeApiUrl = "http://127.0.0.1:7001/merge"
    const uploadSliceUrl = "http://127.0.0.1:7001/uploadFile"
    const simpleUploadUrl = "http://127.0.0.1:7001/simpleUpload"
    /********************** 参数定义end****************/

    /********************** 核心代码 start****************/
    function upload(index = 0/* 当前片下标 */) {
      // 1.先拿到上传得file
      const file = btnFile.files[0]
      isMultiple(file)
    }
    // 2.判断是不是需要分片上传, 用兆fileSizeM来进行比对,计算
    function isMultiple(file, index = 0) {
      const { name, size } = file
      const fileSizeM = Math.ceil(size/1024/1024)
      if (fileSizeM < multipleSize) {
        // 直接上传
        this.directUpload({file, name })
        // 分片上传
      } else {
        // 分片上传主要思路: blob是有切割得方法,而file是继承blob得,所以能够进行将文件切成多少片
        // 分片上传主要就是将一个要上传得大文件,分成一片片得小文件,通过slice进行切割,然后全部上传之后进行合并
        // 切割得起始值,例如 10兆得文件切割成成每份3兆得,那么返回得数组为[{ start: 0, end: 3}, { start: 3, end: 6}, { start: 6, end: 9}, {start: 9, end: 10}]
        // 例如 10兆切成每份4兆 ,那么数组为[{ start: 0, end: 4}, { start: 4, end: 8}, { start: 8, end: 10}]
        const [ fname, fext ] = name.split('.')
        // 获取当前片的起始值,从0开始
        const start = index * chunkSize // 分割的片数的起始值
        // 如果起始值超过文件得兆数,那么就可以调用合并
        if (start > fileSizeM) {// 当超出文件大小,停止递归上传
          // 请求整合
          merge(file.name)
          return
        }
        const blob = file.slice(start, start + chunkSize) // file 继承
        // 为每片进行命名, 然后进行上传
        const blobName = `${fname}.${index}.${fext}`
        const formData = handleFormData({ file: blob, name: blobName })
        axios.post(uploadSliceUrl, formData).then(res => {
          // 递归分片上传
          isMultiple(file, ++index)
        })
      }
    }

    async function merge(name) {
      axios.post(mergeApiUrl, { name: name })
      console.log('合并成功!')
    }

    /********************** 核心代码 end****************/


    /********************** 数据处理utils start****************/

    // 小于50兆直接上传
    async function directUpload({ file, name }) {
      // 直接上传
      const formData = handleFormData({ file, name })
      await axios.post(simpleUploadUrl, formData)
      console.log('50兆以下上传成功')
    }

    // 创建formData
    function handleFormData({ file, name }) {
      const blobFile = new File([file], name)
      const formData = new FormData()
      formData.append('file', blobFile)
      return formData
    }

    /********************** 数据处理utils end****************/
  </script>
</body>

</html>

只看核心代码就好,其中要注意的是每一片都是根据每片的index来做标记的. 这样也能够根据index的从小到大的顺序进行合成${fname}.${index}.${fext}

递归上传之后,如果slice(start,end)中的start是大于文件size就不上传了

服务端: router:

router.post('/uploadFile', controller.upload.uploadFile);
router.post('/merge', controller.upload.merge);
router.post('/simpleUpload', controller.upload.simpleUpload);
'use strict';

const Controller = require('egg').Controller;
const path = require('path');
const fse = require('fs-extra');

class HomeController extends Controller {
  // 每片上传
  async uploadFile() {
    const { ctx } = this;
    // 1.获取上传后的文件
    const file = ctx.request.files[0];
    // 2.将文件名分割 file.filename --- bigFile.27.zip
    const fileNameArr = file.filename.split('.'); // ['bigFile', '27', 'zip']
    // 3. 确定分片存放的位置
    const UPLOAD_DIR = path.resolve(__dirname, 'public/upload');
    // 4.可能上传多个,根据文明名来存 C:\Users\Administrator\Desktop\vue+ts\egg-example\app\controller\public\upload/bigFile
    const chunkDir = `${UPLOAD_DIR}/${fileNameArr[0]}`;
    // 5.如果没有这个文件就新增一个
    if (!fse.existsSync(chunkDir)) {
      await fse.mkdirs(chunkDir);
    }
    // C:\Users\Administrator\Desktop\vue+ts\egg-example\app\controller\public\upload\bigFile\96
    // 6. 分片的路径
    const dPath = path.join(chunkDir, fileNameArr[1]);
    // 7.将临时文件移到当前文件 C:\Users\ADMINI~1\AppData\Local\Temp\egg-multipart-tmp\example\2022\11\03\11\ec23238f-56b5-4367-9e39-b754c8b81473.zip
    await fse.move(file.filepath, dPath, { overwrite: true });
    ctx.body = { sliceKey: fileNameArr[1], msg: '单片上传成功' };
  }

  async merge() {
    // 1.先获取存放的目录
    const UPLOAD_DIR = path.resolve(__dirname, 'public/upload');
    const { ctx } = this;
    // 2.拿到文件名 bigFile.zip
    const { name } = ctx.request.body;
    // 3. 拿到文件名 bigFile
    const fname = name.split('.')[0];
    // 4. 分片所在位置  // C:\Users\Administrator\Desktop\vue+ts\egg-example\app\controller\public\upload\bigFile
    const chunkDir = path.join(UPLOAD_DIR, fname);
    // 5. 读取所有的分片 ['0', '1', .............]
    // 读取一个目录的内容
    const chunks = await fse.readdir(chunkDir);
    // 然后按照片数得从小到大进行排列,然后再合并
    chunks.sort((a, b) => a - b).map(chunkPath => {
      // 合并文件
      fse.appendFileSync(
        path.join(UPLOAD_DIR, name),
        fse.readFileSync(`${chunkDir}/${chunkPath}`)
      );
    });
    // 删除临时文件夹
    fse.removeSync(chunkDir);
    ctx.body = '合并成功';
  }

  // 简单上传
  async simpleUpload() {
    const { ctx } = this;
    // 1.获取上传后的文件
    const file = ctx.request.files[0];
    const UPLOAD_DIR = path.resolve(__dirname, 'public/upload');
    const dPath = path.join(UPLOAD_DIR, file.filename);
    // 7.将临时文件移到当前文件
    await fse.move(file.filepath, dPath, { overwrite: true });
    ctx.body = '哈哈哈啊哈';
  }
}

module.exports = HomeController;


分片必须按照顺序进行合成,所以有一个根据之前的index进行排序

以上仅仅实现了分片上传,并没有实现断点续传

4.分片上传+断点续传(完整代码)

服务端代码不变哈

前端代码

<html lang="zh-CN">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
  <script src="./axios.min.js"></script>
</head>

<body>
  <input type="file" id="btnFile">
  <input type="button" value="上传" onclick="upload()">
  <div>
    进度条:<span id="progress">0</span>
  </div>
  <script>
    /********************** 参数定义start****************/
    let btnFile = document.querySelector('#btnFile')
    const uploadParams = {}
    let localDataParams = {}
    let chunkSize = 3
    const multipleSize = 50
    const mergeApiUrl = "http://127.0.0.1:7001/merge"
    const uploadSliceUrl = "http://127.0.0.1:7001/uploadFile"
    const simpleUploadUrl = "http://127.0.0.1:7001/simpleUpload"
    /********************** 参数定义end****************/
    

     /********************** 核心代码 start****************/
    // 1.上传能够获取到file,然后进行判断需不需要分片上传
    function upload() {
      const file = btnFile.files[0]
      isMultiple(file)
    }

    // 2.判断分片上传
    function isMultiple(file) {
      const { name, size, lastModified, type } = file
      const fileSizeM = Math.ceil(size/1024/1024)
      const saveFileId = `${lastModified}_${fileSizeM}_${name}_${type}`
      // 如果小于50兆,直接上传
      if (fileSizeM < multipleSize) {
        this.directUpload({file, name })
      // 大于50兆分片上传
      } else {
        // 1.先确定要切成多少片,每片得初始值和结束值
        const sliceArr = divideParts(fileSizeM, chunkSize)
        // 2. 得到最大得片数
        const numParts = Math.ceil(fileSizeM / chunkSize);
        // 3. 所有得片数得key, 并默认是首次上传
        const all = Array.from(new Array(numParts), (x, i) => i );
        let todoParts = all
        // 4. 获取要传得数据
        // 4.1 先看看是否需要清除缓存,是否过期
        deleteOverdueStorage(saveFileId)
        // 4.2 判断是否有缓存
        const storageData = localStorage.getItem('upload-function-name')
        const obj = JSON.parse(storageData)
        // 4.3.有缓存从缓存里面拿数据
       
        if (storageData && Object.keys(obj).includes(saveFileId)) {
          // 如果内存中有数据
          uploadParams.checkpoint = obj[saveFileId]
          chunkSize = uploadParams.checkpoint.partSize
          // 未上传得分片下标 =  所有得得下标 filter出number不存在得
          // 这里得doneParts存储得是所有分片对象得数组得下表
          const { doneParts } = obj[saveFileId]      
          const done = doneParts.map(p => p.number);
          const todo = all.filter(p => done.indexOf(p) < 0);
          todoParts = todo
        } else {
          // 定义需要存储得数据
          const checkpoint = {
            name,
            partSize: chunkSize,
            uploadId: saveFileId,
            doneParts: []
          };
          uploadParams.checkpoint = checkpoint
        }
        // 上传得实际数据
        uploadSlicesFun({ todoParts, sliceArr, file, name, saveFileId })
      }
    }

    // 3. 根据没有上传得sliceArr得下标集合todoParts,来进行匹配出还没有上传得sliceArr[key]
    async function uploadSlicesFun({ todoParts, sliceArr , name, file, saveFileId }) {
      // 如果todoParts还有数据,表示要继续上传
      if (todoParts.length) {
        console.time('allTime')
        // 这里只需要遍历还没有上传分片数组下标,
        for(let key= 0; key < todoParts.length ; key ++) {
          const sliceKey = todoParts[key]
          const { start, end } = sliceArr[sliceKey]
          const [ fname, fext ] = name.split('.')
          const blob = file.slice(start, end)
          const blobName = `${fname}.${sliceKey}.${fext}`
          const formData = handleFormData({ file: blob, name: blobName })
          axios.post(uploadSliceUrl, formData).then(res => {
            uploadParams.checkpoint.doneParts.push({ number: +res.data.sliceKey })
            // 每次上传都直接存储key
            localDataParams[saveFileId] = uploadParams.checkpoint
            localDataParams[saveFileId]['lastSaveTime'] = new Date()
            // 在上传过程中,把已经上传的数据存储下来
            saveFinishedData(localDataParams)
            // 当上传得片数和所有得切片数量一致时进行合并
            const { checkpoint: { doneParts } } = uploadParams
            document.getElementById('progress').innerHTML = `${parseInt(doneParts.length / sliceArr.length * 100)}%`
            if (doneParts.length === sliceArr.length) {
              merge(name, saveFileId)
            }
          })
         
          // const res = await axios.post(uploadSliceUrl, formData)
          // uploadParams.checkpoint.doneParts.push({ number: +res.data.sliceKey })
          // // 每次上传都直接存储key
          // localDataParams[saveFileId] = uploadParams.checkpoint
          // localDataParams[saveFileId]['lastSaveTime'] = new Date()
          // // 在上传过程中,把已经上传的数据存储下来
          // saveFinishedData(localDataParams)
          // // 当上传得片数和所有得切片数量一致时进行合并
          // const { checkpoint: { doneParts } } = uploadParams
          // document.getElementById('progress').innerHTML = `${parseInt(doneParts.length / sliceArr.length * 100)}%`
          // if (doneParts.length === sliceArr.length) {
          //   merge(name, saveFileId)
          // }  
        }
      } else {
        // 有可能全部上传,但是尚未合并
        if (uploadParams.checkpoint.doneParts.length === sliceArr.length) {
          merge(name, saveFileId)
        }  
      }
    }

    // 4.当上传得片数和所有得切片数量一致时进行合并
    async function merge(name, saveFileId) {
      await axios.post(mergeApiUrl, { name: name }, { timeout: 1000 * 1200 })
      console.log(uploadParams.checkpoint.doneParts, 'then 后的doneParts')
      console.timeEnd('allTime')
      delete localDataParams[saveFileId]
      saveFinishedData(localDataParams)
    }
    /********************** 核心代码 end****************/


    /********************** 数据处理utils start****************/
    
    // 创建formData
    function handleFormData({ file, name }) {
      const blobFile = new File([file], name)
      const formData = new FormData()
      formData.append('file', blobFile)
      return formData
    }

    // 将文件切割成max份数组,确定切割起始兆和结束兆数
    // 例如 10兆得文件切割成成每份3兆得,那么返回得数组为[{ start: 0, end: 3}, { start: 3, end: 6}, { start: 6, end: 9}, {start: 9, end: 10}]
    // 例如 10兆切成每份4兆 ,那么数组为[{ start: 0, end: 4}, { start: 4, end: 8}, { start: 8, end: 10}]
    function divideParts(fileSize, partSize) {
      const numParts = Math.ceil(fileSize / partSize);
      const partOffs = [];
      for (let i = 0; i < numParts; i++) {
        const start = partSize * i;
        const end = Math.min(start + partSize, fileSize);
        partOffs.push({
          start,
          end
        });
      }
      console.log(partOffs, '片数组')
      return partOffs;
    };

    // 保存checkpoint数组到内存里面
    function saveFinishedData(finishedData) {
      if (!Object.keys(finishedData).length) {
        localStorage.removeItem('upload-function-name')
      } else {
        localStorage.setItem('upload-function-name', JSON.stringify(finishedData))
      }
      
    }

    // 过期了进行删除
    function deleteOverdueStorage(saveFileId) {
      // 只要是时间到了就进行清楚
      const localData = localStorage.getItem('upload-function-name')
      if (!localData) return 
      const savaData = JSON.parse(localData)
      const saveTime = 1000 * 60 * 60
      localDataParams = savaData 
      if(Object.keys(savaData).includes(saveFileId)) {
        const diff = new Date().getTime() / 1000 - savaData[saveFileId].lastSaveTime
        console.log('进来了')
        if (diff > saveTime) {
          console.log('到期每')
          delete savaData[saveFileId]
          saveFinishedData(savaData)
        }
      }
    }

    // 小于50兆直接上传
    async function directUpload({ file, name }) {
      // 直接上传
      const formData = handleFormData({ file, name })
      await axios.post(simpleUploadUrl, formData)
      console.log('50兆以下上传成功')
    }

    /********************** 数据处理utils end****************/

 

  </script>
</body>

</html>

只看核心代码,思路:

首先假设没有缓存,是首次上传

1.首先根据chunkSize以及fileSizeM来计算出需要上传的片的数组

const sliceArr = divideParts(fileSizeM, chunkSize)

例如 10兆得文件切割成成每份3兆得,那么返回得数组为[{ start: 0, end: 3}, { start: 3, end: 6}, { start: 6, end: 9}, {start: 9, end: 10}] 如果 10兆切成每份4兆 ,那么数组为[{ start: 0, end: 4}, { start: 4, end: 8}, { start: 8, end: 10}]

2.获取所有的片数的key,因为数组是固定的,那么一开始需要上传的todoParts = all

 const all = Array.from(new Array(numParts), (x, i) => i );
 let todoParts = all

3.现在已经知道哪些需要上传了,那么就想到上传的时候是不是需要把一些已经传了的数据存起来以及文件信息,所以创建需要存储的对象checkpoint

const checkpoint = {
    name,
    partSize: chunkSize,
    uploadId: saveFileId,
    doneParts: []
};
uploadParams.checkpoint = checkpoint

4.分片上传代码,这里我是对分片的todoParts的数组进行循环,而不是对sliceArr进行循环,因为todoParts我将他最为变动的,后续根据断点续传的数据进行变动

1667474329748.png

这里有两个知识点,第一个就是for循环用let,这里的key是存在当前的局部作用域,不能用var,第二个就是axios.post(uploadSliceUrl, formData).then这里为什么用then而不是用await,如果是用await的话表示每一片都是前一片上传完了之后再进行下一片,这样doneParts里面的key是顺序的,而直接用then能够异步调用,这样doneParts不一定是顺序的,这里没有必要一定要上一片完成了再上传下一片,因为我们知道key就能知道哪些是没有传的.

使用await每片顺序上传:

6cbb32f55d6c1686c524a6c860dc672.png

使用then,异步同时上传

cc60f0295ed6664918ad1ed886f9599.png

这里我用的是214兆,每片5兆,如果顺序上传花费的时间是3630毫秒,异步同时上传是3158毫秒,当然文件越大,或者片的size越小,差距更明显,所以我们这里不要用顺序上传,知道key,就能知道哪些已经上传了

5.上传后进行合并,只要已经上传的和numberParts相等就可以调用merge

if (uploadParams.checkpoint.doneParts.length === sliceArr.length) { merge(name, saveFileId) }

以上是首次上传,并且没有中断,如果中断了,如何断点续传

6.首先应该考虑,如果中断了,我们把数据存到内存,应该以什么格式,能不能多文件断点续传,所以这里采用的是一个对象来存储,并且采用[文件-数据]的形式 { 文件唯一id: { name: partSize: uploadId: doneParts: lastSaveTime: } }

{  
        "1666669949400_215_bigFile.zip_application/zip": {  
                "name""bigFile.zip",  
                "partSize"3,  
                "uploadId""1666669949400_215_bigFile.zip_application/zip",  
                "doneParts": [{  
                        "number"3  
                }],  
                "lastSaveTime""2022-11-03T10:52:21.260Z"  
        },  
        "1666669949400_215_moreBigFile.zip_application/zip": {  
                "name""moreBigFile.zip",  
                "partSize"3,  
                "uploadId""1666669949400_215_moreBigFile.zip_application/zip",  
                "doneParts": [{  
                        "number"38  
                }, {  
                        "number"36  
                }],  
                "lastSaveTime""2022-11-03T10:52:30.989Z"  
        }  
}

lastSaveTime为过期时间,这个设置多久后就进行删除

7. 先判断有没有过期,如果有就删除对应文件deleteOverdueStorage(saveFileId),没有就将doneParts进行拿到

内存里面的doneParts,是每次上传都要进行本地的更新

1667473486438.png

所以分片上传里面最重要的就是doneParts不能错,然后根据doneParts已经上传的filter出没有上传的

 // 未上传得分片下标 =  所有得得下标 filter出number不存在得
    // 这里得doneParts存储得是所有分片对象得数组得下表
    const { doneParts } = obj[saveFileId]      
    const done = doneParts.map(p => p.number);
    const todo = all.filter(p => done.indexOf(p) < 0);
    todoParts = todo

得到了todoParts就开始第4步了,和之前一样

5.效果

测试21.gif

6.遗留的问题

1.进度条这块,应该还有需要优化的地方

2.我们会发现,上传到服务端的片数realParts和实际上中断了存到内存的数据doneParts,可能会不一样,但是realParts > doneParts这个是正常的,因为突然间中断,可能已经上传了,但是代码还没有走到

1667474771892.png 这一步,但是即使doneParts比realParts少了,todoParts重复传几片,还是不会影响最后的文件生产,只是这里也许还可以再处理下.