大文件上传之分片上传、断点续传、秒传(附源码)

1,847 阅读8分钟

开始

最近遇到需要上传文件的需求,脑子里面想的就是:什么?文件上传,这能有什么难度,直接FormData包装一下,扔个后端就行,但是在开发中却总是收到反馈,上传几百兆以上的文件总是经常失败,然后重试又很慢,还非常浪费时间。就算上传成功,下次上传同样的文件又要重新开始上传,对用户体验也是非常的差。

怎么解决呢? 作为一个合格的前端,有问题当然得找后端,这事吧你看怎么解决的好,想办法搞下服务器性能。后端曰:硬件不够,有心无力,我:...。仔细一想这也没毛病,大文件传输太多的报文,丢包重传的概率也很大。既然这样,那就切片上传吧。查了一些资料,然后把自己总结的一些思路和实现分享出来。

本文会通过 前端(react+antd)  和 服务端(nodejs + express)  交互的方式,实现大文件切片上传过程。

建议配合源码阅读fileUpload

整体思路

我们都知道Blob它表示原始数据,也是二进制数据,同时提供数据截取的方法slice,而 FileBlob的子类继承了slice方法,所以可以利用slice方法将文件分割成N份,并对文件MD5加密,然后请求接口并行上传,服务端获取当前加密后的hash生成一个临时文件夹存放当前上传的分片。

所有分片上传完成后分片进行合并得到完整的文件,并把当前文件的hash存进数据库(因为上传的文件可能会有存储在其他服务器中,所以为了方便,可以把上传的文件信息单独存在数据库中),下次上传时候检查当前文件是否已经上传---秒传

当分片上传失败,可以在重新上传时进行判断,只上传上次失败的部分,减少用户的等待时间,缓解服务器压力---断点续传

流程图如下:

绘图2.png

实现步骤

MD5加密

秒传续传需要用到文件的hash,MD5 则是文件的唯一标识,可以利用文件的MD5查询文件是否已经上传或者查询当前文件的上传状态,SparkMD5是md5算法的快速md5实现。

  • SparkMD5 需要接收string或者buffer,而我们拿到的是一个文件对象,所以需要借助浏览器提供的文件读取对象FileReader来读文件,使用方式:new FileReader().readAsArrayBuffer(file)
  • 再利用onload方法就可以循环读取分割的文件。
const fileUpload = ({ file, chunkSize = 1024 * 100 }) => {
/** 读取文件输入异步操作 */
  return new Promise((resolve, reject) => {
    let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
      chunks = Math.ceil(file.size / chunkSize),
      currentChunk = 0,
      spark = new SparkMD5.ArrayBuffer(),
      fileReader = new FileReader(),
      fileChunkList = []; // 切片集合
    fileReader.onload = function (e) {
      spark.append(e.target.result);
      currentChunk++;
      if (currentChunk < chunks) {
        loadNext();
      } else {
        resolve({ fileChunkList, hash: spark.end(), size: file.size, name: file.name });
      }
    };
    fileReader.onerror = () => {
      reject("文件读取失败");
    }
    /** 切割文件 */
    function loadNext() {
      let start = currentChunk * chunkSize,
        end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize,
        fileChunk = blobSlice.call(file, start, end);
      fileChunkList.push({
        fileChunk,
        currentChunk,
        size: fileChunk.size,
      })
      /** 读取文件 */
      fileReader.readAsArrayBuffer(fileChunk);
    }
    loadNext();
  })
}

现在,文件已切片完成,文件分割后我们可以得到一个包含文件分片文件hash大小名字的一个对象

{ fileChunkList, hash, size, name }

文件状态

查询数据库

数据库为了方便操作,设计的相对简单:

image.png

前端得到MD5过后,先查询是否在数据库中已经存在。

const express = require('express')
const router = express.Router()

/** 根据文件hash在数据查询当前文件是否已经上传 */
router.post('/queryFileByHash', (req, res) => {
  const { fileHash } = req.body;
  const sql = `select * from file where fileHash = ?`
  db.query(sql, [fileHash], function (err, results) {
    if (err) {
      res.send({ code: 500, success: false, msg: '查询失败' });
      return;
    }
    res.send({ code: 200, success: true, data: results });
  })
});

如果不存在,则下一步:

查询文件夹

  • 接口会根据当前文件的MD5去查找是否存在文件夹并把当前文件夹下所有文件名返给前端,如果没有查询到,则返回空数组。
  • 前端拿到查询数据过滤掉已经上传的切片。
  • 上传过滤后的所有文件切片。
const express = require('express')
const router = express.Router()
const path = require("path");
const fs = require("fs");
const FILE_STORE_PATH = path.resolve(__dirname, '../static/'),
  WRITE_PATH = path.resolve(__dirname, '../resources');
  
/** 根据文件hash在查询当前文件夹所有文件 */
router.post('/queryDirFileByHash', (req, res) => {
  const { fileHash } = req.body;
  const tempStoragePath = `${FILE_STORE_PATH}/${fileHash}`;
  if (!fs.existsSync(tempStoragePath)) return res.send({ code: 200, success: true, data: [] });
  const fileList = fs.readdirSync(tempStoragePath);
  res.send({ code: 200, success: true, data: fileList });
});

image.png

上传分片

调用 Promise.all 并发上传所有的切片,将切片序号、切片文件、文件 MD5 传给后台。

后台接收到上传请求后,首先查询数据库里面是否存在,如果存在,则提示上传成功。,否则查询名称为文件 MD5 的文件夹是否存在,不存在则创建文件夹,后台采用了multer中间件来处理文件上传。
服务端代码如下:

const express = require('express')
const multer = require("multer");
const router = express.Router();
const path = require("path");
const fs = require("fs");
const FILE_STORE_PATH = path.resolve(__dirname, '../static/'),
  WRITE_PATH = path.resolve(__dirname, '../resources');
  
const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    const { fileHash } =  req.body;
    /** 根据文件MD5动态生成文件夹 */
    const tempStoragePath = `${FILE_STORE_PATH}/${fileHash}`;
    if (!fs.existsSync(tempStoragePath)) {
      fs.mkdirSync(tempStoragePath, { recursive: true });
    }
    cb(null, tempStoragePath);
  },
  filename: function (req, file, cb) {
    cb(null, req.body.fileChunkName);
  }
})
router.post('/upload', upload.single("file"), (req,res)=>{
    res.send({ 'code': 200, success: true });
});

前端代码:

const handleUpload = () => {
    fileUpload({ file: fileList[0] }).then(async (fileMsg) => {
      let fileChunkList = fileMsg.fileChunkList;
      try {
        /** 根据文件md5查询数据库 */
        const queryFileByHashRes = await queryFileByHash({ fileHash: fileMsg.hash });
        if (queryFileByHashRes.data.data.length) {
          message.success("文件上传成功");
          return;
        }
        /** 根据文件md5查询文件夹下文件(秒传) */
        const queryDirFileByHashRes = await queryDirFileByHash({ fileHash: fileMsg.hash });
        const { data } = queryDirFileByHashRes.data; 
        /** 过滤掉已经上传成功的分片(断点续传) */
        if(data.length){
          fileChunkList = fileMsg.fileChunkList.filter(item=>!data.includes(String(item.currentChunk)));
        }
      } catch (error) {
        message.error(error);
        fileChunkList = [];
      }
      const reqs = fileChunkList.map((item, index) => {
        let formData = new FormData();
        formData.append('fileChunkName', item.currentChunk);
        formData.append('fileHash', fileMsg.hash);
        // 文件切片(尽量放在最后,node中间件multer可能会获取不到)
        formData.append('file', item.fileChunk);
        // 调用上传接口
        return upload(formData, (e) => {});
      })
      Promise.all(reqs).then(() => {
        message.success("上传成功");
        // 合并分片
        mergeFile({ fileName: fileMsg.name, fileHash: fileMsg.hash });
      }).catch(()=>{
        message.error("上传失败")
      })
    })
  };

当全部分片上传成功,通知服务端进行合并,当有一个分片上传失败时,提示“上传失败”。在重新上传时,通过文件 MD5 得到文件的上传状态,当服务器已经有该 MD5 对应的切片时,代表该切片已经上传过,无需再次上传,当服务器找不到该 MD5 对应的切片时,代表该切片需要上传,用户只需上传这部分切片,就可以完整上传整个文件,这就是文件的断点续传

上传进度条

文件虽然被拆分,但是全部上传完还是需要一定的时间,此时可以做一些进度条之类的交互,实时显示文件上传进度。显示进度条有两种显示方式:

  1. 每个文件分片上传过程中的进度都提示出来。
  2. 只对已经上传成功的文件分片提示总的进度。(此项目使用)。

xhr中提供了上传进度的事件progress,项目中使用的是axiosaxios在配置时提供了onUploadProgress监听原生progress事件。

const upload = (data,onUploadProgress=()=>{})=>{
    return axios({
        url:'/api/upload',
        data,
        method:'POST',
        onUploadProgress
    })
}
// 进度条
const handleUpload = () => {
    fileUpload({ file: fileList[0] }).then(async (fileMsg) => {
      setChunkTotal(fileMsg.fileChunkList.length);
      let fileChunkList = fileMsg.fileChunkList;
      try {
        /** 根据文件md5查询数据库 */
        ...
        if(data.length){
          fileChunkList = fileMsg.fileChunkList.filter(item=>!data.includes(String(item.currentChunk)));
          setUploadChunkNum(data.length);
        }
      } catch (error) {
        ...
      }
      const reqs = fileChunkList.map((item, index) => {
        ...
        return upload(formData, (e) => {
          // 当前分片全部上传完成
          if (e.loaded === e.total) {
            setUploadChunkNum((uploadChunkNum) => uploadChunkNum + 1);
          }
        });
      })
      ...
  };

合并文件

上传完所有文件分片后前端通知服务器进行合并,服务端接收带通知后根据请求携带参数:文件MD5(查找当前文件下所有文件)和文件名然后合并文件。
需要注意的是:因为服务端存的分片命名是前端在切片时约定生成的(此项目根据切片的index),所以在合并文件时为了合并的顺序准确,需要先对所有文件进行排序。

  • fs.readdirSync读取文件夹下所有文件
  • 遍历读取的文件列表通过fs.readFileSync缓冲到Buffer
  • 然后通过fs.createWriteStream写入Buffer
  • 合并成功后删除文件夹
  • 最后再把文件名文件MD5存进数据库
const express = require('express')
const router = express.Router();
const path = require("path");
const fs = require("fs");
const fsPromise = require("fs/promises")
const db = require("../db")
const { Buffer } = require("buffer")

const FILE_STORE_PATH = path.resolve(__dirname, '../static/upload/'),
  WRITE_PATH = path.resolve(__dirname, '../resources');
  
router.post('/mergeFile',(req,res)=>{
    if (!fs.existsSync(WRITE_PATH)) fs.mkdirSync(WRITE_PATH);
  const { fileName, fileHash } = req.body;
  // 插入当前文件信息
  const insertFileMeg = () => {
    const sql = 'INSERT INTO file (fileHash,fileName) VALUES (?,?)';
    db.query(sql, [fileHash, fileName], function (err, results) {
      if (err) {
        res.send({ code: 500, success: false, msg: '文件信息存储失败' });
        return;
      }
      res.send({ code: 200, success: true, msg: "文件处理成功" });
    })
  }
  try {
    let len = 0
    const bufferList = fs.readdirSync(FILE_STORE_PATH).sort((a, b) =>
      parseInt(a.split('.')[0] - parseInt(b.split('.')[0]))).map((hash, index) => {
        const buffer = fs.readFileSync(`${FILE_STORE_PATH}/${hash}`)
        len += buffer.length;
        return buffer;
      });
    const buffer = Buffer.concat(bufferList, len);
    const ws = fs.createWriteStream(`${WRITE_PATH}/${fileName}`)
    ws.write(buffer);
    ws.close();
    /** 递归删除static文件夹下所有内容 */
    fsPromise.rm(path.resolve(__dirname, FILE_STORE_PATH), { recursive: true, force: true })
    // 文件写入完成,把当前文件信息存进数据库
    insertFileMeg();
  } catch (error) {
    res.send({ code: 500, msg: error, success: false })
  }
})

优化

细心的读者可能会发现,当文件超过上G时,系统计算hash就很耗时也很吃内存并且会阻塞主线程。这种问题目前web上传大文件暂时无解,某云web端上传文件大小也有限制,超过一定文件大小必须在客户端上传。

  • 此时可以参考ReactFiber架构,通过requestIdleCallback来利用浏览器的空闲时间计算,也不会卡死主线程。
  • 通过# Web Workers处理。

最后

大文件切片上传到此就结束了,希望本文可以对你有一些帮助,文中如果存在缺陷或者错误的地方,欢迎指出。

源码参考:github.com/javascript-… ,阅读源码过后可快速上手大文件上传,感谢阅读!