上节我们学了在 Express 里用 multer 包处理 multipart/form-data 类型的请求中的 file。
单个、多个字段的单个、多个 file 都能轻松取出来。
接下来我们就来学习一下在Nest 里使用multer。
一,Nest如何使用multer实现文件上传
首先我们先创建一个Nest项目:
nest new nest-multer-upload -p npm
还需要安装下 multer 的 ts 类型的包:
npm install -D @types/multer
我们在AppController 添加这样一个 handler:
import { Controller, Get, Post, UseInterceptors,UploadedFile,Body } from '@nestjs/common';
import { AppService } from './app.service';
import { FileInterceptor } from '@nestjs/platform-express';@Controller()
export class AppController {constructor(private readonly appService: AppService) {}@Post('file')@UseInterceptors(FileInterceptor('file',{dest:'uploads'}))uploadFile(@UploadedFile() file:Express.Multer.File,@Body() body){console.log('body', body);console.log('file', file);}
}
使用 FileInterceptor 来提取 file 字段,然后通过 UploadedFile 装饰器把它作为参数传入。
然后 npm run start:dev 把服务跑起来,一保存,就可以看到这个目录被创建了:
然后我们来写前端代码,让 nest 服务支持静态文件的访问,然后让 nest 服务支持跨域,再单独跑个 http-server 来提供静态服务。
在根目录创建 index.html,编写前端代码:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title><script src="https://unpkg.com/axios@0.24.0/dist/axios.min.js"></script>
</head>
<body><input id="fileInput" type="file" multiple/><script>const fileInput = document.querySelector('#fileInput');async function formData() {const data = new FormData();data.set('name','张三');data.set('age', 24);data.set('file', fileInput.files[0]);const res = await axios.post('http://localhost:3000/file', data);console.log(res);}fileInput.onchange = formData;</script>
</body>
</html>
先单独跑个 http-server 来提供静态服务:
npx http-server
接下来我们在页面选择一个文件上传:
服务端就打印了file对象并存到uploads文件夹:
再来试下多文件上传:
// 多文件上传@Post('files')@UseInterceptors(FilesInterceptor('files',3,{dest:'uploads'}))uploadFiles(@UploadedFiles() files:Array<Express.Multer.File>,@Body() body) {console.log('body', body);console.log('files', files);}
//把 FileInterceptor 换成 FilesInterceptor,把 UploadedFile 换成 UploadedFiles,都是多加一个 s。
前端代码:
<body><input id="fileInput" type="file" multiple /><script>const fileInput = document.querySelector('#fileInput');async function formData() {const data = new FormData();data.set('name', '张三');data.set('age', 24);[...fileInput.files].forEach(item => {data.append('files', item)})const res = await axios.post('http://localhost:3000/files', data, {headers: { 'content-type': 'multipart/form-data' }});console.log(res);}fileInput.onchange = formData;</script>
</body>
这样就可以上传多文件了:
如果有多个文件的字段:
@Post('filesA')@UseInterceptors(FileFieldsInterceptor([{ name: 'file1', maxCount: 2 },{ name: 'file2', maxCount: 3 }], {dest: 'uploads'}))uploadFileFields(@UploadedFiles() files: { file1?: Express.Multer.File[], file2?: Express.Multer.File[] }, @Body() body) {console.log('body', body);console.log('files', files);}
前端代码和之前都差不多,只是字段名和接口不一样,在这里就不一一赘述了
如果并不知道有哪些字段是 file :
@Post('filesB')@UseInterceptors(AnyFilesInterceptor({dest: 'uploads'}))uploadAnyFiles(@UploadedFiles() files: Array<Express.Multer.File>, @Body() body) {console.log('body', body);console.log('files', files);}
文件的校验:
像文件大小、类型的校验这种逻辑太过常见,Nest 给封装好了,可以直接用:
@Post('filesC')@UseInterceptors(FileInterceptor('file', {dest: 'uploads'}))uploadFile3(@UploadedFile(new ParseFilePipe({validators: [new MaxFileSizeValidator({ maxSize: 1000 }),new FileTypeValidator({ fileType: 'image/jpeg' }),],})) file: Express.Multer.File, @Body() body) {console.log('body', body);console.log('file', file);}//ParseFilePipe:它的作用是调用传入的 validator 来对文件做校验//比如 MaxFileSizeValidator 是校验文件大小、FileTypeValidator 是校验文件类型
我们来试试:
可以看到,返回的也是 400 响应,并且 message 说明了具体的错误信息
而且这个错误信息可以自己修改:
@Post('filesC')@UseInterceptors(FileInterceptor('file', {dest: 'uploads'}))uploadFile3(@UploadedFile(new ParseFilePipe({exceptionFactory:err => {throw new HttpException('错误信息:' + err ,400)},validators: [new MaxFileSizeValidator({ maxSize: 1000 }),new FileTypeValidator({ fileType: 'image/jpeg' }),],})) file: Express.Multer.File, @Body() body) {console.log('body', body);console.log('file', file);}
看看效果:
二,大文件分片上传
当文件很大的时候,上传就会变得比较慢。
假设传一个 100M 的文件需要 3 分钟,那传一个 1G 的文件就需要 30 分钟。
这样是能完成功能,但是产品的体验会很不好。
所以大文件上传的场景,需要做专门的优化。
把 1G 的大文件分割成 10 个 100M 的小文件,然后这些文件并行上传,不就快了?
然后等 10 个小文件都传完之后,再发一个请求把这 10 个小文件合并成原来的大文件。
这就是大文件分片上传的方案。
那如何拆分和合并呢?
浏览器里 Blob 有 slice 方法,可以截取某个范围的数据,而 File 就是一种 Blob。
所以可以在 input 里选择了 file 之后,通过 slice 对 File 分片。
那合并呢?
fs 的 createWriteStream 方法支持指定 start,也就是从什么位置开始写入。
这样把每个分片按照不同位置写入文件里,就完成合并了。
创建个 Nest 项目:
nest new large-file-sharding-upload
在 AppController 添加一个路由:
@Post('upload')@UseInterceptors(FilesInterceptor('files',20,{dest:'uploads'}))uploadFiles(@UploadedFiles() files :Array<Express.Multer.File>,@Body() body) {console.log('body' ,body)console.log('files',files) }
前端代码我们这样写:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title><script src="https://unpkg.com/axios@0.24.0/dist/axios.min.js"></script>
</head>
<body><input id="fileInput" type="file"/><script>/*对拿到的文件进行分片,然后单独上传每个分片,分片名称为文件名+index*/const fileInput = document.querySelector('#fileInput');const chunkSize = 20 * 1024async function formData() {const file = fileInput.files[0]const chunks = []let startPos = 0while(startPos < file.size) {chunks.push(file.slice(startPos, startPos + chunkSize));startPos += chunkSize;}chunks.map((chunk, index) => {const data = new FormData();data.set('name', file.name + '-' + index)data.append('files', chunk);axios.post('http://localhost:3000/upload', data);})console.log(res);}fileInput.onchange = formData;</script>
</body>
</html>
接下来我们来测试一下,这里我测试用的图片是 40k:
每 20k 一个分片,一共是 2 个分片,服务端接收到了这 2 个分片:
接下来我们来进行合并操作:
@Post('upload')@UseInterceptors(FilesInterceptor('files',20,{dest:'uploads'}))uploadFiles(@UploadedFiles() files :Array<Express.Multer.File>,@Body() body) {console.log('body' ,body)console.log('files',files) // 将分片移动到单独的目录const fileName = body.name.match(/(.+)\-\d+$/)[1];const chunkDir = 'uploads/chunks_'+ fileName;if(!fs.existsSync(chunkDir)){fs.mkdirSync(chunkDir);}fs.cpSync(files[0].path, chunkDir + '/' + body.name);fs.rmSync(files[0].path);// 然后我们来合并文件const chunkDirMerge = 'uploads/chunks_'+ fileName;const filesMerge = fs.readdirSync(chunkDirMerge);let count = 0;let startPos = 0;filesMerge.map(file => {const filePath = chunkDirMerge + '/' + file;const stream = fs.createReadStream(filePath);stream.pipe(fs.createWriteStream('uploads/' + fileName, {start: startPos})).on('finish', () => {count ++;// 然后我们在合并完成之后把 chunks 目录删掉。if(count === files.length) {fs.rm(chunkDir, {recursive: true}, () =>{}); }})startPos += fs.statSync(filePath).size;});}
测试一下:
接收到的文件分片:
合并之后:
至此,大文件分片上传就完成了。