大文件分片上传方案全解析:从原理到完整实现

325 阅读11分钟

获取文件对象

<script setup lang="ts">
function uploadFile(e: Event) {
  const file = (e.target as HTMLInputElement).files;
  if (!file) return;

  console.log(file[0]);
}
</script>

<template>
  <div>
    <input type="file" @change="uploadFile"></input>
  </div>
</template>
<style lang="scss" scoped></style>

文件分片

文件分片的核心是使用Blob对象的slice方法。在上一步中,我们获取到的文件是一个File对象,它继承自Blob,因此我们可以使用slice方法对文件进行分片。slice方法的用法如下:

let blob = instanceOfBlob.slice([start [, end [, contentType]]]);

其中,start 和 end 参数代表Blob中的下标,表示被拷贝进新Blob的字节的起始位置和结束位置。contentType 参数用于给新的Blob赋予一个新的文档类型,但在这个场景中我们不需要使用它。

接下来,我们可以使用slice方法来实现文件的分片。以下是一个示例代码:

const CHUNK_SIZE = 1024 * 1024; // 分片大小1M
const createFileChunks = (file: File) => {
    const fileChunkList = []; // 分片数组
    let cur = 0;
    while (cur < file.size) {
        fileChunkList.push({
            file: file.slice(cur, cur + CHUNK_SIZE),
        });
        cur += CHUNK_SIZE; // CHUNK_SIZE为分片的大小
    }
    return fileChunkList;
}

在这个代码中,createFileChunks 函数接收一个File对象作为参数,并将其分割成多个大小为CHUNK_SIZE的片段。这些片段被存储在fileChunkList数组中并返回。

计算文件hash

在文件上传过程中,区分不同文件的一个有效方法是通过文件内容生成唯一的哈希值。文件名可能会被用户随意更改,因此不能作为区分文件的可靠依据。通过文件内容生成的哈希值可以确保每个文件都有唯一的标识符,即使文件名相同,只要内容不同,哈希值也会不同。

哈希值的应用

  1. 区分文件:通过哈希值可以准确区分不同的文件内容。
  2. 实现秒传:如果服务器上已经存在相同哈希值的文件,用户再次上传相同文件时,服务器可以直接跳过上传过程,实现“秒传”功能。

计算文件哈希值

可以使用 spark-md5 这样的工具来计算文件的哈希值。为了优化计算时间,特别是对于大文件,可以采用以下策略:

  1. 第一个和最后一个切片:这两个切片的所有内容都参与哈希计算。
  2. 中间切片:对于中间的切片,可以分别在前面、后面和中间取2个字节参与计算。

这种方法既能保证所有切片都参与了哈希计算,又能减少计算时间。

// 定义一个函数 `calculateFileHash`,用于计算文件块的哈希值
// 参数 `fileChunks` 是一个包含文件块的数组,每个文件块是一个对象,包含 `file` 属性(类型为 `Blob`)
// 返回一个 `Promise`,解析为计算出的哈希值(类型为 `string`)
const calculateFileHash = (fileChunks: { file: Blob }[]): Promise<string> => {
  return new Promise((resolve) => {
    // 创建一个 `SparkMD5` 实例,用于计算 MD5 哈希值
    const spark = new SparkMD5.ArrayBuffer();
    // 用于存储处理后的文件块
    const chunks: Blob[] = [];

    // 遍历 `fileChunks` 数组
    fileChunks.forEach((chunk, index) => {
      // 如果是第一个或最后一个文件块,直接将其添加到 `chunks` 数组中
      if (index === 0 || index === fileChunks.length - 1) {
        chunks.push(chunk.file);
      } else {
        // 对于中间的文件块,只取文件块的开头 2 字节、中间 2 字节和结尾 2 字节
        chunks.push(chunk.file.slice(0, 2)); // 开头 2 字节
        chunks.push(chunk.file.slice(chunk.file.size / 2 - 1, chunk.file.size / 2 + 1)); // 中间 2 字节
        chunks.push(chunk.file.slice(chunk.file.size - 2, chunk.file.size)); // 结尾 2 字节
      }
    });

    // 创建一个 `FileReader` 实例,用于读取文件块内容
    const reader = new FileReader();
    // 将处理后的文件块合并为一个新的 `Blob`,并读取为 `ArrayBuffer`
    reader.readAsArrayBuffer(new Blob(chunks));
    // 当文件读取完成时触发 `onload` 事件
    reader.onload = (e: Event) => {
      // 将读取到的 `ArrayBuffer` 数据添加到 `SparkMD5` 实例中
      spark.append(e?.target?.result as ArrayBuffer);
      // 计算并返回最终的 MD5 哈希值
      resolve(spark.end());
    };
  });
};

文件上传

思路

  1. 并发控制:为了避免浏览器同时创建过多请求(例如1024个分片),需要限制并发请求的数量。通常,浏览器的默认并发请求数为6,因此建议将并发请求数限制在这个范围内。
  2. 请求管理:通过管理请求队列,确保在任一时刻只有一定数量的请求被发送。当一个请求完成后,再发起新的请求,直到所有分片上传完毕。
  3. 使用 FormData:在上传文件时,通常使用 FormData 对象来封装文件分片和额外的元数据信息,以便通过HTTP请求发送。

代码实现

// 向服务端发起合并切片的请求
const mergeRequest = () => {
  fetch("http://localhost:3000/merge", {
    method: "POST",
    body: JSON.stringify({
      fileHash: fileHash.value,
      fileName: fileName.value,
      size: CHUNK_SIZE
    }),
    headers: {
      "Content-Type": "application/json"
    }
  }).then(res => {
    console.log('res', res.json());
    if (res.status === 200) {
      alert("请求成功");
    }
  })
}
// 上传文件分片
const uploadChunks = async (chunks: { file: Blob }[]) => {
  // 将每个文件分片转换为包含分片、文件哈希和分片哈希的对象
  const data = chunks.map((chunk, index) => {
    return {
      chunk, // 当前分片
      fileHash: fileHash.value, // 文件的哈希值
      chunkHash: fileHash.value + "-" + index // 分片的哈希值,由文件哈希和分片索引组成
    }
  })

  // 将每个分片对象转换为FormData对象,用于上传
  const formDatas = data.map(item => {
    const formData = new FormData();
    formData.append("file", item.chunk.file); // 添加分片文件
    formData.append("fileHash", item.fileHash); // 添加文件哈希
    formData.append("chunkHash", item.chunkHash); // 添加分片哈希
    return formData;
  })

  const max = 6; // 最大并发上传数
  let index = 0; // 当前上传的分片索引
  const taskPool: any = []; // 用于存储正在进行的上传任务

  // 循环上传所有分片
  while (index < formDatas.length) {
    // 创建一个上传任务
    const task = fetch("/upload", {
      method: "POST",
      body: formDatas[index]
    })

    // 将任务添加到任务池中
    taskPool.splice(taskPool.findIndex(item => item === task));
    taskPool.push(task);

    // 如果任务池中的任务数量达到最大并发数,等待其中一个任务完成
    if (taskPool.length === max) {
      await Promise.race(taskPool);
    }
    index++; // 处理下一个分片
  }

  // 等待所有任务完成
  await Promise.all(taskPool);
  
  // 向服务端发起merge请求
  mergeRequest();
}

秒传

如果内容相同的文件进行哈希计算时,对应的哈希值应该是一样的。而且我们在服务器上给上传的文件命名时,就是使用对应的哈希值来命名的。因此,在上传之前,是否可以加一个判断:如果服务器上已经存在对应的文件,就不需要再重复上传了,直接告诉用户上传成功。这样给用户的感觉就像是实现了秒传。接下来,我们来看一下如何实现这一点。

前端实现

前端在上传之前,需要将对应文件的哈希值告诉服务器,检查服务器上是否已经存在该文件。如果存在,就直接返回,不再执行上传分片的操作。

// 校验hash值是否存在
const verify = () => {
  return fetch("http://localhost:3000/verify", {
    method: "POST",
    body: JSON.stringify({
      fileHash: fileHash.value,
      fileName: fileName.value,
    }),
    headers: {
      "Content-Type": "application/json"
    }
  })
    .then(res => res.json())
    .then(res => res)
}

断点续传

上传之前先获取已经上传的分片列表,然后过滤掉这些已经上传的分片

已上传的分片列表由后端传递给前端

文件上传的函数中添加一个过滤项 具体请看完整代码

// 将每个分片对象转换为FormData对象,用于上传
const formDatas = data
    .filter(item => !existChunks.includes(item.chunkHash)) // 过滤服务器已存在的分片
    .map(item => {
        const formData = new FormData();
        formData.append("file", item.chunk.file); // 添加分片文件
        formData.append("fileHash", item.fileHash); // 添加文件哈希
        formData.append("chunkHash", item.chunkHash); // 添加分片哈希
        return formData;
    })

完整代码(可运行)

前端代码

创建项目npm create vite

将下面代码粘贴到App.vue文件

<script setup lang="ts">
// 导入 spark-md5 库,用于计算文件的哈希值
import SparkMD5 from "spark-md5";
import { ref } from "vue";

const CHUNK_SIZE = 1024 * 1024; // 设置每分片的大小
const fileName = ref(); // 文件名称
const fileHash = ref(); // 文件hash值 
// 创建分片
const createFileChunks = (file: File) => {
  const fileChunkList = [];
  let cur = 0;
  while (cur < file.size) {
    fileChunkList.push({
      file: file.slice(cur, cur + CHUNK_SIZE),
    });
    cur += CHUNK_SIZE;
  }
  return fileChunkList;
};

/**
 * 计算文件的哈希值
 * @param fileChunks 文件分片数组,每个分片是一个包含 file 属性的对象
 */
const calculateFileHash = (fileChunks: { file: Blob }[]): Promise<string> => {
  return new Promise((resolve) => {
    const spark = new SparkMD5.ArrayBuffer();
    const chunks: Blob[] = [];

    fileChunks.forEach((chunk, index) => {
      if (index === 0 || index === fileChunks.length - 1) {
        chunks.push(chunk.file);
      } else {
        chunks.push(chunk.file.slice(0, 2));
        chunks.push(chunk.file.slice(chunk.file.size / 2 - 1, chunk.file.size / 2 + 1));
        chunks.push(chunk.file.slice(chunk.file.size - 2, chunk.file.size));
      }
    });

    const reader = new FileReader();
    reader.readAsArrayBuffer(new Blob(chunks));
    reader.onload = (e: ProgressEvent<FileReader>) => {
      spark.append(e?.target?.result as ArrayBuffer);
      resolve(spark.end());
    };
  })
}

// 向服务端发起合并切片的请求
const mergeRequest = () => {
  fetch("http://localhost:3000/merge", {
    method: "POST",
    body: JSON.stringify({
      fileHash: fileHash.value,
      fileName: fileName.value,
      size: CHUNK_SIZE
    }),
    headers: {
      "Content-Type": "application/json"
    }
  }).then(res => {
    console.log('res', res.json());
    if (res.status === 200) {
      alert("请求成功");
    }
  })
}

// 上传文件分片
const uploadChunks = async (chunks: { file: Blob }[], existChunks: string[]) => {
  // 将每个文件分片转换为包含分片、文件哈希和分片哈希的对象
  const data = chunks.map((chunk, index) => {
    return {
      chunk, // 当前分片
      fileHash: fileHash.value, // 文件的哈希值
      chunkHash: fileHash.value + "-" + index // 分片的哈希值,由文件哈希和分片索引组成
    }
  })

  // 将每个分片对象转换为FormData对象,用于上传
  const formDatas = data
    .filter(item => !existChunks.includes(item.chunkHash)) // 过滤服务器已存在的分片
    .map(item => {
      const formData = new FormData();
      formData.append("file", item.chunk.file); // 添加分片文件
      formData.append("fileHash", item.fileHash); // 添加文件哈希
      formData.append("chunkHash", item.chunkHash); // 添加分片哈希
      return formData;
    })

  const max = 6; // 最大并发上传数
  let index = 0; // 当前上传的分片索引
  const taskPool: any = []; // 用于存储正在进行的上传任务

  // 循环上传所有分片
  while (index < formDatas.length) {
    // 创建一个上传任务
    const task = fetch("http://localhost:3000/upload", {
      method: "POST",
      body: formDatas[index]
    })

    // 将任务添加到任务池中
    taskPool.splice(taskPool.findIndex((item: Promise<Response>) => item === task));
    taskPool.push(task);

    // 如果任务池中的任务数量达到最大并发数,等待其中一个任务完成
    if (taskPool.length === max) {
      await Promise.race(taskPool);
    }
    index++; // 处理下一个分片
  }

  // 等待所有任务完成
  await Promise.all(taskPool);

  // 向服务端发起merge请求
  mergeRequest();
}

// 校验hash值是否存在
const verify = () => {
  return fetch("http://localhost:3000/verify", {
    method: "POST",
    body: JSON.stringify({
      fileHash: fileHash.value,
      fileName: fileName.value,
    }),
    headers: {
      "Content-Type": "application/json"
    }
  })
    .then(res => res.json())
    .then(res => res)
}

async function uploadFile(e: Event) {
  const file = (e.target as HTMLInputElement).files;
  if (!file) return;

  fileName.value = file[0].name;

  // 文件切片
  const fileChunks = createFileChunks(file[0]);
  // 计算文件哈希值
  const hash = await calculateFileHash(fileChunks);
  fileHash.value = hash;
  console.log("File hash:", hash);
  // 校验hash值是否存在
  const res = await verify();
  if (res.code === 10000) {
    alert("秒传:文件已存在");
    return;
  }
  // 上传文件分片 res.data是服务器已存在的分片哈希数组
  uploadChunks(fileChunks, res.data);
}
</script>

<template>
  <div>
    <input type="file" @change="uploadFile" />
  </div>
</template>
<style lang="scss" scoped></style>

后端代码

npm init

npm i express multiparty fs-extra cors body-parser

创建存放资源的文件夹uplaods和文件index.js

文件夹目录结构

image.png

const express = require("express");
const path = require("path");
const multiparty = require("multiparty");
const fse = require("fs-extra");
const cors = require("cors");
const bodyParser = require("body-parser");

const app = express();

app.use(bodyParser.json());
app.use(cors());

// 文件上传处理配置
const UPLOAD_DIR = path.resolve(__dirname, "uploads");

// 处理文件分片上传路由
app.post("/upload", async (req, res) => {
  const form = new multiparty.Form();

  form.parse(req, async (err, fields, files) => {
    console.log(err, fields, files);

    if (err) {
      return res.status(401).json({
        code: 10001,
        msg: "上传失败",
      });
    }

    // 获取文件名和哈希值
    const chunkHash = fields["chunkHash"][0];
    const fileHash = fields["fileHash"][0];
    // const fileName = fields["fileName"][0];

    // 临时分片存储目录
    const chunkDir = path.resolve(UPLOAD_DIR, fileHash);

    // 创建目录(如果不存在)
    if (!fse.existsSync(chunkDir)) {
      await fse.mkdirSync(chunkDir);
    }

    // 移动分片文件到临时目录
    const oldPath = files.file[0].path;
    await fse.move(oldPath, path.resolve(chunkDir, chunkHash));

    res.status(200).json({
      code: 10000,
      msg: "分片接收成功",
    });
  });
});

// 提取文件后缀名
const extractExt = (filename) => {
  return filename.slice(filename.lastIndexOf("."), filename.length);
};

// 处理合并请求
app.post("/merge", async (req, res) => {
  // 从请求体中获取文件哈希、文件名和文件大小
  const { fileHash, fileName, size } = req.body;

  // 构建文件的完整路径
  const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${extractExt(fileName)}`);

  // 如果文件已经存在,直接返回成功响应
  if (fse.existsSync(filePath)) {
    return res.status(200).json({
      code: 10000,
      msg: "合并成功(文件已存在)",
    });
  }

  // 构建存储文件块的目录路径
  const chunkDir = path.resolve(UPLOAD_DIR, fileHash);

  // 如果文件块目录不存在,返回失败响应
  if (!fse.existsSync(chunkDir)) {
    return res.status(401).json({
      code: 10001,
      msg: "合并失败(文件不存在)",
    });
  }

  // 读取文件块目录中的所有文件块
  const chunkPaths = await fse.readdir(chunkDir);

  // 按文件块的顺序进行排序(假设文件块名称格式为 "chunkName-index")
  chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);

  // 创建一个 Promise 数组,用于处理每个文件块的合并操作
  const list = chunkPaths.map((chunkName, index) => {
    return new Promise((resolve) => {
      // 构建当前文件块的路径
      const chunkPath = path.resolve(chunkDir, chunkName);

      // 创建可读流,用于读取文件块
      const readStream = fse.createReadStream(chunkPath);

      // 创建可写流,用于将文件块写入最终文件
      const writeStream = fse.createWriteStream(filePath, {
        start: index * size, // 写入的起始位置
        end: (index + 1) * size, // 写入的结束位置
      });

      // 当文件块读取完成时,删除该文件块并 resolve Promise
      readStream.on("end", async () => {
        await fse.unlink(chunkPath);
        resolve();
      });

      // 将读取流通过管道传输到写入流
      readStream.pipe(writeStream);
    });
  });

  // 等待所有文件块合并完成
  await Promise.all(list);

  // 删除文件块目录
  await fse.remove(chunkDir);

  // 返回合并成功的响应
  res.status(200).json({
    code: 10000,
    msg: "合并成功",
  });
});

// 验证文件是否存在
app.post("/verify", async (req, res) => {
  const { fileHash, fileName } = req.body;
  const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${extractExt(fileName)}`);

  // 返回服务器已经成功上传的切片
  const chunkDir = path.join(UPLOAD_DIR, fileHash);
  let chunkPaths = [];
  // 如果存在对应的临时文件夹才去读取
  if (fse.existsSync(chunkDir)) {
    chunkPaths = await fse.readdir(chunkDir);
  }

  if (fse.existsSync(filePath)) {
    // 文件存在
    res.status(200).json({
      code: 10000,
      msg: "文件已存在",
      data: filePath,
    });
  } else {
    // 文件不存在
    res.status(200).json({
      code: 10001,
      msg: "文件不存在",
      data: chunkPaths,
    });
  }
});

app.listen(3000, () => {
  console.log("http://localhost:3000");
});