文件分片上传
在实际开发工作中,文件上传是非常常见的功能。有时候如果文件过大,那么上传就需要花费很多时间,这时候用户体验就会很差。
所以针对大文件上传的场景,我们需要优化一下,具体方案是将大文件分成几份并行上传,上传完成后再合并到一起。
那么具体怎么做呢
前端文件分片
首先用户通过<input>元素来选择上传文件,通过访问该元素的files
属性可以获取到上传的文件对象,该对象是File
对象,File
对象是一种特定类型的Blob
,其继承了Blob
的功能,所以File
可以使用Blob
的实例方法。
<input type="file" />
blob
上有个slice
方法,其可以返回一个新的 Blob
对象,其中包含调用它的 blob 的指定字节范围内的数据。我们可以通过使用Blob
对象的slice
方法,将文件分成多份。
我们用一个20M左右的图片(下图)来模拟一下
将该图片文件按1M一份分成20份,react代码如下:
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
npm install -g @nestjs/cli
创建一个nest项目
nest new large_file_nest
nest
的文件上传基于Express
的中间件multer
实现。Multer 处理以 multipart/form-data
格式发布的数据,该格式主要用于通过 HTTP POST
请求上传文件。
为了处理文件上传,Nest 为 Express 提供了一个基于multer
中间件包的内置模块。
首先,为了更好的类型安全,让我们安装 Multer typings 包:
pnpm install -D @types/multer
安装完后,才可以使用 Express.Multer.File
类型
在app.controller.ts
中添加如下代码
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
。
我们给前端代码加入接口调用
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服务端就获取到了上传的文件和数据
我们可以把同个文件的分片放到一起,方便后续合并,完善一下后端代码
@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事件
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方式合并
代码如下
@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 }, () => {}); // 合并完删除分片文件 }
前端上传后调用合并接口后,文件在服务端生成了
但是点开文件一看
发现文件怎么错乱了,排查一下,打印一下分片文件列表看下
@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 }, () => {}); }
发现文件顺序是乱的,于是我们在合并写入前将分片文件排个序,修改一下上传接口代码
@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
一个一个将可读流传输到可写流中,以此来达到合并文件的效果。
@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
获取需要下载的文件信息然后返回即可
@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
读取指定位置的可读流并传输到返回数据中。
@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); }
前端实现文件分片下载
代码如下,主要过程是先获取需要下载的文件信息,根据下载的文件大小和设定的分片大小批量请求分片文件,最后在请求完毕后再将文件合并下载。
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
值重复了
所以修改前端代码如下
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
值正确了。
打开图片检查,没有问题,是对的。