閱讀目錄javascript
一:什麼是分片上傳?css
分片上傳是把一個大的文件分紅若干塊,一塊一塊的傳輸。這樣作的好處能夠減小從新上傳的開銷。好比:
若是咱們上傳的文件是一個很大的文件,那麼上傳的時間應該會比較久,再加上網絡不穩定各類因素的影響,很容易致使傳輸中斷,用戶除了從新上傳文件外沒有其餘的辦法,可是咱們可使用分片上傳來解決這個問題。經過分片上傳技術,若是網絡傳輸中斷,咱們從新選擇文件只須要傳剩餘的分片。而不須要重傳整個文件,大大減小了重傳的開銷。html
以下圖是一個大文件分紅不少小片斷:java
可是咱們要如何選擇一個合適的分片呢?node
所以咱們要考慮以下幾個事情:jquery
1. 分片越小,那麼請求確定越多,開銷就越大。所以不能設置過小。
2. 分片越大,靈活度就少了。
3. 服務器端都會有個固定大小的接收Buffer。分片的大小最好是這個值的整數倍。ios
所以,綜合考慮到推薦分片的大小是2M-5M. 具體分片的大小須要根據文件的大小來肯定,若是文件太大,建議分片的大小是5M,若是文件相對較小,那麼建議分片的大小是2M。git
實現文件分片上傳的步驟以下:github
1. 先對文件進行md5加密。使用md5加密的優勢是:能夠對文件進行惟一標識,一樣能夠爲後臺進行文件完整性校驗進行比對。
2. 拿到md5值之後,服務器端查詢下該文件是否已經上傳過,若是已經上傳過的話,就不用從新再上傳。
3. 對大文件進行分片。好比一個100M的文件,咱們一個分片是5M的話,那麼這個文件能夠分20次上傳。
4. 向後臺請求接口,接口裏的數據就是咱們已經上傳過的文件塊。(注意:爲何要發這個請求?就是爲了能續傳,好比咱們使用百度網盤對吧,網盤裏面有續傳功能,當一個文件傳到一半的時候,忽然想下班不想上傳了,那麼服務器就應該記住我以前上傳過的文件塊,當我打開電腦從新上傳的時候,那麼它應該跳過我以前已經上傳的文件塊。再上傳後續的塊)。
5. 開始對未上傳過的文件塊進行上傳。(這個是第二個請求,會把全部的分片合併,而後上傳請求)。
6. 上傳成功後,服務器會進行文件合併。最後完成。web
二:理解Blob對象中的slice方法對文件進行分割及其餘知識點
在編寫代碼以前,咱們須要瞭解一些基本的知識點,而後在瞭解基礎知識點之上,咱們再去實踐咱們的大文件分片上傳這麼的一個demo。首先咱們來看下咱們的Blob對象,以下代碼所示:
var b = new Blob(); console.log(b);
以下所示:
如上圖咱們能夠看到,咱們的Blob對象自身有 size 和 type兩個屬性,及它的原型上有 slice() 方法。咱們能夠經過該方法來切割咱們的二進制的Blob對象。
2. 學習 blob.slice 方法
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. 理解 async/await 的使用
在我很早以前,我已經對async/await 的使用和優點作了講解,有興趣瞭解該知識點的,能夠看我以前這篇文章.
所以咱們如今來看下以下demo列子:
const hashFile2 = function(file) { return new Promise(function(resolve, reject) { console.log(111); }) }; window.onload = async() => { const hash = await hashFile2(); }
如上代碼,若是咱們直接刷新頁面,就能夠在控制檯中輸出 111 這個的字符。爲何我如今要講解這個呢,由於待會咱們的demo會使用到該知識點,因此提早講解下理解下該知識。
4. 理解 FileReader.readAsArrayBuffer()方法
該方法會按字節讀取文件內容,並轉換爲 ArrayBuffer 對象。readAsArrayBuffer方法讀取文件後,會在內存中建立一個 ArrayBuffer對象(二進制緩衝區),會將二進制數據存放在其中。經過此方式,咱們就能夠直接在網絡中傳輸二進制內容。
其語法結構:
FileReader.readAsArrayBuffer(Blob|File);
Blob|File 必須參數,參數是Blob或File對象。
以下代碼演示:
<!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset=" utf-8"> <title>readAsArrayBuffer測試</title> </head> <body> <input type="file" id="file"/> <script> window.onload = function () { var input = document.getElementById("file"); input.onchange = function () { var file = this.files[0]; if (file) { //讀取本地文件,以gbk編碼方式輸出 var reader = new FileReader(); reader.readAsArrayBuffer(file); reader.onload = function () { console.log(this.result); console.log(new Blob([this.result])) } } } } </script> </body> </html>
若是咱們如今上傳的是文本文件的話,就會打印以下信息,以下所示:
三. 使用 spark-md5 生成 md5文件
瞭解spark-md5,請看npm官網
下面咱們來理解下 上傳文件如何來獲得 md5 的值。上傳文件簡單的以下demo, 代碼所示:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>文件上傳</title> <script src="https://code.jquery.com/jquery-3.4.1.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.0/spark-md5.js"></script> </head> <body> <h1>大文件上傳測試</h1> <div> <h3>自定義上傳文件</h3> <input id="file" type="file" name="avatar"/> <div> <input id="submitBtn" type="button" value="提交"> </div> </div> <script type="text/javascript"> $(function() { const submitBtn = $('#submitBtn'); submitBtn.on('click', async () => { var fileDom = $('#file')[0]; // 獲取到的files爲一個File對象數組,若是容許多選的時候,文件爲多個 const files = fileDom.files; const file = files[0]; // 獲取第一個文件,由於文件是一個數組 if (!file) { alert('沒有獲取文件'); return; } var fileSize = file.size; // 文件大小 var chunkSize = 2 * 1024 * 1024; // 切片的大小 var chunks = Math.ceil(fileSize / chunkSize); // 獲取切片的個數 var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice; var spark = new SparkMD5.ArrayBuffer(); var reader = new FileReader(); var currentChunk = 0; reader.onload = function(e) { const result = e.target.result; spark.append(result); currentChunk++; if (currentChunk < chunks) { loadNext(); console.log(`第${currentChunk}分片解析完成,開始解析${currentChunk + 1}分片`); } else { const md5 = spark.end(); console.log('解析完成'); console.log(md5); } }; function loadNext() { var start = currentChunk * chunkSize; var end = start + chunkSize > file.size ? file.size : (start + chunkSize); reader.readAsArrayBuffer(blobSlice.call(file, start, end)); }; loadNext(); }); }); </script> </body> </html>
如上代碼,首先我在 input type = 'file' 這樣的會選擇一個文件,而後點擊進行上傳,先獲取文件的大小,而後定義一個分片的大小默認爲2兆,使用 var chunks = Math.ceil(fileSize / chunkSize); // 獲取切片的個數 方法獲取切片的個數。
若是 fileSize(文件大小) 小於 chunkSize(2兆)的話,使用向上取整,所以爲1個分片。同理若是除以的結果 是 1.2 這樣的,那麼就是2個分片了,依次類推.... 而後使用 SparkMD5.ArrayBuffer 方法了,詳情能夠看官網(http://npm.taobao.org/package/spark-md5). 先初始化當前的 currentChunk 分片爲0,而後 reader.onload = function(e) {} 方法,若是當前的分片數量小於 chunks 的數量的話,會繼續調用 loadNext()方法,該方法會讀取下一個分片,開始的位置計算方式是:var start = currentChunk * chunkSize;
currentChunk 的含義是第二個分片(從0開始的,所以這裏它的值爲1),結束的位置 計算方式爲:
var end = start + chunkSize > file.size ? file.size : (start + chunkSize);
也就說,若是一個文件的大小是2.1兆的話,一個分片是2兆的話,那麼它就最大分片的數量就是2片了,可是 currentChunk 默認從0開始的,所以第二個分片,該值就變成1了,所以 start的位置就是 var start = 1 * 2(兆)了,而後 var end = start + chunkSize > file.size ? file.size : (start + chunkSize);
若是 start + chunkSize 大於 文件的大小(file.size) 的話,那麼就直接去 file.size(文件的大小),不然的話,結束位置就是 start + chunkSize 了。最後咱們使用
blobSlice 進行切割,就切割到第二個分片的大小了,blobSlice.call(file, start, end),這樣的方法。而後把切割的文件讀取到內存中去,使用 reader.readAsArrayBuffer() 將buffer讀取到內存中去了。繼續會調用 onload 該方法,直到 進入else 語句內,那麼 const md5 = spark.end(); 就生成了一個md5文件了。如上代碼,若是我如今上傳一個大文件的話,在控制檯中就會打印以下信息了:以下圖所示:
四. 使用koa+js實現大文件分片上傳實踐
注:根據網上demo來說解的
先看下整個項目的架構以下:
|---- 項目根目錄 | |--- app.js # node 入口文件 | |--- package.json | |--- node_modules # 全部依賴的包 | |--- static # 存放靜態資源文件目錄 | | |--- js | | | |--- index.js # 文件上傳的js | | |--- index.html | |--- uploads # 保存上傳文件後的目錄 | |--- utils # 保存公用的js函數 | | |--- dir.js
static/index.html 文件代碼以下:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>文件上傳</title> <script src="https://cdn.bootcss.com/axios/0.18.0/axios.min.js"></script> <script src="https://code.jquery.com/jquery-3.4.1.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.0/spark-md5.js"></script> </head> <body> <h1>大文件上傳測試</h1> <div> <h3>自定義上傳文件</h3> <input id="file" type="file" name="avatar"/> <div> <input id="submitBtn" type="button" value="提交"> </div> </div> <script type="text/javascript" src="./js/index.js"></script> </body> </html>
運行頁面,效果以下所示:
static/js/index.js 代碼以下:
$(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); }); }); }); })
如上代碼,和咱們上面生成md5代碼很相似,從添加到formData下面的代碼不同了,咱們能夠來簡單的分析下,看下代碼的具體含義:
const blockCount = Math.ceil(file.size / chunkSize); // 分片總數
上面的代碼的含義是獲取分片的總數,咱們以前講解過,而後使用 for循環遍歷分片,依次把對應的分片添加到 formData數據裏面去,以下所示代碼:
const axiosPromiseArray = []; const blockCount = Math.ceil(file.size / chunkSize); // 分片總數 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); }); });
如上代碼,循環分片的總數,而後依次實列化formData數據,依次放入到formData實列中,而後分別使用 '/file/upload' 請求數據,最後把全部請求成功的數據放入到 axiosPromiseArray 數組中,當全部的分片上傳完成後,咱們會使用 await axios.all(axiosPromiseArray).then(() => {}) 方法,最後咱們會使用 '/file/merge_chunks' 方法來合併文件。
下面咱們來看看 app.js 服務器端的代碼,以下所示:
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端口已經啓動了'); });
如上代碼:分別引入 koa, koa-router, koa-multer, koa-static, path, fs-extra, koa-body 依賴包。
koa-multer 的做用是爲了處理上傳文件的插件。
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 };
1. /file/upload 請求代碼以下:
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'); })
如上代碼,會處理 '/file/upload' 這個請求,upload.single('file'), 的含義是:接受一個文件名稱字段名。
單一文件將存儲在req.file中,這是 koa-multer 插件的用法,具體能夠看 koa-multer官網(https://www.npmjs.com/package/koa-multer)。 獲取到文件後,請求成功回調,而後會在項目中的根目錄下建立一個 uploads 這個目錄,以下代碼能夠看到:
const uploadPath = path.join(__dirname, 'uploads'); const chunksPath = path.join(uploadPath, hash, '/'); if(!fs.existsSync(chunksPath)) mkdirsSync(chunksPath);
最後上傳完成後,咱們能夠在咱們的項目中能夠看到咱們全部的文件都在咱們本地了,以下所示:
咱們也能夠在咱們的網絡中看到以下不少 '/file/upload' 的請求,以下能夠看到不少請求,說明咱們的請求是分片上傳的,以下所示:
2. '/file/merge_chunks'
最後全部的分片請求上傳成功後,咱們會調用 '/file/merge_chunks' 這個請求來合併全部的文件,根據咱們的hash值,來獲取文件分片。
以下代碼:
// 根據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('合併成功');
如上代碼,會循環分片的總數,而後把全部的分片寫入到咱們的filePath目錄中,如這句代碼:
fs.appendFileSync(filePath, fs.readFileSync(chunksPath + hash + '-' +i));
其中 filePath 的獲取 是這句代碼:const filePath = path.join(uploadPath, name); 也就是說在咱們項目的根目錄下的uploads文件夾下,這麼作的緣由是爲了防止網絡忽然斷開或服務器忽然異常的狀況下,文件上傳到一半的時候,咱們本地會保存一部分已經上傳的文件,若是咱們繼續上傳的時候,咱們會跳過哪些已經上傳後的文件,繼續上傳未上傳的文件。這是爲了斷點續傳作好準備的,下次我會分析下如何實現斷點續傳的原理了。若是咱們把上面這兩句代碼註釋掉,以下所示:
// 刪除本次使用的chunk fs.unlinkSync(chunksPath + hash + '-' +i); fs.rmdirSync(chunksPath);
咱們就能夠看到咱們項目本地會有 uploads 會有不少分片文件了,以下所示:
當咱們這個文件上傳完成後,如上代碼,咱們會把它刪除掉,所以若是咱們不把該代碼註釋掉的話,是看不到效果的。
若是咱們繼續上傳另一個文件後,會在咱們項目的根目錄下生成第二個文件,以下所示:
如上就是咱們整個分片上傳的基本原理,咱們尚未作斷點續傳了,下次有空咱們來分析下斷點續傳的基本原理,斷點續傳的原理,無非就是說在咱們上傳的過程當中,若是網絡中斷或服務器中斷的狀況下,咱們須要把文件保存到本地,而後當網絡恢復的時候,咱們繼續上傳,那麼繼續上傳的時候,咱們會比較上傳的hash值是否在我本地的hash值是否相同,若是相同的話,直接跳過該分片上傳,繼續下一個分片上傳,依次類推來進行判斷,雖然使用這種方式來進行比對的狀況下,會須要一點時間,可是相對於咱們從新上傳消耗的時間來說,這些時間不算什麼的。下次有空咱們來分析下斷點續傳的基本原理哦。分片上傳原理基本分析到這裏哦。
github源碼查看