文件分片上传
在实际开发工作中,文件上传是非常常见的功能。有时候如果文件过大,那么上传就需要花费很多时间,这时候用户体验就会很差。
所以针对大文件上传的场景,我们需要优化一下,具体方案是将大文件分成几份并行上传,上传完成后再合并到一起。
那么具体怎么做呢
前端文件分片
首先用户通过<input>元素来选择上传文件,通过访问该元素的files
属性可以获取到上传的文件对象,该对象是File
对象,File
对象是一种特定类型的Blob
,其继承了Blob
的功能,所以File
可以使用Blob
的实例方法。
html
<input type="file" />
blob
上有个slice
方法,其可以返回一个新的 Blob
对象,其中包含调用它的 blob 的指定字节范围内的数据。我们可以通过使用Blob
对象的slice
方法,将文件分成多份。
我们用一个20M左右的图片(下图)来模拟一下
将该图片文件按1M一份分成20份,react代码如下:
tsx
export default function FileUpload() {
function fileSlice(file: File) {
const singleSize = 1024 * 1024; // 设置分片大小为 1MB
let startPos = 0;
const sliceArr = [];
while (startPos < file.size) {
const sliceFile = file.slice(startPos, startPos + singleSize);
sliceArr.push(sliceFile);
startPos += singleSize;
}
return sliceArr;
}
function fileChange(event: React.ChangeEvent<HTMLInputElement>) {
if (event.target.files) {
const file = event.target.files[0];
const fileSliceArr = fileSlice(file);
console.log(fileSliceArr);
}
}
return (
<div className="file-upload">
<input
type="file"
className="upload-input"
onChange={(event) => fileChange(event)}
/>
</div>
);
}
选择文件后结果如下
好,先暂停一下,我们把上传的后端接口实现一下,我们使用nest
框架来实现。
后端文件上传接口实现(nestjs)
全局安装nestjs脚手架@nestjs/cli
javascript
npm install -g @nestjs/cli
创建一个nest项目
javascript
nest new large_file_nest
nest
的文件上传基于Express
的中间件multer
实现。Multer 处理以 multipart/form-data
格式发布的数据,该格式主要用于通过 HTTP POST
请求上传文件。
为了处理文件上传,Nest 为 Express 提供了一个基于multer
中间件包的内置模块。
首先,为了更好的类型安全,让我们安装 Multer typings 包:
javascript
pnpm install -D @types/multer
安装完后,才可以使用 Express.Multer.File
类型
在app.controller.ts
中添加如下代码
typescript
import {
Controller,
Post,
UploadedFile,
UseInterceptors,
Body,
} from '@nestjs/common';
import { AppService } from './app.service';
import { FileInterceptor } from '@nestjs/platform-express';
import * as fs from 'fs';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Post('upload')
@UseInterceptors(
FileInterceptor('file', {
dest: 'files', // 指定存储文件的地方
}),
)
fileUpload(@UploadedFile() file: Express.Multer.File, @Body() body) {
console.log(file)
console.log(body)
}
}
要上传单个文件,只需将
FileInterceptor()
拦截器绑定到路由处理程序并使用@UploadedFile()
装饰器从request
中提取file
。
我们给前端代码加入接口调用
tsx
import "./index.scss";
import axios from "axios";
export default function FileUpload() {
function fileSlice(file: File) {
const singleSize = 1024 * 1024; // 设置分片大小为 1MB
let startPos = 0;
const sliceArr = [];
while (startPos < file.size) {
const sliceFile = file.slice(startPos, startPos + singleSize);
sliceArr.push(sliceFile);
startPos += singleSize;
}
return sliceArr;
}
function fileChange(event: React.ChangeEvent<HTMLInputElement>) {
if (event.target.files) {
const file = event.target.files[0];
const fileSliceArr = fileSlice(file);
fileSliceArr.forEach((fileFragments, index) => {
const formData = new FormData();
formData.set("file", fileFragments);
formData.set("name", file.name);
formData.set("index", index + "");
axios({
method: "POST",
url: "http://localhost:3000/upload",
data: formData,
});
});
}
}
return (
<div className="file-upload">
<input
type="file"
className="upload-input"
onChange={(event) => fileChange(event)}
/>
</div>
);
}
如此,nest服务端就获取到了上传的文件和数据
我们可以把同个文件的分片放到一起,方便后续合并,完善一下后端代码
typescript
@Post('upload')
@UseInterceptors(
FileInterceptor('file', {
dest: 'files',
}),
)
fileUpload(@UploadedFile() file: Express.Multer.File, @Body() body) {
const fileName = body.name;
const chunksDir = `files/chunks_${fileName}`;
if (!fs.existsSync(chunksDir)) {
fs.mkdirSync(chunksDir);
}
fs.cpSync(file.path, `${chunksDir}/${fileName}-${body.index}`);
fs.rmSync(file.path);
}
重新上传后,结果如下:
接下来是把分片合并
文件合并分片
我们需要在前端分片上传完毕后,调用合并的接口
完善一下前端代码的change事件
tsx
function fileChange(event: React.ChangeEvent<HTMLInputElement>) {
if (event.target.files) {
const file = event.target.files[0];
const fileSliceArr = fileSlice(file);
const fetchList: Promise<undefined>[] = [];
fileSliceArr.forEach((fileFragments, index) => {
const formData = new FormData();
formData.set("file", fileFragments);
formData.set("name", file.name);
formData.set("index", index + "");
fetchList.push(
axios({
method: "POST",
url: "http://localhost:3000/upload",
data: formData,
})
);
});
Promise.all(fetchList).then(() => {
axios({
method: "POST",
url: "http://localhost:3000/merge", // 调用合并接口
data: {
name: file.name,
},
});
});
}
}
然后是服务端接口的实现,文件的合并方式常见的有
- buffer方式合并
- stream方式合并
buffer方式合并
代码如下
typescript
@Post('buffer_merge')
fileBufferMerge(@Body() body: { name: string }) {
const chunksDir = `files/chunks_${body.name}`;
const files = fs.readdirSync(chunksDir);
const outputFilePath = `files/${body.name}`;
const buffers = [];
files.forEach((file) => {
const filePath = `${chunksDir}/${file}`;
const buffer = fs.readFileSync(filePath);
buffers.push(buffer);
});
const concatBuffer = Buffer.concat(buffers);
fs.writeFileSync(outputFilePath, concatBuffer);
fs.rm(chunksDir, { recursive: true }, () => {}); // 合并完删除分片文件
}
前端上传后调用合并接口后,文件在服务端生成了
但是点开文件一看
发现文件怎么错乱了,排查一下,打印一下分片文件列表看下
tsx
@Post('buffer_merge')
fileBufferMerge(@Body() body: { name: string }) {
const chunksDir = `files/chunks_${body.name}`;
const files = fs.readdirSync(chunksDir);
console.log(files); // 打印文件列表看一下
const outputFilePath = `files/${body.name}`;
const buffers = [];
files.forEach((file) => {
const filePath = `${chunksDir}/${file}`;
const buffer = fs.readFileSync(filePath);
buffers.push(buffer);
});
const concatBuffer = Buffer.concat(buffers);
fs.writeFileSync(outputFilePath, concatBuffer);
fs.rm(chunksDir, { recursive: true }, () => {});
}
发现文件顺序是乱的,于是我们在合并写入前将分片文件排个序,修改一下上传接口代码
typescript
@Post('buffer_merge')
fileBufferMerge(@Body() body: { name: string }) {
const chunksDir = `files/chunks_${body.name}`;
const files = fs.readdirSync(chunksDir).sort((a, b) => {
const aIndex = a.slice(a.lastIndexOf('-'));
const bIndex = b.slice(b.lastIndexOf('-'));
return Number(bIndex) - Number(aIndex);
});
const outputFilePath = `files/${body.name}`;
const buffers = [];
files.forEach((file) => {
const filePath = `${chunksDir}/${file}`;
const buffer = fs.readFileSync(filePath);
buffers.push(buffer);
});
const concatBuffer = Buffer.concat(buffers);
fs.writeFileSync(outputFilePath, concatBuffer);
fs.rm(chunksDir, { recursive: true }, () => {});
}
重新跑下nest服务,再重新上传文件后发现文件正常了。
至此合并文件成功。
stream流方式合并
代码如下,主要方案是用fs.createReadStream
创建可读流,用fs.createWriteStream
创建可写流,fs.createWriteStream
的第二个参数options
中有个start
选项,其可以指定在文件开头之后的某个位置写入数据。然后通过管道方法pipe
一个一个将可读流传输到可写流中,以此来达到合并文件的效果。
typescript
@Post('stream_merge')
fileMerge(@Body() body: { name: string }) {
const chunksDir = `files/chunks_${body.name}`;
const files = fs.readdirSync(chunksDir).sort((a, b) => {
const aIndex = a.slice(a.lastIndexOf('-'));
const bIndex = b.slice(b.lastIndexOf('-'));
return Number(bIndex) - Number(aIndex);
});
let startPos = 0;
const outputFilePath = `files/${body.name}`;
files.forEach((file, index) => {
const filePath = `${chunksDir}/${file}`;
const readStream = fs.createReadStream(filePath);
const writeStream = fs.createWriteStream(outputFilePath, {
start: startPos,
});
readStream.pipe(writeStream).on('finish', () => {
if (index === files.length - 1) {
fs.rm(chunksDir, { recursive: true }, () => {}); // 合并完删除分片文件
}
});
startPos += fs.statSync(filePath).size;
});
}
buffer方式和stream流方式对比
buffer方式合并时,读取的文件有多大,合并的过程占用的内存就有多大,相当于把这个大文件的全部内容都一次性载入到内存中,很吃内存,效率很低。
stream流方式,不同于buffer,无需一次性的把文件数据全部放入内存,所以用stream流方式处理会更高效。
文件分片下载
遇到大文件下载时,可以通过将大文件拆分成多个小文件并同时下载来提高效率。下面是一个简单的前后端文件分片下载的简单实现。
后端接口实现
后端需要两个接口,一个是获取需要下载的文件信息的接口,另一个是获取文件分片的接口。
获取下载的文件信息的接口比较简单,使用fs.statSync
获取需要下载的文件信息然后返回即可
typescript
@Get('file_size')
fileDownload() {
const filePath = `files/banner.jpg`;
if (fs.existsSync(filePath)) {
const stat = fs.statSync(filePath);
return {
size: stat.size,
fileName: 'banner.jpg',
};
}
}
获取文件分片的接口是根据前端传递的start
、end
参数,使用fs.createReadStream
读取指定位置的可读流并传输到返回数据中。
typescript
@Get('file_chunk')
fileGet(@Query() params, @Res() res) {
const filePath = `files/banner.jpg`;
const fileStream = fs.createReadStream(filePath, {
start: Number(params.start),
end: Number(params.end),
});
fileStream.pipe(res);
}
前端实现文件分片下载
代码如下,主要过程是先获取需要下载的文件信息,根据下载的文件大小和设定的分片大小批量请求分片文件,最后在请求完毕后再将文件合并下载。
tsx
import "./index.scss";
import axios from "axios";
export default function FileDownload() {
function fileDownload() {
const singleSize = 1024 * 1024; // 设置分片大小为 1MB
axios({
method: "GET",
url: "http://localhost:3000/file_size",
}).then((res) => {
if (res.data) {
const fileSize = res.data.size;
const fileName = res.data.fileName;
let startPos = 0;
const fetchList: Promise<Blob>[] = [];
while (startPos < fileSize) {
fetchList.push(
new Promise((resolve) => {
axios({
method: "GET",
url: "http://localhost:3000/file_chunk",
params: {
start: startPos,
end: startPos + singleSize,
},
responseType: "blob",
}).then((res) => {
resolve(res.data);
});
})
);
startPos += singleSize;
}
Promise.all(fetchList).then((res) => {
const mergedBlob = new Blob(res);
const downloadUrl = window.URL.createObjectURL(mergedBlob);
const link = document.createElement("a");
link.href = downloadUrl;
link.setAttribute("download", fileName);
link.click();
window.URL.revokeObjectURL(downloadUrl);
});
}
});
}
return (
<div className="file-download">
<button onClick={fileDownload}>下载</button>
</div>
);
}
这时候文件就下载好了,打开文件一看
发现文件怎么错乱了,仔细检查发现,分片下载接口的参数start
值和上一个接口end
值重复了
所以修改前端代码如下
tsx
import "./index.scss";
import axios from "axios";
export default function FileDownload() {
function fileDownload() {
const singleSize = 1024 * 1024; // 设置分片大小为 1MB
axios({
method: "GET",
url: "http://localhost:3000/file_size",
}).then((res) => {
if (res.data) {
const fileSize = res.data.size;
const fileName = res.data.fileName;
let startPos = 0;
const fetchList: Promise<Blob>[] = [];
while (startPos < fileSize) {
fetchList.push(
new Promise((resolve) => {
axios({
method: "GET",
url: "http://localhost:3000/file_chunk",
params: {
start: startPos,
end: startPos + singleSize,
},
responseType: "blob",
}).then((res) => {
resolve(res.data);
});
})
);
startPos = startPos + singleSize + 1; // 修改的地方
}
Promise.all(fetchList).then((res) => {
const mergedBlob = new Blob(res);
const downloadUrl = window.URL.createObjectURL(mergedBlob);
const link = document.createElement("a");
link.href = downloadUrl;
link.setAttribute("download", fileName);
link.click();
window.URL.revokeObjectURL(downloadUrl);
});
}
});
}
return (
<div className="file-download">
<button onClick={fileDownload}>下载</button>
</div>
);
}
再次下载,发现参数start
值和end
值正确了。
打开图片检查,没有问题,是对的。