一文徹底搞透文件上傳
創建項目
基於 Vite + Vue3 創建最簡前端項目 —— file-upload,並在項目根目錄下基於 express 創建 Node Server,如何已熟悉,可以直接進入下一節。
前端項目
通過 Vite 來創建項目,然後選擇自己熟悉的技術棧,比如 Vue、React,只需要創建一個最簡單的項目即可,比如:
npm create vite@latest file-upload
執行 npm run dev
啓動前端項目
清空 /src/App.vue
文件,並輸入如下內容
<script setup>
</script>
<template>
<div>
我是 App.vue
</div>
</template>
<style scoped>
</style>
效果:
Node Server
在項目根目錄下創建 ./server/index.js 文件,作爲服務端項目,並安裝 express 作爲框架
npm i express
代碼如下:
import express from 'express'
const app = express()
app.get('/', (req, res) => {
res.send('Hello World!')
})
app.listen(3000, () => {
console.log('Server listening on port 3000')
})
啓動 node server
nodemon server/index.js
瀏覽器訪問 http://localhost:3000
好了,到這裏,基本準備工作就完成了,接下來就進入本文重點 —— 文件上傳。
最簡單的文件上傳
首先安裝以下三個 npm 包
npm i axios cors multer
-
axios 是爲了發送網絡請求
-
cors 是爲了處理跨域問題
-
是一個 node.js 中間件,用於處理 multipart/form-data 類型的表單數據,它主要用於上傳文件
前端 —— /src/App.vue:
<script setup>
import { ref } from 'vue'
import axios from 'axios'
const inputRef = ref(null)
async function handleUpload() {
// 獲取文件對象
const file = inputRef.value.files[0]
const formData = new FormData()
formData.append('file', file)
const { data } = await axios.request({
url: 'http://localhost:3000/uplaod',
method: 'POST',
data: formData,
// 上傳進度,這個是通過 XMLHttpRequest 實現的能力
onUploadProgress: function (progressEvent) {
// 當前已上傳完的大小 / 總大小
const percentage = Math.round((progressEvent.loaded * 100) / progressEvent.total)
console.log('Upload Progress: ', `${percentage}%`)
}
})
console.log('data = ', data)
}
</script>
<template>
<div>
<input type="file" ref="inputRef" />
<button @click="handleUpload">Upload</button>
</div>
</template>
邏輯很簡單
-
一個文件選擇輸入框,並通過 inputRef 獲取到該 DOM
-
一個上傳按鈕,選擇文件後,點擊上傳
-
通過 inputRef 獲取到文件對象,並添加到 FormData 對象中,文件上傳一般都通過該對象完成
-
通過 axios 發送請求,並監聽上傳進度
後端 —— /server/index.js:
import express from'express'
import cors from'cors'
import multer from'multer'
import { dirname, resolve } from'path'
import { fileURLToPath } from'url'
import { existsSync, mkdirSync } from'fs'
// 解決 ESM 無法使用 __dirname 變量的問題
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const app = express()
// 解決跨域問題
app.use(cors())
/**
* Multer 是一個 node.js 中間件,用於處理 multipart/form-data 類型的表單數據,它主要用於上傳文件
* 注意: Multer 不會處理任何非 multipart/form-data 類型的表單數據。
*/
const uplaod = multer({
// 存儲,上傳的文件落盤
storage: multer.diskStorage({
// 將文件放到 /server/uploads 目錄下
destination: (_, __, cb) => {
const uploadsDir = resolve(__dirname, 'uploads')
if (!existsSync(uploadsDir)) {
mkdirSync(uploadsDir)
}
cb(null, uploadsDir)
},
// 文件名使用原始文件名
filename: (_, file, cb) => {
cb(null, file.originalname)
}
})
})
app.get('/', (_, res) => {
res.send('Hello World!')
})
app.post('/uplaod', uplaod.single('file'), (_, res) => {
res.send('File uploaded successfully')
})
app.listen(3000, () => {
console.log('Server listening on port 3000')
})
關鍵地方都寫了註釋,核心邏輯主要在 multer 配置項中。multer 是一個 node.js 中間件,用於處理 multipart/form-data 類型的表單數據,其主要用來處理文件上傳。
效果展示:
數據提交格式科普
提交 POST 請求時,我們一般會使用三種類型的 Content-Type:multipart/form-data、x-www-urlencoded、application/json,這三種類型有什麼區別呢?
multipart/form-data
multipart/form-data 是一種 MIME 類型,用於在 HTTP 請求中傳輸表單數據,這種類型的請求通常用於文件上傳,因爲它允許將文件作爲請求的一部分進行傳輸。當然,multipart/form-data 也可以用於傳輸其他類型的數據,例如文本字段、單選按鈕、複選框等。
它表示的數據是以多部分的形式進行編碼的,每個部分都有自己的 Content-Type 和 Content-Disposition 頭,用於描述數據的類型和文件信息,比如,以當前的上傳請求爲例:
其中的 ----WebKitFormBoundary 來自於 Request Header 中的 Content-Type,用來分割每一部分的數據,就是一個分隔符,這在服務端解析數據的時候會用到。
x-www-urlencoded
x-www-urlencoded 是另一種 MIME 類型,也用於在 HTTP 請求中傳輸表單數據。
和 multipart/form-data 區別在於,它通常用戶傳輸簡單的文本數據,如表單字段中的文本輸入框、單選按鈕、複選框等的值,它不適合傳輸文件或二進制數據。
當 Content-Type 爲 x-www-form-urlencoded 格式時,表單數據會被編碼爲 URL 編碼(類似於在 URL 中的查詢字符串使用的編碼),並放在請求體中,服務器接收到請求後,會解析請求體,提取其中的表單字段和值。
示例:
const formData = new URLSearchParams();
formData.append('username', 'your_username');
formData.append('password', 'your_password');
axios.post('/your_api_url', formData, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
.then(response => {
// 處理響應
})
.catch(error => {
// 處理錯誤
});
import express from'express'
import bodyParser from'body-parser'
const app = express();
// 使用 body-parser 中間件來解析 x-www-form-urlencoded 類型的數據
app.use(bodyParser.urlencoded({ extended: true }));
app.post('/your-route', (req, res) => {
// 現在你可以訪問 req.body 來獲取解析後的數據
console.log(req.body);
res.send('Data received');
});
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
application/json
application/json 用於傳輸 JSON 格式的數據,JSON 是一種輕量級的數據交換格式,通常用於 Web API,比如:
import axios from 'axios'
axios.request({
url: 'your-route',
method: 'POST',
data: {
key1: 'value1',
key2: 'value2'
}
})
所以,總的來說,multipart/form-data
用於複雜表單數據,包括文件上傳;x-www-urlencoded
用於簡單表單數據;而application/json
用於 Web API 的數據傳輸,特別是需要傳輸結構化數據的時候。
分片(大文件上傳)
爲什麼要支持分片上傳??
-
提高上傳穩定性和可靠性。文件在上傳過程中,網絡鏈接可能會出現中斷,如果不分片上傳,一旦鏈接中斷,整個文件就需要重新上傳。而採用分片上傳後,即使中途連接斷開,只需要重新上傳未完成的部分即可(斷點續傳),大大提高了上傳的成功率和效率
-
降低網絡壓力和帶寬佔用。分片上傳可以人爲控制每一片的大小,避免出現單次上傳佔用過多帶寬的情況,從而減輕對網絡的壓力;同時,也可以人爲的控制上傳速度,不至於當其它應用受影響時束手無策。
說了理論,接下來就進入實戰階段了。大致思路是:
-
前端將上傳的文件按照一定大小進行切片,然後併發上傳
-
切片可能會涉及主線程的長時間佔用,可以採用通過 web worker 來完成
-
併發上傳涉及併發控制,這是一道常見的面試題
-
服務端需要維護已上傳的分片列表,從而支持斷點續傳的能力,待所有分片都上傳後,將所有分片合併成一個完整的文件
分片上傳
代碼量比較少,就直接全部列出來了,都是基於之前的代碼進行迭代改造的
前端 —— App.vue
邏輯還是以點擊上傳按鈕爲開始(handleUpload)
-
定義了一個 allFileChunks 數組,用來存放所有的文件切片
-
調用 splitChunkForFile 方法對文件進行切片,並將切片放到 allFileChunks 數組中,等待上傳
-
splitChunkForFile 方法的邏輯很簡單,通過 file.slice 方法從文件的 start 位置切到 end 位置(不包含 end)
-
然後將產生的切片通過 new File 重新封裝爲一個 File 對象,並存放到 allFileChunks 數組中
-
遍歷 allFileChunks 數組,上傳所有切片,這裏有一些需要注意的點
-
在 formData 對象中增加了一些 key,比如 uuid、index、total,都是爲了輔助服務端做切片的分類、維護和合並,具體作用可以看代碼註釋
-
另外就是 uuid 這塊兒,Demo 中直接使用的文件名,但在實際使用時需要確保唯一性,比如可以對文件做 hash
-
可以看到,上傳切片時沒有做併發控制,目前的邏輯是不論有多少切片都一股腦的扔給瀏覽器做上傳,後面我們會在 併發控制 章節完善這部分內容
<script setup>
import { ref } from 'vue'
import axios from 'axios'
const inputRef = ref(null)
// 分片大小,單位字節
// const chunkSize = 1024
const chunkSize = 4
/**
* 文件切片
* @param { File } file 文件對象
* @param { number } start 開始位置
* @param { number } end 結束位置
* @param { Array } allFileChunks 所有文件切片
* @returns void
*/
function splitChunkForFile(file, start, end, allFileChunks) {
const { name, size, type, lastModified } = file
// 說明文件已經切割完畢
if (start > size) {
return
}
const chunk = file.slice(start, Math.min(end, size))
const newFileChunk = new File([chunk], name + allFileChunks.length, {
type,
lastModified,
})
allFileChunks.push(newFileChunk)
splitChunkForFile(file, end, end + chunkSize, allFileChunks)
}
// 上傳文件
async function handleUpload() {
// 獲取文件對象
const file = inputRef.value.files[0]
// 存放所有切片
const allFileChunks = []
// 文件切片
splitChunkForFile(file, 0, chunkSize, allFileChunks)
// 遍歷所有切片並上傳
for (let i = 0; i < allFileChunks.length; i++) {
const fileChunk = allFileChunks[i]
const formData = new FormData()
formData.append('file', fileChunk)
// 標識當前 chunk 屬於哪個文件,方便服務端做內容分類和合並,實際場景中這塊兒需要考慮唯一性
formData.append('uuid', file.name)
// 標識當前 chunk 是文件的第幾個 chunk,即保證 chunk 順序
formData.append('index', i)
// 標識總共有多少 chunk,方便服務端判斷是否已經接收完所有 chunk
formData.append('total', allFileChunks.length)
axios.request({
url: 'http://localhost:3000/uplaod',
method: 'POST',
data: formData,
// 上傳進度,這個是通過 XMLHttpRequest 實現的能力
onUploadProgress: function (progressEvent) {
// 當前已上傳完的大小 / 總大小
const percentage = Math.round((progressEvent.loaded * 100) / progressEvent.total)
console.log('Upload Progress: ', `${percentage}%`)
}
}).then(res => {
console.log('result = ', res.data)
})
}
}
</script>
<template>
<div>
<input type="file" ref="inputRef" />
<button @click="handleUpload">Upload</button>
</div>
</template>
服務端 —— index.js
相比於前面的代碼,所有的改動都在 /upload
API 中,主要是結合前端給到的相關字段做切片的維護和合並,整體邏輯爲:
-
通過 allFiles 對象來維護所有文件的所有切片,其數據結構爲
{ fileName: [chunk1 路徑, chunk2 路經, ...] }
,以文件名爲 key,文件的所有 chunk 的存放路徑組成的數組爲 value,就像上面說的,實際應用中不能以文件名爲 key,可以使用文件的 hash 爲 key,以保證其唯一性。這裏我們只以演示原理爲目的 -
各個 chunk 的落盤(存儲)還是上面 multer 的邏輯,沒有改動
-
如果發現當前文件的所有 chunk 均已上傳完畢 —— allFiles[uuid].length === total 時(total 是前端給的),就認爲當前文件的所有 chunk 都上傳完了,開始合併 chunk
-
接下來就是按順序同步讀取每個 chunk 的內容,並依次寫入文件,順便刪掉磁盤上存放的臨時 chunk 文件
-
到此,分片上傳就完成了
import express from'express'
import cors from'cors'
import multer from'multer'
import { dirname, resolve } from'path'
import { fileURLToPath } from'url'
import { existsSync, mkdirSync, unlinkSync, readFileSync, appendFileSync } from'fs'
// 解決 ESM 無法使用 __dirname 變量的問題
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const app = express()
// 解決跨域問題
app.use(cors({
maxAge: 86400
}))
/**
* Multer 是一個 node.js 中間件,用於處理 multipart/form-data 類型的表單數據,它主要用於上傳文件
* 注意: Multer 不會處理任何非 multipart/form-data 類型的表單數據。
*/
const uplaod = multer({
// 存儲,上傳的文件落盤
storage: multer.diskStorage({
// 將文件放到 /server/uploads 目錄下
destination: (_, __, cb) => {
const uploadsDir = resolve(__dirname, 'uploads')
if (!existsSync(uploadsDir)) {
mkdirSync(uploadsDir)
}
cb(null, uploadsDir)
},
// 文件名使用原始文件名
filename: (_, file, cb) => {
cb(null, file.originalname)
}
})
})
app.get('/', (_, res) => {
res.send('Hello World!')
})
// 以 uuid 爲 key,文件所有的 chunks 組成的數組爲 value
const allFiles = {}
app.post('/uplaod', uplaod.single('file'), (req, res) => {
const { uuid, index, total } = req.body
// chunk 的存儲路徑
const { path } = req.file
if (!allFiles[uuid]) allFiles[uuid] = []
allFiles[uuid][index] = path
if (allFiles[uuid].filter(item => item).length === +total) {
// 說明已經接收完了所有的 chunk
const destFilePath = resolve(__dirname, 'uploads', uuid)
// 合併臨時文件並將其刪除
allFiles[uuid].forEach(async filePath => {
const content = readFileSync(filePath)
appendFileSync(destFilePath, content)
unlinkSync(filePath)
})
allFiles[uuid] = []
res.send(`file —— ${uuid} uploaded successfully`)
return
}
res.send(`chunk —— ${uuid + index} uploaded successfully`)
})
app.listen(3000, () => {
console.log('Server listening on port 3000')
})
效果展示
如果文件足夠大(上傳需要一定的時間)或者在服務端的文件合併邏輯處打個斷點,就可以看到 /server/uploads
目錄下有很多很臨時的 chunk 文件。
切片優化 + 併發控制
爲什麼要做切片優化?現在的切片邏輯有什麼問題嗎?
-
遞歸邏輯致使棧溢出
-
主線程被切片邏輯長時間佔用
換一個大點的文件,點擊上傳,出現如下報錯,很明顯棧溢出了。回顧邏輯,可以知道 splitChunkForFile 方法是一個遞歸函數,這就意味着,如果文件比較大,切片比較多,遞歸層級就會很深,自然就出現了棧溢出的問題。
另外,即使拋開溢出問題不談,當前切片邏輯是同步執行的,當切片數量很大時,主線會被長時間佔用,甚至瀏覽器被卡死。這點可以將切片邏輯換成循環執行來驗證(遞歸無法驗證,因爲會先出現棧溢出的情況):
/**
* 文件切片
* @param { File } file 文件對象
* @returns Array<File> 文件的所有切片組成的 File 對象
*/
function splitChunkForFile(file) {
const { name, size, type, lastModified } = file
// 存放所有切片
const allFileChunks = []
// 每一片的開始位置
let start = 0
// 循環切每一片,直到整個文件切完
while (start <= size) {
const chunk = file.slice(start, Math.min(start + chunkSize, size))
const newFileChunk = new File([chunk], name + allFileChunks.length, {
type,
lastModified,
})
start += chunkSize
allFileChunks.push(newFileChunk)
}
return allFileChunks
}
// 文件切片
const allFileChunks = splitChunkForFile(file)
效果如下:
爲什麼需要併發控制?現在沒有併發控制有什麼問題嗎?
現在的邏輯是:先獲取文件的所有切片,然後遍歷切片數組,將所有切片一股腦全部發送給服務端。
-
沒有幾個服務器能扛住上萬的 QPS
-
可以看到有太多的請求處於連接等待狀態,也有大量請求直接就失敗了
優化思路
-
將切片任務放到 Web Worker 中去完成,從而解決主線程被長時間佔用問題
-
增加併發控制,從而解決 QPS 過高的問題
實戰
/src/App.vue
點擊 Upload 按鈕之後,邏輯如下:
-
將文件交給 Web Worker,由 Web Worker 執行切片操作,然後等待 Web Worker 回傳文件切片
-
收到 Worker 回傳的文件切片之後,將切片存入 allFileChunks 數組,並調用 uploadChunk 上傳切片文件
-
uploadChunk 方法是一個支持併發控制的上傳方法,併發控制的核心思路是
-
在請求開始之前時當前併發數的檢測 和 是否已全部上傳完的檢測,這保證了請求數量不超過併發數
-
後續請求的觸發是通過 Promise.then 的回調來完成的,即每完成一個請求,就發送下一個請求
-
文件上傳的相關邏輯和之前一樣,沒有變化
<script setup>
import { ref } from 'vue'
import axios from 'axios'
import WebWorker from './web-worker.js?worker'
const inputRef = ref(null)
// 分片大小,單位 MB
const chunkSize = 1024 * 1024
// 分片大小,單位字節
// const chunkSize = 4
// 存放所有的文件切片
let allFileChunks = []
// WebWorker 實例
let worker = null
// 記錄當前文件已經切了多少片了
let hasBeenSplitNum = 0
// 當前上傳的 chunk 在 allFileChunks 數組中的索引
let allFileChunksIdx = 0
// 當前併發數
let curConcurrencyNum = 0
// 最大併發數
const MAX_CONCURRENCY_NUM = 6
// 上傳文件
async function handleUpload() {
// 上傳開始前的數據初始化
allFileChunksIdx = 0
allFileChunks = []
hasBeenSplitNum = 0
curConcurrencyNum = 0
// 獲取文件對象
const file = inputRef.value.files[0]
// 實例化 WebWorker,用來做文件切片
worker = new WebWorker()
// 將文件切片工作交給 web worker 來完成
worker.postMessage({ operation: 'splitChunkForFile', file, chunkSize })
// 總 chunk 數
const total = Math.ceil(file.size / chunkSize)
// 接收 worker 發回的切片(持續發送,worker 每完成一個切片就發一個)
worker.onmessage = function (e) {
const { data } = e
const { operation } = data
if (operation === 'splitChunkForFile') {
hasBeenSplitNum += 1
pushFileChunk(data.file)
// 說明整個文件已經切完了,釋放 worker 實例
if (hasBeenSplitNum === total) {
this.terminate()
}
}
}
}
/**
* 將 worker 完成切片存放到 allFileChunks 中,並觸發上傳邏輯
* @param { File } file 文件切片的 File 對象
*/
function pushFileChunk(file) {
allFileChunks.push(file)
uploadChunk()
}
/**
* 併發上傳文件切片,併發數 6(同一域名瀏覽器最多有 6個併發)
*/
function uploadChunk() {
if (curConcurrencyNum >= MAX_CONCURRENCY_NUM || allFileChunksIdx >= allFileChunks.length) return
// 獲取文件對象
const file = inputRef.value.files[0]
const { name, size } = file
// 總 chunk 數
const total = Math.ceil(size / chunkSize)
// 併發數 + 1
curConcurrencyNum += 1
// 從 allFileChunks 中獲取指定索引的 fileChunk,這個索引的存在還是爲了保證按順序取和上傳 chunk
const fileChunk = allFileChunks[allFileChunksIdx]
const formData = new FormData()
formData.append('file', fileChunk)
// 標識當前 chunk 屬於哪個文件,方便服務端做內容分類和合並,實際場景中這塊兒需要考慮唯一性
formData.append('uuid', name)
// 標識當前 chunk 是文件的第幾個 chunk,即保證 chunk 順序
formData.append('index', allFileChunksIdx)
// 標識總共有多少 chunk,方便服務端判斷是否已經接收完所有 chunk
formData.append('total', total)
axios.request({
url: 'http://localhost:3000/uplaod',
method: 'POST',
data: formData,
// 上傳進度,這個是通過 XMLHttpRequest 實現的能力
onUploadProgress: function (progressEvent) {
// 當前已上傳完的大小 / 總大小
const percentage = Math.round((progressEvent.loaded * 100) / progressEvent.total)
console.log('Upload Progress: ', `${percentage}%`)
}
}).then(res => {
console.log('result = ', res.data)
}).finally(() => {
/**
* 當前請求完成,
*/
// 併發數 - 1
curConcurrencyNum -= 1
// 上傳下一個切片
uploadChunk()
})
// 更新 chunk 索引,方便取下一個 chunk
allFileChunksIdx += 1
uploadChunk()
}
</script>
<template>
<div>
<input type="file" ref="inputRef" />
<button @click="handleUpload">Upload</button>
</div>
</template>
/src/web-worker.js
Worker 是獨立於主線程運行的線程,有自己獨立的執行上下文和事件循環。當然了,如果系統資源有限,還是回和其它線程(比如主線程)產生競爭。
它在這裏負責完成上面的同步切片操作,每切完一個,就回傳給主線程,由主線程完成上傳。所以,整個文件上傳是由主線程和 Worker 線程異步協同完成的。
/**
* 文件切片
* 通過循環將整個文件切片,在切的過程中每切一片,就當切好的分片發給主線程,當真個文件切完之後告訴主線程已完成
* @param { File } file 文件對象
* @param { number } chunkSize 單個切片的大小,單位字節
*/
function splitChunkForFile(file, chunkSize) {
const { name, size, type, lastModified } = file
// 每一片的開始位置
let start = 0
// chunk 索引,用來將序號添加到文件名上
let chunkIdx = 0
// 循環切每一片,直到整個文件切完
while (start < size) {
const chunk = file.slice(start, Math.min(start + chunkSize, size))
const newFileChunk = new File([chunk], name + chunkIdx++, {
type,
lastModified,
})
start += chunkSize
// 將當前切片發給主線程
this.postMessage({ operation: 'splitChunkForFile', file: newFileChunk })
}
}
// 接收主線程的消息
onmessage = function (e) {
const { data } = e
const { operation } = data
if (operation === 'splitChunkForFile') {
// 表示給對文件切片操作
splitChunkForFile.apply(this, [data.file, data.chunkSize])
}
// 還可以擴展其它操作
}
效果展示
可以發現切片不在卡主線程、請求也更早的開始了(不需要等到全部切片完成)、併發也只有 6 個。
斷點續傳
斷點續傳是在分片上傳的基礎上來實現的,當上傳過程中出現故障時,能夠從上次中斷的地方繼續上傳,而不是從頭開始。這點對於大文件的上傳尤爲重要,因爲上傳時間較長,出現中斷的概率較高。
實戰
斷點續傳的實現,和分片一樣,也需要前後端一起配合,具體調整如下。
/src/App.vue
-
在上傳邏輯開始執行前先通過
getFileChunksByFileName
方法獲取當前文件已經上傳的切片列表,然後執行後續的切片邏輯 -
在上傳切片之前,檢測一下當前切片是否已經上傳,如果已經上傳,則直接結束當前切片的上傳,進入下一個切片的上傳邏輯
這就是前端側改造的核心邏輯。
/**
* 獲取文件已上傳的切片
* @param { string } fileName 文件名
*/
async function getFileChunksByFileName(fileName) {
const { data } = await axios.request({
url: 'http://localhost:3000/get-file-chunks-by-uuid',
method: 'GET',
params: {
uuid: fileName
}
})
return data
}
/server/index.js
增加一個獲取指定文件已上傳切片列表的接口,供前端調用。
// 獲取指定文件的切片列表,即獲取該文件已上傳的切片,用於斷點續傳
app.get('/get-file-chunks-by-uuid', (req, res) => {
// 這裏的 uuid 就是文件名
const { uuid } = req.query || {}
res.send(allFiles[uuid] || [])
})
效果
當前文件我先提前上傳了一部分,就是看到的前 6 個切片,然後刷新頁面打斷執行。接着再次上傳該文件,前 6 個切片瞬間完成(不需要重新上傳)。
切片大小
可以看到,文中的切片大小有兩種,一是 4B,二是 1MB。一直沒有提及切片的大小的設計,但這裏還是要簡單說明一下。
大家都知道 HTTP1.1 對於帶寬的利用率是很糟糕的,這是 HTTP1.1 的一個核心問題,其中一個原因就是 TCP 的慢啓動。一旦一個 TCP 連接建立之後,就進入了發送數據的狀態,剛開始 TCP 協議回採用一個非常慢的速度去發送數據,然後慢慢加快發送數據的速度,知道發送數據的速度達到一個理想狀態,這個過程就叫慢啓動。很像一輛車啓動加速的過程。
但慢啓動是 TCP 爲了減少網絡擁塞的一種策略,這個沒辦法改變。所以,這裏提到切片大小的設計,意思是切片大小不可設置的過小,但設置過大也不可取。關於切片大小其實沒有一個固定的標準,通常需要根據實際大小和網絡環境進行調整,以下是一些常見的策略:
-
小文件(幾百 MB 以下):切片大小可以設置爲 10MB 到 20MB 之間
-
中等大小文件(幾百 MB 到幾 GB):切片大小可以設置爲 5MB 到 10MB 之間
-
大文件(幾十 GB 以上):切片大小可以設置爲 1MB 到 5MB 之間
總的來說,對於較小的文件,切片可以較大,以減少分片數量和控制開銷;對於大文件,可以適當減小切片大小,以確保每個分片都在合理的時間內上傳完成,並支持更細粒度的斷點續傳。
總結
本文的目標是:一文搞透文件上傳。當你讀到這裏,希望不負初衷。現在回顧一下文章的整體內容
-
從最簡單的創建項目開始,前端用 Vite + Vue3 + axios,服務端用 express + multer(處理文件上傳的中間件)
-
寫了最簡單的文件上傳
-
前端一個 file 類型的 input 輸入框,然後取到 file 對象放到 FormData 對象中,通過 axios 完成上傳
-
服務端基於 multer 中間件完成文件的落盤
-
順便普及了下三種常見的 Content-Type —— multipart/form-data、x-www-urlencoded、application/json 之間的異同點和使用場景
-
有了上面基礎知識的地基,接下來就進入了文件上傳的核心難點,也是文件上傳場景常見的面試題 —— 大文件上傳
-
我們從分片開始,這是實現後面斷點續傳的基礎
-
前端將一個大文件切成一個個片段,然後將這些片段依次上傳給服務端
-
服務端接收前端給到的分片,當所有分片上傳完成之後,合併所有分片得到最終文件
-
後來解決了第一版存在的兩個問題
-
對大文件切片,導致主線程被長時間佔用。這點通過將切片操作放到 Web Worker 來解決
-
大量併發,導致 QPS 過高,這裏通過併發控制的方式來實現,並結合 Worker 實現切片 + 上傳協同完成
-
有了分片邏輯,接下來實現了斷點續傳,提升上傳效率。邏輯很簡單,就是上傳前獲取到當前文件已上傳的切片列表,然後在上傳切片之前查看該切片是否已經上傳,如果已上傳,直接跳過,從而避免文件從頭上傳
-
最後,通過 HTTP1.1 慢啓動問題,引出了切片大小的設計,一般是 5MB 左右
新視頻和文章會第一時間在微信公衆號發送,歡迎關注:李永寧 lyn
文章已收錄到 github 倉庫 liyongning/blog,歡迎 Watch 和 Star。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/zCD2_hVEmBnCdBGmeiF34w