【笔记】文件分片上传、续传、秒传

  1. 介绍背景
    之前在下载一些比较大的游戏的时候发现了一个功能就是下载的时候可以暂停、下次打开的时候还可以继续下载、还有在qq中传文件的时候很大的文件,有时候就1s就上传成功了。带着这些现象我也自己探索学习了下怎么实现分片上传、续传以及秒传的。

  2. 原理介绍
    普通上传文件
    一般来说平时开发中经常遇到上传文件的需求,一般的话我们就是上传的一张图片、或者一个pdf文件之类的。大致经历以下步骤:
    ○ 用户选择文件,js校验文件大小、格式是否正确。
    ○ 使用ajax请求将文件通过接口提交给后端。
    ○ 后端处理文件返回文件地址给前端,前端数据回显。
    分片上传
    ○ 用户选择文件后,js校验文件大小、格式是否正确。
    ○ 根据文件大小,使用file.slice方法进行文件分割。(slice属于Blob对象的一个方法,而File对象是继承Blob对象的,因此File对象也含有slice方法)
    ○ 使用SparkMD5和FileReader API生成文件唯一的md5值。
    ○ 使用ajax请求将文件提交到后端
    ■ 后端根据文件的md5值建一个文件夹,将分片的文件写入文件夹。
    ○ 前端收到信息后,根据当前分片文件个数做出判断
    ■ 如果当前分片索引小于总分片个数继续上传下一个分片
    ■ 如果相等则表示所有分片都已上传完成,请求合并文件
    ○ 后端收到合并文件请求后,对相应的md5值的文件夹中的文件进行排序合并文件,最后返回文件地址。
    ○ 前端接受到后端返回的数据进行数据回显。
    断点续传

在文件上传的过程中,可能会遇到网络中断、服务器异常、断电之类的不可抗拒的原因导致上传中断,如果小文件还好。重新上传无伤大雅,但是如果上传的几个G的文件,这个时候只能重新来。体验十分糟糕。所以此时的断点续传就变得很有用了。
○ 在分片上传的基础上,中途断开请求。
○ 用户重新上传的时候请求接口,后端检查md5值的文件夹是否存在
○ 如果存在返回下个需要上传的分片索引,前端继续从对应的分片进行上传。
○ 最后合成文件
秒传

秒传文件功能确实十分好用,用户使用体验感很棒。实现的原理就是相同文件的md5值是不变的,只有改变文件内容时候才会变,改变文件名称不受影响。
○ 上传之前对文件生成唯一的md5值,后端存储文件时候将md5值作为文件的名称
○ 下次上传时候后端根据上传文件的md5值去找文件是否存在,如果存在就立即返回文件地址
○ 前端根据地址回显
3. 代码部分
普通文件上传
前端关键代码

  <div class="upload">
    <h3>普通上传</h3>
    <form>
      <div class="upload-file">
        <label for="file">请选择文件</label>
        <input type="file" name="file" id="file" accept="image/*">
      </div>
      <div class="upload-progress">
        当前进度:
        <p>
          <span style="width: 0;" id="current"></span>
        </p>
      </div>
      <div class="upload-link">
        文件地址:<a id="links" href="javascript:void();" target="_blank">文件链接</a>
      </div>
    </form>
  </div>
 // 获取元素
  const file = document.querySelector('#file');
  let current = document.querySelector('#current');
  let links = document.querySelector('#links');
  let baseUrl = 'http://localhost:3333';

  // 监听文件事件
  file.addEventListener('change', (e) => {
    console.log(e.target.files);
    let file = e.target.files[0];
    // if (file.type.indexOf('image') == -1) {
    //   return alert('文件格式只能是图片!');
    // }
    // if ((file.size / 1000) > 100) {
    //   return alert('文件不能大于100KB!');
    // }
    links.href = '';
    file.value = '';
    this.upload(file);
  }, false);

  // 普通文件
  async function upload(file) {
    let formData = new FormData();
    formData.append('file', file);
    let data = await axios({
      url: baseUrl + '/testApi/uploadImg',
      method: 'post',
      data: formData,
      onUploadProgress: function (progressEvent) {
        current.style.width = Math.round(progressEvent.loaded / progressEvent.total * 100) + '%';
      }
    });
    if (data.data.code == 200) {
      links.href = data.data.data;
    } else {
      alert('上传失败!')
    }
  }

后端关键代码(nodejs)

const Koa = require('koa');
const Router = require('koa-router');
const cors = require('koa2-cors');
const path = require('path');
const KoaStatic = require('koa-static');
const koaBody = require('koa-body'); //解析上传文件的插件
const fs = require('fs');
const baseUrl = 'http://localhost:3333';
const utils = require('./utils')

// 创建一个Koa对象表示web app本身:
const app = new Koa();
app.use(cors());
// 使用koa-static处理静态资源
app.use(KoaStatic('./'));

app.use(koaBody({
  uploadDir: path.join(__dirname, 'upload/'), // 设置文件上传目录
  multipart: true,
  formidable: {
    maxFileSize: 2000 * 1024 * 1024    // 设置上传文件大小最大限制,默认2M
  }
}))

// 上传文件处理
function uploadFn (ctx, destPath) {
  return new Promise((resolve, reject) => {
    const file = ctx.request.files.file
    const reader = fs.createReadStream(file.path)
    let filePath = path.join(__dirname, 'upload/' + `/${file.name}`)
    const upStream = fs.createWriteStream(filePath)
    reader.pipe(upStream)
    resolve(file.name)
  })
}

let testApi = new Router();

// 上传图片
testApi.post('/testApi/uploadImg', async ctx => {
  await uploadFn(ctx).then((name) => {
    ctx.body = {
      code: 200,
      data: baseUrl + '/upload/' + name,
      msg: '文件上传成功'
    }
  })
});


app.use(testApi.routes());


// 在端口3333监听:
app.listen(3333);
console.log(`服务器已启动  http://localhost:3333`)

分片、断点续传、秒传文件
前端完整代码

<!DOCTYPE html>
<html lang="zn">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>文件断点续传</title>
  <script src="https://cdn.bootcdn.net/ajax/libs/axios/0.21.1/axios.min.js"></script>
  <script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.0/spark-md5.min.js"></script>
  <style>
    body {
      margin: 0;
      font-size: 16px;
      background: #f8f8f8;
    }

    h1,
    h2,
    h3,
    h4,
    h5,
    h6,
    p {
      margin: 0;
    }

    /* * {
    outline: 1px solid pink;
} */

    .upload {
      box-sizing: border-box;
      margin: 30px auto;
      padding: 15px 20px;
      width: 500px;
      height: auto;
      border-radius: 15px;
      background: #fff;
    }

    .upload h3 {
      font-size: 20px;
      line-height: 2;
      text-align: center;
    }

    .upload .upload-file {
      position: relative;
      margin: 30px auto;
    }

    .upload .upload-file label {
      display: flex;
      justify-content: center;
      align-items: center;
      width: 100%;
      height: 150px;
      border: 1px dashed #ccc;
    }

    .upload .upload-file input {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      opacity: 0;
    }

    .upload-progress {
      display: flex;
      align-items: center;
    }

    .upload-progress p {
      position: relative;
      display: inline-block;
      flex: 1;
      height: 15px;
      border-radius: 10px;
      background: #ccc;
      overflow: hidden;
    }

    .upload-progress p span {
      position: absolute;
      left: 0;
      top: 0;
      width: 0;
      height: 100%;
      background: linear-gradient(to right bottom, rgb(163, 76, 76), rgb(231, 73, 52));
      transition: all .4s;
    }

    .upload-link {
      margin: 30px auto;
    }

    .upload-link a {
      text-decoration: none;
      color: rgb(6, 102, 192);
    }

    @media all and (max-width: 768px) {
      .upload {
        width: 300px;
      }
    }
  </style>
</head>

<body>
  <div class="upload">
    <h3>普通上传</h3>
    <form>
      <div class="upload-file">
        <label for="file">请选择文件</label>
        <input type="file" name="file" id="file" accept="image/*">
      </div>
      <div class="upload-progress">
        当前进度:
        <p>
          <span style="width: 0;" id="current"></span>
        </p>
      </div>
      <div class="upload-link">
        文件地址:<a id="links" href="javascript:void();" target="_blank">文件链接</a>
      </div>
    </form>
  </div>
  <div class="upload">
    <h3>大文件上传</h3>
    <form>
      <div class="upload-file">
        <label for="file">请选择文件</label>
        <input type="file" name="file" id="big-file" accept=".mp4">
      </div>
      <div class="upload-progress">
        当前进度:
        <p>
          <span style="width: 0;" id="big-current"></span>
        </p>
      </div>
      <div class="upload-link">
        文件地址:<a id="big-links" href="" target="_blank">文件链接</a>
      </div>
    </form>
  </div>
</body>
<script>
  // 获取元素
  const file = document.querySelector('#file');
  let current = document.querySelector('#current');
  let links = document.querySelector('#links');
  let baseUrl = 'http://localhost:3333';


  const bigFile = document.querySelector('#big-file');
  let bigCurrent = document.querySelector('#big-current');
  let bigLinks = document.querySelector('#big-links');
  let fileArr = [];
  let md5Val = '';
  let ext = '';


  // 监听文件事件
  file.addEventListener('change', (e) => {
    console.log(e.target.files);
    let file = e.target.files[0];
    // if (file.type.indexOf('image') == -1) {
    //   return alert('文件格式只能是图片!');
    // }
    // if ((file.size / 1000) > 100) {
    //   return alert('文件不能大于100KB!');
    // }
    links.href = '';
    file.value = '';
    this.upload(file);
  }, false);

  // 上传文件
  async function upload(file) {
    let formData = new FormData();
    formData.append('file', file);
    let data = await axios({
      url: baseUrl + '/testApi/uploadImg',
      method: 'post',
      data: formData,
      onUploadProgress: function (progressEvent) {
        current.style.width = Math.round(progressEvent.loaded / progressEvent.total * 100) + '%';
      }
    });
    if (data.data.code == 200) {
      links.href = data.data.data;
    } else {
      alert('上传失败!')
    }
  }

  function suffix(file_name) {
    var result = /\.[^\.]+/.exec(file_name);
    return result;
  }
  // 监听大文件上传
  bigFile.addEventListener('change', (e) => {
    let file = e.target.files[0];
    console.log(file)
    // if (file.type.indexOf('application') == -1) {
    //   return alert('文件格式只能是文档应用!');
    // }
    // if ((file.size / (1000 * 1000)) > 100) {
    //   return alert('文件不能大于100MB!');
    // }
    ext = suffix(file.name)
    uploadBig(file);
  }, false);

  function uploadBig(file) {
    fileArr = sliceFile(file)
    console.log(fileArr)
    md5File(fileArr).then(res => {
      md5Val = res
      checkFile()
    })
  }

  // 切割文件
  function sliceFile(file) {
    const files = [];
    const chunkSize = 10 * 1024 * 1024;
    for (let i = 0; i < file.size; i += chunkSize) {
      const end = i + chunkSize >= file.size ? file.size : i + chunkSize;
      let currentFile = file.slice(i, (end > file.size ? file.size : end));
      files.push(currentFile);
    }
    return files;
  }

  // 获取文件md5值
  function md5File(files) {
    const spark = new SparkMD5.ArrayBuffer();
    let fileReader;
    for (var i = 0; i < files.length; i++) {
      fileReader = new FileReader();
      fileReader.readAsArrayBuffer(files[i]);
    }
    return new Promise((resolve) => {
      fileReader.onload = function (e) {
        spark.append(e.target.result);
        if (i == files.length) {
          resolve(spark.end());
        }
      }
    })
  }

  async function checkFile() {
    let data = await axios({
      url: `${baseUrl}/testApi/uploadBigFile?uploadType=bigFile&type=check&current=0&md5Val=${md5Val}&total=${fileArr.length}&ext=${ext}`,
      method: 'post'
    })
    console.log(data)
    if (data.data.code == 221) {
      uploadSlice(data.data.data)
    } else if (data.data.code == 220) { // 文件秒传成功
      alert('文件秒传成功');
      bigCurrent.style.width = '100%';
      bigLinks.href = data.data.data
    }

  }


  async function uploadSlice(chunkIndex = 0) {
    let formData = new FormData();
    formData.append('bigFile', fileArr[chunkIndex]);
    let data = await axios({
      url: `${baseUrl}/testApi/uploadBigFile?uploadType=bigFile&type=upload&current=${chunkIndex}&md5Val=${md5Val}&total=${fileArr.length}&ext=${ext}`,
      method: 'post',
      data: formData,
    })

    if (data.data.code == 200) { // 文件上传成功
      if (chunkIndex < fileArr.length - 1) {
        bigCurrent.style.width = Math.round((chunkIndex + 1) / fileArr.length * 100) + '%';
        ++chunkIndex;
        uploadSlice(chunkIndex);
      } else {
        mergeFile();
      }
    } else if (data.data.code == 221) {
      chunkIndex = data.data.data
      console.log("继续上传,继续上传切片:" + data.data.data)
      bigCurrent.style.width = Math.round((data.data.data + 1) / fileArr.length * 100) + '%';
      ++chunkIndex;
      uploadSlice(chunkIndex);
    }
  }

  async function mergeFile() {
    let data = await axios.post(
      `${baseUrl}/testApi/uploadBigFile?uploadType=bigFile&type=merge&md5Val=${md5Val}&total=${fileArr.length}&ext=${ext}`
    );
    if (data.data.code == 200) {
      alert('上传成功!');
      bigCurrent.style.width = '100%';
      bigLinks.href = data.data.data
    } else {
      alert(data.data.msg);
    }
  }
</script>

</html>

后端接口代码(nodejs)
app.js

const Koa = require('koa');
const Router = require('koa-router');
const cors = require('koa2-cors');
const path = require('path');
const KoaStatic = require('koa-static');
const koaBody = require('koa-body'); //解析上传文件的插件
const fs = require('fs');
const baseUrl = 'http://localhost:3333';
const utils = require('./utils')



// 创建一个Koa对象表示web app本身:
const app = new Koa();
app.use(cors());
// 使用koa-static处理静态资源
app.use(KoaStatic('./'));

app.use(koaBody({
  uploadDir: path.join(__dirname, 'upload/'), // 设置文件上传目录
  multipart: true,
  formidable: {
    maxFileSize: 2000 * 1024 * 1024    // 设置上传文件大小最大限制,默认2M
  }
}))

// 上传文件处理
function uploadFn (ctx, destPath) {
  return new Promise((resolve, reject) => {
    const file = ctx.request.files.file
    const reader = fs.createReadStream(file.path)
    let filePath = path.join(__dirname, 'upload/' + `/${file.name}`)
    const upStream = fs.createWriteStream(filePath)
    reader.pipe(upStream)
    resolve(file.name)
  })
}

let testApi = new Router();

// 上传图片
testApi.post('/testApi/uploadImg', async ctx => {
  await uploadFn(ctx).then((name) => {
    ctx.body = {
      code: 200,
      data: baseUrl + '/upload/' + name,
      msg: '文件上传成功'
    }
  })
});

// 上传大文件
testApi.post('/testApi/uploadBigFile', async ctx => {
  let type = ctx.request.query.type;
  let md5Val = ctx.request.query.md5Val;
  let total = ctx.request.query.total;
  let current = ctx.request.query.current;
  let ext = ctx.request.query.ext;
  if (!type) {
    return ctx.body = {
      code: 101,
      msg: '上传类型不能为空!'
    }
  }
  if (!md5Val) {
    return ctx.body = {
      code: 101,
      msg: '文件md5值不能为空!'
    }
  }
  if (!total) {
    return ctx.body = {
      code: 101,
      msg: '文件切片数量不能为空!'
    }
  }





  // 合并文件
  if (type == 'merge') {
    if (!ext) {
      ctx.body = {
        code: 101,
        msg: '文件名后缀不能为空'
      }
    }
    let oldPath = path.join(__dirname, `upload/bigFile/${md5Val}`);
    let newPath = path.join(__dirname, `upload/resultBigFile/${md5Val}${ext}`);
    let data = await utils.mergeFile(oldPath, newPath);
    if (data.code == 200) {
      ctx.body = {
        code: 200,
        data: `${baseUrl}/upload/resultBigFile/${md5Val}${ext}`,
        msg: '文件合并成功!'
      }
    } else {
      ctx.body = {
        code: 101,
        msg: '文件合并失败!'
      }
    }
  }

  // 检查文件是否存在
  if (type == 'check') {
    // 秒传_判断文件夹中是否存在相同得到md5文件名称
    if (fs.existsSync(path.join(__dirname, `upload/resultBigFile/${md5Val}${ext}`))) {
      return ctx.body = {
        code: 220,
        data: `${baseUrl}/upload/resultBigFile/${md5Val}${ext}`,
        msg: '上传成功'
      }
    }
    let curFileDir = path.join(__dirname, `upload/bigFile/${md5Val}`)
    utils.checkDirExist(curFileDir)
    console.log("ssssssss")
    let curFileArray = await fs.readdirSync(curFileDir)

    ctx.body = {
      code: 221,
      data: curFileArray.length,
      msg: '获取成功!'
    }

  }

  // 上传文件
  if (type == 'upload') {
    // 正常分片上传
    let dir = path.join(__dirname, `upload/bigFile/${md5Val}`);
    utils.checkDirExist(dir)
    const file = ctx.request.files.bigFile
    const reader = fs.createReadStream(file.path)
    let filePath = path.join(__dirname, `upload/bigFile/${md5Val}/${md5Val}_${current}`)
    const upStream = fs.createWriteStream(filePath)
    reader.pipe(upStream)
    ctx.body = {
      code: 200,
      msg: '获取成功!'
    }
  }
});

app.use(testApi.routes());

// 在端口3333监听:
app.listen(3333);
console.log(`服务器已启动  http://localhost:3333`)
utils.js
const fs = require('fs');

module.exports = {
  // 传入文件夹的路径看是否存在,存在不用管,不存在则直接创建文件夹
  checkDirExist: ((p) => {
    if (!fs.existsSync(p)) {
      fs.mkdirSync(p);
    }
  }),

  //获取路径文件信息(文件名、后缀名)
  getFileInfo: ((filePath, key) => {
    filePath = filePath.split('?')[0]; //去参数
    var re = /([^\.\/\\]+)\.([a-z]+)$/i,
      resultArr = re.exec(filePath),
      info = {};
    if (resultArr) {
      info.name = resultArr[1];
      info.type = resultArr[2];
    }
    return key ? info[key] : info;
  }),

  mergeFile: ((filePath, newPath) => {
    return new Promise((resolve, reject) => {
      let files = fs.readdirSync(filePath);
      filesArray = []
      files.forEach(el => {
        filesArray.push({ file: el, sortNum: el.split('_')[1] })
      })
      let newFile = fs.createWriteStream(newPath);
      let filesArr = filesArray.sort(function (a, b) { return a.sortNum - b.sortNum });
      main();
      function main (index = 0) {
        let currentFile = filePath + '/' + filesArr[index].file;
        let stream = fs.createReadStream(currentFile);
        stream.pipe(newFile, { end: false });
        stream.on('end', function () {
          if (index < filesArr.length - 1) {
            index++;
            main(index);
          } else {
            resolve({ code: 200 });
          }
        })
        stream.on('error', function (error) {
          reject({ code: 102, data: { error } })
        })
      }

    })
  })
}

4.项目git地址:
https://gitee.com/momobeizi/big-file-upload.git

posted @ 2022-03-28 09:39  明月南楼  阅读(538)  评论(0)    收藏  举报