web前端开发

node+js实现分片下载

2021-10-15  本文已影响0人  YiYaYiYaHei

如果下载一个G级文件,通过一次请求去下载容易造成内存泄露,因此可以把文件分割成几段返回给前端端,由前端拿到所有片段后合并成一个完整的文件。
Range: bytes=开始字节-结束字节;
Content-Range: bytes 开始字节-结束字节/文件总字节数

1. 原理

参考的这个:前端多线程大文件下载实践
Range更多介绍,超全
利用HTTP/1.1提供的range字段,在前端请求后端时,请求头中携带Range,后端获取该字段就可以知道当前要下载哪段文件。

图1-1
图1-2
图1-3
图1-4

2. 服务端实现

function createFileResHeader(fileName, size) {
    return {
      // 告诉浏览器这是一个需要以附件形式下载的文件(浏览器下载的默认行为,前端可以从这个响应头中获取文件名:前端使用ajax请求下载的时候,后端若返回文件流,此时前端必须要设置文件名-主要是为了获取文件后缀,否则前端会默认为txt文件)
      'Content-Disposition': 'attachment; filename=' + encodeURIComponent(fileName),
      // 告诉浏览器是二进制文件,不要直接显示内容
      '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字段,返回文件大小,前端根据文件大小去决定要分成几段
        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)(一个请求的时候正常,多个并发请求的时候就会报这个,原因暂时未知)
         * res.writeHead 和res.setHeader 啥区别,官网没有给出明确说明,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;
    }
  });

3. 客户端实现

<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属性的地址必须是非跨域的地址,如果引用的是第三方的网站或者说是前后端分离的项目(调用后台的接口),这时download就会不起作用。
       * 此时,如果是下载浏览器无法解析的文件,例如.exe,.xlsx..那么浏览器会自动下载,但是如果使用浏览器可以解析的文件,比如.txt,.png,.pdf....浏览器就会采取预览模式
       * 所以,对于.txt,.png,.pdf等的预览功能我们就可以直接不设置download属性(前提是后端响应头的Content-Type: application/octet-stream,如果为application/pdf浏览器则会判断文件为 pdf ,自动执行预览的策略)
       */
      fileName && el.setAttribute('download', fileName);
      el.href = url;
      console.log(el);
      document.body.appendChild(el);
      el.click();
      document.body.removeChild(el);
    };

    // 根据header里的contenteType转换请求参数
    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'}是对于数组参数的处理
        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字节)的文件
      let result = new Uint8Array(totalLength);
      let offset = 0;
      for (let item of list) {
        result.set(item, offset);
        offset += item.length;
      }
      return result;
    }
    /**
     * ajax实现文件下载、获取文件下载进度
     * @param {String} method - 请求方法get/post
     * @param {String} url
     * @param {Object} [params] - 请求参数,{name: '文件下载'}
     * @param {Object} [config] - 方法配置
     */
    function downLoadAjaxEvt(ajaxResponse) {
      const fileSize = ajaxResponse.getResponseHeader('Content-Length') * 1;
      // 两种解码方式,区别自行百度: decodeURIComponent/decodeURI(主要获取后缀名,否则某些浏览器会一律识别为txt,导致下载下来的都是txt)
      const fileName = decodeURIComponent((ajaxResponse.getResponseHeader('content-disposition') || '; filename="未知文件"').split(';')[1].trim().slice(9));

      // 5M为一片  浏览器并发请求一般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);
        // 释放一个之前已经存在的、通过调用 URL.createObjectURL() 创建的 URL 对象
        URL.revokeObjectURL(href);
      })
    }

    // 数组排序
    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>

遗留问题

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

参考文章

上一篇下一篇

猜你喜欢

热点阅读