20211028


文件上传

1. 文件上传场景

img

2.大文件分块上传

在上传大文件时,为了提高上传的效率,我们一般会使用 Blob.slice 方法对大文件按照指定的大小进行切割,然后通过多线程进行分块上传,等所有分块都成功上传后,再通知服务端进行分块合并

需要用到FormDataFileReaderFileBlob对象

  1. 如何实现大文件分块上传?

    获取上传文件–>计算文件的md5–>判断文件是否存在–>如果不存在则实行并发分片上传–>上传全部分块执行合并操作

  2. 文件md5

    使用spark-md5),分片上传需要一个唯一标识,而md5非常合适,使用的是ArrayBuffer方法,另一种是用SparkMD5.hashBinary( ) 直接将整个文件的二进制码传入直接返回文件的md5、这种方法对于小文件会比较有优势——简单并且速度快

    const calcFileMD5 = (file) => {
      return new Promise((resolve, reject) => {
        let chunkSize = 2097152, // 2M
          chunks = Math.ceil(file.size / chunkSize),
          currentChunk = 0,
          spark = new SparkMD5.ArrayBuffer(),
          fileReader = new FileReader();
       
        fileReader.onload = (e) => {
          spark.append(e.target.result);
          currentChunk++;
          if (currentChunk < chunks) {
            loadNext();
          } else {
            resolve(spark.end()); // 输出结果
          }
        };
       
        fileReader.onerror = (e) => {
          reject(fileReader.error);
        };
       
        function loadNext() {
          let start = currentChunk * chunkSize,
            end = start + chunkSize >= file.size ? file.size : start + chunkSize;
          fileReader.readAsArrayBuffer(file.slice(start, end));
        }
        loadNext();
      });
    }
    1. 判断文件是否上传

      function checkFileExist(url, name, md5) {
        return request.get(url, {
          params: {
            name,
            md5,
          },
        }).then((response) => response.data);
      }
      // node逻辑
      const fse = require('fs-extra');
      
      router.get("/upload/exists", async (ctx) => {
        const { name: fileName, md5: fileMd5 } = ctx.query;
        const filePath = path.join(UPLOAD_DIR, fileName);
        const isExists = await fse.pathExists(filePath); // 判断哪是否已经存在文件
        if (isExists) {
          ctx.body = {
            status: "success",
            data: {
              isExists: true,
              url: `${RESOURCE_URL}/${fileName}`,
            },
          };
        } else {
          let chunkIds = [];
          const chunksPath = path.join(TMP_DIR, fileMd5);
          const hasChunksPath = await fse.pathExists(chunksPath);
          // 判断分块文件夹是否相同
          if (hasChunksPath) {
            let files = await readdir(chunksPath);
            chunkIds = files.filter((file) => {
              return IGNORES.indexOf(file) === -1;
            });
          }
          ctx.body = {
            status: "success",
            data: {
              isExists: false,
              chunkIds,
            },
          };
        }
      });
      1. 并发控制,参考async-pool

        asyncPool 函数,它用于实现异步任务的并发控制。该函数接收 3 个参数:

        • poolLimit(数字类型):表示限制的并发数;
        • array(数组类型):表示任务数组;
        • iteratorFn(函数类型):表示迭代函数,用于实现对每个任务项进行处理,该函数会返回一个 Promise 对象或异步函数。
        async function asyncPool(poolLimit, array, iteratorFn) {
          const ret = []; // 存储所有的异步任务
          const executing = []; // 存储正在执行的异步任务
          for (const item of array) {
            // 调用iteratorFn函数创建异步任务
            const p = Promise.resolve().then(() => iteratorFn(item, array));
            ret.push(p); // 保存新的异步任务
        
            // 当poolLimit值小于或等于总任务个数时,进行并发控制
            if (poolLimit <= array.length) {
              // 当任务完成后,从正在执行的任务数组中移除已完成的任务
              const e = p.then(() => executing.splice(executing.indexOf(e), 1));
              executing.push(e); // 保存正在执行的异步任务
              if (executing.length >= poolLimit) {
                await Promise.race(executing); // 等待较快的任务执行完成
              }
            }
          }
          return Promise.all(ret);
        }
        1. upload函数

          如果文件没有上传则执行此操作

          function upload({ 
            url, file, fileMd5, 
            fileSize, chunkSize, chunkIds,
            poolLimit = 1,
          }) {
            const chunks = typeof chunkSize === "number" ? Math.ceil(fileSize / chunkSize) : 1;
            return asyncPool(poolLimit, [...new Array(chunks).keys()], (i) => {
              if (chunkIds.indexOf(i + "") !== -1) { // 已上传的分块直接跳过
                return Promise.resolve();
              }
              let start = i * chunkSize;
              let end = i + 1 == chunks ? fileSize : (i + 1) * chunkSize;
              const chunk = file.slice(start, end); // 对文件进行切割
              return uploadChunk({
                url,
                chunk,
                chunkIndex: i,
                fileMd5,
                fileName: file.name,
              });
            });
          }

          对于切割完的文件块,会通过 uploadChunk 函数,来执行实际的上传操作:

          function uploadChunk({ url, chunk, chunkIndex, fileMd5, fileName }) {
            let formData = new FormData();
            formData.set("file", chunk, fileMd5 + "-" + chunkIndex);
            formData.set("name", fileName);
            formData.set("timestamp", Date.now());
            return request.post(url, formData);
          }
          1. 合并操作,node中使用的是createReadStreamcreateWriteStream以流的方式来读写文件

            function concatFiles(url, name, md5) {
              return request.get(url, {
                params: {
                  name,
                  md5,
                },
              });
            }
            // node逻辑
            
            const util = require("util");
            const fs = require("fs");
            const readdir = util.promisify(fs.readdir);
            
            router.get("/upload/concatFiles", async (ctx) => {
              const { name: fileName, md5: fileMd5 } = ctx.query;
              await concatFiles(
                path.join(TMP_DIR, fileMd5),
                path.join(UPLOAD_DIR, fileName)
              );
              ctx.body = {
                status: "success",
                data: {
                  url: `${RESOURCE_URL}/${fileName}`,
                },
              };
            });
            
            // 合并文件使用流的方式
            async function concatFiles(sourceDir, targetPath) {
              const readFile = (file, ws) => // 写入目标文件
                new Promise((resolve, reject) => {
                  fs.createReadStream(file)
                    .on("data", (data) =>ws.write(data))
                    .on("end", resolve)
                    .on("error", reject);
                });
              const files = await readdir(sourceDir); // 读取目录下的所有的分块文件
              const sortedFiles = files
              .filter((file) => {
                return IGNORES.indexOf(file) === -1;
              })
              .sort((a, b) => a - b);
              const writeStream = fs.createWriteStream(targetPath);
              // 循环进行写入
              for (const file of sortedFiles) {
                let filePath = path.join(sourceDir, file);
                // console.log(filePath, 'filePath');
                await readFile(filePath, writeStream);
                await unlink(filePath); // 删除已合并的分块
              }
              writeStream.end();
             }
            

3.ArrayBuffer

  1. ArrayBuffer又称类型化数组,是一个二进制数据的原始缓存区(分配一段可以存放数据的连续内存区域),无法直接读取或者写入只能通过视图(TypedArray视图和DataView视图)来读写,视图的作用是以指定格式解读二进制数据。

  2. 数组是放在堆中,ArrayBuffer数组则把数据放在栈中(所以取数据时后者快)

  3. 可以根据需要传递类型化数组或者DataView对象来解释原始缓存区

  4. ArrayBuffer初始化后固定大小,数组则可以自由增减。

    类型化数组有以下几种

    名称 占用字节 描述

    Int8Array 1 8位二补码有符号整数

    Uint8Array 1 8位无符号整数

    Uint8ClampedArray 1 8位无符号整型固定数组(数值在0~255之间)

    Int16Array 2 16位二补码有符号整数

    Uint16Array 2 16位无符号整数

    Int32Array 4 32 位二补码有符号整数

    Uint32Array 4 32 位无符号整数

    Float32Array 4 32 位 IEEE 浮点数

    Float64Array 8 64 位 IEEE 浮点数

  5. 这个接口的原始设计的目的,与WebGL项目有关。所谓WebGL,就是指浏览器与显卡之间的通信接口,为了满足JavaScript与显卡之间大量的、实时的数据交换,他们之间的数据通信必须是二进制的,而不能是传统的文本格式,文本格式传递一个32位整数,两端的JavaScript脚本和显卡都要进行格式转化,将非常耗时。这时要是存在一种机制,可以像C语言一样,可以直接操作字节,将4个字节的32位整数,以二进制形式原封不动的送入显卡,脚本的性能就会大幅提升。直接操作内存,大大增强了JavaScript处理二进制数据的能力,使得开发者有可能通过JavaScript与操作系统的原生接口进行二进制通信

4、项目地址

https://github.com/rookiewxy/electron-react-server

https://github.com/rookiewxy/electron-react-demo

5、参考资料

https://juejin.cn/post/6980142557066067982#heading-15


Author: wxy
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint policy. If reproduced, please indicate source wxy !
  TOC