全栈实现大文件上传(后端 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 目录删掉。

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

相关推荐
@大迁世界6 分钟前
TypeScript 的本质并非类型,而是信任
开发语言·前端·javascript·typescript·ecmascript
GIS之路15 分钟前
GDAL 实现矢量裁剪
前端·python·信息可视化
勇哥java实战分享16 分钟前
短信平台 Pro 版本 ,比开源版本更强大
后端
是一个Bug18 分钟前
后端开发者视角的前端开发面试题清单(50道)
前端
Amumu1213820 分钟前
React面向组件编程
开发语言·前端·javascript
学历真的很重要21 分钟前
LangChain V1.0 Context Engineering(上下文工程)详细指南
人工智能·后端·学习·语言模型·面试·职场和发展·langchain
计算机毕设VX:Fegn089524 分钟前
计算机毕业设计|基于springboot + vue二手家电管理系统(源码+数据库+文档)
vue.js·spring boot·后端·课程设计
上进小菜猪40 分钟前
基于 YOLOv8 的智能杂草检测识别实战 [目标检测完整源码]
后端
持续升级打怪中42 分钟前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路1 小时前
GDAL 实现矢量合并
前端