切換語言為:簡體
前端React、後端NestJs 實現大檔案分片上傳和下載

前端React、後端NestJs 實現大檔案分片上傳和下載

  • 爱糖宝
  • 2024-07-04
  • 2083
  • 0
  • 0

檔案分片上傳

在實際開發工作中,檔案上傳是非常常見的功能。有時候如果檔案過大,那麼上傳就需要花費很多時間,這時候使用者體驗就會很差。

所以針對大檔案上傳的場景,我們需要最佳化一下,具體方案是將大檔案分成幾份並行上傳,上傳完成後再合併到一起。

那麼具體怎麼做呢

前端檔案分片

首先使用者透過<input>元素來選擇上傳檔案,透過訪問該元素的files 屬性可以獲取到上傳的檔案物件,該物件是File 物件,File 物件是一種特定型別的Blob,其繼承了Blob的功能,所以File可以使用Blob的例項方法。

<input type="file" />

blob上有個slice方法,其可以返回一個新的 Blob 物件,其中包含呼叫它的 blob 的指定位元組範圍內的資料。我們可以透過使用Blob物件的slice方法,將檔案分成多份。

我們用一個20M左右的圖片(下圖)來模擬一下

前端React、後端NestJs 實現大檔案分片上傳和下載

將該圖片檔案按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>
  );
}

選擇檔案後結果如下

前端React、後端NestJs 實現大檔案分片上傳和下載

好,先暫停一下,我們把上傳的後端介面實現一下,我們使用nest框架來實現。

後端檔案上傳介面實現(nestjs)

全域性安裝nestjs腳手架@nestjs/cli

npm install -g @nestjs/cli

建立一個nest專案

nest new large_file_nest

前端React、後端NestJs 實現大檔案分片上傳和下載

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>
  );
}

前端React、後端NestJs 實現大檔案分片上傳和下載

如此,nest服務端就獲取到了上傳的檔案和資料

前端React、後端NestJs 實現大檔案分片上傳和下載

我們可以把同個檔案的分片放到一起,方便後續合併,完善一下後端程式碼

  @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);
  }

重新上傳後,結果如下:

前端React、後端NestJs 實現大檔案分片上傳和下載

接下來是把分片合併

檔案合併分片

我們需要在前端分片上傳完畢後,呼叫合併的介面

完善一下前端程式碼的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 }, () => {}); // 合併完刪除分片檔案
  }

前端上傳後呼叫合併介面後,檔案在服務端生成了

前端React、後端NestJs 實現大檔案分片上傳和下載

但是點開檔案一看

前端React、後端NestJs 實現大檔案分片上傳和下載

發現檔案怎麼錯亂了,排查一下,列印一下分片檔案列表看下

	@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 }, () => {});
  }

前端React、後端NestJs 實現大檔案分片上傳和下載

發現檔案順序是亂的,於是我們在合併寫入前將分片檔案排個序,修改一下上傳介面程式碼

	@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服務,再重新上傳檔案後發現檔案正常了。

前端React、後端NestJs 實現大檔案分片上傳和下載

至此合併檔案成功。

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',
      };
    }
  }

獲取檔案分片的介面是根據前端傳遞的startend引數,使用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>
  );
}

前端React、後端NestJs 實現大檔案分片上傳和下載

這時候檔案就下載好了,開啟檔案一看

前端React、後端NestJs 實現大檔案分片上傳和下載

發現檔案怎麼錯亂了,仔細檢查發現,分片下載介面的引數start值和上一個介面end值重複了

前端React、後端NestJs 實現大檔案分片上傳和下載

所以修改前端程式碼如下

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值正確了。

前端React、後端NestJs 實現大檔案分片上傳和下載

開啟圖片檢查,沒有問題,是對的。

前端React、後端NestJs 實現大檔案分片上傳和下載

0則評論

您的電子郵件等資訊不會被公開,以下所有項目均必填

OK! You can skip this field.