大文件并行下载

745 阅读3分钟

1.解决思路

利用head里面的range限制下载范围。把一个大文件,分多次小文件下载,最后进行合并。

HTTP 范围请求

HTTP 协议范围请求允许服务器只发送 HTTP 消息的一部分到客户端。范围请求在传送大的媒体文件,或者与文件下载的断点续传功能搭配使用时非常有用。如果在响应中存在 Accept-Ranges 首部(并且它的值不为 “none”),那么表示该服务器支持范围请求。

在一个 Range 首部中,可以一次性请求多个部分,服务器会以 multipart 文件的形式将其返回。如果服务器返回的是范围响应,需要使用 206 Partial Content 状态码。假如所请求的范围不合法,那么服务器会返回  416 Range Not Satisfiable 状态码,表示客户端错误。服务器允许忽略  Range  首部,从而返回整个文件,状态码用 200 。

Range 语法

Range<unit>=<range-start>-
Range<unit>=<range-start>-<range-end>
Range<unit>=<range-start>-<range-end><range-start>-<range-end><range-start>-<range-end>
  • unit:范围请求所采用的单位,通常是字节(bytes)。
  • <range-start>:一个整数,表示在特定单位下,范围的起始值。
  • <range-end>:一个整数,表示在特定单位下,范围的结束值。这个值是可选的,如果不存在,表示此范围一直延伸到文档结束。

例子:

# 一次请求单个
curl http://i.imgur.com/z4d4kWk.jpg -i -H "Range: bytes=0-1023"
# 一次请求多个
curl http://www.example.com -i -H "Range: bytes=0-50, 100-150"

2.流程

  1. 请求后端,获取文件大小
  2. 根据放回的文件大小,计算文件分片
  3. 使用并发下载分片 (使用asyncpool并发控制)
  4. 分片下载后转出Uint8Array
  5. 执行合并
  6. 利用BolbURL保存图片

3.实现

//下载 主入口函数 url为下载的参数
function multiThreadedDownload(url) {
 if (!url || !/https?/.test(url)) return;
 console.log("多线程下载开始: " + +new Date());
 download({
  url,
  chunkSize: 0.1 * 1024 * 1024,
  poolLimit: 6,
 }).then((buffers) => {
  console.log("多线程下载结束: " + +new Date());
  saveAs({ buffers, name: "我的压缩包", mime: "application/zip" });//6.利用BolbURL保存图片
 });
}


//并发下载分片代码
async function download({ url, chunkSize, poolLimit = 1 }) {
  const contentLength = await getContentLength(url);//1.先获取文件大小
  const chunks = typeof chunkSize === "number" ? Math.ceil(contentLength / chunkSize) : 1;//2.根据放回的文件大小,计算文件分片
  //3.使用并发下载分片
  const results = await asyncPool(
    poolLimit,
    [...new Array(chunks).keys()],
    (i) => {
      let start = i * chunkSize;
      let end = i + 1 == chunks ? contentLength - 1 : (i + 1) * chunkSize - 1;
      return getBinaryContent(url, start, end, i);
    }
  );
  const sortedBuffers = results
    .map((item) => new Uint8Array(item.buffer));//4.分片下载后转出Uint8Array
  return concatenate(sortedBuffers); //5.执行合并
}

//下载后,保存文件
function saveAs({ name, buffers, mime = "application/octet-stream" }) {
  const blob = new Blob([buffers], { type: mime });
  const blobUrl = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.download = name || Math.random();
  a.href = blobUrl;
  a.click();
  URL.revokeObjectURL(blob);//Object URL 是一种伪协议 只能在本地识别
}

//`ArrayBuffer` 对象转换为 `Uint8Array` 对象
function concatenate(arrays) {
  if (!arrays.length) return null;
  let totalLength = arrays.reduce((acc, value) => acc + value.length, 0);
  let result = new Uint8Array(totalLength); //需要先把 `ArrayBuffer` 对象转换为 `Uint8Array` 对象,才能操作
  let length = 0;
  for (let array of arrays) {
    result.set(array, length);
    length += array.length;
  }
  return result;
}

//根据参数 发起有范围的下载请求,一次一个
function getBinaryContent(url, start, end, i) {
  return new Promise((resolve, reject) => {
    try {
      let xhr = new XMLHttpRequest();
      xhr.open("GET", url, true);
      xhr.setRequestHeader("range", `bytes=${start}-${end}`); // 请求头上设置范围请求信息
      xhr.responseType = "arraybuffer"; // 设置返回的类型为arraybuffer  ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区
      xhr.onload = function () {
        resolve({
          index: i, // 文件块的索引
          buffer: xhr.response, // 范围请求对应的数据
        });
      };
      xhr.send();
    } catch (err) {
      reject(new Error(err));
    }
  });
}

//并发控制
async function asyncPool(poolLimit, array, iteratorFn) {
  const ret = []; // 存储所有的异步任务
  const executing = []; // 存储正在执行的异步任务
  for (const item of array) {
    // 调用iteratorFn函数创建异步任务
    const p = Promise.resolve().then(() => iteratorFn(item, array));
    ret.push(p); // 保存新的异步任务

    // 当poolLimit值小于或等于总任务个数时,进行并发控制
    if (poolLimit <= array.length) {
      // 当任务完成后,从正在执行的任务数组中移除已完成的任务
      const e = p.then(() => executing.splice(executing.indexOf(e), 1));
      executing.push(e); // 保存正在执行的异步任务
      if (executing.length >= poolLimit) {
        await Promise.race(executing); // 等待较快的任务执行完成
      }
    }
  }
  return Promise.all(ret);
}

//通过head方式 或者文件大小 Content-Length
function getContentLength(url) {
  return new Promise((resolve, reject) => {
    let xhr = new XMLHttpRequest();
    xhr.open("HEAD", url);
    xhr.send();
    xhr.onload = function () {
      resolve(
        xhr.getResponseHeader("Content-Length") 
      );
    };
    xhr.onerror = reject;
  });
}