node+js實現(xiàn)分片下載

如果下載一個G級文件翘单,通過一次請求去下載容易造成內(nèi)存泄露系谐,因此可以把文件分割成幾段返回給前端端,由前端拿到所有片段后合并成一個完整的文件踢匣。
Range: bytes=開始字節(jié)-結束字節(jié);
Content-Range: bytes 開始字節(jié)-結束字節(jié)/文件總字節(jié)數(shù)

1. 原理

參考的這個:前端多線程大文件下載實踐
Range更多介紹童社,超全
利用HTTP/1.1提供的range字段求厕,在前端請求后端時,請求頭中攜帶Range扰楼,后端獲取該字段就可以知道當前要下載哪段文件呀癣。

圖1-1

圖1-2

圖1-3

圖1-4

2. 服務端實現(xiàn)

function createFileResHeader(fileName, size) {
    return {
      // 告訴瀏覽器這是一個需要以附件形式下載的文件(瀏覽器下載的默認行為,前端可以從這個響應頭中獲取文件名:前端使用ajax請求下載的時候弦赖,后端若返回文件流项栏,此時前端必須要設置文件名-主要是為了獲取文件后綴,否則前端會默認為txt文件)
      'Content-Disposition': 'attachment; filename=' + encodeURIComponent(fileName),
      // 告訴瀏覽器是二進制文件蹬竖,不要直接顯示內(nèi)容
      'Content-Type': 'application/octet-stream',
      // 下載文件大姓由颉(HEAD請求時,主要獲取Content-Length)
      'Content-Length': size,
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Headers': 'X-Requested-With',
      'Access-Control-Allow-Methods': 'PUT,POST,GET,DELETE,OPTIONS',
      //如果不暴露header案腺,那就Refused to get unsafe header "Content-Disposition"
      "Access-Control-Expose-Headers":'Content-Disposition'
    }
  }
  // 大文件下載 - 分片下載  (head請求不會返回響應體)
  app.get('/slice/download', async (req, res) => {
    // 獲取文件路徑
    const fileName = req.query.name;
    let filePath = path.join(__dirname,'../public/upload/' + fileName);
    // 1庆冕、 判斷文件是否存在
    try {
      fs.accessSync(filePath);
    } catch (error) {
      res.send({
        status: 201,
        message: '下載的文件資源不存在'
      });
    }
    try {
      // 獲取文件大小
      const size = fs.statSync(filePath).size;
      const range = req.headers['range'];
      const {start, end} = getRange(range);
      if (!range) {
        // 2康吵、 head請求同時請求頭中不帶range字段劈榨,返回文件大小,前端根據(jù)文件大小去決定要分成幾段
        res.writeHead(200, Object.assign({'Accept-Ranges': 'bytes'}, createFileResHeader(fileName, size)));
      } else {
        const resHeaderParams = {};
        // 3晦嵌、檢查請求范圍
        if (start >= size || end >= size) {
          res.status = 416;
          resHeaderParams['Content-Range'] = `bytes */${size}`;
        } else {
          // 4同辣、返回206:客戶端表明自己只需要目標URL上的部分資源的時候返回的
          res.status = 206;
          resHeaderParams['Content-Range'] = `bytes ${start}-${end ? end : size - 1}/${size}`;
        }
        /**
         * 這里不能使用res.writeHead前端會報: xxx.net::ERR_CONTENT_LENGTH_MISMATCH 206 (Partial Content)(一個請求的時候正常,多個并發(fā)請求的時候就會報這個惭载,原因暫時未知)
         * res.writeHead 和res.setHeader 啥區(qū)別旱函,官網(wǎng)沒有給出明確說明,https://blog.csdn.net/qq_45515863/article/details/103213937
         */
        // res.writeHead(res.status, Object.assign({'Accept-Ranges': 'bytes'}, createFileResHeader(fileName, size), resHeaderParams), 200);
        res.statusCode = 206;
        res.setHeader("Accept-Ranges", "bytes");
        res.setHeader("Content-Range", `bytes ${start}-${end ? end : size - 1}/${size}`);
        /* res.setHeader("Content-Disposition", 'attachment; filename=' + encodeURIComponent(fileName));
        res.setHeader("Content-Type", "application/octet-stream"); */
      }
      // 5描滔、返回部分文件
      fs.createReadStream(filePath, {start, end}).pipe(res);
    } catch (err) {
      res.send({
        status: 201,
        message: err
      })
      return;
    }
  });
  • 客戶端第一次請求時使用head請求(head請求不會返回響應體)同時請求頭中不帶range字段棒妨,服務端返回文件大小,客戶端根據(jù)文件大小去決定要分成幾段含长。
  • range范圍不合法券腔,返回416伏穆。


    圖2-1
  • range范圍合法,返回206纷纫。
    圖2-2

    http狀態(tài)碼
  • res.writeHead
    node.js中res.writeHead的用法總結

3. 客戶端實現(xiàn)

<body>
  <button onclick="ajaxEvt('head', requestUrl, null, downLoadAjaxEvt)">大文件下載(分片下載)</button>
  <script>
    const requestUrl = 'http://192.168.66.183:13666/slice/download?name=DOC.zip';
    function downloadEvt(url, fileName = '未知文件') {
      const el = document.createElement('a');
      el.style.display = 'none';
      el.setAttribute('target', '_blank');
      /**
       * download的屬性是HTML5新增的屬性
       * href屬性的地址必須是非跨域的地址枕扫,如果引用的是第三方的網(wǎng)站或者說是前后端分離的項目(調(diào)用后臺的接口),這時download就會不起作用辱魁。
       * 此時烟瞧,如果是下載瀏覽器無法解析的文件,例如.exe,.xlsx..那么瀏覽器會自動下載染簇,但是如果使用瀏覽器可以解析的文件参滴,比如.txt,.png,.pdf....瀏覽器就會采取預覽模式
       * 所以,對于.txt,.png,.pdf等的預覽功能我們就可以直接不設置download屬性(前提是后端響應頭的Content-Type: application/octet-stream剖笙,如果為application/pdf瀏覽器則會判斷文件為 pdf 卵洗,自動執(zhí)行預覽的策略)
       */
      fileName && el.setAttribute('download', fileName);
      el.href = url;
      console.log(el);
      document.body.appendChild(el);
      el.click();
      document.body.removeChild(el);
    };

    // 根據(jù)header里的contenteType轉(zhuǎn)換請求參數(shù)
    function transformRequestData(contentType, requestData) {
      requestData = requestData || {};
      if (contentType.includes('application/x-www-form-urlencoded')) {
        // formData格式:key1=value1&key2=value2,方式二:qs.stringify(requestData, {arrayFormat: 'brackets'}) -- {arrayFormat: 'brackets'}是對于數(shù)組參數(shù)的處理
        let str = '';
        for (const key in requestData) {
          if (Object.prototype.hasOwnProperty.call(requestData, key)) {
            str += `${key}=${requestData[key]}&`;
          }
        }
        return encodeURI(str.slice(0, str.length - 1));
      } else if (contentType.includes('multipart/form-data')) {
        const formData = new FormData();
        for (const key in requestData) {
          const files = requestData[key];
          // 判斷是否是文件流
          const isFile = files ? files.constructor === FileList || (files.constructor === Array && files[0].constructor === File) : false;
          if (isFile) {
            for (let i = 0; i < files.length; i++) {
              formData.append(key, files[i]);
            }
          } else {
            formData.append(key, files);
          }
        }
        return formData;
      }
      // json字符串{key: value}
      return Object.keys(requestData).length ? JSON.stringify(requestData) : '';
    }

    function ajaxEvt(method = 'get', url, params = null, cb, config = {}) {
      const _method = method.toUpperCase();
      const _config = Object.assign({
        contentType: ['POST', 'PUT'].includes(_method) ? 'application/x-www-form-urlencoded' : 'application/json;charset=utf-8',  // 請求頭類型
        async: true,                                               // 請求是否異步-true異步弥咪、false同步
        token: 'token',                                             // 用戶token
        range: '',
        responseType: ''
      }, config);
      const ajax = new XMLHttpRequest();

      const queryParams = transformRequestData(_config.contentType, params);
      const _url = `${url}${_method === 'GET' && queryParams ? '?' + queryParams : ''}`;

      ajax.open(_method, _url, _config.async);
      ajax.setRequestHeader('Authorization', _config.token);
      ajax.setRequestHeader('Content-Type', _config.contentType);
      _config.range && ajax.setRequestHeader('Range', _config.range);
      // responseType若不設置过蹂,會導致下載的文件可能打不開
      _config.responseType && (ajax.responseType = _config.responseType);
      // 獲取文件下載進度
      ajax.addEventListener('progress', (progress) => {
        const percentage = ((progress.loaded / progress.total) * 100).toFixed(2);
        const msg = `下載進度 ${percentage}%...`;
        console.log(msg);
      });
      // 如果前端報“xxx.net::ERR_CONTENT_LENGTH_MISMATCH 206 (Partial Content)”,可以考慮是否是后端的header設置不對(ajax.readyState=4 & ajax.status=0)
      ajax.onload = function () {
        // this指向ajax
        (typeof cb === 'function') && cb(this);
      };
      // send(string): string:僅用于 POST 請求
      ajax.send(queryParams);
    }

    function arrayBufferEvt(response, i, resolve) {
      response.response.arrayBuffer().then(result => {
        resolve({i, buffer: result});
      });
    }
    // 合并buffer
    function concatBuffer(list) {
      let totalLength = 0;
      for (let item of list) {
        totalLength += item.length;
      }
      // 實際上Uint8Array目前只能支持9位聚至,也就是合并最大953M(999999999字節(jié))的文件
      let result = new Uint8Array(totalLength);
      let offset = 0;
      for (let item of list) {
        result.set(item, offset);
        offset += item.length;
      }
      return result;
    }
    /**
     * ajax實現(xiàn)文件下載酷勺、獲取文件下載進度
     * @param {String} method - 請求方法get/post
     * @param {String} url
     * @param {Object} [params] - 請求參數(shù),{name: '文件下載'}
     * @param {Object} [config] - 方法配置
     */
    function downLoadAjaxEvt(ajaxResponse) {
      const fileSize = ajaxResponse.getResponseHeader('Content-Length') * 1;
      // 兩種解碼方式扳躬,區(qū)別自行百度: decodeURIComponent/decodeURI(主要獲取后綴名脆诉,否則某些瀏覽器會一律識別為txt,導致下載下來的都是txt)
      const fileName = decodeURIComponent((ajaxResponse.getResponseHeader('content-disposition') || '; filename="未知文件"').split(';')[1].trim().slice(9));

      // 5M為一片  瀏覽器并發(fā)請求一般6個
      const spliceSize = Math.ceil(fileSize / 6);
      const length = Math.ceil(fileSize / spliceSize);
      console.log('返回', length);
      const reqList = [];
      for (let i = 0; i < length; i++) {
        let start = i * spliceSize;
        let end = (i === length - 1) ?  fileSize - 1  : (i + 1) * spliceSize - 1;
        reqList.push(new Promise((resolve, reject) => {
          ajaxEvt('get', `${requestUrl}&time=${Date.now()+i}`, null, (response) => arrayBufferEvt(response, i, resolve), {responseType: 'blob', range: `bytes=${start}-${end}`})
        }));
      }
      Promise.all(reqList).then(res => {
        sortList(res);
        const arrBufferList = res.map(item => new Uint8Array(item.buffer));
        const allBuffer = concatBuffer(arrBufferList);
        const blob = new Blob([allBuffer]);
        const href = URL.createObjectURL(blob);
        downloadEvt(href, fileName);
        // 釋放一個之前已經(jīng)存在的贷币、通過調(diào)用 URL.createObjectURL() 創(chuàng)建的 URL 對象
        URL.revokeObjectURL(href);
      })
    }

    // 數(shù)組排序
    function sortList(_list) {
      const length = _list.length;
      for(let i = 0; i < length - 1; i++) {
        for(let j = i + 1; j < length; j++) {
          if (_list[i].i > _list[j].i) {
            let temp = null;
            temp = _list[j];
            _list[j] = _list[i];
            _list[i] = temp;
          }
        }
      }
    }
  </script>
</body>
  • 瀏覽器并發(fā)數(shù)一般6個


    圖3-1

    圖3-2

    圖3-3

遺留問題

  1. 953M以上的文件使用Uint8Array合并buffer報Invalid typed array length
  2. 大文件上傳WebUploader工具

參考文章

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市役纹,隨后出現(xiàn)的幾起案子偶摔,更是在濱河造成了極大的恐慌,老刑警劉巖促脉,帶你破解...
    沈念sama閱讀 219,188評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件辰斋,死亡現(xiàn)場離奇詭異,居然都是意外死亡瘸味,警方通過查閱死者的電腦和手機宫仗,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,464評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來旁仿,“玉大人藕夫,你說我怎么就攤上這事。” “怎么了毅贮?”我有些...
    開封第一講書人閱讀 165,562評論 0 356
  • 文/不壞的土叔 我叫張陵梭姓,是天一觀的道長。 經(jīng)常有香客問我嫩码,道長誉尖,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,893評論 1 295
  • 正文 為了忘掉前任铸题,我火速辦了婚禮铡恕,結果婚禮上,老公的妹妹穿的比我還像新娘丢间。我一直安慰自己探熔,他們只是感情好,可當我...
    茶點故事閱讀 67,917評論 6 392
  • 文/花漫 我一把揭開白布烘挫。 她就那樣靜靜地躺著诀艰,像睡著了一般。 火紅的嫁衣襯著肌膚如雪饮六。 梳的紋絲不亂的頭發(fā)上其垄,一...
    開封第一講書人閱讀 51,708評論 1 305
  • 那天,我揣著相機與錄音卤橄,去河邊找鬼绿满。 笑死,一個胖子當著我的面吹牛窟扑,可吹牛的內(nèi)容都是我干的喇颁。 我是一名探鬼主播,決...
    沈念sama閱讀 40,430評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼嚎货,長吁一口氣:“原來是場噩夢啊……” “哼橘霎!你這毒婦竟也來了?” 一聲冷哼從身側響起殖属,我...
    開封第一講書人閱讀 39,342評論 0 276
  • 序言:老撾萬榮一對情侶失蹤姐叁,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后忱辅,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體七蜘,經(jīng)...
    沈念sama閱讀 45,801評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡谭溉,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,976評論 3 337
  • 正文 我和宋清朗相戀三年墙懂,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片扮念。...
    茶點故事閱讀 40,115評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡损搬,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情巧勤,我是刑警寧澤嵌灰,帶...
    沈念sama閱讀 35,804評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站颅悉,受9級特大地震影響沽瞭,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜剩瓶,卻給世界環(huán)境...
    茶點故事閱讀 41,458評論 3 331
  • 文/蒙蒙 一驹溃、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧延曙,春花似錦豌鹤、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,008評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至愿卸,卻和暖如春灵临,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背趴荸。 一陣腳步聲響...
    開封第一講書人閱讀 33,135評論 1 272
  • 我被黑心中介騙來泰國打工俱诸, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人赊舶。 一個月前我還...
    沈念sama閱讀 48,365評論 3 373
  • 正文 我出身青樓睁搭,卻偏偏與公主長得像,于是被迫代替她去往敵國和親笼平。 傳聞我的和親對象是個殘疾皇子园骆,可洞房花燭夜當晚...
    茶點故事閱讀 45,055評論 2 355

推薦閱讀更多精彩內(nèi)容