檔案分片上傳
在實際開發工作中,檔案上傳是非常常見的功能。有時候如果檔案過大,那麼上傳就需要花費很多時間,這時候使用者體驗就會很差。
所以針對大檔案上傳的場景,我們需要最佳化一下,具體方案是將大檔案分成幾份並行上傳,上傳完成後再合併到一起。
那麼具體怎麼做呢
前端檔案分片
首先使用者透過<input>元素來選擇上傳檔案,透過訪問該元素的files
屬性可以獲取到上傳的檔案物件,該物件是File
物件,File
物件是一種特定型別的Blob
,其繼承了Blob
的功能,所以File
可以使用Blob
的例項方法。
<input type="file" />
blob
上有個slice
方法,其可以返回一個新的 Blob
物件,其中包含呼叫它的 blob 的指定位元組範圍內的資料。我們可以透過使用Blob
物件的slice
方法,將檔案分成多份。
我們用一個20M左右的圖片(下圖)來模擬一下
將該圖片檔案按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> ); }
選擇檔案後結果如下
好,先暫停一下,我們把上傳的後端介面實現一下,我們使用nest
框架來實現。
後端檔案上傳介面實現(nestjs)
全域性安裝nestjs腳手架@nestjs/cli
npm install -g @nestjs/cli
建立一個nest專案
nest new large_file_nest
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> ); }
如此,nest服務端就獲取到了上傳的檔案和資料
我們可以把同個檔案的分片放到一起,方便後續合併,完善一下後端程式碼
@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); }
重新上傳後,結果如下:
接下來是把分片合併
檔案合併分片
我們需要在前端分片上傳完畢後,呼叫合併的介面
完善一下前端程式碼的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 }, () => {}); // 合併完刪除分片檔案 }
前端上傳後呼叫合併介面後,檔案在服務端生成了
但是點開檔案一看
發現檔案怎麼錯亂了,排查一下,列印一下分片檔案列表看下
@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 }, () => {}); }
發現檔案順序是亂的,於是我們在合併寫入前將分片檔案排個序,修改一下上傳介面程式碼
@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服務,再重新上傳檔案後發現檔案正常了。
至此合併檔案成功。
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', }; } }
獲取檔案分片的介面是根據前端傳遞的start
、end
引數,使用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> ); }
這時候檔案就下載好了,開啟檔案一看
發現檔案怎麼錯亂了,仔細檢查發現,分片下載介面的引數start
值和上一個介面end
值重複了
所以修改前端程式碼如下
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
值正確了。
開啟圖片檢查,沒有問題,是對的。