likes
comments
collection
share

分块上传实现

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

在之前的文章 文件上传 - 掘金 (juejin.cn) 中,分块上传讲的比较随便,要学的东西还是挺多的,静下心来好好整理一下分块上传的内容

分块上传实现

分块上传是什么

使用流可以有效地减少内存的消耗和提高文件上传的速度,但是流的传输仍然是一次性的,如果上传的文件过大,仍然会对服务器的内存和网络带宽造成压力。因此,为了进一步提高文件上传的效率和稳定性,可以结合分块文件上传的方式。

分块文件上传是将大文件切分成多个小块进行上传,这些小块独立上传并验证,上传失败可以重新上传,上传成功后再进行合并。这样可以避免一次性上传大文件时出现的内存不足和网络传输失败等问题,同时也能够有效地提高上传的效率和稳定性。

不说废话了,直接上代码!

分块上传实现

技术栈

前端技术栈:js、aixos

后端技术栈:koa

思路

前缀知识掌握

对于读写文件操作,都是异步操作,我们如何获得一个异步的返回值?如下:

function get() {
    let data;
    setTimeout(() => {
        data = 1;
        // 在这里return data 是无效的。
    }, 1000);
}

使用promise实现

function get() {
    return new Promise(resolve => {
        let data;
        setTimeout(() => {
            data = 1;
            resolve(data);
        }, 1000);
    })
}
get().then(res => console.log(res));

实现:

对于前端来说,我们将资源分块上传到服务器(多次发送请求),而且需要上传资源的哈希值的和每一块资源文件的索引,在分块上传完资源文件后,还需要再发起一个请求,告诉服务端可以合并分块的资源了。

上传资源的哈希值以便后端将分块资源保存在同一个目录下方便合并

上传资源的索引以便后端按索引顺序合并分块资源

我们需要封装三个方法

  • uploadFile 分块上传文件
  • generateFileHash 生成文件资源哈希值
  • mergeChunks 向服务端请求资源合并

实现uploadFile函数

定义一个数组 uploadedChunks 表示已经上传的文件块,每次上传成功则往数组 push 后端返回的内容,当数组长度跟文件块总数一样则调用 mergeChunks 合并资源。

<!-- 我们只需要一个 input:file 就可以 -->
<input type="file" id="fileInput">

<script>
// 监听上传事件
fileInput.addEventListener('change', async () => {
  const file = fileInput.files[0];
  const response = await uploadFile(file);
  console.log(response);
});

/**
 * 将文件拆分为多个文件块进行上传
 * @param {File} file 要上传的文件
 * @return {Promise} 返回一个Promise对象,该对象的resolve参数为文件上传成功后的响应结果
 */
function uploadFile(file) {
  return new Promise(async (resolve, reject) => {
    const fileHash = await generateFileHash(file); // 生成文件哈希值
    let uploadedChunks = []; // 已经上传成功的文件块位置
    let totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE); // 文件块总数
    uploadChunk(0); // 开始上传第一个文件块
  
    // 分块上传文件
    async function uploadChunk(chunkIndex) {
        ...
        if(uploadedChunks.length >= totalChunks) mergeChunks(file.name, fileHash, resolve);
        ...
    }
  })
}
</script>

实现 generateFileHash 计算文件哈希函数

这里我用原生js简单实现一个哈希函数方便理解

  • 当然,文件的哈希值计算一般都是用二进制哈希函数,如果要计算文件的哈希值,可以使用其他适用于二进制数据的哈希算法,如SHA-1、SHA-256、MD5等

  • 我是想的哈希算法是一种字符串哈希算法,适用于对字符串进行哈希计算。其实并不适用于二进制文件计算哈希值,这里只是为了方便理解

用MD5算法得到的文件哈希值通常是一个32个字符长的16进制数字字符串。

function generateFileHash(file) {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.readAsBinaryString(file); // 读取文件二进制字符串
        // 当然,使用`reader.readAsDataURL()`方法读取文件并将其转换为Base64编码字符串也是可行的,不过Base64编码通常会增加文件大小会带来一些性能问题
        reader.onload = function() {
          const data = this.result;
          resolve(toHash(data));
        }
    })
}
function toHash(key) { 
    key = typeof key === 'string' ? key : JSON.stringify(key);
    let hash = 0;
    for(let i = 0; i < key.length; i++) {
        hash += (hash << 5) + key.charCodeAt(i);
    }
    return hash.toString();
}
// 在哈希函数中,左移位操作可以用来增加随机性,从而提高哈希函数的均匀性和抗冲突能力。
// 左移 5 位是一种比较常见的位移操作,它可以产生一些随机性,但也可以根据具体需求进行调整。

// &是位运算中的一种,表示按位与运算。按位与运算的规则是对于两个操作数的每一个对应位执行 AND 运算,
// 只有两个操作数的对应位都为 1 时,结果才为 1,否则为 0。

// 101010 & 111100 = 101000
// 在哈希函数中,使用 & 运算通常是为了将哈希值限制在一个固定的范围内,例如限制在 32 位整数范围内。
// 因为哈希函数产生的哈希值通常是一个很大的整数,如果不进行限制,则可能会导致哈希值占用过多的内存空间,或者造成哈希表的过度扩张,影响性能。

// 在实现哈希表时,一般会将哈希值对哈希表的大小取模,从而将哈希值限制在一个固定的范围内。
// 例如,如果哈希表的大小为 1024,那么可以将哈希值与 1023 进行 & 运算,从而得到一个在 0 到 1023 范围内的整数,作为哈希表的索引。这种方式可以保证哈希值在哈希表范围内均匀分布,提高哈希表的查找效率。

实现 uploadChunk 分块上传函数

const FILE_CHUNK_SIZE = 5 * 1024 * 1024; // 设置每个文件块的大小为5MB

// totalChunks: 文件块总数,之前已经计算过了

async function uploadChunk(chunkIndex) { // chunkIndex代表文件索引,也就是第几块文件
  const start = chunkIndex * FILE_CHUNK_SIZE;
  const end = chunkIndex === totalChunks - 1 ? file.size : (chunkIndex + 1) * FILE_CHUNK_SIZE;
  const chunk = file.slice(start, end);

  // 创建FormData对象并添加要上传的数据
  const formData = new FormData();
  formData.append('chunkIndex', chunkIndex);
  formData.append('fileHash', fileHash);
  formData.append('file', chunk);

  try {
    // 发送文件块上传请求
    const res = await axios.post('http://127.0.0.1:3000/upload', formData);
    console.log(res.data); // 获取返回的信息
    uploadedChunks.push({ index: chunkIndex, position: res.data.position });
    if (uploadedChunks.length === totalChunks) {
      // 所有文件块上传成功,合并文件块
      mergeChunks(file.name, fileHash, resolve);
    } else {
      // 递归调用上传下一个文件块
      uploadChunk(chunkIndex + 1);
    }
  } catch (err) {
    console.log('文件块上传失败', err);
    reject(err);
  }
}

注意失败重传,只需要在传递失败的时候重新请求该块即可。当然,也可以使用http1.1的断点传输功能(加个range请求头)

一般来说http2自带重传机制,但是还是难免因为网络延迟带来问题,这个时候就需要我们自实现失败重传了

要注意的是,如果某一文件一直重传失败,我们可以定义一个函数,并告诉服务端删除该文件,并抛出上传错误。那么,我这里是递归实现重传的,怎么让它实现取消重传呢

很简单,因为外面定义的变量会因为闭包延长生命周期,可以在外面定义一个值,每次重传该值 +1,当该值超过设置最大值时则取消重传。也可以设置时间,当超过该时间则取消重传。

实现 mergeChunks 函数请求合并分块资源

/**
 * 合并所有文件块为完整文件
 * @param {string} filename 文件名
 * @param {string} fileHash 文件哈希值
 * @param {Function} resolve 文件上传成功后的回调函数
 */
async function mergeChunks(filename, fileHash, resolve) {
  try {
    // 发送合并文件块请求
    const res = await axios.post('http://127.0.0.1:3000/merge', { filename, fileHash });
    console.log(res.data);
    resolve(res.data); // 成功
  } catch (err) {
    console.log('合并文件块失败', err);
  }
}

后端koa实现路由

而后端接收到这些资源块后,先将资源存储于目录(以文件资源哈希值命名)下(前端请求体包括资源哈希值,块索引),分块文件以索引值命名,而对于合并文件,直接对目录下的文件进行排序并合并即可

router.post('/upload', async (ctx) => {
  const { chunkIndex, fileHash } = ctx.request.body; // 前端请求体包括块索引和文件哈希值
  const chunkDir = `${UPLOAD_DIR}/${fileHash}`; // 保存该文件块的目录
  const chunkPath = `${chunkDir}/${chunkIndex}`; // 保存该文件块的路径
  const file = ctx.request.files.file; // 获取上传的文件块

  // 校验文件块大小是否超过限制
  if (file.size > MAX_SIZE) {
    return ctx.body = { code: 413, msg: '文件块大小超过限制' }; // 413(Payload Too Large),表示请求实体过大,服务器无法处理
  }
  
  // 将文件块保存到临时文件夹
  if (!fs.existsSync(chunkDir)) {
    fs.mkdirSync(chunkDir);
  }
  try {
      await fs.move(file.filepath, chunkPath);
      ctx.body = { code: 201, msg: '文件块上传成功' }; // 201状态码表示该请求成功,并因此创建资源,通常用于post请求
  } catch(err) {
      ctx.status = 400;
      ctx.body = { code: 400, data: { chunkIndex, err } };
  }
});

// 合并文件块为完整文件
router.post('/merge', async (ctx) => {
  const { filename, fileHash } = ctx.request.body;
  const filePath = `${UPLOAD_DIR}/${filename}`; // 保存完整文件的路径
  const chunkDir = `${UPLOAD_DIR}/${fileHash}`;
 
  // 获取所有文件块并按照顺序排序
  let chunks = await fs.readdir(chunkDir);
  chunks.sort((a, b) => a - b);
  
  // 将所有文件块合并为完整文件
  await Promise.all(
    chunks.map((chunkPath, index) =>
      fs.appendFile(filePath, fs.readFileSync(`${chunkDir}/${chunkPath}`))
    )
  );
  // 删除临时文件夹
  await fs.remove(chunkDir);

  ctx.body = { code: 201, msg: '文件合并成功' };
});

最终实现

前端代码

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.js"></script>
</head>
<body>
      
<input type="file" id="fileInput">
<progress id="progressBar" max="100" value="0"></progress>

<script>

fileInput.addEventListener('change', async () => {
  const file = fileInput.files[0];
  const response = await uploadFile(file);
  console.log(response);
});
const FILE_CHUNK_SIZE = 5 * 1024 * 1024; // 每个文件块的大小为10MB
/**
 * 将文件拆分为多个文件块进行上传
 * @param {File} file 要上传的文件
 * @return {Promise} 返回一个Promise对象,该对象的resolve参数为文件上传成功后的响应结果
 */
function uploadFile(file) {
  return new Promise(async (resolve, reject) => {
    const fileHash = await generateFileHash(file); // 生成文件哈希值
    let uploadedChunks = []; // 已经上传成功的文件块位置
    let totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE); // 文件块总数
    // 上传文件块
    async function uploadChunk(chunkIndex) {
      const start = chunkIndex * FILE_CHUNK_SIZE;
      const end =
        chunkIndex === totalChunks - 1 ? file.size : (chunkIndex + 1) * FILE_CHUNK_SIZE;
      const chunk = file.slice(start, end);

      // 创建FormData对象并添加要上传的数据
      const formData = new FormData();
      formData.append('chunkIndex', chunkIndex);
      formData.append('fileHash', fileHash);
      formData.append('file', chunk);

      try {
        // 发送文件块上传请求
        const res = await axios.post('http://127.0.0.1:3000/upload', formData);
        console.log(res.data);
        uploadedChunks.push({ index: chunkIndex, position: res.data.position });
        if (uploadedChunks.length === totalChunks) {
          // 所有文件块上传成功,合并文件块
          mergeChunks(file.name, fileHash, resolve);
        } else {
          // 继续上传下一个文件块
          uploadChunk(chunkIndex + 1);
        }
      } catch (err) {
        console.log('文件块上传失败', err);
        reject(err);
      }
    }
    uploadChunk(0); // 开始上传第一个文件块
  });
}

/**
 * 合并所有文件块为完整文件
 * @param {string} filename 文件名
 * @param {string} fileHash 文件哈希值
 * @param {Function} resolve 文件上传成功后的回调函数
 */
async function mergeChunks(filename, fileHash, resolve) {
  try {
    // 发送合并文件块请求
    const res = await axios.post('http://127.0.0.1:3000/merge', { filename, fileHash}, {
        onUploadProgress: function(progressEvent) {
            // 计算进度条的值
            const progress = (progressEvent.loaded / progressEvent.total) * 100;
            // 设置进度条的值
            progressBar.value = progress;
        }
    });
    console.log(res.data);
    resolve(res.data);
  } catch (err) {
    console.log('合并文件块失败', err);
  }
}

/**
 * 生成文件哈希值
 * @param {File} file 要生成哈希值的文件
 * @return {string} 返回该文件的哈希值
 */
function generateFileHash(file) {
  return new Promise((resolve, reject) => {
    const chunkSize = 2 * 1024 * 1024; // 每个文件块的大小为2MB
    const chunks = Math.ceil(file.size / chunkSize); // 文件块总数
    const spark = new SparkMD5.ArrayBuffer();
    const fileReader = new FileReader();
    let currentChunk = 0;
    fileReader.onload = function (e) {
      spark.append(e.target.result);
      currentChunk++;

      if (currentChunk < chunks) {
        loadNext();
      } else {
        resolve(spark.end());
      }
    };
    fileReader.onerror = function () {
      reject('文件读取失败');
    };
    function loadNext() {
      const start = currentChunk * chunkSize;
      const end = Math.min(start + chunkSize, file.size);
      fileReader.readAsArrayBuffer(file.slice(start, end));
    }
    loadNext();
  });
}

</script>
</body>
</html>

后端koa实现

初始化项目

npm init -y # 初始化项目
npm i koa koa-body koa-router koa-static fs-extra -S # 安装依赖
mkdir uploads # 创建存放资源目录
touch index.js # 创建index.js文件

index.js下

const Koa = require('koa');
const {koaBody} = require('koa-body');
const Router = require('koa-router');
const static = require('koa-static');
const fs = require('fs-extra');

const app = new Koa();
const router = new Router();

const UPLOAD_DIR = './uploads'; // 上传文件保存路径
const MAX_SIZE = 100 * 1024 * 1024; // 最大上传文件大小为100MB
// 跨域
app.use(async (ctx, next) => {
    ctx.set('Access-Control-Allow-Origin', '*');
    ctx.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
    ctx.set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); // 令牌
    ctx.set('Access-Control-Allow-Credentials', true);
    // 处理预检请求
    if (ctx.method === 'OPTIONS') {
      return ctx.status = 204;
    }
    await next();
})

// 创建临时文件夹
if (!fs.existsSync(UPLOAD_DIR)) {
  fs.mkdirSync(UPLOAD_DIR);
}

// 处理上传请求
router.post('/upload', async (ctx) => {
  const { chunkIndex, fileHash } = ctx.request.body;
  const chunkDir = `${UPLOAD_DIR}/${fileHash}`; // 保存该文件块的目录
  const chunkPath = `${chunkDir}/files${chunkIndex}`; // 保存该文件块的路径
  const file = ctx.request.files.file; // 获取上传的文件块

  // 校验文件块大小是否超过限制
  if (file.size > MAX_SIZE) {
      ctx.status = 413;
      return ctx.body = { code: 413, msg: '文件块大小超过限制' };
  }

  // 将文件块保存到临时文件夹
  if (!fs.existsSync(chunkDir)) {
    fs.mkdirSync(chunkDir);
  }
  try {
      await fs.move(file.filepath, chunkPath);
      ctx.body = { code: 201, msg: '文件块上传成功' }; // 201状态码表示该请求成功,并因此创建资源,通常用于post请求
  } catch(err) {
      ctx.status = 400;
      ctx.body = { code: 400, data: { chunkIndex, err } };
  }
});

// 合并文件块为完整文件
router.post('/merge', async (ctx) => {
  const { filename, fileHash } = ctx.request.body;
  const filePath = `${UPLOAD_DIR}/${filename}`; // 保存完整文件的路径
  const chunkDir = `${UPLOAD_DIR}/${fileHash}`;
  // 获取所有文件块并按照顺序排序
  let chunks = await fs.readdir(chunkDir);
  chunks.sort((a, b) => a - b);

  // 将所有文件块合并为完整文件
  await Promise.all(
    chunks.map((chunkPath, index) =>
      fs.appendFile(filePath, fs.readFileSync(`${chunkDir}/${chunkPath}`))
    )
  );
  // 删除临时文件夹
  await fs.remove(chunkDir);

  ctx.body = { code: 201, msg: '文件合并成功' };
});

// 静态文件中间件,提供临时文件的访问路径
app.use(static(UPLOAD_DIR));

// 请求体解析中间件
app.use(koaBody({
    multipart: true,
    formidable: {
      maxFileSize: 100 * 1024 * 1024,  // 设置上传文件大小上限,默认为 2MB
    },
}));

// 路由中间件
app.use(router.routes()).use(router.allowedMethods());

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

运行

node index.js

抽样哈希

直接计算大文件哈希值无疑是非常慢的,开启webworker进行计算也是一种思想,不过我这里要讲的是抽样哈希的计算。

当需要快速计算大文件的哈希值时,一种常用的技术是采用哈希抽样算法。这种算法可以牺牲一定的哈希命中率,以提升哈希计算效率。

哈希抽样算法的基本思想是,在文件中随机选择一些位置,计算这些位置处的数据的哈希值,然后将这些哈希值合并起来得到一个整体的哈希值。由于只对一小部分数据进行哈希计算,所以哈希计算速度比较快。

分块上传实现

 function createFileChunks({ file,setProgress }){
      const fileChunkList = [];
      const chunkSum = Math.ceil(file?.size / maxChunkSize)
      const chunkSize = Math.ceil(file?.size / chunkSum);
      let start = 0;
      for (let i = 0; i < chunkSum; i++) {
        const end = start + chunkSize;
        if(i == 0 || i == chunkSum - 1) {
          fileChunkList.push({
            index: i,
            filename: file?.name,
            file: file.slice(start, end),
          });
        } else {
          fileChunkList.push({
            index: i,
            filename: file?.name,
            file:sample(file,start,end),
          });
        }
        start = end;
      }
      return fileChunkList;
    };
        // 抽样函数
    function sample(file,start,end) {
      const pre = file.slice(start, start + 1024 * 2)
      const after = file.slice(end - 1024 * 2, end)
      const merge = new Blob([pre, after])
      return merge
    }

完结撒花

分块上传实现

祝大家拿到心仪的offer

分块上传实现

这不点赞关注?

分块上传实现