文件分片上传前后端全过程(js+express保姆级教程喂宝宝版)

165 阅读11分钟

按照惯例,在正式开始之前要吟唱一段前言,这也算是武林高手里开打前的起手式了。本篇文章所用代码写在两年前,不过非常可惜这篇代码在我手里到现在还没有实际的应用场景。在开始前得提前声明一下免责事项,因为是两年前写的demo,故代码的时效性不做保证,仅供各位看官参考。废话少说,锣鼓一响好戏开场。

原理

还是按照惯例(别怪我话多,平时没人跟我说话憋的,大家见谅)开始介绍一下该功能的实现原理,文件的分片上传及续传,核心就是文件的分片,而前端能做到文件的分片依靠的就是blob对象下的slice方法。当然了,前端没办法直接获取blob文件了,我们通过type=file的input拿到的是file对象。不过File是继承于Blob对象的,mdn还特地标注了一句***File 接口还继承了 Blob 接口的方法。** *所以我们可以直接拿到file对象,然后通过file对象的slice方法对文件进行切片。

那么后端呢?这里以Node.js为例哈。后端也是可以对分片的文件进行拼接的,这里卖个关子,用到的是啥API后面会介绍,不过原理都是读取文件然后按顺序粘在一起组合成一个完整的文件。

怎么样,原理是不是非常简单,是不是已经跃跃欲试,那我们就正式开始吧。

前端部分

本来是打算做线性叙事的,对部分需要注意的点可能更好理解,但是这样步骤标题可能让人有点晕头转向,所以还是按前后端的部分划分。

step1:选择文件

既然要做文件的分片上传,第一步当然是获取文件了

     <head>
         <meta charset="UTF-8" />
         <meta name="viewport" content="width=device-width, initial-scale=1.0" />
         <title>Document</title>
         <style>
           .progress {
             width: 300px;
             height: 10px;
             background-image: linear-gradient(90deg, pink, skyblue);
             background-size: 0%;
             background-repeat: no-repeat;
             border: 1px solid red;
           }
         </style>
       </head>
       <body>
         <input type="file" id="file" />
         <div class="progress"></div>
         <span class="progress-num">0</span>
         <button id="btn">上传按钮</button>
       </body>

通过文件输入框去选择文件,progress用于显示上传的进度,progress-num显示上传的进度数字,按钮用于触发上传。页面搭建完毕接下来就是逻辑了。接下来我们对文件进行处理。

step2: 获取文件

本来这快觉得没必要写的,想了想既然是喂给宝宝的,还是加上吧

         const fileDom = document.querySelector("#file");
         const btn = document.querySelector("#btn");
         const progress = document.querySelector(".progress");
         const progressNum = document.querySelector(".progress-num");
     ​
     ​
         let formData = new FormData();
         let files = null;
         let percent = 0;
     ​
         fileDom.addEventListener("change", (e) => {
           files = e.target.files;
         });
     ​
         btn.addEventListener("click", function (e) {
           if (files.length === 1) {
             sliceUpload(files[0], 10 * 1024 * 1024);
           }
         });

没错,本次示例只做单文件的分片上传,至于多文件就需要大家自己开动脑筋补上了。sliceUpload方法是后面我们的重点,对文件分片及上传。

step3:文件分片及上传

文件分片及上传这里放一起单纯是因为我懒,这里其实是可以分为两个部分的——分片和上传。

1.分片

首先说分片吧,还记得上面的sliceUpload方法吗,有两个参数,一个是file用于分片的文件,还有一个是分片的大小,这里我定的是10M。首先我们获取一下要分多少片

     let pieces = Math.ceil(file.size / maxChunkSize);

其实文件的分片很好做的,但是告诉后端,这个分片是不是这个分片(有点哲学那味儿了)是比较麻烦的,所以我们需要给每个分片取一个名字,类似于我们的身份证ID。这里我们用到的是spark-md5库,这个库可以根据MD5算法来根据文件生成一个唯一的hash值。这里我用的是原生的HTML,所以直接下载了js文件然后引入。

     const spark = new SparkMD5.ArrayBuffer();
     let index = 0; // 当前操作的文件切片索引
     let tempFile = file.slice(maxChunkSize * index, maxChunkSize * ++index); //当前操作的文件切片
     let fileChunkList = []; // 存放切片后文件

这里用的是SparkMD5的ArrayBuffer方法,所以我们需要使用FileReader将切片后的临时文件读取为ArrayBuffer,然后生成hash。因为这里的切片文件一定是多个,所以这里我封装了一个递归方法用于递归生成。

       const loadNext = () => {
         let tempFile = file.slice(maxChunkSize * index, maxChunkSize * ++index);
         const reader = new FileReader();
         reader.readAsArrayBuffer(tempFile);
         reader.onload = (e) => {
           spark.append(e.target.result);
           fileChunkList.push({
             file: tempFile,
             hash: SparkMD5.ArrayBuffer.hash(e.target.result),
             index,
           });
           // 递归计算下一个切片
           loadNext();
         };
       };
2.web-worker优化处理

这里生成文件hash的操作比较耗时的,如果文件大一点的情况下尤为明显,这个时候我就想到了Web Worker。我们可以将这一步操作放在webworker中进行,等到读取完毕了以后传递消息给页面然后让用户上传:

     // filemd5.js
     self.importScripts("../spark-md5.min.js");
     ​
     self.onmessage = (e) => {
       let { file, maxChunkSize } = e.data;
       let pieces = Math.ceil(file.size / maxChunkSize);
       let index = 0;
       let fileChunkList = [];
       const spark = new SparkMD5.ArrayBuffer();
       const loadNext = () => {
         let tempFile = file.slice(maxChunkSize * index, maxChunkSize * ++index);
         const reader = new FileReader();
         reader.readAsArrayBuffer(tempFile);
         reader.onload = (e) => {
           spark.append(e.target.result);
           fileChunkList.push({
             file: tempFile,
             hash: SparkMD5.ArrayBuffer.hash(e.target.result),
             index,
           });
           if (index === pieces) {
             // 切片处理完毕后发送消息给页面
             self.postMessage({
               hash: spark.end(),
               fileChunkList,
             });
             self.close();
           }
           // 递归计算下一个切片
           loadNext();
         };
       };
       loadNext();
     };
     ​

然后在页面中我们定义一个方法去使用这个worker

         const getFileMd5 = (maxChunkSize) => {
           return new Promise((resolve, reject) => {
             let worker = new Worker("./web-worker/fileMd5.js");
             worker.postMessage({ file: files[0], maxChunkSize });
             worker.onmessage = (e) => {
               resolve(e.data);
             };
             worker.onerror = (err) => {
               reject(err);
             };
           });
         };

这样我们就可以得到这个文件的切片列表以及整个文件的hash。

3.上传切片

切片处理好以后我们就可以将切片文件上传了。

           let successCount = 0;
           let { fileChunkList, hash } = await getFileMd5(maxChunkSize);
           fileChunkList.forEach((item) => {
             let formData = new FormData();
             formData.append("pieceHash", item.hash + "_" + item.index); // 分片的MD5 索引用于后端进行分片合成
             formData.append("fileHash", hash); // 文件MD5
             formData.append("file", item.file);
             formData.append("fileName", file.name); // 后端合成后的文件名称
             formData.append("totalCount", pieces); // 分片总数,用于后端判断是否进行合成文件分片
             const controller = new AbortController();
             fileChunkList.abortController = controller;
             new Promise((resolve, reject) => {
               // 这里换成自己的服务地址就好了
               fetch("http://10.0.0.0:9527/upload/cutFile", {
                 method: "post",
                 mode: "cors",
                 body: formData,
                 signal: controller.signal,
               })
                 .then((res) => {
                   res
                     .json()
                     .then((result) => {
                       resolve(result);
                     })
                     .catch((err) => {
                       resolve(err);
                     });
                 })
                 .catch((err) => {
                   reject(err);
                 });
             }).then((res) => {
               successCount += 1;
               percent = (100 / pieces) * successCount;
               progressNum.innerHTML = percent;
               progress.style.backgroundSize = percent + "%";
             });
           });

这里要注意的是我这里测试用的切片数量不多,所以可以直接用循环去上传,大家在具体使用的时候要注意浏览器的最大请求并发数。这里其实可以用一个队列去做请求的控制,虽然后面我写了一个请求队列控制的方法,但是这里就暂时这样吧,正如我上面说的,我很懒。后面有机会再写一个关于请求控制的方法吧。

前端部分到这里就差不多了,是不是非常简单?接下来我们转到后端部分。

后端部分

后端我用的是express,虽然是保姆级教程,但是搭建一个基础的express服务也实在是没有必要再写了,我们直接看代码吧。

step1:接收切片文件

首先我们需要创建一个路由,然后在这里拿到前端传过来的文件切片。这里我用的是multer中间件来处理formdata的数据(也可以用别的),注意这个中间件只能处理formdata的数据。顺便说一下,用了cors中间件来设置跨域(也可以自己写)

     const multer = require("multer");
     const cors = require("cors");
     ​
     // 跨域配置
     const corsOptions = {
       // origin: "http://10.0.0.0:5500/", // 指定允许的来源
     };
     ​
     app.use(cors(corsOptions)); //利用cors中间件设置跨域
     app.use("/static", express.static("public")); //开启静态目录
     ​
     app.post("/upload/cutFile", uploadPieceHandler.array("file"), async (req, res) => {
       ...
     });

step2: formdata数据处理

上面可以看到我们在路由的第二个参数使用到了一个中间件配置,这个配置就是multer返回的一个配置,现在我们看看这个配置是如何创建的。

     const uploadPieceHandler = multer({
       limits: {
         fileSize: 1024 * 1024 * 10.5, // 限制文件大小为10M(0.5M误差)
       },
       fileFilter: (req, file, cb) => {
         // 切片后的文件类型
         const allowedTypes = ["application/octet-stream"];
         if (!allowedTypes.includes(file.mimetype)) {
           cb(new Error("Invalid file type."));
           return;
         }
         cb(null, true);
       },
       // 自定义文件存储设置
       storage: multer.diskStorage({
         destination: (req, file, cb) => {
           let defaultRootDirPath = __dirname + "/uploads/"; // 分片文件存放地址
           // 如果根目录没有uploads文件夹就创建
           if (!fs.existsSync("uploads")) {
             fs.mkdirSync("uploads");
           }
           let fileDirPath = defaultRootDirPath + req.body.fileHash; // 使用整体文件hash作为文件夹名
           if (!fs.existsSync(fileDirPath)) {
             fs.mkdirSync(fileDirPath);
           }
           cb(null, fileDirPath);
         },
         // 使用文件hash作为单个切片的文件名
         filename: (req, file, cb) => {
           cb(null, `${req.body.pieceHash}`);
         },
       }),
     });

如果有需要其他配置或者有不了解的也可以看看Multer的文档

step3:合并文件

合并文件一共有两种方法——使用Buffer和使用流,这里更推荐使用流。

1.Buffer处理

虽然Buffer的处理方式有很多问题,但是这里还是给大家介绍下吧。使用Buffer时间我们依赖的是Buffer的concat方法,这个方法处理起来比较简单,只要我们按照顺序不停的调用Buffer的concat方法将切片文件拼在一起即可。Buffer的方式较为适合文件小的场景(文件小还分片干什么。。。),因为心智负担不重。

使用buffer;(分片大小不宜超过100kb,实测超过1M时读取的数据不全,buffer长度不正确)

     // 这里传入的文件名列表是已经按之前索引排序过的,这也是为什么前端传递的时候需要在hash后面加上一个索引
     const pasteFileHandle = (fileNameList, hash, fileName) => {
       // 根据文件hash读取指定的文件
       let dirPath = __dirname + "/uploads/" + hash;
        let len = 0;
        let bufferList = [];
        for (let i = 0; i < fileNameList.length; i++) {
          const tempBuffer = fs.readFileSync(dirPath + "/" + fileNameList[i]);
          console.log(i, tempBuffer.length);
          len += tempBuffer.length;
          bufferList.push(tempBuffer);
        }
        const resultBuffer = Buffer.concat(bufferList, len);
     };
2.流式处理

文件除了使用Buffer的处理方式我们还可以使用流的方式处理,而我们对切片文件的处理就是使用fs下的createWriteStream方法,这个方法会创建一个可写流。接着我们使用fs.createReadStream方法来创建可读流,然后我们再通过管道pipe进行两个流管道的链接,这样文件就被合并完成了。

     const pasteFileHandle = (fileNameList, hash, fileName) => {
       let dirPath = __dirname + "/uploads/" + hash;
       // 使用流 (实测10M分片大小无影响)
       // 创建目标写入流(这里的路径就是文件路径,最后的就是保存的文件名称)
       const writeStream = fs.createWriteStream(`${dirPath}/${fileName}`);
       // 递归消费每个切片并创建可读流
       const streamMergeRecursive = (fileNameList) => {
         if (!fileNameList.length) {
           // 消费完毕关闭可写流,防止内存泄漏
           writeStream.end();
           console.log("写入完成");
           return;
         }
         // 创建单个分片的可读流
         const pieceStream = fs.createReadStream(dirPath + "/" + fileNameList.shift());
         // 将可读流连接到可写流中
         pieceStream.pipe(writeStream, { end: false });
         // 完毕以后开启下一次递归
         pieceStream.on("end", function () {
           streamMergeRecursive(fileNameList);
         });
         pieceStream.on("error", function (error) {
           // 监听错误事件,关闭可写流,防止内存泄漏
           console.error(error);
           writeStream.close();
         });
       };
       streamMergeRecursive(fileNameList);
     };

step4:逻辑处理

以上三步都算是预备工作,咱们路由的回调方法内部还是空空如也,所以接下来我们就需要根据上面的方法来完成文件的合并,接下来我们就补足路由回调的内部方法逻辑。

     // 拿到参数
     const param = req.body;
     // 随便写的,这里最好还是逻辑处理了再返回
     res.send({
       code: 200,
       content: {},
       message: "请求成功",
     });
     // 因为上传顺序可能错乱,所以这里根据文件夹里的文件数量来判断是否上传完毕
     const checkNeedPaste = (hash, totalCount) => {
       let path = __dirname + "/uploads/" + hash;
       return _fs.readdir(path);
     };
     let fileNameList = await checkNeedPaste(param.fileHash);
     if (fileNameList.length == param.totalCount) {
       // 合并前先根据索引排个序
       fileNameList.sort((a, b) => {
         let prevNum = +a.split("_")[1];
         let nextNum = +b.split("_")[1];
         return prevNum - nextNum;
       });
       pasteFileHandle(fileNameList, param.fileHash, param.fileName);
     }

验证

到这里基本就大功告成了,我们来看看效果(不会以为我还要上传一个演示视频吧?给个图片看看差不多得了)

image.png

image.png

源文件大概是4M多,我这里的切片大小是1M,可以看到文件大小数量也是能对应上的,并且合并后的音频文件也是能正常播放的(嗯,天籁~~)。

文件续传即秒传

提前说好,这俩我都没写代码(不是因为懒,主要是为了省电环保不写闲置代码),单纯就是说下思路。

1.文件续传

文件续传一般出现在这些场景下:

  1. 用户关闭了上传页面
  2. 用户网络中断
  3. 系统崩溃

在这些场景中,用户的某一个文件的部分分片可能上传了部分,也可能都没开始上传。如果还没开始上传那就好说了,直接按流程走就完事了。那如果是部分分片上已经上传了,用户已经上传的这部分切片就没必要发送到服务器了。所以我们可能根据文件hash来通知客户端,哪些切片是已经上传了的。

这个时候我们可以在服务端创建一个检测路由,客户端将文件hash传递过来,然后服务端检测这个文件hash是否已经创建了文件夹。如果创建了服务端就返回该文件夹下的文件名称列表,如果没有创建就返回一个空数组。

     // 随便写写,不保证能跑
     const _fs = require("node:fs/promises");
     ​
     app.post("/checkFile", uploadHandler.single("file"), (req, res) => {
     const {hash} = req.body
      if (!fs.existsSync(fileDirPath)) {
        res.send({
         code: 200,
         content: {result:[]},
         message: "请求成功",
       });
      }else{
       res.send({
         code: 200,
         content: {result:_fs.readdir(__dirname + "/uploads/" + hash)},
         message: "请求成功",
       });
      }
     });

至于前端就简单了,根据接口返回的数组来判断哪些接口不需要上传即可,这里就不写了。

2.秒传

说的吓人,其实就是检查一下文件是不是已经上传过了,如果上传过了服务端直接通知客户端不用上传了,就这么回事。那这里就更好写了,我们不能直接判断hash,因为只要有一个切片这个hash都是会存在的。因为这里我们没用数据库,如果有数据库保存一个状态即可,如果这个文件hash的状态是已上传过自己返回就行,但是这里我们没用数据库咋办嘞,我们可以用文件名来读取合并后的文件,如果能读取到说明文件已经合并。

当然了,文件MD5都读取了,你用切片数量来判断是否上传完毕也是可以的。

这里的判断依据都是每次的切片大小不变、文件名不变以及使用文件名作为服务器合并后的文件名

     // 客户端传文件MD5和名称
     app.post("/checkFile", uploadHandler.single("file"), (req, res) => {
     const {hash, fileName} = req.body
      if (!fs.existsSync(fileDirPath)) {
        res.send({
         code: 200,
         content: {result:[]},
         message: "请求成功",
       });
      }else{
        try {
          const data = fs.readFileSync(filePath);
          res.send({
            code: 200,
            content: {result:'done'}, // 不用传了
             message: "请求成功",
          });
        } catch (error) {
          if (error.code === 'ENOENT') {
            console.log('文件不存在');
          } else {
            console.error('读取错误:', error);
          }
          const fileNameList = _fs.readdir(__dirname + "/uploads/" + hash)
          res.send({
            code: 200,
            content: {result:fileNameList},
            message: "请求成功",
          });
        }
      }
     });

注意事项(免责声明)

以上的代码是仅做学习交流使用的demo,代码健壮性不足以上生产,还有很多地方需要注意随便列举几项:

  1. 文件路径不要使用字符串拼接,最好使用path.join
  2. 客户端的请求并发
  3. 递归深度限制及引用的及时释放
  4. 部分分片可能在上传阶段处理不当而损坏
  5. 对错误的捕获处理
  6. .....

当然了,在实际业务中的处理肯定是更麻烦的,例如用户控制上传的暂停及恢复、断网上传中断及恢复等等,这些实现就不在这里过多赘述了,有兴趣的可以自行实现或查询相关文档。