G
Q
Q
and
M
E

Nest.js 大文件分片上传

文件上传是常见需求,只要指定content-typemultipart/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/axios@0.24.0/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/axios@0.24.0/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接口:

posted @ 2024-02-29 21:30  sy0313  阅读(21)  评论(0编辑  收藏  举报