文件上傳

2021-12-29 JAD DESIGN
背景1

文件上傳是個非常普遍的場景,特別是在一些資源管理相關的業務中。

文件上傳的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 方法對其進行切割,並封裝一些上傳需要的數據,文件切割的速度很快,不影響主線程渲染。
2.計算整個文件的 MD5 值。
3.獲得文件的 MD5 值之後,我們將 MD5 值以及文件大小發送到後端,後端查詢是否存在該文件,如果不存在的話,查詢是否存在該文件的切片文件,如果存在,返回切片文件的詳細信息。
4.根據後端返回結果,依次判斷是否滿足「秒傳」 或是 「斷點續傳」 的條件。如果滿足,更新文件切片的狀態與文件進度。
5.根據文件切片的狀態,發送上傳請求,由於存在並發限制,我們限制 request 創建個數,避免頁面卡死。
6.後端收到文件後,首先保存文件,保存成功後記錄切片信息,判斷當前切片是否是最後一個切片,如果是最後一個切片,記錄文件信息,認為文件上傳成功,清空切片記錄。

7. 擴展

其實除了上述流程以外,還有很多值得改進的地方,比如:

1.文件 Hash 值的計算是 CPU 密集型任務,線程在計算 Hash 值的過程中,頁面處於假死狀態。所以,該任務一定不能在當前線程進行,我們使用 Web Worker 執行計算任務2.根據當前的網絡情況動態的調整切片的大小,類似於 TCP 的擁塞控制3.並發重試,切片上傳的過程中,我們有可能因為各種原因導致某個切片上傳失敗,比如網絡抖動、後端文件進程佔用等等。對於這種情況,最好的方案就是為切片上傳增加一個失敗重試機制。由於切片不大,重試的代價很小,我們設定一個最大重試次數,如果在次數內依然沒有上傳成功,認為上傳失敗。4.多人上傳同一個文件,只要其中一人上傳成功即可認為其他人上傳成功

相關焦點

  • input file文件上傳與批量上傳
    ### 文件上傳- 利用 input 標籤設置 type="file" 打開本地的資源管理器;- input 標籤的 accept 屬性可以設置上傳什麼類型的文件;accept 屬性並不會驗證選中文件的類型.
  • 大規格文件的上傳優化
    在開發過程中,收到這樣一個問題反饋,在網站上傳 100 MB 以上的文件經常失敗,重試也要等老半天,這就難為需要上傳大規格文件的用戶了。那麼應該怎麼做才能快速上傳,就算失敗了再次發送也能從上次中斷的地方繼續上傳呢?
  • Wish如何上傳我的CSV文件? Wish上傳CSV文件操作流程
    你可以通過如Microsoft Excel或Google Drive表格等方式創建CSV文件。本質上,CSV文件是一張每個單元格均有對應屬性的電子表格。下面,Wish將會展示如何通過Google Drive表格建立CSV文件,並上傳到商戶後臺。   創建CSV文件 1)  為你的產品創建一個電子表格吧,這裡有一個模板供你參考。
  • JavaScript文件上傳詳解
    demo4 moxie文件上傳,進度提示 demo5 使用plupload實現了圖片上傳demo6 斷點續傳 demo7 plupload ui widget的示例 本教程包含7個 demo ,它們循序漸進、由淺入深地講解文件上傳。
  • PHP上傳文件和下載
    >在 B/S 程序中文件上傳已經成為一個常用功能。1.2 在伺服器端通過 PHP 處理上傳上傳文件的接收和處理是通過 PHP 腳本來處理的,具體需要通過以下三個方面信息:1)設置 PH 配置文件中的指令:用於精細地調節 PHP 的文件上傳功能。2)$FILES 多維數組:用於存儲各種與上傳文件有關的信息,其他數據還是使用 $_POST 獲取。
  • Python之Django文件上傳
    一、目標學習在Django下做個文件上傳的頁面、學習創建文件上傳目錄及設定二、試驗平臺windows7 , python3.7,Django2.1.5,三、概述本例較為簡單,僅介紹主要代碼,四、在項目根目錄創建靜態文件夾
  • XSS姿勢——文件上傳XSS
    0x01 簡單介紹一個文件上傳點是執行XSS應用程式的絕佳機會。
  • PHP編碼安全:上傳文件安全
    1、文件上傳漏洞以下是一個不安全的上傳代碼示例,即文件上傳PHP接收代碼upload.php。<?" value="上傳"></form>這是一個簡單的上傳文件功能,其中由用戶上傳文件,如果上傳成功,保存文件的路徑為http://伺服器路徑/uploads/文件名稱。
  • 滲透測試中文件上傳技巧
    <上傳文件名fuzz字典根據語言、解析漏洞、中間件、系統特性以及一些繞過WAF的方法:黑名單、大小寫、ADS流、截斷、空格、長度、htaccess等生存文件名字典。A0上傳文件時如果轉換時比如轉成PDF等文件,嘗試在上傳文件中加入payload進行SSRF<iframe src="file:///etc/passwd" width=400height=400/><iframe src="file:///c:/windows/win.ini
  • 從建站打拿站 -- PHP(文件上傳)
    李白寫過那麼多詩,他自己會背麼?
  • 《雲班課》上傳音頻文件教程
    但是很多小夥伴都還不太清楚該如何上傳錄音文件,怎麼提交音頻文件?下面小編為大家帶來了雲班課上傳錄音文件的詳細教程,一起來看看。 雲班課怎麼上傳錄音文件? 超過2分鐘的錄音文件上傳方法: 1、在文件管理裡找到錄音機,然後找到那個需要上傳的音頻; 2、點編輯,再複製,把這個文件隨便粘貼到一個有名字的文件夾裡;
  • 前端本地文件操作與上傳
    如果需要限制上傳文件的大小就可以通過判斷size屬性有沒有超,單位是字節,而要判斷是否為圖片文件就可以通過type類型是否以image開頭。通過判斷文件名的後綴可能會不準,而通過這種判斷會比較準。/form-data" method="post">    <input type="file" name="fileContent"></form>如果xhr.send的是FormData類型話,它會自動設置enctype,如果你用默認表單提交上傳文件的話就得在form上面設置這個屬性,因為上傳文件只能使用POST的這種編碼。
  • 拆解 UI 組件 el-upload 文件上傳
    :是否支持選擇多個文件;limit:最大允許選擇的文件個數;auto-upload:選擇完文件是否自動開始上傳;disable:是否阻止用戶選擇文件;on-exceed:當用戶選擇的文件個數,超出 limit 屬性定義的值時觸發的回調;第二部分:上傳服務選擇完文件即可進行上傳處理
  • jQuery實現異步上傳一個或多個文件
    本文實例為大家分享了jQuery實現異步上傳一個或多個文件的具體代碼,供大家參考,具體內容如下首先使用SpringMvc文件上傳,需要引入第三方上傳文件的jar:<dependency> <groupId>commons-fileupload</groupId> <artifactId
  • 常用圖片文件下載上傳方法
    本文整理了前端常用的下載文件以及上傳文件的方法。以vue+element ui+axios為例,不使用el封裝好的上傳組件,這裡自行進行封裝實現,完整demo可以查看【閱讀原文】上傳文件以圖片為例,文件上傳可以省略預覽圖片功能圖片上傳可以使用2種方式:文件流和base64;1.文件流上傳+預覽:
  • 「ThinkPHP5開發連載78」tp5連載雜項之上傳-文件上傳
    文件上傳①新建Index控制器,並新建fileUpload方法②新建fileupload.html模板,並展示文件上傳的按鈕等注意:表單上傳文件,要加enctype="multipart/form-data"屬性。
  • 如何使用Node.js上傳文件
    我們將在下面簡要討論上下文,然後我將以自己的方式結束您上傳帶節點的文件。問題:信息過載搜索「JavaScript文件上傳」將產生大量結果,但乍看之下很難說出哪些實際示例針對您的特定需求。上傳文件當我們說「上傳」時,我們在談論什麼?「向上」和「向下」的概念分別暗示伺服器和客戶端存儲。我們正在獲取源文件並將其從一個存儲位置移動到另一個存儲位置。當我們在前端構建時,很明顯用戶的客戶端是「向下」位置,並且上傳會將用戶文件的副本從其客戶端「向上」移動到伺服器,雲或CDN。
  • iOS 6 的 Safari 文件上傳功能詳解
    iOS 6 給 Safari 瀏覽器帶來的另外一個功能是文件上傳,終於 Safari 終於支持 input 輸入框的文件類型了,並且還支持 HTML媒體捕獲(HTML Media Capture)。上傳多張圖片或者視頻如果你想一次上傳多張圖片,可以使用 HTML5 一個叫做 multiple 的布爾屬性,不過這個時候,就不能使用攝像頭了。
  • 滲透測試 對文件上傳安全檢測與webshell分析
    centos系統.下面我們將滲透測試過程裡,對文件上傳漏洞的檢測與webshell的分析進行記錄,希望更多的人了解什麼是滲透測試.我們直擊漏洞根源,查看代碼在uplpod.php文件裡,可以看到有個lang變量給了language.php,並附加條件,設置的指定文件都存在,才可以將參數值傳遞過去,代碼截圖如下:仔細看,我們看到代碼調用了save_file的調用方式,由此可以導致langup值可以偽造,追蹤溯源看到該值是對應的WEB前端用戶的文件上傳功能,在用戶文件上傳這裡,並沒有做安全效驗與安全白名單攔截機制
  • 20+ 個很棒的 jQuery 文件上傳插件或教程
    文件上傳是網站很常見的功能之一,通過使用 jQuery 可以讓上傳過程更加人性化,更好的用戶體驗。