本文使用node.js實現文件分片上傳,沒有使用node.js的框架。前端使用javascript實現,也沒有使用框架。這裏用到了mongoDB數據庫。(本文代碼練習用,非項目)javascript
npm init -y
初始化項目。npm install mongodb —save
安裝上mongodb包。主要的思路就是將文件切片後,分片上傳,後端將全部的分片都接收完成後,合併爲一個完整的文件。經過<progress>
展現上傳的進度,當點擊暫停的時候,停止全部未完成的請求,當點擊繼續上傳的時候,從新發起請求。html
畫了一張流程圖,以下: 前端
前端:當點擊上傳文件的按鈕的時候,會發起第一個請求,後續的處理須要等待第一個請求完成。由於第一個請求的響應結果中會包含當前文件的上傳狀況,根據文件上傳的狀況不一樣作出相應的處理。java
後端:文件的每一個分片與其索引值對應。上傳到後端後,索引爲1的分片存放爲文件以後,uploadedFilesUrls
數組索引值爲1的位置存放的就是已上傳的分片文件存放在服務器的路徑。當全部分片上傳完畢以後,按索引的順序將分片文件合併爲一個整的文件。node
經過輸入框選入文件的時候,獲得選中的文件,並取文件的信息組成一個字符串,經過這個字符串生成一個hash
值,這個值將做爲文件id
(這裏的id
並非惟一的,後文會說到)上傳給後端。這裏用到的jsSHA
是用於生成一個表明文件的hash
字符串的。git
// 改變當前選中的文件
function changeSelectedFile (event) {
let fileUploadElement = event.target;
let selectedFile = fileUploadElement.files[0]; // 獲得當前選中的文件
globalData.selectedFile = selectedFile;
// 使用SHA-512算法生成一個標誌文件的hash字符串
const { name, lastModified, size, type, webkitRelativePath } = selectedFile;
let fileStr = `${name}${lastModified}${size}${type}${webkitRelativePath}`;
let shaObj = new jsSHA('SHA-512', 'TEXT'); // 建立一個jsSHA實例,表明採用SHA-512算法,要轉換的數據爲文本格式
shaObj.update(fileStr); // 傳入要轉換的數據
globalData.selectedFileHash = shaObj.getHash('HEX'); // 獲得表明文件的hash值
}
複製代碼
封裝一個請求的方法,用於發起請求,上傳文件的分片信息。這裏使用了fetch
來發起請求,是爲了使用async/await
以及使用停止控制器AbortController
。github
// 將文件的分片上傳
function uploadBlock (body) {
const controller = globalData.controller;
let url = '/api/uploadFile';
let headersObj = {
'Content-Type': 'multipart/form-data'
};
return fetch(url, {
method: 'POST',
body,
headers: new Headers(headersObj),
signal: controller.signal // 使用控制器實例的signal標誌請求的狀況
}).then(res => res.json())
.catch(error => ({ error }))
.then(response => ({ response }));
}
複製代碼
以上代碼中的controller
是定義在存放全局變量的globalData
對象中的,是AbortController
的一個實例,用於在點擊「暫停上傳」按鈕的時候停止全部未完成的請求。web
let globalData = {
...
controller: new AbortController() // 停止控制器的實例
}
複製代碼
將第一個文件分片整理好後,發起上傳請求。selectedFile
是選中的文件,可以直接使用slice
方法將文件分片。建立一個FormData
實例,將數據放入formData
實例中,上傳到服務器。這裏是點擊「上傳文件」後發起的第一個請求,須要等待這個請求的完成並根據請求響應的結果作出相應的處理,如上文的流程圖中所示。算法
let start = 0, end = blockSize;
let blockContent = selectedFile.slice(start, end);
let formData = new FormData();
formData.set('fileId', selectedFileHash);
formData.set('fileName', fileName);
formData.set('blockLength', blockLength);
formData.set('blockIndex', 0);
formData.set('blockContent', blockContent);
const { response } = await uploadBlock(formData);
複製代碼
請求成功時候的具體處理。拿到請求返回的結果中的fileUrl
和uploadedIndexs
,fileUrl
是文件上傳成功後存放在服務器上的文件的路徑,uploadedIndexs
是已上傳的文件分片的索引。mongodb
fileUrl
存在,說明這個文件已經被上傳過了,直接把進度條的值改成100
就能夠了。不然就根據已上傳的分片索引uploadedIndexs
獲得未上傳的分片的索引,調用uploadBlock
依次將文件分片上傳。
if (response.code === 1) { // 請求成功
const { data } = response;
const { fileUrl, uploadedIndexs } = data;
if (fileUrl) { // 文件已經上傳完成過
setProgress(100);
} else {
let uploadedIndexsArr = Object.keys(uploadedIndexs); // 已上傳的分片索引
let allIndexs = Array.from({ length: blockLength }, (item, index) => `${index}`); // 全部分片的索引數組
let notUploadedIndexsArr = allIndexs.filter((item) => uploadedIndexsArr.indexOf(item) === -1); // 沒有上傳的分片的索引
let notUploadedIndexsArrLength = notUploadedIndexsArr.length;
for (let i = 0; i < notUploadedIndexsArrLength; i++) {
let item = notUploadedIndexsArr[i];
start = item * blockSize;
end = (item + 1) * blockSize;
end = end > fileSize ? fileSize : end;
let blockContent = selectedFile.slice(start, end);
formData.set('blockIndex', item);
formData.set('blockContent', blockContent);
const { response } = await uploadBlock(formData);
const { data } = response;
const { fileUrl, uploadedIndexs } = data;
if (fileUrl) {
setProgress(100);
} else {
let completedPart = Math.ceil((Object.keys(uploadedIndexs).length / blockLength) * 100);
setProgress(completedPart);
}
}
}
}
複製代碼
以上就是前端的主要代碼。
建立一個簡單的Node.js
服務器。由於是練習,因此只建立了一個服務器,請求資源和請求接口都是在這個服務器上。以請求路徑是否以/api
開頭來簡單地判斷是請求接口仍是請求資源。
建立一個MongoClient
實例:dbClient
,調用dbClient
的connect
方法鏈接mongoDB
數據庫。使用dbClient.db(dbName)
鏈接數據庫,mongoDB
是數據庫對象。經過const collection = mongoDB.collection('documents');
鏈接某個集合,以上的documents
是集合名稱。經過collection
作增刪改查的操做,好比向數據庫中增長一條數據使用的是collection.insertOne
。
使用http.createServer()
建立一個服務器,並監聽它的request
事件。當服務器收到請求的時候,回調函數中的代碼就會執行。
let mongoDB = null; // 聲明一個表明數據庫的變量
const MongoClient = require('mongodb').MongoClient;
const assert = require('assert');
const dbUrl = 'mongodb://127.0.0.1:27017';
const dbName = 'practice';
const dbClient = new MongoClient(dbUrl, {useNewUrlParser: true, useUnifiedTopology: true});
const serverPort = 8080;
const server = http.createServer();
server.on('request', (req, res) => {
const { url: requestUrl } = req;
let parsedUrl = url.parse(requestUrl);
let pathName = parsedUrl.pathname;
if (/^\/api/.test(pathName)) { // 以/api開頭的表示接口請求
fileUploadRequest(req, res, parsedUrl);
} else { // 不然是資源請求
requestResource(req, res, parsedUrl)
.catch(err => {
handleError(JSON.stringify(err), res)
});
}
});
server.listen(serverPort, () => {
console.log(`server is running at http://127.0.0.1:${serverPort}`);
// 鏈接數據庫
dbClient.connect((err) => {
assert.equal(null, err);
mongoDB = dbClient.db(dbName);
console.log('mongodb connected successfully');
});
});
複製代碼
如下是對請求資源的處理,根據請求路徑,到指定路徑下讀取文件,文件讀取完成後將文件內容展現到瀏覽器中。
// 請求資源的處理方式
async function requestResource (req, res, parsedUrl) {
let pathname = parsedUrl.pathname;
let filePath = pathname.substr(1);
filePath = filePath === '' ? 'index.html' : filePath;
let suffix = path.extname(filePath).substr(1);
let fileData = await readFile(filePath);
res.writeHead(200, {'Content-Type': `text/${suffix}`});
res.write(fileData.toString());
res.end();
}
複製代碼
以上代碼中的readFile
是封裝後的方法。文件讀寫,數據庫讀寫等異步操做,都須要進行以下這樣簡單的封裝,這樣就能使用async/await
了。
// 讀取文件
function readFile (path) {
return new Promise((resolve, reject) => {
fs.readFile(path, (err, data) => {
if (err) {
reject();
} else {
resolve(data);
}
});
});
}
複製代碼
當Cotent-Type
爲 multipart/form-data
的請求,消息正文是以二進制數據的方式傳遞的。經過監聽data
和end
事件,拿到消息正文。
// 文件上傳請求的處理方式
function fileUploadRequest (req, res) {
req.on('error', (err) => {
handleError(err.message, res);
});
let body = [];
req.on('data', (chunk) => {
body.push(chunk);
});
req.on('end', () => {
body = Buffer.concat(body);
let formattedData = formatData(body);
storeFile(formattedData, res);
});
}
複製代碼
formatData
將獲得的二進制數據整理爲一個對象。消息正文的數據格式以下圖,其中fileContent
部分是文件的二進制數據。具體的實現方式能夠查看源碼中的formatData.js
文件。
storeFile
將文件的分片數據存儲爲單個文件。
storeFile
按照上文的流程圖對請求信息做出處理。主要部分是當文件不是首次上傳的時候的處理。當文件分片所有上傳完畢的時候,將分片整合爲一個文件,並刪除分片的文件。
writeFile
是和上文的readFile
相似的封裝的方法,用於將分片數據寫成一個文件。經過判斷存在數據庫中的uploadedIndexs
對象的全部關鍵字數量是否爲所有分片的數量來判斷分片是否上傳完畢。分片上傳完畢以後將全部分片合併爲一個文件,而後刪除全部的分片文件,更新對應的數據上fileUrl
的值。
let { fileUrl, blockLength, uploadedIndexs, uploadedFilesUrls } = result;
if (fileUrl || uploadedIndexs[blockIndex]) { // 文件已經上傳完成過或者當前分片已經上傳過
handleSuccess(result, res);
} else {
let path = `./upload/${fileName}.${blockIndex}`;
let blockUrl = await writeFile(path, blockContent, false);
uploadedFilesUrls[blockIndex] = blockUrl;
uploadedIndexs[blockIndex] = true;
let blocksUploadCompleted = Object.keys(uploadedIndexs).length === blockLength;
if (blocksUploadCompleted) { // 分塊上傳完畢,將全部分片合成爲一個文件,並刪除分塊文件
let blockFileUrls = uploadedFilesUrls.slice(0); // 複製分片文件路徑的數組
let path = `./upload/${fileName}`;
let uploadedFileUrl = await combineBlocksIntoFile(uploadedFilesUrls, path);
storageData.fileUrl = uploadedFileUrl;
await updateData(collection, { fileId }, storageData);
blockFileUrls.forEach((item) => { // 刪除分片文件
fs.unlink(item, (err) => {
if (err) throw err;
});
});
handleSuccess(storageData, res);
} else {
storageData.uploadedFilesUrls = uploadedFilesUrls;
storageData.uploadedIndexs = uploadedIndexs;
await updateData(collection, { fileId }, storageData);
handleSuccess(storageData, res);
}
}
複製代碼
combineBlocksIntoFile
是合併文件的方法。
// 將分片文件合併成一個文件
function combineBlocksIntoFile (uploadedFilesUrls, fileUrl) {
return new Promise((resolve) => {
let writeStream = fs.createWriteStream(fileUrl); // 在fileUrl的位置建立一個可寫流
let readStream; // 定義一個可讀流
combineFile();
function combineFile () {
if (!uploadedFilesUrls.length) { // 分片已經合併完畢
writeStream.end(); // 在這裏結束寫入
let uploadedFileUrl = getAbsolutePath(fileUrl);
resolve(uploadedFileUrl);
} else {
let currentBlockUrl = uploadedFilesUrls.shift();
readStream = fs.createReadStream(currentBlockUrl);
// 將可讀流的數據放在可寫流中,第二個參數的設置表示當讀取完成後不結束寫入,由於須要從多個文件讀取
readStream.pipe(writeStream, { end: false });
readStream.on('end', () => {
combineFile(); // 在當前分片文件讀取完成後從新調用函數
});
}
}
});
}
複製代碼
遇到的問題:
那是一個漆黑的夜晚,爲了遍歷未上傳的分片的索引,並將索引對應的分片上傳,寫出瞭如下代碼:
notUploadedIndexsArr.forEach(async (item) => {
...
const { response } = await uploadBlock(formData);
});
複製代碼
將forEach
的回調函數定義爲async
函數。預想是並行發送全部的分片請求,減小用戶的等待時間。實際上請求發送的間隔的確爲幾毫秒,可是分片越多,請求的等待時間就越長。我使用500M
每片來劃分的時候還好,使用50M
每片的時候,直接把瀏覽器都整卡住了。
猜測這是由於本文使用Node.js
搭建的服務器是單線程的,而文件的讀取操做是異步的,每一個分片上傳的時候,我都須要將分片存儲爲一個文件。拿一個劃分紅了200個分片的文件來講,這就至關於服務器同時(幾毫秒的差別)收到了200個請求,都須要作一些處理,而後將分塊數據寫爲一個文件,由於文件讀寫操做是異步的,因此fs
模塊須要一會兒將200個請求中拿到的數據寫成200個文件,就像一次搬1塊磚和一次搬200塊磚的差異。
解決方法: 使用for
循環,一片一片地上傳文件。
一開始是將全部文件合併爲一個完整的數據以後,寫成一個文件,可是由於Node.js
中的Buffer
不能容納超過 (2^31)-1
約爲2GB的數據,報錯了。
解決方法:使用了Stream
來實現文件合併。
遺留的問題:
生成的hash
值並不能惟一表明該文件,由於要生成文件的hash
值,須要將文件先轉換爲ArrayBuffer
格式的數據,而當文件很大時,這個過程比較耗時,因此我直接是用文件的一些信息簡單組合成字符串後,經過這個字符串生成的hash
值。如下爲將文件內容轉換爲hash
值的方式:
function changeSelectedFile (event) {
let fileUploadElement = event.target;
let selectedFile = fileUploadElement.files[0];
let reader = new FileReader();
reader.readAsArrayBuffer(selectedFile); // 將文件(Blob數據)轉換爲ArrayBuffer
reader.addEventListener('load', (event) => {
let fileArrayBuffer = event.target.result;
let shaObj = new jsSHA('SHA-512', 'ARRAYBUFFER'); // 建立一個使用SHA-512算法,輸入的數據類型爲ARRAYBUFFER的jsSHA實例
shaObj.update(fileArrayBuffer); // 傳入要轉換的數據
globalData.selectedFileHash = shaObj.getHash('HEX'); // 獲得的hash值,輸出類型爲HEX
globalData.selectedFile = selectedFile;
});
}
複製代碼
還有不少狀況沒有處理。好比說特定的錯誤沒有返回特定狀態碼,直接統一返回500
了。
ps: 本文的練習使用的谷歌瀏覽器版本爲78.0.3904.70,是當前的最新版,支持async/await
;使用的node
版本爲v10.16.0,也支持async/await
。
源碼地址。