用JavaScript和Node.js實現文件分片上傳

本文使用node.js實現文件分片上傳,沒有使用node.js的框架。前端使用javascript實現,也沒有使用框架。這裏用到了mongoDB數據庫。(本文代碼練習用,非項目)javascript

準備工做

  1. 安裝好node和mongoDB。
  2. 使用npm init -y初始化項目。
  3. 使用npm install mongodb —save安裝上mongodb包。

實現思路

主要的思路就是將文件切片後,分片上傳,後端將全部的分片都接收完成後,合併爲一個完整的文件。經過<progress>展現上傳的進度,當點擊暫停的時候,停止全部未完成的請求,當點擊繼續上傳的時候,從新發起請求。html

畫了一張流程圖,以下: 前端

前端:當點擊上傳文件的按鈕的時候,會發起第一個請求,後續的處理須要等待第一個請求完成。由於第一個請求的響應結果中會包含當前文件的上傳狀況,根據文件上傳的狀況不一樣作出相應的處理。java

後端:文件的每一個分片與其索引值對應。上傳到後端後,索引爲1的分片存放爲文件以後,uploadedFilesUrls數組索引值爲1的位置存放的就是已上傳的分片文件存放在服務器的路徑。當全部分片上傳完畢以後,按索引的順序將分片文件合併爲一個整的文件。node

前端實現

  1. 經過輸入框選入文件的時候,獲得選中的文件,並取文件的信息組成一個字符串,經過這個字符串生成一個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值
    }
    複製代碼
  2. 封裝一個請求的方法,用於發起請求,上傳文件的分片信息。這裏使用了fetch來發起請求,是爲了使用async/await以及使用停止控制器AbortControllergithub

    // 將文件的分片上傳
    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() // 停止控制器的實例
    }
    複製代碼
  3. 將第一個文件分片整理好後,發起上傳請求。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);
    複製代碼
  4. 請求成功時候的具體處理。拿到請求返回的結果中的fileUrluploadedIndexsfileUrl是文件上傳成功後存放在服務器上的文件的路徑,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);
          }
        }
      }
    }
    複製代碼

    以上就是前端的主要代碼。

後端實現

  1. 建立一個簡單的Node.js服務器。由於是練習,因此只建立了一個服務器,請求資源和請求接口都是在這個服務器上。以請求路徑是否以/api開頭來簡單地判斷是請求接口仍是請求資源。

    建立一個MongoClient實例:dbClient,調用dbClientconnect方法鏈接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');
      });
    });
    複製代碼
  2. 如下是對請求資源的處理,根據請求路徑,到指定路徑下讀取文件,文件讀取完成後將文件內容展現到瀏覽器中。

    // 請求資源的處理方式
    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); 
          }
        });
      });
    }
    複製代碼
  3. Cotent-Typemultipart/form-data的請求,消息正文是以二進制數據的方式傳遞的。經過監聽dataend事件,拿到消息正文。

    // 文件上傳請求的處理方式
    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將文件的分片數據存儲爲單個文件。

  4. 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);
      }
    }
    複製代碼
  5. 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(); // 在當前分片文件讀取完成後從新調用函數
            });
          }
        }
      });
    }
    複製代碼

問題

遇到的問題:

  1. 那是一個漆黑的夜晚,爲了遍歷未上傳的分片的索引,並將索引對應的分片上傳,寫出瞭如下代碼:

    notUploadedIndexsArr.forEach(async (item) => {
        ...
        const { response } = await uploadBlock(formData);
      });
    複製代碼

    forEach的回調函數定義爲async函數。預想是並行發送全部的分片請求,減小用戶的等待時間。實際上請求發送的間隔的確爲幾毫秒,可是分片越多,請求的等待時間就越長。我使用500M每片來劃分的時候還好,使用50M每片的時候,直接把瀏覽器都整卡住了。

    猜測這是由於本文使用Node.js搭建的服務器是單線程的,而文件的讀取操做是異步的,每一個分片上傳的時候,我都須要將分片存儲爲一個文件。拿一個劃分紅了200個分片的文件來講,這就至關於服務器同時(幾毫秒的差別)收到了200個請求,都須要作一些處理,而後將分塊數據寫爲一個文件,由於文件讀寫操做是異步的,因此fs模塊須要一會兒將200個請求中拿到的數據寫成200個文件,就像一次搬1塊磚和一次搬200塊磚的差異。

    解決方法: 使用for循環,一片一片地上傳文件。

  2. 一開始是將全部文件合併爲一個完整的數據以後,寫成一個文件,可是由於Node.js中的Buffer不能容納超過 (2^31)-1約爲2GB的數據,報錯了。

    解決方法:使用了Stream來實現文件合併。

遺留的問題:

  1. 生成的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;
      });
    }
    複製代碼
  2. 還有不少狀況沒有處理。好比說特定的錯誤沒有返回特定狀態碼,直接統一返回500了。

ps: 本文的練習使用的谷歌瀏覽器版本爲78.0.3904.70,是當前的最新版,支持async/await;使用的node版本爲v10.16.0,也支持async/await

源碼地址

參考地址

  1. 使用Fetch
  2. 使用Node.js
  3. 經過Node.js使用MongoDB
  4. POST文件上傳
  5. Node.js合併多個文件
  6. 中斷一個正在發出的請求
相關文章
相關標籤/搜索