前言
通常,我们可以通过设置 Content-Type
为 multipart/form-data
来上传文件。
然而,当处理大文件时,上传时间可能会非常长。这时候大文件分片上传的策略。
分片上传的原理
- 将大文件分割成多个小文件(例如,将 1GB 的文件分割成 10 个 100MB 的分片)。
- 并行上传这些分片,以加快上传速度。
- 分片上传完成后,通过服务端合并这些分片。
在浏览器端分片
在浏览器端,我们可以使用 Blob
对象的 slice
方法来分割文件。
Blob
是 File
对象的超集,因此我们可以通过 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 目录删掉。
浏览器上传文件后,服务器端成功合并: