文件上传是常见需求,只要指定content-type
为multipart/form-data
,内容就会以如下图这种形式传递到服务端:
服务端再按照multipart/form-data
的格式提取数据,就能达到其中的文件。
但是当文件很大的时候,事情变得不一样了
假设传一个100M的文件需要三分钟,那么传 1G 的文件就需要 30 分钟
所以大文加上传的场景,需要专门的优化
把1G的大文件分割成 10个 100M 的小文件,然后把这些小文件并行上传,就会变快了
然后等这10个小文件都上传完毕,再发一个请求把这些小文件合并成原来的大文件
这就是大文件分片上传
那如何拆分和合并呢?
浏览器里Blob对象有slice方法,可以截取某个范围的数据,而File就是一种Blob
可以在input里选择了file之后,通过slice对file分片
那合并呢?
nodejs中fs对象的createWriteStream
方法支持指定start,也就是从什么位置开始写入
这样把每个分片按照不同位置写入文件,完成合并
先来创建个Nest项目:
nest new large-file-sharding-upload
在AppController添加一个路由:
这是一个Post接口,会读取请求体里的files文件字段传入该方法
这里需要安装一下 multer 包的类型:npm install -D @types/multer
我们在main.js中开启一下跨域访问:
然后在任意的目录中添加一个index.html,后续通过vs code的live server插件启动这个页面:
<!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/[email protected]/dist/axios.min.js"></script>
</head>
<body>
<input id="fileInput" type="file" multiple/>
<script>
const fileInput = document.querySelector('#fileInput');
fileInput.onchange = async function () {
const data = new FormData();
data.set('name','光');
data.set('age', 20);
[...fileInput.files].forEach(item => {
data.append('files', item)
})
const res = await axios.post('http://localhost:3000/upload', data);
console.log(res);
}
</script>
</body>
</html>
input指定multiple,可以选择多个文件,但是我们这里没有做多文件的演示
选择文件之后,通过post请求upload接口,携带FormData,FormData里保存着files和其他字段
这时候,Nest服务端就接受到了上传的内容和其他字段:
当然,我们并不想上传如此多的文件,后边会将他们合并成一个大文件,所以要修改一下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/[email protected]/dist/axios.min.js"></script>
</head>
<body>
<input id="fileInput" type="file"/>
<script>
const fileInput = document.querySelector('#fileInput');
const chunkSize = 20 * 1024;
fileInput.onchange = async function () {
const file = fileInput.files[0];
console.log(file);
const chunks = [];
let startPos = 0;
while(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);
})
}
</script>
</body>
</html>
对拿到的文件进行分片,然后单独上传分片,分片名字为文件名+index
这里我们测试的图片是80多k,所以每20k一个分片,一共是四个分片
服务端接收到了这四个分片:
我们把这四个分片移动到单独的目录,方便后续操作:
@Post('upload')
@UseInterceptors(FilesInterceptor('files', 20, {
dest: 'uploads'
}))
uploadFiles(@UploadedFiles() files: Array<Express.Multer.File>, @Body() body: { name: string }) {
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);
}
然后用正则匹配出文件名:
在uploads文件夹下创建chunk_文件名
的目录,把文件复制过去,然后删除同路径同名文件
分片文件移动成功了
不过直接以 chunk_文件名 做目录名,太容易产生冲突了,所以可以在上传时在文件名中间加个随机字符串“
这样产生冲突的概率就很小了:
接下来就是在全部分片上传完成之后的合并请求:
@Get('merge')
merge(@Query('name') name: string) {
const chunkDir = 'uploads/chunks_'+ name;
const files = fs.readdirSync(chunkDir);
let startPos = 0;
files.map(file => {
const filePath = chunkDir + '/' + file;
const stream = fs.createReadStream(filePath);
stream.pipe(fs.createWriteStream('uploads/' + name, {
start: startPos
}))
startPos += fs.statSync(filePath).size;
})
}
接受文件名,然后查找对应的chunks目录,把下面的文件读取出来,按照不同的start位置写入到同一个文件中
然后再合并之后把chunks目录删掉:
然后在前端代码中,当分片全部上传完成后,调用merge接口: