文件上傳是個非常普遍的場景,特別是在一些資源管理相關的業務中。
文件上傳的3種實現方式
•經典的form和input上傳
這種方式基本沒有什麼人用了
•使用formData上傳
就是用js去構建form表單的數據,簡單高效
•使用fileReader讀取文件數據進行上傳
這裡呢,我簡單寫了個Demo,使用的是通過js去構建formDate這種方式,同時設置他請求頭裡面的Content-Type為multipart/form-data格式
<template>
<div>
<input
type="file"
@change="uploadFile"
>
</div>
</template>
<script>
import Http from '@/api/http.js'
export default {
methods: {
async uploadFile(e) {
const file = e.target.files[0]
this.sendFile(file)
},
// 文件上傳方法
sendFile(file) {
let formdata = new FormData()
formdata.append('file', file)
Http.post('/upload/file', formdata, {'Content-Type': 'multipart/form-data'})
},
}
}
</script>然後後端這裡是用node寫的,文件上傳的位置是public下面的upload文件夾下,現在是空的,這裡簡單寫了個讀取的方法,很簡單
async file() {
const {ctx} = this;
const file = ctx.request.files[0];// file包含了文件名,文件類型,大小,路徑等信息
const fileName = ctx.request.files[0].filename;// file包含了文件名,文件類型,大小,路徑等信息
const fileObj = fs.readFileSync(file.filepath);
// 將文件存到指定位置
fs.writeFileSync(path.join(uploadPath, fileName), fileObj);
this.success({}, '文件上傳成功');
}
背景2任何問題,量級比較小的情況下,都比較簡單,比如說你做增刪改查沒問題,但是比方說你想做高並發、高流量、分布式就會比較難
文件上傳其實很簡答,但是比方說我們要上傳1個G的文件或者2個G的文件件,你該怎麼做?在文件比較大的時候,普通的上傳方式可能會遇到以下。
•上傳耗時久。非常容易卡頓•由於各種網絡原因上傳失敗,且失敗之後需要從頭開始。比方說我上傳1個G的文件,刷新頁面,或者說網路報錯,可能之前已經上傳的200m的內容呢,就白費了
思路解決方案呢,就是切片+秒傳
那麼我把這個文件呢,切成1m甚至100k,依次上傳,就算中間有中斷,但是之前已經上傳的區塊呢,後端是可以存起來的,下一次我只需要接著上一次的進度上傳就可以了。再一個就是瀏覽器發送請求是可以並發的,多個請求同時發送,提高了傳輸速度的上限。
秒傳指的是文件在傳輸之前計算其內容的散列值,也就是 Hash 值,將該值傳到後臺,如果後臺存在 Hash 值一致的文件,認為該文件上傳完成。
該方案很巧妙的解決了上述提出的一系列問題,也是目前資源管理類系統的通用解決方案。
1. 切片上傳文件切片和核心是使用 Blob 對象的 slice 方法
首先我先定義單個切片的大小,對之前的邏輯稍作改造,如果文件大小小於一個切片就直接上傳,如果大於一個切片,那麼就走切片上傳的邏輯
邏輯呢,大體是這樣的,拿到文件之後呢,我們先在這裡計算一下這個文件的hash值,然後再執行cutBlob切片這個方法,這個cutBlob無非就是創建了一個數組,然後通過file.slice把文件切成一個個的切片,最終切成多少塊是和你的size是相關的。我在這裡定義了一個切片的大小,2m,最終呢,放進chunk這個數組當中,切片完成之後呢,再把這些切片依次上傳到後端,那麼後端呢收到請求以後,首先先創建一個以這個文件hash命名的文件夾,在文件夾下依次生成對應的切片
//前端
<script>
import Http from '@/api/http.js'
import SparkMD5 from 'spark-md5'
export default {
data() {
return {
remainChunks: [], // 剩餘切片
chunkSize: 5 * 1024 * 1024 // 切片大小
}
},
methods: {
async uploadFile(e) {
const file = e.target.files[0]
if (file.size < this.chunkSize) {
//簡單上傳
this.sendFile(file)
} else {
//切片上傳
this.createFileMd5(file).then(async hash => {
const chunkInfo = await this.cutBlob(file, hash)
this.remainChunks = chunkInfo.chunkArr
for (let i = 0; i < this.remainChunks.length; i++) {
this.sendChunk(this.remainChunks[i])
}
})
}
},
// 單個文件上傳方法
sendFile(file) {
let formdata = new FormData()
formdata.append('file', file)
Http.post('/upload/file', formdata, {'Content-Type': 'multipart/form-data'})
},
// 切片上傳計算文件的hash值
createFileMd5(file) {
const spark = new SparkMD5.ArrayBuffer() // 文件hash處理
return new Promise((resolve) => {
const reader = new FileReader()
reader.readAsArrayBuffer(file)
reader.addEventListener('loadend', () => {
const content = reader.result
// 生成文件hash
spark.append(content)
const hash = spark.end()
resolve(hash)
})
})
},
// 對文件進行切片
cutBlob(file, hash) {
const chunkArr = [] // 所有切片緩存數組
const chunkNums = Math.ceil(file.size / this.chunkSize) // 切片總數
return new Promise((resolve) => {
let cur = 0
for (let i = 0; i < chunkNums; i++) {
// 如果已上傳則跳過
let contentItem = file.slice(cur, cur + this.chunkSize)
chunkArr.push({
index: i,
hash,
total: chunkNums,
name: file.name,
size: file.size,
chunk: contentItem
})
cur += this.chunkSize
}
resolve({
chunkArr
})
})
},
// 切片上傳
async sendChunk(item) {
let formdata = new FormData()
formdata.append('file', new File([item.chunk], item.name))
formdata.append('hash', item.hash)
formdata.append('index', item.index)
formdata.append('total', item.total)
formdata.append('name', item.name)
// eslint-disable-next-line max-len
await Http.post('/upload/fileChunk', formdata, {'Content-Type': 'multipart/form-data'})
},
}
}
</script>//後端
async fileChunk() {
const {ctx} = this;
const {index, hash, total} = ctx.request.body;
const file = ctx.request.files[0];// file包含了文件名,文件類型,大小,路徑
等信息
const fileName = hash + '-' + index;// file包含了文件名,文件類型,大小,路徑等信息
const fileObj = fs.readFileSync(file.filepath);
// 將文件存到指定位置
if (!fs.existsSync(path.join(uploadPath, hash))) {
fs.mkdirSync(path.join(uploadPath, hash));
}
fs.writeFileSync(path.join(uploadPath, hash, fileName), fileObj);
const files = fs.readdirSync(path.join(uploadPath, hash, '/'));
if (files.length != total || !files.length) {
this.success({}, '切片上傳成功');
return;
}
}此時,被切片的文件已經成功的上傳到了後端的指定位置。並生成了以文件的hash值為命名的文件夾,文件夾下包含上傳的文件切片。
緊接著我們需要對文件進行合併
2. 文件合併1.前端發送切片完成後,發送一個合併請求,後端收到請求後,將之前上傳的切片文件合併。2.後臺記錄切片文件上傳數據,當後臺檢測到切片上傳完成後,自動完成合併。3.創建一個和源文件大小相同的文件,根據切片文件的起止位置直接將切片寫入對應位置。
這三種方案中,前兩種都是比較通用的方案,且都是可行的,方案一的好處就是流程比較清晰,代價在於多發了一次請求,就是你需要去多寫一個回調函數。方案二比方案一少了一次請求,而且呢,邏輯也都挪到了後端去做,是一種比較好的方式。
方案三比較好的,相當於直接省略了文件合併的步驟,速度比較快。但是不用語言的實現難度不同。如果沒有合適的 API 的話,自己實現的難度很大。
那麼這裡呢,我們改造下他的後端,在切片上傳的方法中,增加一個判斷,首先讀取對應hash值文件夾下面的文件,如果個數與切片個數不符,就正常返回,如果文件個數等於切片個數,就執行merge的方法
async fileChunk() {
const {ctx} = this;
const {index, hash, total} = ctx.request.body;
const file = ctx.request.files[0];// file包含了文件名,文件類型,大小,路徑等信息
const fileName = hash + '-' + index;// file包含了文件名,文件類型,大小,路徑等信息
const fileObj = fs.readFileSync(file.filepath);
// 將文件存到指定位置
if (!fs.existsSync(path.join(uploadPath, hash))) {
fs.mkdirSync(path.join(uploadPath, hash));
}
fs.writeFileSync(path.join(uploadPath, hash, fileName), fileObj);
const files = fs.readdirSync(path.join(uploadPath, hash, '/'));
if (files.length != total || !files.length) {
this.success({}, '切片上傳成功');
return;
}
this.fileMerge()
}
async fileMerge() {
const {ctx} = this;
const {total, hash, name} = ctx.request.body;
const dirPath = path.join(uploadPath, hash, '/');
const filePath = path.join(uploadPath, name); // 合併文件
// 已存在文件,則表示已上傳成功
if (fs.existsSync(filePath)) {
this.success({}, '文件已存在');
return;
// 如果沒有切片hash文件夾則表明上傳失敗
} else if (!fs.existsSync(dirPath)) {
this.error(-1, '文件上傳失敗');
return;
} else {
// 創建文件寫入流
const fileWriteStream = fs.createWriteStream(filePath);
for (let i = 0; i < total; i++) {
const chunkpath = dirPath + hash + '-' + i;
const tempFile = fs.readFileSync(chunkpath);
fs.appendFileSync(filePath, tempFile);
fs.unlinkSync(chunkpath);
}
fs.rmdirSync(path.join(uploadPath, hash));
fileWriteStream.close();
}
}
3. 限制請求個數在嘗試將一個 5G 大小的文件上傳的時候,發現前端瀏覽器出現卡死現象,原因是切片文件過多,瀏覽器一次性創建了太多了 xhr 請求。這是沒有必要的,拿 chrome 瀏覽器來說,默認的並發數量只有 6,過多的請求並不會提升上傳速度,反而是給瀏覽器帶來了巨大的負擔。因此,我們有必要限制前端請求個數。
這裡呢,我們加一個js 異步並發控制,這個異步並發控制的邏輯是:運用 Promise 功能,定義一個數組 fetchArr,每執行一個異步處理往 fetchArr 添加一個異步任務,當異步操作完成之後,則將當前異步任務從 fetchArr 刪除,則當異步 fetchArr 數量沒有達到最大數的時候,就一直往 fetchArr 添加,如果達到最大數量的時候,運用 Promise.race Api,每完成一個異步任務就再添加一個,最後執行。
上面這邏輯剛好適合大文件分片上傳場景,將所有分片上傳完成之後,執行回調請求後端合併分片。
// 請求並發處理
sendRequest(arr, max = 6) {
let fetchArr = []
let toFetch = () => {
if (!arr.length) {
return Promise.resolve()
}
const chunkItem = arr.shift()
const it = this.sendChunk(chunkItem)
it.then(() => {
// 成功從任務隊列中移除
fetchArr.splice(fetchArr.indexOf(it), 1)
}, err => {
// 如果失敗則重新放入總隊列中
arr.unshift(chunkItem)
console.log(err)
})
fetchArr.push(it)
let p = Promise.resolve()
if (fetchArr.length >= max) {
p = Promise.race(fetchArr)
}
return p.then(() => toFetch())
}
toFetch()
},
4. 斷點續傳切片上傳有一個很好的特性就是上傳過程可以中斷,不論是人為的暫停還是由於網絡環境導致的連結的中斷,都只會影響到當前的切片,而不會導致整體文件的失敗,下次開始上傳的時候可以從失敗的切片繼續上傳。
這裡需要我們在每次開始上傳前,去詢問一遍後端以及上傳的切片數。並且返回已經上傳成功的切片次序的數組,那麼後續再次切片的時候呢,就跳過這些已經上傳完成的切片。後端呢,也新增一個查詢切片文件是否已上傳的方法,去讀取對應hash值文件夾下已上傳的文件的後綴,並返回給前端。
//前端改造,新增詢問下載進度的接口
sendBlob() {
this.createFileMd5(this.file).then(async hash => {
let {data} = await this.getUploadedChunks(hash)
let uploaded = data.data.chunks
this.uploadedChunkSize = uploaded.length
const chunkInfo = await this.cutBlob(this.file, hash, uploaded)
this.remainChunks = chunkInfo.chunkArr
this.sendRequest(this.remainChunks, 6)
})
}//後端改造,新增詢問下載進度的接口
checkSnippet() {
const {ctx} = this;
const {hash} = ctx.request.body
// 切片上傳目錄
const chunksPath = path.join(uploadPath, hash, '/')
let chunksFiles = []
if (fs.existsSync(chunksPath)) {
// 切片文件
chunksFiles = fs.readdirSync(chunksPath)
}
let chunks = chunksFiles.length ? chunksFiles.map(v => v.split('-')[1] - 0) : []
this.success({chunks}, '查詢成功')
}
5. 秒傳秒傳指的是文件如果在後臺已經存了一份,就沒必要再次上傳了,直接返回上傳成功。在體量比較大的應用場景下,秒傳是個必要的功能,既能提高用戶上傳體驗,又能節約自己的硬碟資源。
秒傳的關鍵在於計算文件的唯一性標識。
文件的不同不是命名的差異,而是內容的差異,所以我們將整個文件的二進位碼作為入參,計算 Hash 值,將其作為文件的唯一性標識。
這個具體實現呢,和之前的斷點續傳也很類似,就是在每次開始上傳前,去詢問一遍後端,當前文件是否已經上傳過。所以說這裡呢,我們對前後端這裡稍作改造,把這個邏輯合併到之前斷點續傳的方法當中,首先需要我麼在文件合併成功的方法當中呢,記錄下已經上傳成功的文件的hash值,我是記錄在public文件夾下面的record.js這個文件當中的,你也可以記錄到資料庫當中,我這裡就簡單實現一下。同時改造下查詢分片是否上傳成功的方法,先查詢下當前文件對應的hash值是否存在,如果存在,則直接返回符合秒傳的條件,前端則根據條件,跳出後續上傳的邏輯,並更新進度。
//後端改造,新增詢問下載進度的接口
checkSnippet() {
const {ctx} = this;
const {hash} = ctx.request.body
let content = fs.readFileSync(path.join('app/public/record.js'), 'utf-8').split('\n');
if (content.includes(hash)) {
this.success({hasUpload: true, chunks: []}, '已上傳')
return
}
// 切片上傳目錄
const chunksPath = path.join(uploadPath, hash, '/')
let chunksFiles = []
if (fs.existsSync(chunksPath)) {
// 切片文件
chunksFiles = fs.readdirSync(chunksPath)
}
let chunks = chunksFiles.length ? chunksFiles.map(v => v.split('-')[1] - 0) : []
this.success({chunks, hasUpload: false}, '查詢成功')
}
6. 整體流程最後再梳理一遍整體的流程
1.獲得文件後,使用 Blob 對象的 slice 方法對其進行切割,並封裝一些上傳需要的數據,文件切割的速度很快,不影響主線程渲染。
7. 擴展
2.計算整個文件的 MD5 值。
3.獲得文件的 MD5 值之後,我們將 MD5 值以及文件大小發送到後端,後端查詢是否存在該文件,如果不存在的話,查詢是否存在該文件的切片文件,如果存在,返回切片文件的詳細信息。
4.根據後端返回結果,依次判斷是否滿足「秒傳」 或是 「斷點續傳」 的條件。如果滿足,更新文件切片的狀態與文件進度。
5.根據文件切片的狀態,發送上傳請求,由於存在並發限制,我們限制 request 創建個數,避免頁面卡死。
6.後端收到文件後,首先保存文件,保存成功後記錄切片信息,判斷當前切片是否是最後一個切片,如果是最後一個切片,記錄文件信息,認為文件上傳成功,清空切片記錄。其實除了上述流程以外,還有很多值得改進的地方,比如:
1.文件 Hash 值的計算是 CPU 密集型任務,線程在計算 Hash 值的過程中,頁面處於假死狀態。所以,該任務一定不能在當前線程進行,我們使用 Web Worker 執行計算任務2.根據當前的網絡情況動態的調整切片的大小,類似於 TCP 的擁塞控制3.並發重試,切片上傳的過程中,我們有可能因為各種原因導致某個切片上傳失敗,比如網絡抖動、後端文件進程佔用等等。對於這種情況,最好的方案就是為切片上傳增加一個失敗重試機制。由於切片不大,重試的代價很小,我們設定一個最大重試次數,如果在次數內依然沒有上傳成功,認為上傳失敗。4.多人上傳同一個文件,只要其中一人上傳成功即可認為其他人上傳成功