大文件上传

197 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第3天,点击查看活动详情

整体思路

  1. 选择文件,将文件按照一定的规则进行切片并标识(这里选择md5)
  2. 根据文件的md5和文件名查询文件状态,如果文件已存在,给出提示,如果文件不存在,检查是否存在分片,如果存在分片,返回客户端存在的分片列表
  3. 客户端对比获取未上传的分片
  4. 上传分片(分片文件夹名为文件的md5,文件名为分片序号)
  5. 客户端所有分片上传完成后,通知服务端进行合并
  6. 服务端将分片按序号合并成文件
  7. 暂停上传,续传

大文件上传

文件切片加密

md5 是文件的唯一标识,可以利用文件的md5查询文件的上传状态。

根据文件的修改时间、文件名称、最后修改时间等信息,通过 spark-md5 生成文件的 MD5。需要注意的是,大规格文件需要分片读取文件,将读取的文件内容添加到 spark-md5 的 hash 计算中,直到文件读取完毕,最后返回最终的 hash 码到 callback 回调函数里面。这里可以根据需要添加文件读取的进度条(读取不是上传)。

文件读取 实现代码:

const md5File = (file) => {
  return new Promise((resolve, reject) => {
    // 文件截取
    let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
      chunkSize = file?.size / 100,
      chunks = 100,
      currentChunk = 0,
      spark = new SparkMD5.ArrayBuffer(), // 追加数组缓冲区
      fileReader = new FileReader(); // 读取文件

    fileReader.onload = function (e) {
      console.log('read chunk nr', currentChunk + 1, 'of', chunks)
      spark.append(e.target.result);
      currentChunk ++;

      if (currentChunk < chunks) {
        loadNext();
      } else {
        let result = spark.end() // 完成md5的计算,返回十六进制结果
        resolve(result)
      }
    };

    fileReader.onerror = function () {
      console.warn('文件读取错误');
    };

    const loadNext = () => {
      const start = currentChunk * chunkSize,
        end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
      // 文件切片
      fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
    }
    loadNext();
  })
}

查询文件状态

根据上面生成的文件的md5和文件名,调接口,查询文件状态 (ps:后台服务这里使用的是node,代码放在最后)

const checkFileMD5 = (fileName, fileMd5Value) => {
  let url = BaseUrl + '/myupload/check/file?fileName=' + fileName + "&fileMd5Value=" + fileMd5Value
  return axios.get(url)
}

根据接口返回判断,如果文件已经存在,提示已秒传(所谓秒传,就是不用传了,我有😄,你也可以给其他提示,比如:文件已存在)

  const { data } = await checkFileMD5(file.name, fileMd5Value)
  if (data?.file) {
    // 如果文件已存在, 就秒传
    uploadPercent.value = 100
    uploadStatus.value = 'success'
    // ElMessage.success('文件已秒传')
    console.log('文件已存在,不上传了(文件已秒传)')
    return
  }
  • 接口返回的data,如果文件已存在 文件已存在

  • 如果文件不存在,存在切片,但是切片不全,有丢失 切片丢失

检查并上传切片

如果文件不存在,那就根据服务端返回的分片列表,与本地分片列表对比,上传服务端没有的切片

 const chunkSize = 5 * 1024 * 1024 // 定义好切片的大小

  async function checkAndUploadChunk(file, fileMd5Value, chunkList) {
    let chunks = Math.ceil(file.size / chunkSize)
    const requestList = []
    for (let i = 0; i < chunks; i++) {
      let exit = chunkList.indexOf(i + "") > -1
      // 如果不存在,则上传
      if (!exit) {
        requestList.push(upload({ i, file, fileMd5Value, chunks }))
      }
    }

    // 并发上传
    if (requestList?.length) {
      await Promise.all(requestList)
    }
  }

// 上传分片
function upload({ i, file, fileMd5Value, chunks }) {
  uploadCurrentChunk = 0
  let end = (i + 1) * chunkSize >= file.size ? file.size : (i + 1) * chunkSize
  let form = new FormData()
  form.append("data", file.slice(i * chunkSize, end)) //file对象的slice方法用于切出文件的一部分
  form.append("total", chunks) //总片数
  form.append("index", i) //当前是第几片     
  form.append("fileMd5Value", fileMd5Value)
 
  return axios({
    method: 'post',
    url: BaseUrl + "/myupload/upload",
    data: form
  }).then((res) => {
    if (res.data.stat) {
      uploadCurrentChunk = uploadCurrentChunk + 1
      // 下面这是我使用的进度条控制,根据情况自己修改
      // uploadPercent.value = Math.ceil((uploadCurrentChunk / chunks) * 100)
      // uploadStatus.value = uploadPercent.value == 100 ? 'success' : 'exception'
    }

  })

}

服务端合并

所有的分片上传完成,调接口通知服务端合成 服务端按切片序号排序后合并,如果正常合并成功,告诉客户端上传成功,否则上传失败

// 所有的分片上传完成,通知服务端合成
function notifyServer(file, fileMd5Value) {
  let url = BaseUrl + '/myupload/merge?md5=' + fileMd5Value + "&fileName=" + file.name + "&size=" + file.size
  axios.get(url).then(({ data }) => {
    if (data.stat) {
      ElMessage.success('上传成功')
    } else {
      ElMessage.error('上传失败')
    }
  })
}

暂停上传

暂停上传,即取消当前正在上传的upload切片 实现: 这里用aixos的CancelToken 取消上传

const CancelToken = axios.CancelToken;
let source = CancelToken.source();

在使用axios上传切片时,增加参数

cancelToken:source.token

暂停的时候,执行

source.cancel("中断上传!")

续传

续传,就是再重新上传一下,上面2~6再来一遍

服务端代码

目录结构 目录

let express = require("express");
let app = express();
let formidable = require("formidable");
let path = require("path");
let uploadDir = "nodeServer/uploads";
let fs = require("fs-extra");
let concat = require("concat-files");

// 处理跨域
app.all("*", (req, res, next) => {
  res.header("Access-Control-Allow-Origin", "*");
  res.header(
    "Access-Control-Allow-Headers",
    "Content-Type,Content-Length, Authorization, Accept,X-Requested-With"
  );
  res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");
  res.header("X-Powered-By", " 3.2.1");
  if (req.method === "OPTIONS") res.send(200); /*让options请求快速返回*/
  else next();
});

// 列出文件夹下所有文件
function listDir(path) {
  return new Promise((resolve, reject) => {
    fs.readdir(path, (err, data) => {
      if (err) {
        reject(err);
        return;
      }
      // 把mac系统下的临时文件去掉
      if (data && data.length > 0 && data[0] === ".DS_Store") {
        data.splice(0, 1);
      }
      resolve(data);
    });
  });
}

// 文件或文件夹是否存在
function isExist(filePath) {
  return new Promise((resolve, reject) => {
    fs.stat(filePath, (err, stats) => {
      // 文件不存在
      if (err && err.code === "ENOENT") {
        resolve(false);
      } else {
        resolve(true);
      }
    });
  });
}

// 获取文件Chunk列表
async function getChunkList(filePath, folderPath, callback) {
  let isFileExit = await isExist(filePath);
  let result = {};
  // 如果文件已在存在, 不用再继续上传, 真接秒传
  if (isFileExit) {
    result = {
      stat: 1,
      file: {
        isExist: true,
        name: filePath,
      },
      desc: "file is exist",
    };
  } else {
    let isFolderExist = await isExist(folderPath);
    // 如果文件夹(md5值后的文件)存在, 就获取已经上传的块
    let fileList = [];
    if (isFolderExist) {
      fileList = await listDir(folderPath);
    }
    result = {
      stat: 1,
      chunkList: fileList,
      desc: "folder list",
    };
  }
  callback(result);
}

/**
 * 检查md5
 */
app.get("/myupload/check/file", (req, resp) => {
  let query = req.query;
  let fileName = query.fileName;
  let fileMd5Value = query.fileMd5Value;
  // 获取文件Chunk列表
  getChunkList(
    path.join(uploadDir, fileName),
    path.join(uploadDir, fileMd5Value),
    (data) => {
      resp.send(data);
    }
  );
});

app.all("/myupload/upload", (req, resp) => {
  const form = new formidable.IncomingForm({
    uploadDir: "nodeServer/tmp",
  });
  form.parse(req, function (err, fields, file) {
    let index = fields.index;
    let fileMd5Value = fields.fileMd5Value;
    let folder = path.resolve(__dirname, "nodeServer/uploads", fileMd5Value);
    folderIsExit(folder).then((val) => {
      let destFile = path.resolve(folder, fields.index);

      copyFile(file.data.filepath, destFile).then(
        (successLog) => {
          resp.send({
            stat: 1,
            desc: index,
          });
        },
        (errorLog) => {
          resp.send({
            stat: 0,
            desc: "Error",
          });
        }
      );
    });
  });

  // 文件夹是否存在, 不存在则创建文件
  function folderIsExit(folder) {
    return new Promise(async (resolve, reject) => {
      await fs.ensureDirSync(path.join(folder));
      resolve(true);
    });
  }
  // 把文件从一个目录拷贝到别一个目录
  function copyFile(src, dest) {
    let promise = new Promise((resolve, reject) => {
      fs.rename(src, dest, (err) => {
        if (err) {
          reject(err);
        } else {
          resolve("copy file:" + dest + " success!");
        }
      });
    });
    return promise;
  }
});

// 合并文件
async function mergeFiles(srcDir, targetDir, newFileName) {
  let fileArr = await listDir(srcDir);
  fileArr.sort((x, y) => {
    return x - y;
  });
  // 把文件名加上文件夹的前缀
  for (let i = 0; i < fileArr.length; i++) {
    fileArr[i] = srcDir + "/" + fileArr[i];
  }
  concat(fileArr, path.join(targetDir, newFileName), (err) => {
    if (err) {
      return false;
    }
    return true;
  });
}

// 合成
app.all("/myupload/merge", (req, resp) => {
  let query = req.query;
  let md5 = query.md5;
  let fileName = query.fileName;
  const res = mergeFiles(path.join(uploadDir, md5), uploadDir, fileName);
  resp.send({
    stat: res ? 1 : 0,
  });
});

app.listen(2222, () => {
  console.log("文件上传服务启动,端口监听2222!");
});