前后端通过切片上传文件

最近在做前端上传文件,主要包括选择单个|多个文件、单个|多个文件夹、拖拽文件|文件夹方式上传文件,上传时通过分片计算文件hash值实现断点续传、秒传功能,简要介绍下具体实行思路

获取用户选择文件

注意这种方式用到的部分特性是非标准的

拖拽上传

从可拖拽区域中获取过滤获取拖拽的文件的FileSystemEntry

  async drop(e) {
    e.stopPropagation();
    e.preventDefault();

    let items = e.dataTransfer.items;
    let dropFilesEntrys = [];
    for (let item of items) {
      if (!item || !item.webkitGetAsEntry) continue;
      item = item.webkitGetAsEntry();
      if (!item) continue;
      let filesEntrys = await this.getFilesEntrys(item);
      dropFilesEntrys.push(...filesEntrys);
    }
    let files = await this.transformFilesEntrys(dropFilesEntrys);
    
    this.handerFiles(files);
  },

获取FileSystemEntry包含的FilesEntrys,FileSystemEntry可能代表文件系统中的文件或者目录

  /**
   * 获取drop区域的文件
   * @returns FilesEntrys
   */
  async getFilesEntrys(item) {
    let filesEntrys = [];
    let scanFiles = async (item) => {
      return new Promise((resolve) => {
        if (item.isDirectory) {
          let directoryReader = item.createReader();
          directoryReader.readEntries(async (entries) => {
            for (const entrie of entries) {
              await scanFiles(entrie);
            }
            resolve();
          });
        } else {
          filesEntrys.push(item);
          resolve();
        }
      });
    }
    await scanFiles(item);
    return filesEntrys;
  },

获取FilesEntrys中的Files,FilesEntry的file方法返回一个File对象,该对象可用于从文件中读取数据

  /**
   * FilesEntrys转为Files
   */
  async transformFilesEntrys(filesEntrys) {
    let files = [];
    let fileEntry2File = async (fileEntry) => {
      return new Promise((resolve) => {
        fileEntry.file((file) => {
          // 附加文件路径
          file._webkitRelativePath = fileEntry.fullPath;
          resolve(file);
        });
      });
    }
    for (const filesEntry of filesEntrys) {
      files.push(await fileEntry2File(filesEntry));
    }
    return files;
  },

至此我们已经拿到可以用于上传的File对象

通过input选择文件

获取input选择的File对象比较简单通过evt.target.files即可拿到文件

  onFileSelected(evt) {

    if (!evt.target || !evt.target.files || evt.target.files.length <= 0) {
      return;
    }

    let files = Array.from(evt.target.files).map((val) => {
      if (val.webkitRelativePath) {
        val._webkitRelativePath = val.webkitRelativePath;
      } else {
        val._webkitRelativePath = val.name;
      }
      return val;
    });
    
    this.handerFiles(files);
  },

计算文件hash

通过File对象计算文件hash值

  • 通过file.slice(start, end).arrayBuffer()获取制定切片的文件二进制数据
  • 通过SparkMD5.ArrayBuffer.hash(buffer)计算二进制hash

此处需要用到SparkMD5

async getFileInfo(file) {
        return new Promise(async (resolve, reject) => {
          try {
            let spark = new SparkMD5.ArrayBuffer();
            let blockList = [];
            let hash = null;
            let fileSize = file.size;
            let order = 0;
            let fileName = file.name;
            let fileTyle = this.getFilesTypeByName(fileName);

            let blockListLength = Math.ceil(fileSize / splitFileAize);
            for (let start = 0; start < fileSize; start += splitFileAize) {
              let end = (start + splitFileAize) > fileSize ? fileSize : start + splitFileAize;
              let buffer = await file.slice(start, end).arrayBuffer();
              spark.append(buffer);
              blockList.push({
                order: order++,
                start: start,
                end: end,
                hash: SparkMD5.ArrayBuffer.hash(buffer)
              });
            }

            hash = spark.end();
            spark.destroy();
            resolve({
              name: fileName,
              type: fileTyle,
              size: fileSize,
              _webkitRelativePath: file._webkitRelativePath,
              hash,
              block: {
                list: blockList,
                size: splitFileAize,
                length: blockList.length,
              }
            });
          } catch (error) {
            reject(error);
          }
        });
      }
    },

数据库设计

字段类型描述
hashtext文件hash值
nametext文件名称
sizebigint文件大小
block_sizebigint切片大小
created_attimestamp创建时间
upload_pathtext上传的地址
typetext文件类型
block_listjsonb[]切片信息
CREATE TABLE core.upload_files
(
    hash text COLLATE pg_catalog."default" NOT NULL,
    name text COLLATE pg_catalog."default" NOT NULL,
    size bigint,
    block_size bigint,
    created_at timestamp without time zone,
    upload_path text COLLATE pg_catalog."default",
    type text COLLATE pg_catalog."default",
    block_list jsonb[],
    rename text COLLATE pg_catalog."default",
    CONSTRAINT upload_files_pkey PRIMARY KEY (hash)
)

接口设计

获取文件信息

主要用于检查filehash值的文件是否已经存在,若干存在则跳过上传,如果不存在,检查有哪些切片已经存在并返回存在的切片

地址

/upload/file/:filehash/precreate

参数

参数描述
name文件名称
size文件大小
filehash文件hash值
type文件类型

##上传切片文件

地址

/upload/file/:filehash/block?order=0&hash=:blockhash

参数

参数描述
order切片order
hash切片hash值
filehash文件hash值
file切片二进制数据

主要代码

解析表单中的文件,此处我有用到async-busboy

/**
 * 获取表单文件
 * @param {*} req 
 */
async function getAsyncBusboy(req) {
  return new Promise((resolve, reject) => {
    asyncBusboy(req, {
      onFile: function (fieldname, file, filename, encoding, mimetype) {
        resolve({
          fieldname,
          file,
          filename,
          encoding,
          mimetype
        })
      }
    })
  });
}


从fileStream中生成文件

/**
 * 从fileStream中生成文件
 * @param {*} filePath 
 * @param {*} fileStream 
 */
async function writeFileFromFS(filePath, fileStream) {
  return new Promise((resolve, reject) => {
    let writeStream = fs.createWriteStream(filePath, {
      emitClose: true,
    });
    fileStream.pipe(writeStream);

    writeStream.on('close', function (error) {
      resolve(true);
    });
    writeStream.on('error', function (error) {
      reject(false);
    });
  })
}

##合并切片文件

地址

/upload/file/:filehash/create

参数

参数描述
filehash文件hash
/**
 * 按照files顺序合并文件覆盖mergePath
 * @param {path} mergePath 
 * @param {path} files 
 */
async function mergeFiles(mergePath, files) {
  try {
    if (!Array.isArray(files) || files.length === 0) return;

    await fsPromises.mkdir(path.parse(mergePath).dir, {
      recursive: true
    });
    await fsPromises.copyFile(files.shift(), mergePath);
    for (const file of files) {
      await fsPromises.appendFile(mergePath, await fsPromises.readFile(file));
    }
  } catch (error) {
    throw error;
  }
  return true;
}