断点上传前后端的思路及实现

68 阅读2分钟

断点上传前后端的思路及功能的简单实现

前端

  • 切片上传功能, 中断上传功能;
  • 限制同一时间内能够同时调用的请求数量;
  • 断网续传。

切片上传

  • fileInput.files[0] 获取 file;
  • slice 将 file 切片;
  • FormData 传递数据;
  • 遍历, 发送 fetch 请求。
const file = fileInput.files[0];
const url = '/upload';

const chunkSize = 1024 * 1024; // 分片大小 1MB
const totalChunks = Math.ceil(file.size/chunkSize);

abortController = new AbortController();
const signal = abortController.signal;

for (let i = 0; i < totalChunks; i++) {
  const start = i * chunkSize;
  const end = Math.min(start + chunkSize, fileSize);
  const chunk = file.slice(start, end);

  const formData = new FormData();

  formData.append('index', i);
  formData.append('filename', file.name);
  formData.append('totalChunks', totalChunks);
  formData.append('chunk', chunk);

  fetch(url, {
    signal,
    method: 'POST',
    headers: {
      'Content-Range': `bytes ${start}-${end - 1}/${fileSize}`,
    },
    body: formData,
  })
}

中断上传

  • AbortController 创建 controller;
  • fetch 请求时将 controller.signal 传入;
  • 通过 controller.abort() 中断请求。

控制并发请求数量

  • 建立 Scheduler 类;
  • 通过 add 方法添加 promise 对象, 并返回 promise;
  • 同一时间内只能进行 5 次请求;
  • 其中一个请求完成后, 再进行下一个请求;
  • 修改前面的 fetch 请求。
function Scheduler() {
  this.queue = [];
  this.run = [];
  this.max = 5;
}

Scheduler.prototype.add = function(task) {
  this.queue.push(task);
  return this.scheduler();
};

Scheduler.prototype.scheduler = function() {
  if (this.queue.length && this.run.length < this.max) {
    const promise = this.queue.shift();

    this.run.push(promise);

    promise.then(() => {
      this.run.splice(this.run.indexOf(promise), 1);
    });

    return promise;
  } else {
    return Promise.race(this.run)
      .then(() => {
        return this.scheduler();
      });
  }
};
schedule.add(
  fetch(url, {
    signal,
    method: 'POST',
    headers: {
      'Content-Range': `bytes ${start}-${end - 1}/${fileSize}`,
    },
    body: formData,
  })
)
  .then(() => {
    ... do something ...
  });

断网续传

  • window.addEventListener('online', handler) 监听网络恢复事件;
  • 前端记录已上传切片的 index;
  • 网络连接后, 再次遍历, 根据记录的 index 判断是否已经上传。

后端

  • /upload 所需参数:
interface Params {
  filename: string;
  index: string;
  totalChunks: string;
  chunk: Buffer;
}
  • 使用 express 的 multer 中间件处理多格式的 formData;
  • multer.diskStorage 完全控制保存到磁盘的切片文件名;
  • 切片文件命名规则: ${filename}-${index};
  • 所有切片上传完成后通过 stream 形式合并成一个大文件, 并删除切片。
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    // 设置文件存储的目录
    cb(null, './uploads/');
  },
  filename: (req, file, cb) => {
    const {filename, index} = req.body;
    const chunkFilePath = `${filename}-${index}`;

    cb(null, chunkFilePath);  // 定义文件名, 会覆盖同名文件
  }
});
const upload = multer({ storage });

app.post('/upload', upload.single("chunk"), (req, res) => {
  const { filename, index, totalChunks } = req.body;

  if (+index === totalChunks - 1) {
    mergeChunks(filename);
    res.status(200).send('File upload complete');
  } else {
    res.status(200).send('Chunk uploaded');
  }
});
async function mergeChunks(fileName) {
  const chunkFiles = fs.readdirSync(UPLOAD_DIR)
    .filter(file => file.startsWith(fileName + '-'))
    .sort((a, b) => {
      const aIndex = a.slice(fileName.length + 1);
      const bIndex = b.slice(fileName.length + 1);

      return aIndex - bIndex;
    });
  const filePath = path.join(UPLOAD_DIR, fileName);
  const writeStream = fs.createWriteStream(filePath, {
    flags: 'w',
    encoding: 'binary',
  });

  for (let chunkFile of chunkFiles) {
    const chunkFilePath = path.join(UPLOAD_DIR, chunkFile);
    const readStream = fs.createReadStream(chunkFilePath);

    await new Promise((resolve, reject) => {
      readStream.pipe(writeStream, {end: false});
      readStream.on('end', () => resolve());
      readStream.on('error', error => reject(error));
    });

    try {
      fs.unlinkSync(chunkFilePath);  // 删除切片文件
      console.info(chunkFilePath + '删除成功');
    } catch (e) {
      console.info(e);
    }
  }

  writeStream.end();
  console.info('File merge done');
}

总结

可以看到后端逻辑简单, 需要做的事情不多, 需要注意的就是 node V8 内存大小限制, 使用 stream 处理文件。
前端需要做的事情更多, 较为繁琐: 文件切片、并发控制、中断上传、记录已传输的 index 等。