造轮子之大文件并行下载、并发数控制、断点续传

2,497 阅读5分钟

使用自己熟悉的技术栈:expressvue2

大文件并行下载,在我的生活里面是要比大文件上传有着更多频次的。就比如经常用下载百度网盘的东西,经常使用其他下载工具。不管是哪个工具吧,都有暂停下载的功能,当然也有恢复下载的功能。

大文件并行下载实现原理:

  1. 首先获取文件大小
  2. 之后根据每次下载大小对文件进行下载区间划分,同时生成对应的下载区间请求
  3. 并发下载,一直到全部下载完毕
  4. 合并下载所有区间文件,之后下载到本地
  5. 断点续传是取消请求,过滤掉已请求完毕的请求,之后重新发起请求

本文以下载.zip文件为示例,实现.zip文件下载。

基本实现

image.png

获取下载文件大小

这里使用一个接口获取文件大小,没有中断的情况下首先获取要下载的文件大小,之后根据大小决定是否进行分片下载。本文主要是针对大文件下载的,所以小文件的直接下载没有做,感兴趣的实现这个逻辑。

    ...
    async startDownloadBigFile() {
      // 前端切片进度展示数据初始化
      this.fileChunkResults = [];
      const {data, message} = await this.getFileContentLength();
      if(!data) {
         return this.$message.warning(message)
      }      
      this.fileSize = data.fileSize
      // 切片数 fileChunkNum  单一切片大小 FileChunkSize
      const fileChunkNum = Math.ceil(this.fileSize / FileChunkSize);
      // 切片请求
      const requestList = this.generateChunkRequest(fileChunkNum);
      this.downloadFile(requestList);
    },
    // 获取文件大小请求
    getFileContentLength() {
      return new Promise((resolve, reject) => {
        axios({
          method: "get",
          url: "http://localhost:3000/file/getFileSize",
          params: {
            fileName: "download.zip",
          },
        })
          .then((res) => {
            resolve(res.data);
          })
          .catch((err) => {
            reject(err);
          });
      });
    }

后端使用path模块和fs-extra库,获取文件路径,判断是否存在路径。使用stat方法获取下载文件大小。

...
const path = require('path');
const fse = require("fs-extra");
const Result = require("./Result");

// 跨域设置
const cors = require('cors')
app.use(cors())

const UPLOAD_DIR = path.resolve(__dirname, ".", "resource");

app.get('/file/getFileSize', async function (req, res, next) {
  const {fileName} = req.query
  const  filePath = path.resolve(UPLOAD_DIR, fileName);
  if (!fse.existsSync(filePath)) {
    res.send(Result.error('请在resource目录下放置.zip文件,改名为download.zip'))
  }

// 通常情况下,我们会认为1KB等于1000字节,1MB等于1000KB,但在计算机中,1KB等于1024字节,1MB等于1024KB。
// 所以,如果磁盘显示的大小为30.9MB,那么实际的字节数应该是30.9 * 1024 * 1024,大约等于32400998字节。
// 但是,有些操作系统或软件在显示存储大小时,可能会使用1000作为转换系数,这就可能导致显示的大小和实际的大小有所不同。这也是为什么你看到的大小是30.9M,但实际的字节数是30858587字节。
// 总的来说,这是一个常见的混淆点,你需要根据具体的情况来判断应该使用哪个转换系数
  const {size} = await fse.stat(filePath)
  res.send(Result.success({
    fileSize: size
  }))
});

其中Result为一个返回类

module.exports = class Result {
    constructor(status, message, data) {
      this.status = status;
      this.message = message;
      this.data = data;
    }
  
    static success(data) {
      return new Result(200, 'Success', data);
    }
  
    static error(message, status = 500) {
      return new Result(status, message, null);
    }
  }

划分下载区间切片生成对应请求

在进行并行请求之前,需要进行文件的切片划分,切片大小可以根据实际情况实际需求进行调整。单个切片以及待下载文件大小已经确定,可以通过二者计算出切片个数。

const FileChunkSize = 3 * 1024 * 1024
// this.fileSize 为文件大小
const fileChunkNum = Math.ceil(this.fileSize / FileChunkSize);

切片个数已经确定接下来生成切片请求,切片请求需要使用HTTP协议的header头告诉后端每次的下载区间,也就是range属性,格式:bytes=${start}-${end}。同时切片还需要其他一些数据,比如用户在前端显示下载进度等,这里添加了切片索引:index,切片大小size和切片下载百分比percentage

   // 切片请求列表 requestList
   const requestList = this.generateChunkRequest(fileChunkNum);
   ...
   generateChunkRequest(fileChunkNum) {
      this.fileChunkResults = [...new Array(fileChunkNum).keys()].map((i) => {
        let start = i * FileChunkSize;
        
        // 注意这里要减去1,因为切片默认从0开始
        let end =
          i + 1 === fileChunkNum
            ? this.fileSize - 1
            : (i + 1) * FileChunkSize - 1;

        return {
          range: `bytes=${start}-${end}`,
          size: end - start,
          index: i,
          buffer: null,
          percentage: 0,
        };
      });

      return this.fileChunkResults
        .map(({ range, index }) =>
          this.getFileBinaryContent(range, index)
        );
    },

生成切片请求的具体方法

 getFileBinaryContent(range, index) {
      return () => {
        return new Promise((resolve, reject) => {
          axios({
            method: "get",
            url: "http://localhost:3000/file/down",
            params: {
              fileName: "download.zip",
            },
            headers: {
              range,
            },
            onDownloadProgress: (progressEvent) => {
              const {loaded, total} = progressEvent;
              let complete = parseInt((loaded / total) * 100);
              // 实时更新切片下载的百分比
              this.fileChunkResults[index].percentage = complete;
            },
            // 注意使用arraybuffer
            responseType: "arraybuffer",
          })
            .then((res) => {
              // 保存下载的数据
              this.fileChunkResults[index].buffer = res.data;
              resolve(res);
            })
            .catch((err) => {
              reject(err);
            });
        });
      };
    },

对应后端下载接口

app.get('/file/down', function (req, res, next) {
  const {fileName} = req.query
  const file = path.resolve(UPLOAD_DIR, fileName);
  fse.stat(file).then(stat => {
    const fileSize = stat.size;
    const range = req.headers.range;

    const parts = range.replace(/bytes=/, "").split("-");
    const start = parseInt(parts[0], 10);
    const end = parts[1] ? parseInt(parts[1], 10) : fileSize-1;

    const chunksize = (end-start)+1;
    const stream = fse.createReadStream(file, {start, end});
    const head = {
        'Content-Range': `bytes ${start}-${end}/${fileSize}`,
        'Accept-Ranges': 'bytes',
        'Content-Length': chunksize,
        'Content-Type': 'application/zip',
    };

    res.writeHead(206, head);
    stream.pipe(res);
  });
});

同时将切片请求数据保存到数组this.fileChunkResults中,用于前端显示。增加用于显示总进度的fakeUploadPercentage。通过每个切片下载进度计算总进度。每个切片加载进度使用axiosonDownloadProgress方法实现实时监听。

<template>
  <div class="big-file-download">
    <el-button @click="startDownloadBigFile" size="small">分片下载</el-button>
    <el-form label-width="100px" label-position="top">
      <el-form-item label="上传总进度:">
        <el-progress :percentage="fakeUploadPercentage"></el-progress>
      </el-form-item>
    </el-form>
    <el-table :data="fileChunkResults" style="width: 100%">
      <el-table-column prop="index" label="chunk index" width="400">
      </el-table-column>
      <el-table-column prop="size" label="size(Mb)" width="180">
        <template slot-scope="{ row }">
          {{ row.size | transformByte }}
        </template>
      </el-table-column>
      <el-table-column min-width="180" prop="percentage" label="percentage">
        <template slot-scope="{ row }">
          <el-progress :percentage="row.percentage"></el-progress>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>
<script>
const FileChunkSize = 3 * 1024 * 1024
export default {
  data() {
    return {
      fileChunkResults: [],
      fileSize: 0,
      fakeUploadPercentage: 0,
    };
  },
  computed: {
    // 通过切片每个下载进度计算总进度
    uploadPercentage() {
      if (!this.fileChunkResults.length) return 0;
      const loaded = this.fileChunkResults
        .map((item) => item.size * item.percentage)
        .reduce((acc, cur) => acc + cur);
      return parseInt((loaded / this.fileSize).toFixed(2));
    },
  },
  filters: {
    transformByte(val) {
      return Number((val / 1024 / 1024).toFixed(0));
    },
  },
  watch: {
    // 监听uploadPercentage得到总进度
    uploadPercentage(now) {
      if (now > this.fakeUploadPercentage) {
        this.fakeUploadPercentage = now;
      }
    },
  }
  ...

并发下载并发数控制

到了这里就开始发起请求了,同时限制发起请求数。也就是并发数控制,并发数控制的原因一方面源于浏览器的限制:谷歌浏览器限制并发数为6个,就算一次全部发出去也需要等待;另外源于一些其他需要,比如后端限制,资源限制等。

当全部请求结束,执行回调函数。对于并发控制这里只是一种实现,我认为并不出彩的一种方案。这个以后可以改进,有其他文章给出了更好的并发控制,可以参考文末文章。

    const LIMIT = 6;
    requestWithLimit(prmiseQueue, callback = null) {
      // 请求数量记录,默认为 0
      let count = 0;
      // 递归调用,请求接口数据
      const run = () => {
        // 接口每调用一次,记录数加 1
        count++;
        const p = prmiseQueue.shift();
        p().then((res) => {
          // 接口调用完成,记录数减 1
          count--;
          if (!prmiseQueue.length && !count) {
            // 这里可以对所有接口返回的数据做处理,以便输出
            callback && callback();
          }
          // prmiseQueue 长度不为 0 且记录小于限制的数量时递归调用
          if (prmiseQueue.length && count < LIMIT) {
            run();
          }
        }).catch((err)=>{
        })
      };

      // 根据 limit 并发调用
      for (let i = 0; i < Math.min(prmiseQueue.length, LIMIT); i++) {
        run();
      }
    },

合并区间切片文件,下载到本地

合并区间切片文件并下载到本地就是全部请求执行完毕要做的事情了,也是并发控制函数的回调函数。为了拼接好切片数据,需要对切片进行先后下载顺序排序。之后采用Uint8Array处理,再之后合并,再之后下载。

      async downloadFile(requestList) {
      // 并发控制函数
      this.requestWithLimit(requestList, () => {
        const sortedBuffers = this.fileChunkResults
          .sort((a, b) => a.index - b.index)
          .map((item) => new Uint8Array(item.buffer));
        const buffers = this.concatenate(sortedBuffers);
        this.saveAs({ buffers, name: "我的压缩包", mime: "application/zip" });
        });
      },
      // 合并方法
      concatenate(arrays) {
        return arrays.reduce((acc, val) => {
        let tmp = new Uint8Array(acc.length + val.length);
        tmp.set(acc, 0);
        tmp.set(val, acc.length);
        return tmp;
      });
    },
    // 下载方法
    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);
    },

断点续传

image.png

中断下载

发起请求的是axios,中断请求也就是中断axios请求。发起请求时axios增加用于取消请求的配置属性cancelToken。保存用于取消请求的函数cancel。每次请求成功删除保存的对应的取消请求数据。

   <script>
    import axios from "axios";
    const CancelToken = axios.CancelToken;
    export default {
    data() {
      this.chunkRequestList = [];
       ...
    },
    ...
    // 取消请求方法
    pauseDownload() {
      this.chunkRequestList.forEach(({ cancel }) => cancel());
      this.chunkRequestList = [];
    },
    getFileBinaryContent(range, index) {
      return () => {
        return new Promise((resolve, reject) => {
          axios({
            method: "get",
            url: "http://localhost:3000/file/down",
            params: {
              fileName: "download.zip",
            },
            headers: {
              range,
            },
            // 监听切片下载进度变化
            onDownloadProgress: (progressEvent) => {
              const {loaded, total} = progressEvent;
              let complete = parseInt((loaded / total) * 100);

              this.fileChunkResults[index].percentage = complete;
            },
            // 用于取消请求
            cancelToken: new CancelToken((cancel) => {
              // 保存取消请求数据
              this.chunkRequestList.push({
                cancelIndex: index,
                cancel,
              });
            }),
            responseType: "arraybuffer",
          })
            .then((res) => {
              this.fileChunkResults[index].buffer = res.data;
              // 去除请求
              if (this.chunkRequestList) {
                const curIndex = this.chunkRequestList.findIndex(
                  ({ cancelIndex }) => cancelIndex === index
                );
                this.chunkRequestList.splice(curIndex, 1);
              }
              resolve(res);
            })
            .catch((err) => {
              reject(err);
            });
        });
      };
    },
    ...

恢复下载

取消之后需要恢复。这块本来我是想实现切片层级的断点续传的:也就是记录切片下载的长度,通过range,之后发起请求时候仅发起未下载部分的请求。但是遇到一个问题,就是中断请求时,因为请求没有结束,所以不会走promisethen逻辑,也就没法保存已经下载的arraybuffer数据,就没法真正实现切片层级的断点续传,或许有解决的办法吧,暂时没找到。或许可以考虑将切片单元变得允许范围最小,这样应该会好些。

所以我实现的断点续传只是切片下载完成之后,不再下载该部分切片,但是没下载完毕的还需要重新下载。好吧,我暂时也没解决。

恢复下载,需要过滤掉百分比为100的切片,也就是过滤这部分请求即可。

export default {
  data() {
    return {
     
    };
  },
  ...
   // 恢复下载 过滤掉已经下载完毕的请求
   resumeDownload() {
      const requestList = this.fileChunkResults.filter(
        ({ percentage }) => percentage !== 100
      ).map(({ range, index }) =>
        this.getFileBinaryContent(range, index)
      );
      this.fileSize > 0 && this.downloadFile(requestList);
   },
  getFileBinaryContent(range, index) {
      return () => {
        return new Promise((resolve, reject) => {
          // const loadedPercentage = this.fileChunkResults[index].percentage
          axios({
            ...
            .then((res) => {
              this.fileChunkResults[index].buffer = res.data;
              // 去除请求
              if (this.chunkRequestList) {
                const curIndex = this.chunkRequestList.findIndex(
                  ({ cancelIndex }) => cancelIndex === index
                );
                this.chunkRequestList.splice(curIndex, 1);
              }
              resolve(res);
            })
            .catch((err) => {
              reject(err);
            });
        });
      };
    },
    async downloadFile(requestList) {
      this.requestWithLimit(requestList, () => {
        const sortedBuffers = this.fileChunkResults
          .sort((a, b) => a.index - b.index)
          .map((item) => new Uint8Array(item.buffer));
        const buffers = this.concatenate(sortedBuffers);
        this.saveAs({ buffers, name: "我的压缩包", mime: "application/zip" });
      });
    }
    ...

完毕。

代码地址

github:github.com/zhensg123/r…

参考文章

JavaScript 中如何实现大文件并行下载?