美文网首页web前端开发
node+js实现分片下载

node+js实现分片下载

作者: YiYaYiYaHei | 来源:发表于2021-10-15 16:51 被阅读0次

    如果下载一个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;
        }
      });
    
    • 客户端第一次请求时使用head请求(head请求不会返回响应体)同时请求头中不带range字段,服务端返回文件大小,客户端根据文件大小去决定要分成几段。
    • range范围不合法,返回416。


      图2-1
    • range范围合法,返回206。
      图2-2
      http状态码
    • res.writeHead
      node.js中res.writeHead的用法总结

    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>
    
    • 浏览器并发数一般6个


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

    遗留问题

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

    参考文章

    相关文章

      网友评论

        本文标题:node+js实现分片下载

        本文链接:https://www.haomeiwen.com/subject/nrymoltx.html