>  기사  >  대용량 파일을 빠르게 업로드하는 방법은 무엇입니까? 어떻게 구현하는지 살펴보겠습니다!

대용량 파일을 빠르게 업로드하는 방법은 무엇입니까? 어떻게 구현하는지 살펴보겠습니다!

青灯夜游
青灯夜游앞으로
2022-04-19 10:40:015261검색

대용량 파일을 빠르게 업로드하는 솔루션에 대해서도 들어보셨을 거라 생각합니다. 사실 그것은 파일 리소스를 압축하거나 업로드하기 전에 파일 리소스를 덩어리로 나누는 것 외에는 아무것도 아닙니다.

이 글에서는 리소스를 부분적으로 업로드하는 방법만 소개하며, 프론트엔드(vue3 + vite)와 서버(nodejs +) 간의 상호작용을 통해 대용량 파일을 부분적으로 업로드하는 간단한 기능을 구현해보겠습니다. 코아2).

아이디어 정리


질문 1: 리소스 청크의 책임은 누구에게 있습니까? 자원 통합을 담당하는 사람은 누구입니까?

물론 이 문제도 매우 간단합니다. 청킹은 프런트 엔드가 담당하고 통합은 서버가 담당해야 합니다.

질문 2: 프런트 엔드는 리소스를 어떻게 분류하나요?

첫 번째 단계는 업로드된 파일 리소스를 선택한 다음 해당 파일 개체 File을 가져올 수 있습니다. File.prototype.slice 메서드는 리소스 분할을 달성할 수 있습니다. 물론 Blob.prototype이라고 말하는 사람들도 있습니다. Blob.prototype.slice === File.prototype.slice이기 때문에 슬라이스 메서드를 사용합니다. Blob.prototype.slice === File.prototype.slice

问题 3:服务端怎么知道什么时候要整合资源?如何保证资源整合的有序性?

由于前端会将资源分块,然后单独发送请求,也就是说,原来 1 个文件对应 1 个上传请求,现在可能会变成 1 个文件对应 n 个上传请求,所以前端可以基于 Promise.all 将这多个接口整合,上传完成在发送一个合并的请求,通知服务端进行合并。

合并时可通过 nodejs 中的读写流(readStream/writeStream),将所有切片的流通过管道(pipe)输入最终文件的流中。

在发送请求资源时,前端会定好每个文件对应的序号,并将当前分块、序号以及文件 hash 等信息一起发送给服务端,服务端在进行合并时,通过序号进行依次合并即可。

问题 4:如果某个分块的上传请求失败了,怎么办?

一旦服务端某个上传请求失败,会返回当前分块失败的信息,其中会包含文件名称、文件 hash、分块大小以及分块序号等,前端拿到这些信息后可以进行重传,同时考虑此时是否需要将 Promise.all 替换为 Promise.allSettled 更方便。

前端部分


创建项目

通过 pnpm create vite 创建项目,对应文件目录如下.

대용량 파일을 빠르게 업로드하는 방법은 무엇입니까? 어떻게 구현하는지 살펴보겠습니다!

请求模块

src/request.js

该文件就是针对 axios 进行简单的封装,如下:

import axios from "axios";
const baseURL = 'http://localhost:3001';
export const uploadFile = (url, formData, onUploadProgress = () => { }) => {
  return axios({
    method: 'post',
    url,
    baseURL,
    headers: {
      'Content-Type': 'multipart/form-data'
    },
    data: formData,
    onUploadProgress
  });
}
export const mergeChunks = (url, data) => {
  return axios({
    method: 'post',
    url,
    baseURL,
    headers: {
      'Content-Type': 'application/json'
    },
    data
  });
}

文件资源分块

根据 DefualtChunkSize = 5 * 1024 * 1024 ,即 5 MB ,来对文件进行资源分块进行计算,通过 spark-md5[1] 根据文件内容计算出文件的 hash 值,方便做其他优化,比如:当 hash 值不变时,服务端没有必要重复读写文件等。

// 获取文件分块
const getFileChunk = (file, chunkSize = DefualtChunkSize) => {
  return new Promise((resovle) => {
    let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
      chunks = Math.ceil(file.size / chunkSize),
      currentChunk = 0,
      spark = new SparkMD5.ArrayBuffer(),
      fileReader = new FileReader();
    fileReader.onload = function (e) {
      console.log('read chunk nr', currentChunk + 1, 'of');
      const chunk = e.target.result;
      spark.append(chunk);
      currentChunk++;
      if (currentChunk < chunks) {
        loadNext();
      } else {
        let fileHash = spark.end();
        console.info(&#39;finished computed hash&#39;, fileHash);
        resovle({ fileHash });
      }
    };
    fileReader.onerror = function () {
      console.warn(&#39;oops, something went wrong.&#39;);
    };
    function loadNext() {
      let start = currentChunk * chunkSize,
        end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
      let chunk = blobSlice.call(file, start, end);
      fileChunkList.value.push({ chunk, size: chunk.size, name: currFile.value.name });
      fileReader.readAsArrayBuffer(chunk);
    }
    loadNext();
  });
}

发送上传请求和合并请求

通过 Promise.all 方法整合所以分块的上传请求,在所有分块资源上传完毕后,在 then 中发送合并请求。

// 上传请求
const uploadChunks = (fileHash) => {
  const requests = fileChunkList.value.map((item, index) => {
    const formData = new FormData();
    formData.append(`${currFile.value.name}-${fileHash}-${index}`, item.chunk);
    formData.append("filename", currFile.value.name);
    formData.append("hash", `${fileHash}-${index}`);
    formData.append("fileHash", fileHash);
    return uploadFile(&#39;/upload&#39;, formData, onUploadProgress(item));
  });
  Promise.all(requests).then(() => {
    mergeChunks(&#39;/mergeChunks&#39;, { size: DefualtChunkSize, filename: currFile.value.name });
  });
}

进度条数据

分块进度数据利用 axios 中的 onUploadProgress 配置项获取数据,通过使用computed 根据分块进度数据的变化自动自动计算当前文件的总进度。

// 总进度条
const totalPercentage = computed(() => {
  if (!fileChunkList.value.length) return 0;
  const loaded = fileChunkList.value
    .map(item => item.size * item.percentage)
    .reduce((curr, next) => curr + next);
  return parseInt((loaded / currFile.value.size).toFixed(2));
})
// 分块进度条
const onUploadProgress = (item) => (e) => {
  item.percentage = parseInt(String((e.loaded / e.total) * 100));
}

服务端部分


搭建服务

  • 使用 koa2 搭建简单的服务,端口为 3001

  • 使用 koa-body 处理接收前端传递 'Content-Type': 'multipart/form-data' 类型的数据

  • 使用 koa-router 注册服务端路由

  • 使用 koa2-cors 处理跨域问题

目录/文件划分

server/server.js

该文件是服务端具体的代码实现,用于处理接收和整合分块资源。

server/resources

질문 3: 서버는 리소스를 통합할 시기를 어떻게 알 수 있나요? 자원 통합의 질서를 보장하는 방법은 무엇입니까?

  • 프런트엔드는 리소스를 여러 개의 청크로 나눈 다음 별도로 요청을 보내므로, 즉 원래는 1개의 파일이 1개의 업로드 요청에 해당했지만 이제는 n개의 업로드 요청에 해당하는 1개의 파일이 될 수 있으므로 프런트엔드는 -end는 Promise를 기반으로 할 수 있으며 업로드가 완료된 후 서버에 병합을 알리기 위해 병합 요청이 전송됩니다.

    병합 시 nodejs의 readStream/writeStream을 사용하여 파이프를 통해 모든 슬라이스의 스트림을 최종 파일의 스트림에 입력할 수 있습니다.
  • 리소스 요청을 보낼 때 프런트 엔드는 각 파일에 해당하는 시퀀스 번호를 결정하고 현재 블록, 시퀀스 번호, 파일 해시 및 기타 정보를 서버에 병합할 때 병합할 수 있습니다. 시퀀스 번호를 통해 순차적으로 .

    🎜질문 4: 특정 청크에 대한 업로드 요청이 실패하면 어떻게 해야 하나요? 🎜🎜🎜서버의 업로드 요청이 실패하면 현재 청크 실패에 대한 정보가 반환됩니다. 여기에는 파일 이름, 파일 해시, 청크 크기, 청크 일련 번호 등이 포함됩니다. 프론트엔드는 이 정보를 얻은 후 재전송할 수 있습니다. 또한 현재 Promise.all을 Promise.allSettled로 바꾸는 것이 더 편리한지 여부도 고려하세요. 🎜🎜🎜🎜프런트엔드 부분🎜🎜🎜🎜🎜🎜프로젝트 생성🎜🎜🎜🎜pnpm create vite를 통해 프로젝트를 생성하며, 해당 파일 디렉터리는 다음과 같습니다.🎜🎜대용량 파일을 빠르게 업로드하는 방법은 무엇입니까? 어떻게 구현하는지 살펴보겠습니다!🎜🎜🎜요청 모듈🎜🎜🎜src/request.js🎜🎜이 파일 단순히 axios용입니다. 패키지는 다음과 같습니다. 🎜
    // 上传请求
    router.post(
      &#39;/upload&#39;,
      // 处理文件 form-data 数据
      koaBody({
        multipart: true,
        formidable: {
          uploadDir: outputPath,
          onFileBegin: (name, file) => {
            const [filename, fileHash, index] = name.split(&#39;-&#39;);
            const dir = path.join(outputPath, filename);
            // 保存当前 chunk 信息,发生错误时进行返回
            currChunk = {
              filename,
              fileHash,
              index
            };
            // 检查文件夹是否存在如果不存在则新建文件夹
            if (!fs.existsSync(dir)) {
              fs.mkdirSync(dir);
            }
            // 覆盖文件存放的完整路径
            file.path = `${dir}/${fileHash}-${index}`;
          },
          onError: (error) => {
            app.status = 400;
            app.body = { code: 400, msg: "上传失败", data: currChunk };
            return;
          },
        },
      }),
      // 处理响应
      async (ctx) => {
        ctx.set("Content-Type", "application/json");
        ctx.body = JSON.stringify({
          code: 2000,
          message: &#39;upload successfully!&#39;
        });
      });
    🎜🎜파일 리소스 청크🎜🎜🎜 DefualtChunkSize = 5 * 1024 * 1024, 즉 5MB에 따라 파일의 리소스 청크가 계산되며 이를 기반으로 계산됩니다. Spark-md5[1]를 통해 파일 내용을 가져옵니다. 파일의 해시 값은 다른 최적화에 편리합니다. 예를 들어 해시 값이 변경되지 않은 경우 서버는 파일을 반복적으로 읽고 쓸 필요가 없습니다. 🎜
    // 合并请求
    router.post(&#39;/mergeChunks&#39;, async (ctx) => {
      const { filename, size } = ctx.request.body;
      // 合并 chunks
      await mergeFileChunk(path.join(outputPath, &#39;_&#39; + filename), filename, size);
      // 处理响应
      ctx.set("Content-Type", "application/json");
      ctx.body = JSON.stringify({
        data: {
          code: 2000,
          filename,
          size
        },
        message: &#39;merge chunks successful!&#39;
      });
    });
    // 通过管道处理流 
    const pipeStream = (path, writeStream) => {
      return new Promise(resolve => {
        const readStream = fs.createReadStream(path);
        readStream.pipe(writeStream);
        readStream.on("end", () => {
          fs.unlinkSync(path);
          resolve();
        });
      });
    }
    // 合并切片
    const mergeFileChunk = async (filePath, filename, size) => {
      const chunkDir = path.join(outputPath, filename);
      const chunkPaths = fs.readdirSync(chunkDir);
      if (!chunkPaths.length) return;
      // 根据切片下标进行排序,否则直接读取目录的获得的顺序可能会错乱
      chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
      console.log("chunkPaths = ", chunkPaths);
      await Promise.all(
        chunkPaths.map((chunkPath, index) =>
          pipeStream(
            path.resolve(chunkDir, chunkPath),
            // 指定位置创建可写流
            fs.createWriteStream(filePath, {
              start: index * size,
              end: (index + 1) * size
            })
          )
        )
      );
      // 合并后删除保存切片的目录
      fs.rmdirSync(chunkDir);
    };
    🎜🎜업로드 요청 및 병합 요청 보내기🎜🎜🎜Promise.all 메소드를 통해 모든 청크된 업로드 요청을 통합합니다. 모든 청크된 리소스가 업로드된 후 병합 요청을 보냅니다. 🎜rrreee🎜🎜진행률 표시줄 데이터🎜🎜🎜차단된 진행률 데이터는 axios의 onUploadProgress 구성 항목을 사용하여 데이터를 얻고, 계산된 블록 진행률 데이터의 변경 사항을 기반으로 현재 파일의 전체 진행률을 자동으로 계산합니다. 🎜🎜🎜rrreee r 서버 부품 🎜🎜🎜🎜🎜🎜 건설 서비스 🎜🎜🎜🎜🎜🎜 KOA2를 사용하여 간단한 서비스를 구축합니다. code> Content- Type': 'multipart/form-data' 데이터 유형🎜🎜🎜🎜koa-router를 사용하여 서버 라우팅 등록🎜🎜🎜🎜koa2-cors를 사용하여 도메인 간 문제 처리🎜🎜🎜🎜디렉토리 /파일 분할🎜🎜🎜server/server.js🎜🎜이 파일은 서버의 특정 코드 구현으로, 청크된 리소스의 수신 및 통합을 처리하는 데 사용됩니다. 🎜🎜server/resources🎜🎜이 디렉터리는 단일 파일의 여러 블록과 최종 블록 통합 이후의 리소스를 저장하는 데 사용됩니다. 🎜🎜🎜🎜블록 리소스가 병합되지 않은 경우, 이 파일과 관련된 모든 청크를 저장하기 위해 이 디렉터리에 현재 파일 이름을 가진 디렉터리를 생성합니다. 청크된 리소스를 병합해야 할 경우 이 파일에 해당하는 디렉터리의 모든 청크된 리소스를 읽은 다음 원본 파일에 통합되었습니다 🎜
  • 分块资源合并完成,会删除这个对应的文件目录,只保留合并后的原文件,生成的文件名比真实文件名多一个 _ 前缀,如原文件名 "测试文件.txt" 对应合并后的文件名 "_测试文件.txt"

接收分块

使用 koa-body 中的 formidable 配置中的 onFileBegin 函数处理前端传来的 FormData 中的文件资源,在前端处理对应分块名时的格式为:filename-fileHash-index,所以这里直接将分块名拆分即可获得对应的信息。

// 上传请求
router.post(
  &#39;/upload&#39;,
  // 处理文件 form-data 数据
  koaBody({
    multipart: true,
    formidable: {
      uploadDir: outputPath,
      onFileBegin: (name, file) => {
        const [filename, fileHash, index] = name.split(&#39;-&#39;);
        const dir = path.join(outputPath, filename);
        // 保存当前 chunk 信息,发生错误时进行返回
        currChunk = {
          filename,
          fileHash,
          index
        };
        // 检查文件夹是否存在如果不存在则新建文件夹
        if (!fs.existsSync(dir)) {
          fs.mkdirSync(dir);
        }
        // 覆盖文件存放的完整路径
        file.path = `${dir}/${fileHash}-${index}`;
      },
      onError: (error) => {
        app.status = 400;
        app.body = { code: 400, msg: "上传失败", data: currChunk };
        return;
      },
    },
  }),
  // 处理响应
  async (ctx) => {
    ctx.set("Content-Type", "application/json");
    ctx.body = JSON.stringify({
      code: 2000,
      message: &#39;upload successfully!&#39;
    });
  });

整合分块

通过文件名找到对应文件分块目录,使用 fs.readdirSync(chunkDir) 方法获取对应目录下所以分块的命名,在通过 fs.createWriteStream/fs.createReadStream 创建可写/可读流,结合管道 pipe 将流整合在同一文件中,合并完成后通过 fs.rmdirSync(chunkDir) 删除对应分块目录。

// 合并请求
router.post(&#39;/mergeChunks&#39;, async (ctx) => {
  const { filename, size } = ctx.request.body;
  // 合并 chunks
  await mergeFileChunk(path.join(outputPath, &#39;_&#39; + filename), filename, size);
  // 处理响应
  ctx.set("Content-Type", "application/json");
  ctx.body = JSON.stringify({
    data: {
      code: 2000,
      filename,
      size
    },
    message: &#39;merge chunks successful!&#39;
  });
});
// 通过管道处理流 
const pipeStream = (path, writeStream) => {
  return new Promise(resolve => {
    const readStream = fs.createReadStream(path);
    readStream.pipe(writeStream);
    readStream.on("end", () => {
      fs.unlinkSync(path);
      resolve();
    });
  });
}
// 合并切片
const mergeFileChunk = async (filePath, filename, size) => {
  const chunkDir = path.join(outputPath, filename);
  const chunkPaths = fs.readdirSync(chunkDir);
  if (!chunkPaths.length) return;
  // 根据切片下标进行排序,否则直接读取目录的获得的顺序可能会错乱
  chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
  console.log("chunkPaths = ", chunkPaths);
  await Promise.all(
    chunkPaths.map((chunkPath, index) =>
      pipeStream(
        path.resolve(chunkDir, chunkPath),
        // 指定位置创建可写流
        fs.createWriteStream(filePath, {
          start: index * size,
          end: (index + 1) * size
        })
      )
    )
  );
  // 合并后删除保存切片的目录
  fs.rmdirSync(chunkDir);
};

前端 & 服务端 交互


前端分块上传

测试文件信息:

대용량 파일을 빠르게 업로드하는 방법은 무엇입니까? 어떻게 구현하는지 살펴보겠습니다!

选择文件类型为 19.8MB,而且上面设定默认分块大小为 5MB ,于是应该要分成 4 个分块,即 4 个请求。

대용량 파일을 빠르게 업로드하는 방법은 무엇입니까? 어떻게 구현하는지 살펴보겠습니다!

服务端分块接收

대용량 파일을 빠르게 업로드하는 방법은 무엇입니까? 어떻게 구현하는지 살펴보겠습니다!

前端发送合并请求

대용량 파일을 빠르게 업로드하는 방법은 무엇입니까? 어떻게 구현하는지 살펴보겠습니다!

服务端合并分块

대용량 파일을 빠르게 업로드하는 방법은 무엇입니까? 어떻게 구현하는지 살펴보겠습니다!

扩展 —— 断点续传 & 秒传


有了上面的核心逻辑之后,要实现断点续传和秒传的功能,只需要在取扩展即可,这里不再给出具体实现,只列出一些思路。

断点续传

断点续传其实就是让请求可中断,然后在接着上次中断的位置继续发送,此时要保存每个请求的实例对象,以便后期取消对应请求,并将取消的请求保存或者记录原始分块列表取消位置信息等,以便后期重新发起请求。

取消请求的几种方式:

  • 如果使用原生 XHR 可使用 (new XMLHttpRequest()).abort() 取消请求

  • 如果使用 axios 可使用 new CancelToken(function (cancel) {}) 取消请求

  • 如果使用 fetch 可使用 (new AbortController()).abort() 取消请求

秒传

不要被这个名字给误导了,其实所谓的秒传就是不用传,在正式发起上传请求时,先发起一个检查请求,这个请求会携带对应的文件 hash 给服务端,服务端负责查找是否存在一模一样的文件 hash,如果存在此时直接复用这个文件资源即可,不需要前端在发起额外的上传请求。

最后


前端分片上传的内容单纯从理论上来看其实还是容易理解的,但是实际自己去实现的时候还是会踩一些坑,比如服务端接收解析 formData 格式的数据时,没法获取文件的二进制数据等。

更多编程相关知识,请访问:编程视频!!

성명:
이 기사는 weixin에서 복제됩니다. 침해가 있는 경우 admin@php.cn으로 문의하시기 바랍니다. 삭제