likes
comments
collection
share

大文件上传实现-切片上传功能(Vue + Express)

作者站长头像
站长
· 阅读数 4

前言

值得注意的是:采用流的传输方式,Content-Typeapplication/octet-stream

大文件上传实现-切片上传功能(Vue + Express)

如果没有看上一篇,可以花点时间先看看。

需求分析

分片上传功能,就是大文件切分小文件,同时上传,完成之后在服务端合并起来,以此来提高上传速度。

那么前端要做的就是切片,即大文件切成多个文件片段,同时上传。

后端要做的就分成两步,一步是接收文件片段,一步是合并文件。

实现切片功能

实现后端切片上传接口

上一节,上传之后的文件放在了 temp 目录下面,但要做分片上传然后合并,得分两个文件夹,一个用来放分片的,一个用来放合并之后的文件。

合并之后存放文件的文件夹,定为 uploads。

index.js 文件修改如下代码,建立了临时文件夹和上传文件夹。

// 临时文件夹,切片文件将会被上传到这个文件夹中
const TEMP_DIR = path.resolve(__dirname, "temp");
// 上传文件夹,文件将会被移动到这个文件夹中
const UPLOADS_DIR = path.resolve(__dirname, "uploads");

// 确保临时文件夹存在
fs.ensureDir(TEMP_DIR);
// 确保上传文件夹存在
fs.ensureDir(UPLOADS_DIR);

实现上传接口,将文件切片放到临时文件夹里面,按照文件名区分不同文件,属于同一文件的切片放到一起。

app.post("/upload/:filename", async (req, res) => {
  // 获取文件名
  const { filename } = req.params;
  // 切片名
  const { chunkFilename } = req.query;
  // 拼接切片文件夹路径
  const chunkDir = path.resolve(TEMP_DIR, filename);
  // 确保切片文件夹存在
  await fs.ensureDir(chunkDir);
  // 拼接文件路径
  const chunkPath = path.resolve(chunkDir, chunkFilename);
  // 创建可读流
  const ws = fs.createWriteStream(chunkPath);
  // 将可读流中的数据写入到可写流中,完成文件上传
  await pipeStream(req, ws);
  // 响应结果
  res.json({ success: true });
});

辅助函数pipeStream是上一篇文章提到的,这里再看一遍:

/**
 * 数据从可读流流向可写流
 * @param {ReadableStream} rs 可读流
 * @param {WritableStream} ws 可写流
 * @returns 返回一个Promise,当流结束时,Promise会被resolve
 */
function pipeStream(rs, ws) {
  return new Promise((resolve, reject) => {
    rs.pipe(ws).on("finish", resolve).on("error", reject);
  });
}

用 postman 测试一下:

大文件上传实现-切片上传功能(Vue + Express)

大文件上传实现-切片上传功能(Vue + Express)

大文件上传实现-切片上传功能(Vue + Express)

上面用整个文件测试,换成切片也一样,都要上传文件名,切片名(序号拼接文件名)。

实现前端切片上传功能

前面在做前端上传功能的时候,一直用的是文件本身的文件名,但文件名是可以改的,如果相同文件名,那么就会覆盖了上传在服务端的文件,因此对应一个文件,应该得有一个唯一的标识作为文件名,避免重复。那么就需要对文件进行“摘要”了,这里用到 SubtleCrypto.digest()api, MDN

src 目录下面新建一个 getFilename.js 文件,实现如下:

大文件上传实现-切片上传功能(Vue + Express)

/**
 * 对文件摘要拿到文件名,耗时操作
 * @param {File} file 文件对象
 * @returns 文件名
 */
async function getFilename(file) {
  // 获取文件后缀名
  const extension = file.name.split(".").pop();
  // 获取文件摘要
  const digestName = await calculateDigest(file);
  return `${digestName}.${extension}`;
}

/**
 *
 * @param {File} file 文件对象
 * @returns 返回十六进制的字符串
 */
async function calculateDigest(file) {
  // 读取文件buffer
  const arrayBuffer = await file.arrayBuffer();
  // 计算摘要buffer
  const digestBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer);
  // 转换为十六进制字符串
  const digestArray = Array.from(new Uint8Array(digestBuffer));
  const digestHex = digestArray
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
  return digestHex;
}

export default getFilename;

看一下效果

大文件上传实现-切片上传功能(Vue + Express)

大文件上传实现-切片上传功能(Vue + Express)

这是一个耗时操作,在按钮上面加一个loading效果。

<el-button @click="handleUpload" :loading="calculating">开始上传</el-button>

大文件上传实现-切片上传功能(Vue + Express)

下面对文件进行切片处理,实现一个将文件进行切片的函数。测试的视频文件1.5G,暂定文件切片大小 100M, src 下面新建一个 createChunks.js 文件,如下:

大文件上传实现-切片上传功能(Vue + Express)

/**
 *
 * @param {File} file 文件对象
 * @param {Number} chunkSize 切片大小
 * @param {String} filename 文件名
 * @returns 切片数组
 */
export default function createChunks(file, chunkSize, filename) {
  // 兼用处理
  filename = filename || file.name;

  const chunks = [];
  // 计算切片数量
  const count = Math.ceil(file.size / chunkSize);
  // 循环切片
  for (let i = 0; i < count; i++) {
    // 获取到切片
    const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize);
    // 拼接对应的切片文件名
    const chunkFilename = `${i}-${filename}`;
    chunks.push({
      chunk,
      chunkFilename,
    });
  }
  return chunks;
}

试一下:

大文件上传实现-切片上传功能(Vue + Express)

大文件上传实现-切片上传功能(Vue + Express)

效果如下,前面切片的大小都是一样的,最后一个小一点:

大文件上传实现-切片上传功能(Vue + Express)

现在有了切片,文件名,切片名,可以进行上传了,之前是单个文件上传,现在是多个,用循环进行多个请求,在Promise.all之后拿到最终的请求结果:

const handleUpload = async () => {
  if (!selectedFile.value.file) {
    return ElMessage.warning("请先选择文件");
  }
  const file = selectedFile.value.file;

  calculating.value = true;
  // 获取文件名
  const filename = await getFilename(file);
  calculating.value = false;

  const CHUNK_SIZE = 100 * 1024 * 1024;
  // 创建分片
  const chunks = createChunks(file, CHUNK_SIZE, filename);

  // 批量上传分片
  const requests = chunks.map((chunkInfo) => {
    return axiosInstance.post(`/upload/${filename}`, chunkInfo.chunk, {
      headers: {
        "Content-Type": "application/octet-stream",
      },
      params: {
        chunkFilename: chunkInfo.chunkFilename,
      },
      onUploadProgress(progressEvent) {
        progressInfo.value[chunkInfo.chunkFilename] = Math.round(
          (progressEvent.loaded * 100) / progressEvent.total
        );
      },
    });
  });
  await Promise.all(requests);
  ElMessage.success("上传分片完成");
};

注意这里,获取分片上传进度,改了一下,直接用分片名做键,进度做值:

大文件上传实现-切片上传功能(Vue + Express)

模板也改下:

<template v-if="Object.keys(progressInfo).length > 0">
    <div class="progress" v-for="(percent, name) in progressInfo" :key="name">
      <span>{{ name }}</span>
      <el-progress :percentage="percent"></el-progress>
    </div>
  </template>

来看看效果:

大文件上传实现-切片上传功能(Vue + Express)

可以看到,并发了15个请求,由于浏览器限制了同一个域名下最多并行发送6个请求,所以看到是6个并发进度,完了再继续后面的请求。

到这里,分片部分的前端后端功能就完成了,下面进行文件合并功能开发。

实现合并功能

实现后端合并接口

合并接口需要做的事情,就是接收文件名,然后根据文件名去 temp 文件下面查找对应的文件分片,将其合并放到 uploads 文件夹下面,代码如下(以100M的切片大小为例):

...
// 切片大小
const CHUNK_SIZE = 1024 * 1024 * 100; // 100MB
...
app.get("/merge/:filename", async (req, res) => {
  // 获取文件名
  const { filename } = req.params;
  // 拼接切片文件夹路径
  const chunkDir = path.resolve(TEMP_DIR, filename);
  // 读取切片文件夹中的文件名
  const chunks = await fs.readdir(chunkDir);
  if (chunks.length === 0) {
    res.json({ success: false, message: "切片文件不存在" });
    return;
  } else {
    // 按照切片的索引进行排序
    chunks.sort((a, b) => a.split("-")[0] - b.split("-")[0]);
    // 将合并的文件名路径
    const uploadsPath = path.resolve(UPLOADS_DIR, filename);
    // 写入文件的异步任务
    const writeTasks = chunks.map((chunkFilename, index) => {
      // 拼接切片文件夹路径
      const chunkPath = path.resolve(chunkDir, chunkFilename);
      // 创建可读流
      const rs = fs.createReadStream(chunkPath)
      // 创建可读流
      const ws = fs.createWriteStream(uploadsPath, {
        start: index * CHUNK_SIZE,
      });
      return pipeStream(rs, ws);
    });
    await Promise.all(writeTasks);
    // 合并完成后,删除切片文件夹
    await fs.rm(chunkDir, { recursive: true, force: true });
    return res.json({ success: true });
  }
});

整体合并流程就是:根据分片创建可读流,指定起始位置写入待合并的文件中(可写流),注意看可写流的配置参数start: index * CHUNK_SIZE, 它控制可写流起始的偏移。

前面我们计算得到的文件名是321f84eca184e49fbdaea3683e6e6d0e4a1bd69bee988e6c3232ab2930574e85.mp4,

大文件上传实现-切片上传功能(Vue + Express)

用 postman 来试一下:

大文件上传实现-切片上传功能(Vue + Express)

大文件上传实现-切片上传功能(Vue + Express)

没有问题,请求之后,切片被从temp文件夹下面,合并到了uploads文件夹下面了。

到这里,合并的后端功能就算完成了,下面前端部分,前端上传完切片之后,再请求一下合并的接口就好了。

前端调用合并请求

大文件上传实现-切片上传功能(Vue + Express)

  // 合并分片
  axiosInstance.get(`/merge/${filename}`).then(() => {
    ElMessage.success("合并分片完成");
  });

看一下效果:

大文件上传实现-切片上传功能(Vue + Express)

总结

文章如果有帮助,还望顺手点个赞和收藏,这是写文章的重要动力来源哦~