1、什么是分片上傳
分片上傳就是把一個大的文件分成若干塊,一塊一塊的傳輸。這樣做的好處可以減少重新上傳的開銷。比如:如果我們上傳的文件是一個很大的文件,那么上傳的時間應該會比較久,再加上網絡不穩定各種因素的影響,很容易導致傳輸中斷,用戶除了重新上傳文件外沒有其他的辦法,但是我們可以使用分片上傳來解決這個問題。通過分片上傳技術,如果網絡傳輸中斷,我們重新選擇文件只需要傳剩余的分片。而不需要重傳整個文件,大大減少了重傳的開銷。
但是我們要如何選擇一個合適的分片呢?因此我們要考慮如下幾個事情:
1. 分片越小,那么請求肯定越多,開銷就越大。因此不能設置太小。
2. 分片越大,靈活度就少了。
3. 服務器端都會有個固定大小的接收Buffer。分片的大小最好是這個值的整數倍。
因此,綜合考慮到推薦分片的大小是2M-5M,具體分片的大小需要根據文件的大小來確定,如果文件太大,建議分片的大小是5M,如果文件相對較小,那么建議分片的大小是2M。
實現文件分片上傳的步驟如下:
1. 先對文件進行md5加密。使用md5加密的優點是:可以對文件進行唯一標識,同樣可以為后台進行文件完整性校驗進行比對。
2. 拿到md5值以后,服務器端查詢下該文件是否已經上傳過,如果已經上傳過的話,就不用重新再上傳。
3. 對大文件進行分片。比如一個100M的文件,我們一個分片是5M的話,那么這個文件可以分20次上傳。
4. 向后台請求接口,接口里的數據就是我們已經上傳過的文件塊。(注意:為什么要發這個請求?就是為了能斷點續傳,比如我們使用百度網盤對吧,網盤里面有續傳功能,當一個文件傳到一半的時候,突然想下班不想上傳了,那么服務器就應該記住我之前上傳過的文件塊,當我打開電腦重新上傳的時候,那么它應該跳過我之前已經上傳的文件塊。再上傳后續的塊)。
5. 開始對未上傳過的文件塊進行上傳。(這個是第二個請求,會把所有的分片合並,然后上傳請求)。
6. 上傳成功后,服務器會進行文件合並。最后完成。
2、理解Blob對象中的slice方法對文件進行分割及其他知識點
可以看下我之前的博客:利用blob對象實現大文件分片上傳
Blob對象自身有 size 和 type兩個屬性,及它的原型上有 slice() 方法。我們可以通過該方法來切割我們的二進制的Blob對象。
blob.slice(startByte, endByte) 是Blob對象中的一個方法,File對象它是繼承Blob對象的,因此File對象也有該slice方法的。
參數:
startByte: 表示文件起始讀取的Byte字節數。
endByte: 表示結束讀取的字節數。
返回值:var b = new Blob(startByte, endByte); 該方法的返回值仍然是一個Blob類型。
我們可以使用 blob.slice() 方法對二進制的Blob對象進行切割,但是該方法也是有瀏覽器兼容性的,因此我們可以封裝一個方法:如下所示:
function blobSlice(blob, startByte, endByte) { if (blob.slice) { return blob.slice(startByte, endByte); } // 兼容firefox
if (blob.mozSlice) { return blob.mozSlice(startByte, endByte); } // 兼容webkit
if (blob.webkitSlice) { return blob.webkitSlice(startByte, endByte); } return null; }
3、具體實現
$(document).ready(() => { const chunkSize = 2 * 1024 * 1024; // 每個chunk的大小,設置為2兆 // 使用Blob.slice方法來對文件進行分割。 // 同時該方法在不同的瀏覽器使用方式不同。
const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice; const hashFile = (file) => { return new Promise((resolve, reject) => { const chunks = Math.ceil(file.size / chunkSize); let currentChunk = 0; const spark = new SparkMD5.ArrayBuffer(); const fileReader = new FileReader(); function loadNext() { const start = currentChunk * chunkSize; const end = start + chunkSize >= file.size ? file.size : start + chunkSize; fileReader.readAsArrayBuffer(blobSlice.call(file, start, end)); } fileReader.onload = e => { spark.append(e.target.result); // Append array buffer
currentChunk += 1; if (currentChunk < chunks) { loadNext(); } else { console.log('finished loading'); const result = spark.end(); // 如果單純的使用result 作為hash值的時候, 如果文件內容相同,而名稱不同的時候 // 想保留兩個文件無法保留。所以把文件名稱加上。
const sparkMd5 = new SparkMD5(); sparkMd5.append(result); sparkMd5.append(file.name); const hexHash = sparkMd5.end(); resolve(hexHash); } }; fileReader.onerror = () => { console.warn('文件讀取失敗!'); }; loadNext(); }).catch(err => { console.log(err); }); } const submitBtn = $('#submitBtn'); submitBtn.on('click', async () => { const fileDom = $('#file')[0]; // 獲取到的files為一個File對象數組,如果允許多選的時候,文件為多個
const files = fileDom.files; const file = files[0]; if (!file) { alert('沒有獲取文件'); return; } const blockCount = Math.ceil(file.size / chunkSize); // 分片總數
const axiosPromiseArray = []; // axiosPromise數組
const hash = await hashFile(file); //文件 hash // 獲取文件hash之后,如果需要做斷點續傳,可以根據hash值去后台進行校驗。 // 看看是否已經上傳過該文件,並且是否已經傳送完成以及已經上傳的切片。
console.log(hash); for (let i = 0; i < blockCount; i++) { const start = i * chunkSize; const end = Math.min(file.size, start + chunkSize); // 構建表單
const form = new FormData(); form.append('file', blobSlice.call(file, start, end)); form.append('name', file.name); form.append('total', blockCount); form.append('index', i); form.append('size', file.size); form.append('hash', hash); // ajax提交 分片,此時 content-type 為 multipart/form-data
const axiosOptions = { onUploadProgress: e => { // 處理上傳的進度
console.log(blockCount, i, e, file); }, }; // 加入到 Promise 數組中
axiosPromiseArray.push(axios.post('/file/upload', form, axiosOptions)); } // 所有分片上傳后,請求合並分片文件
await axios.all(axiosPromiseArray).then(() => { // 合並chunks
const data = { size: file.size, name: file.name, total: blockCount, hash }; axios.post('/file/merge_chunks', data).then(res => { console.log('上傳成功'); console.log(res.data, file); alert('上傳成功'); }).catch(err => { console.log(err); }); }); }); })
我們需要獲取分片的總數 —— 然后使用 for循環遍歷分片的總數 —— 然后依次實例化formData數據 —— 依次把對應的分片添加到 formData數據里面去。
然后分別使用 '/file/upload' 請求數據,最后把所有請求成功的數據放入到 axiosPromiseArray 數組中,當所有的分片上傳完成后,我們會使用 await axios.all(axiosPromiseArray).then(() => {}) 方法,最后我們會使用 '/file/merge_chunks' 方法來合並文件。
const Koa = require('koa'); const app = new Koa(); const Router = require('koa-router'); const multer = require('koa-multer'); const serve = require('koa-static'); const path = require('path'); const fs = require('fs-extra'); const koaBody = require('koa-body'); const { mkdirsSync } = require('./utils/dir'); const uploadPath = path.join(__dirname, 'uploads'); const uploadTempPath = path.join(uploadPath, 'temp'); const upload = multer({ dest: uploadTempPath }); const router = new Router(); app.use(koaBody()); /** * single(fieldname) * Accept a single file with the name fieldname. The single file will be stored in req.file. */ router.post('/file/upload', upload.single('file'), async (ctx, next) => { console.log('file upload...') // 根據文件hash創建文件夾,把默認上傳的文件移動當前hash文件夾下。方便后續文件合並。
const { name, total, index, size, hash } = ctx.req.body; const chunksPath = path.join(uploadPath, hash, '/'); if(!fs.existsSync(chunksPath)) mkdirsSync(chunksPath); fs.renameSync(ctx.req.file.path, chunksPath + hash + '-' + index); ctx.status = 200; ctx.res.end('Success'); }) router.post('/file/merge_chunks', async (ctx, next) => { const { size, name, total, hash } = ctx.request.body; // 根據hash值,獲取分片文件。 // 創建存儲文件 // 合並
const chunksPath = path.join(uploadPath, hash, '/'); const filePath = path.join(uploadPath, name); // 讀取所有的chunks 文件名存放在數組中
const chunks = fs.readdirSync(chunksPath); // 創建存儲文件
fs.writeFileSync(filePath, ''); if(chunks.length !== total || chunks.length === 0) { ctx.status = 200; ctx.res.end('切片文件數量不符合'); return; } for (let i = 0; i < total; i++) { // 追加寫入到文件中
fs.appendFileSync(filePath, fs.readFileSync(chunksPath + hash + '-' +i)); // 刪除本次使用的chunk
fs.unlinkSync(chunksPath + hash + '-' +i); } fs.rmdirSync(chunksPath); // 文件合並成功,可以把文件信息進行入庫。
ctx.status = 200; ctx.res.end('合並成功'); }) app.use(router.routes()); app.use(router.allowedMethods()); app.use(serve(__dirname + '/static')); app.listen(9000, () => { console.log('服務9000端口已經啟動了'); });
utils/dir.js,該代碼的作用是判斷是否有這個目錄,有這個目錄的話,直接返回true,否則的話,創建該目錄
const path = require('path'); const fs = require('fs-extra'); const mkdirsSync = (dirname) => { if(fs.existsSync(dirname)) { return true; } else { if (mkdirsSync(path.dirname(dirname))) { fs.mkdirSync(dirname); return true; } } } module.exports = { mkdirsSync };
我們先看 '/file/upload' 這個請求,獲取到文件后,請求成功回調,然后會在項目中的根目錄下創建一個 uploads 這個目錄
我們也可以在我們的網絡中看到很多 '/file/upload' 的請求,說明我們的請求是分片上傳的
最后所有的分片請求上傳成功后,我們會調用 '/file/merge_chunks' 這個請求來合並所有的文件,根據我們的hash值,來獲取文件分片。然后我們會循環分片的總數,然后把所有的分片寫入到我們的filePath目錄中
fs.appendFileSync(filePath, fs.readFileSync(chunksPath + hash + '-' +i));
其中 filePath 的獲取 是這句代碼:const filePath = path.join(uploadPath, name); 也就是說在我們項目的根目錄下的uploads文件夾下,這么做的原因是為了防止網絡突然斷開或服務器突然異常的情況下,文件上傳到一半的時候,我們本地會保存一部分已經上傳的文件,如果我們繼續上傳的時候,我們會跳過哪些已經上傳后的文件,繼續上傳未上傳的文件。這是為了斷點續傳做好准備的,下次我會分析下如何實現斷點續傳的原理了。
如上就是我們整個分片上傳的基本原理,我們還沒有做斷點續傳了,下次有空我們來分析下斷點續傳的基本原理,斷點續傳的原理,無非就是說在我們上傳的過程中,如果網絡中斷或服務器中斷的情況下,我們需要把文件保存到本地,然后當網絡恢復的時候,我們繼續上傳,那么繼續上傳的時候,我們會比較上傳的hash值是否在我本地的hash值是否相同,如果相同的話,直接跳過該分片上傳,繼續下一個分片上傳,依次類推來進行判斷,雖然使用這種方式來進行比對的情況下,會需要一點時間,但是相對於我們重新上傳消耗的時間來講,這些時間不算什么的。下次有空我們來分析下斷點續傳的基本原理哦。