全栈实现大文件上传(后端 Nest)

前言

通常,我们可以通过设置 Content-Typemultipart/form-data 来上传文件。

然而,当处理大文件时,上传时间可能会非常长。这时候大文件分片上传的策略。

分片上传的原理

  • 将大文件分割成多个小文件(例如,将 1GB 的文件分割成 10 个 100MB 的分片)。
  • 并行上传这些分片,以加快上传速度。
  • 分片上传完成后,通过服务端合并这些分片。

在浏览器端分片

在浏览器端,我们可以使用 Blob 对象的 slice 方法来分割文件。
BlobFile 对象的超集,因此我们可以通过 File 对象的 slice 方法来获取文件的一部分。

html 复制代码
<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<!-- 引入axios库,用于发送HTTP请求 -->
		<script src="https://unpkg.com/axios@0.24.0/dist/axios.min.js"></script>
	</head>
	<body>
		<!-- 文件输入框,用于选择文件 -->
		<input id="fileInput" type="file" />
		<script>
			// 获取文件输入框DOM元素
			const fileInput = document.querySelector('#fileInput');
			// 设置每个文件块的大小为20KB
			const chunkSize = 20 * 1024; // 20KB

			// 当文件输入框的值改变时触发的事件
			fileInput.onchange = async function () {
				try {
					// 获取选中的文件
					const file = fileInput.files[0];
					// 初始化文件块数组
					const chunks = [];
					// 将文件分割成多个块
					for (let startPos = 0; startPos < file.size; startPos += chunkSize) {
						chunks.push(file.slice(startPos, startPos + chunkSize));
					}

					// 生成一个随机字符串,用于构建文件名
					const randomStr = Math.random().toString().slice(2, 8);
					// 对文件名进行编码
					const baseFileName = encodeURIComponent(randomStr + '_' + file.name);
					// 创建一个任务数组,每个任务负责上传一个文件块
					const tasks = chunks.map((chunk, index) => {
						// 使用FormData来构建表单数据
						const data = new FormData();
						// 构建每个文件块的名称
						const chunkName = `${baseFileName}-${index}`;
						// 设置表单数据
						data.set('name', chunkName);
						// 添加文件块到表单数据
						data.append('files', chunk);
						// 发送POST请求,上传文件块
						return axios.post('http://localhost:3000/upload', data);
					});

					// 等待所有文件块上传完成
					await Promise.all(tasks);
					// 发送GET请求,通知服务器合并文件块
					await axios.get(`http://localhost:3000/merge?name=${baseFileName}`);
					// 打印上传和合并成功的消息
					console.log('Upload and merge completed successfully.');
				} catch (error) {
					console.error('An error occurred:', error);
				}
			};
		</script>
	</body>
</html>

对拿到的文件进行 20kb 的分片,然后单独上传每个分片。

起个静态服务:

bash 复制代码
npx http-server .

在 Nest 中实现分片上传

创建 Nest 项目:

bash 复制代码
nest new file-sharding-upload-test -p npm

安装 multer 包的类型:

bash 复制代码
npm install @types/multer -D

在 main.ts 里开启跨域支持:

运行项目:

bash 复制代码
npm run start:dev

接收分片

在 AppController 实现 upload 路由:

接受分片,并将它们移动到单独的目录:

typescript 复制代码
import {
  Body,
  Controller,
  Post,
  UploadedFiles,
  UseInterceptors,
} from '@nestjs/common';
import { promises as fsPromises } from 'fs';
import * as path from 'path';
import { FilesInterceptor } from '@nestjs/platform-express';

@Controller()
export class AppController {

  @Post('upload')
  // 使用文件拦截器拦截上传的文件,'files' 是字段名,20 是最大文件数量,dest 是文件存储目录
  @UseInterceptors(
    FilesInterceptor('files', 20, {
      dest: 'uploads', // 设置文件上传的目录
    }),
  )
  async uploadFiles(
    @UploadedFiles() files: Array<Express.Multer.File>, // 通过装饰器获取上传的文件数组
    @Body() body: { name: string }, // 通过装饰器获取请求体中的数据
  ) {
    // 从请求体中的 name 字段提取文件名
    const fileNameMatch = body.name.match(/(.+)-\d+$/);
    if (!fileNameMatch) {
      throw new Error('Invalid file name'); // 如果文件名不符合规则,抛出错误
    }
    const fileName = fileNameMatch[1]; // 获取文件名
    const chunkDir = path.join('uploads', 'chunks_' + fileName); // 设置分片文件存储的目录

    try {
      await fsPromises.mkdir(chunkDir, { recursive: true }); // 创建分片文件目录
      await fsPromises.copyFile(files[0].path, path.join(chunkDir, body.name)); // 将上传的文件复制到分片目录
      await fsPromises.unlink(files[0].path); // 删除原始上传文件
    } catch (error) {
      console.error('Error during file upload:', error);
      throw error;
    }
  }
}

在 uploads 目录下创建 chunks_文件名 的目录,把文件复制过去,然后删掉原始文件。

上传文件:

确实分成很多片了。

合并分片

合并 :服务器端的 fs.createWriteStream 支持设置开始写入的位置(start),便于将分片按顺序合并成一个文件。

添加一个 merge 的接口:

typescript 复制代码
@Get('merge')
  async merge(@Query('name') name: string): Promise<void> {
    const chunkDir = `uploads/chunks_${name}`; // 定义文件块存储目录

    try {
      const files = fs.readdirSync(chunkDir); // 读取目录中的所有文件

      // 对文件进行排序,以便按顺序合并。
      files.sort((a, b) => {
        const indexA = parseInt(a.split('-').pop(), 10);
        const indexB = parseInt(b.split('-').pop(), 10);
        return indexA - indexB;
      });

      // 用于指定写入流的开始位置
      let startPos = 0;

      for (const file of files) {
        const filePath = `${chunkDir}/${file}`; // 完整的文件块路径
        const fileStats = await fs.promises.stat(filePath); // 获取文件块的统计信息

        const writeStream = fs.createWriteStream(`uploads/${name}`, {
          flags: 'a', // 追加模式,将数据写入文件末尾
          start: startPos, // 指定写入开始位置
        });

        // 创建读取流,用于读取文件块数据,并通过管道将其写入写入流。
        const stream = fs.createReadStream(filePath);
        // 等待流式传输完成。
        await new Promise((resolve, reject) => {
          stream.pipe(writeStream).on('finish', resolve).on('error', reject);
        });

        startPos += fileStats.size; // 更新下一次写入的开始位置
      }

      // 合并完成后删除分块目录
      await fs.promises.rm(chunkDir, { recursive: true });
    } catch (error) {
      console.error('An error occurred during the merge process:', error);
      throw error;
    }
  }

接收文件名,然后查找对应的 chunks 目录,把下面的文件读取出来,按照不同的 start 位置写入到同一个文件里。在合并完成之后把 chunks 目录删掉。

浏览器上传文件后,服务器端成功合并:

相关推荐
颜淡慕潇31 分钟前
【K8S问题系列 |1 】Kubernetes 中 NodePort 类型的 Service 无法访问【已解决】
后端·云原生·容器·kubernetes·问题解决
熊的猫1 小时前
JS 中的类型 & 类型判断 & 类型转换
前端·javascript·vue.js·chrome·react.js·前端框架·node.js
瑶琴AI前端1 小时前
uniapp组件实现省市区三级联动选择
java·前端·uni-app
会发光的猪。1 小时前
如何在vscode中安装git详细新手教程
前端·ide·git·vscode
尘浮生1 小时前
Java项目实战II基于Spring Boot的光影视频平台(开发文档+数据库+源码)
java·开发语言·数据库·spring boot·后端·maven·intellij-idea
尚学教辅学习资料2 小时前
基于SpringBoot的医药管理系统+LW示例参考
java·spring boot·后端·java毕业设计·医药管理
我要洋人死2 小时前
导航栏及下拉菜单的实现
前端·css·css3
科技探秘人3 小时前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
monkey_meng3 小时前
【Rust中的迭代器】
开发语言·后端·rust
科技探秘人3 小时前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome