1. 需求
前端在上传大文件的时候,为了网页性能,需要对大文件进行切割,然后上传到后端,并在全部切片上传完成之后通知后端进行合并,生成最终要上传的完整文件。 使用到的框架(库):
2. 需求分析
练习项目,先不考虑异常情况,假设文件切片全部上传成功。
前端
- 切割
- 切割之后,按顺序上传 ,并传递文件名+切片序号给后端
- 上传全部切片后,通知后端合并
后端
- 上传接口,并创建临时的文件夹对切片进行保存
- 合并接口,获取接口参数的文件名字段 ,获取该文件名对应的临时文件夹 ,读取里面的文件列表 ,进行排序后,利用Stream合并成一个完整的文件。
3. 尝试单个文件上传和保存
先尝试单个文件的上传,把整个流程走通。
3.1 前端
用input[file] 元素选择文件,然后用到Axios调用接口,Axios直接引用CDN。
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>大文件</title>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.5.0/axios.min.js"></script>
</head>
<body>
<input type="file" id="inputFile">
<button id="uploadFile">发送</button>
<script>
document.querySelector('#uploadFile').addEventListener('click', function (e) {
const file = document.querySelector('#inputFile').files[0]
const formData = new FormData()
formData.set('name', file.name)
formData.append('files', file)
// http://localhost:7788/upload 我本地开启的服务
return window.axios.post('http://localhost:7788/upload', formData, {
contentType: 'multipart/form-data'
})
})
</script>
3.2 nest后端
安装nest 脚手架
cli
npm i -g @nestjs/cli
1. nest/cli创建nest项目
nest cli的文档
cli
nest new big-file-upload
并在main.ts中 设置nest支持同源
js
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors() // 设置允许不同域名的访问
await app.listen(7788);
}
bootstrap();
2. 安装 @types/multer 类型依赖
cli
yarn add @types/multer -D
3. 在 app.controller.js中添加upload接口
接口肯定为Post请求,需要用拦截器 Interceptors 接收文件,具体用FilesInterceptor接收。
js
import { AppService } from './app.service';
@Controller()
export class AppController{
constructor(privite readonly appService: AppService) {}
@Post('upload')
@UseInterceptors(FilesInterceptor('files', 2, {
dest: 'uploads' // 这里的uploads 为上传文件存放的目录,需要在src同级目录中创建
}))
uploadFile(@UploadedFiles() files: Array<Express.Multer.File>, @Body() body) {
console.log(body)
console.log(files)
return true
}
}
4. 优化: 存放的文件名, 以及uploads
配置multer的diskStorage,对存放文件的文件夹做预处理,如果文件夹不存在,则利用node的fs模块创建。 文件名也是如此,对乱码的源文件名进行编码处理。文档地址
js
可创建文件导出 storage ,也可以对在appController.ts中定义
const dirPath = path.join(process.cwd(), '/uploads'); // 拼凑出完成路径
const storage = multer.diskStorage({
destination(
req: express.Request,
file: Express.Multer.File,
callback: (error: Error | null, filename: string) => void,
) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath);
}
callback(null, dirPath);
},
filename(
req: e.Request,
file: Express.Multer.File,
callback: (error: Error | null, filename: string) => void,
) {
const filename = Buffer.from(file.originalname, 'latin1').toString('utf8');
callback(null, filename);
},
});
3.3 express 后端
安装依赖
js
yarn add express multer cors
创建 index.js 文件,并完成逻辑
代码逻辑和 上面的nest是一样的,利用multer 去接收文件
js
const path = require('path')
const fs = require('fs')
const express = require('express')
const multer = require('multer')
const cors = require('cors')
const app = express()
app.use(cors()); // express支持同源
const dirPath = path.join(process.cwd(), '/uploads');
const storage = multer.diskStorage({
destination: function (req, file, cb) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath)
}
cb(null, dirPath)
},
filename: function (req, file, cb) {
const filename = Buffer.from(file.originalname, "latin1").toString(
"utf8"
);
cb(null, filename)
}
})
const upload = multer({ storage })
app.post('/upload', upload.array('files', 2), function (req, res, next) {
console.log('req.body', req.body);
res.send('上传成功')
})
app.listen(7878, () => {
console.log('http://localhost:7788 开启成功') // 在前端代码中需要替换对应的端口
});
4. 前端切割文件
上面是对单个文件直接进行上传处理, 现在主要是对该上传文件进行切割,然后逐一上传。 大部分逻辑还是同3.1。
4.1 切割
js
<script>
document.querySelector('#uploadFile').addEventListener('click', function (e) {
const file = document.querySelector('#inputFile').files[0]
const chunks = []
let startPos = 0
const chunkSize = 50 * 1024 // 10k
while (startPos < file.size) {
const chunk = file.slice(startPos, startPos + chunkSize)
chunks.push(chunk)
startPos += chunkSize
}
// 4.2 发送请求
})
</script>
4.2 逐一发送请求
js
const httpList = chunks.map((item, index) => {
const formData = new FormData()
formData.set('name', `${file.name}_${index}`)
formData.append('files', item)
return window.axios.post('http://localhost:7788/upload', formData, {
contentType: 'multipart/form-data'
})
})
// 4.3 通知合并
4.3 上传完毕后通知后端合并
javascript
Promise.allSettled(httpList).then(list => {
console.log(file)
window.axios.get('http://localhost:7788/merge?name=' + encodeURIComponent(file.name))
})
5. 后端合并chunk文件
5.1 nest 合并文件
在上述第3点的例子中,我们知道了单个文件上传的后端逻辑。现在在此基础上对上传接口进行改造, 以及新增合并接口。
- 创建临时文件夹,存放切片
- 新增合并接口,生成完整文件
5.1.1 改造上传接口
js
@Post('upload')
@UseInterceptors(
FilesInterceptor('files', 2, {
storage: storage,
}),
)
uploadFile(
@UploadedFiles() files: Array<Express.Multer.File>,
@Body() params: { name: string },
): boolean {
const file = files[0];
const filename = params.name.match(/(.+)_\d+$/)[1]; // 在第四点钟,文件名加序号作为切片名字
// 创建临时文件夹
const fileNamePath = dirPath + '/chunk_' + filename;
if (!fs.existsSync(fileNamePath)) {
fs.mkdirSync(fileNamePath);
}
// 文件默认上传到uploads中, 将文件复制到临时文件夹中 并删除原文件
fs.cpSync(file.path, `${fileNamePath}/${params.name}`);
fs.rmSync(file.path);
return true;
}
5.1.2 改造上传接口
利用fs.createReadStream 和 fs.createWriteStream 对文件进行创建和写入。
js
@Get('merge')
mergeFile(@Query() params: { name: string }) {
const chunkPath = 'uploads/chunk_' + params.name
const fileList = fs.readdirSync(chunkPath); // 读取临时文件夹的文件,log出来可以看到是不按顺序的
// 封装的一个 获取切片文件名后面的序号
const getIndex = (str) => parseInt(str.match(/_\d+$/)[0].replace('_', ''), 10);
// 对文件列表进行排序,保证合并的正确性
const sortedList = fileList.sort((prev, next) => {
return getIndex(prev) - getIndex(next);
});
let count = 0;
let startPos = 0;
sortedList.forEach((file) => {
const filePath = `${chunkPath}/${file}`;
const stream = fs.createReadStream(filePath); // 创建文件流
stream
.pipe(
fs.createWriteStream('uploads/' + params.name, {
start: startPos,
}),
)
.on('finish', () => {
count++;
// 每次切片写入完成后,都会检查是否是最后一个文件,是的话,就删除临时文件夹
if (count === sortedList.length) {
fs.rm(
chunkPath,
{
recursive: true,
},
() => {},
);
}
});
startPos += fs.statSync(filePath).size;
});
return true;
}
5.2 express 合并文件
这里的业务逻辑 和 用到的语法和nest的大致一样。可以把express单文件代码参照nest的代码进行改造。
6. 待优化点
- 前端可以使用多并发,加快上传效率;
- 对上传失败情况进行处理,如果上传失败了,对该切片进行记录,提供按钮给用户重新上传;
- 断点续传。
7. 结语
该项目还很粗糙,需要花时间去优化以及学习nest更多知识。
大文件切片上传前端 + nest源码。前端代码在static/index.html 内。