「从零开始的大文件上传 」都 2022 年了我怎么还在写这个话题...(一)

简介: 「从零开始的大文件上传 」都 2022 年了我怎么还在写这个话题...(一)

背景介绍


大家好,我是寒草🌿,好久不见了,这时大家可能会问:

分明最近就看你疯狂的发《架构整洁之道》的文章,怎么就好久不见呢?


虽然这么说也没错,但是我确实好久没有写一些符合我自己风格或者设计思想的文章了,而本次文章的主题也是与我上一周开发的大文件上传需求有关,内容是这样的:


文件限制大小 2G,100个,批量上传,最大并发量 5 个,并支持上传的取消,以及显示上传的进度。


这个需求看上去没啥,主要是文件体积与数量比较大,但也算是比较基础的需求,而我却想提出更加强大,更加通用的文件上传能力,即:


  • 多数量
  • 大体积
  • 多并发
  • 支持断点续传


通用文件上传能力~

而整个过程肯定也不是一蹴而就,我也是第一次弄,遇到了很多奇奇怪怪的疑问,有的我现在还需要去啃 W3C 的文档,大家也可以参与讨论,也算是我给读者留下的思考题:

  • 本地上传的 File 对象中的 webkitRelativePath 属性为什么没有 path 或者 URL 信息

这个可能要从隐私与安全性以及文件规范入手

  • 文件上传后的 File 对象中有 uid 属性,然而这个 uidMDN 以及 W3CFile 文档中都没有提及,那么这个 uid 是怎么来的,在各浏览器的兼容性如何?

如果您对上面的问题有了解,欢迎指导 ☀️,这里也为大家提供一些参考链接:

关于文件上传我至少会出三篇文章,内容依次为:

  • 实现上面提到的基础需求
  • 断点续传的原理及优化
  • 后续能力的实现


这一篇文章就是关于我如何去实现上文提到的基础需求,而为了后续的能力扩展,我需要保证其可扩展性以及通用性。


如果我的实现哪里有问题,也请大佬不吝赐教,一起交流探讨才能更好的进步~


前期设计


此处设计为大的方向,而代码的设计也应属于前期设计,但是为了文章的可读性,我将其拆解到了下面的编码实现章节。如果哪里有问题请大家和平的探讨,毕竟我也是第一次做~


说来惭愧,我最开始就有完成我全部目标的宏图伟愿,就粗略的画下了这个图:


网络异常,图片无法展示
|


上图就是简单的列了一下我想要去实现的各个能力,以及业务层与逻辑层的通信方式。


Tip: 其实我的描述不够准确,视图指的是上传能力的消费者,属于业务层;而逻辑层指的是文件上传对象的实例。


下面我就从通信方式以及通用能力两个方向来继续谈~

在设计与实现过程中参考:


通信方式


现在我已经实现完成了基础功能,整体的通信方式与上图中的也是大体一致:


  • 消费者 -> 能力提供者:消费者通过构造函数的参数对文件上传能力进行实例化
  • 能力提供者 -> 消费者:文件上传能力的提供者在关键节点调用消费者注入的方法


大家可能就有疑问了,我把什么定义为关键节点呢?


  • 上传进度变化
  • 文件上传成功
  • 文件上传失败
  • 全部文件上传完成

PROGRESSSUCCESSFAILEND 四个状态,那消费者注入的方法也是用来处理这些关键节点产生的信息。


能力拆解


  • 文件分片

且不说文件分片的原理,我们下次再聊,在这里我想要加入的能力是根据文件大小,并发数,文件数量等因素对大文件进行智能分片。

  • hash 计算

根据文件内容生成 hash 值,对于大文件有两种手段:

  • 全量文件内容计算 hash 值,时间过长,可以采用 web-worker 或者 requestIdleCallback 不阻塞UI 线程影响用户操作
  • 对文件内容进行抽样,生成 hash 值,可以大幅提升效率,但是会影响断点续传中文件存在性判断的准确率
  • 上传进度

如果采用了大文件分片,上传的进度需要根据  hash 计算时常,单片上传进度计算单一文件的整体上传进度

  • 并发控制

限制并发量,避免海量请求对接口进行爆破的情况出现,并智能的调度请求

  • 断点续传

支持文件上传的暂停,恢复

  • 肯定还有很多我前期没有想到的...


话不多说,接下来的一章我们将关注代码实现的思考与细节。


编码实现


类的诞生


首先我的想法就是将其与视图分割,使得它的能力可以用在不同“皮囊”之下,如下图:


网络异常,图片无法展示
|


所以,第一步就是新建一个类:


class ZetaUploader {
  constructor() {}
}


之后我们继续想,之前我们前期设计 - 能力拆解章节写了那么多能力,而且在一个通用的模块中进行:


  • 判断消费者文件是否分片
  • 判断消费者是否要开启断点续传
  • ...


支持与不支持文件分片的逻辑是完全不一致的,在一个类里封装如此多判断逻辑和名称一样实现逻辑却不一样的是及其痛苦的,也会极大的增加使用和维护成本,于是我决定对其进行拆解:


网络异常,图片无法展示
|


我们利用龙生九子,各不相同的传统概念,决定以 ZetaUploader 为基类,提供基础能力与内部变量,去产出他各不相同的子类,而这一次,我要与大家一同实现的就是 BasicZetaUploader,提供并发上传大文件的能力:


  • 大文件上传
  • 并发控制
  • 上传状态响应(即之前说的 PROGRESS,SUCCESS,FAIL,END 四个状态的响应)


这时我们就需要思考作为基类,ZetaUploader 要有哪些内容,这些内容一定是各个子类都需要的,这里我简单列举一下我要在基类放置的变量与方法:


变量


  • fileState

存放整个上传过程中上传成功上传失败上传取消的文件数

  • progressEvent

上文提到的,消费者注入的用于在关键节点通知消费者处理上传进度变化的方法

  • concurrency

并发数,即同时上传数

  • xhrMap

正在进行上传的 XMLHttpRequest 实例,可以用来进行 xhr 请求的取消操作「abort」

  • unUploadFileList

未进行上传的文件列表,正在等待上传

  • xhrOptions

发起请求的基础信息,包括:url,method,header,getXhrDataByFile。其中 getXhrDataByFile 为根据 file 生成请求数据的方法


方法


  • isRequestSuccess


用于判断请求是否成功,由于该方法不依托于 ZetaUploader 实例,于是我将其定义为了静态方法


static isRequestSuccess(progressEvent) {
  return String(progressEvent.target.status).startsWith('2');
}


这里为什么参数是 progressEvent 呢,onload 的语法为:


XMLHttpRequest.onload = callback;


其中 callback 是请求成功完成时要执行的函数。它接收一个 ProgressEvent 对象作为它的第一个参数,this 的值(即上下文)与此回调的 XMLHttpRequest 相同。

基类完整代码如下:


class ZetaUploader {
  fileState = {
    failCount: 0,
    successCount: 0,
    abortCount: 0
  }
  /* progressEvent arguments
  * @params {Object} [params]
  * @params {ENUM} [params?.state] FAIL PROGRESS SUCCESS END
  * @params {Info} [params?.info]
  * @params {File} [info?.file]
  * @params {Number} [info?.progress]
  * @params {FileState} [info?.fileState]
  */
  progressEvent = () => { };
  concurrency = 1;
  xhrMap = null;
  unUploadFileList = [];
  /*
  * @params {Object} [xhrOptions]
  * @params {String} [params.url]
  * @params {String} [params.method]
  * @params {Object} [params.headers]
  * @params {Function} [params.getXhrDataByFile]
  */
  xhrOptions = null; 
  static isRequestSuccess(progressEvent) {
    return String(progressEvent.target.status).startsWith('2');
  }
  constructor(progressEvent, fileList, concurrency, xhrOptions) {
    const basicXhrOptions = {
      url: '',
      method: 'post',
      headers: [],
      getXhrDataByFile: file => {
        const formData = new FormData();
        formData.append('file', file);
        return formData;
      }
    };
    // BASIC ATTRS
    this.progressEvent = progressEvent;
    this.concurrency = concurrency;
    this.xhrOptions = Object.assign(basicXhrOptions, xhrOptions);
    // COMPUTED ATTRS
    this.unUploadFileList = fileList;
    this.xhrMap = new Map();
  }
}


工厂方法


这时我们的子类 BasicZetaUploader 就要开始写了,现在他的内容是这样的:


class BasicZetaUploader extends ZetaUploader {
  constructor(progressEvent, fileList, concurrency, xhrOptions) {
    super(progressEvent, fileList, concurrency, xhrOptions);
  }
}


大家先和我进入思考,我们每个文件上传的请求都是独立的,而且要独立的监听其上传进度的变化以及请求结果,所以我们需要一个用来批量的产出方法的方法工厂,参数为 file, xhrOptions, onProgress,即根据:


  • 文件对象
  • 请求的配置信息
  • 进度变化时的处理方法


来制造每个文件的请求方法。因为生成 xhr 请求的工厂依赖于 onProgress,所以我

先来讲一下这部分内容~


onProgress 工厂


onProgress 的用法如下:


XMLHttpRequest.onprogress = function (event) {
  event.loaded;
  event.total;
};


参数为:


  • event.loaded 已传输的数据量
  • event.total 总共的数据量


我写了一个 progressFactory 方法,他也是一个工厂方法,通过 file 来生成 onProgress


progressFactory(file) {
  return e => {
    this.progressEvent('PROGRESS', {
      file,
      progress: parseInt(String((e.loaded / e.total) * 100))
    });
  };
}


request 工厂


有了 onProgress 工厂后我们就可以开写正儿八经的 requestFactory 了,其实也比较简单,就是初始化了一个 XMLHttpRequestPromise ,只不过这里有个巧思:


Promiserejectresolve 通过 xhr 的回调控制


其余就没有什么可以讲解的了,就是一个新建 xhr 并为其添加回调的过程。整体代码如下:


requestFactory(file, xhrOptions, onProgress) {
  const { url, method, headers, getXhrDataByFile } = xhrOptions;
  const xhr = new XMLHttpRequest();
  xhr.open(method, url);
  Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key]));
  let _resolve = () => { };
  let _reject = () => { };
  xhr.onprogress = onProgress;
  xhr.onload = e => {
    // 需要加入response的判断
    if (ZetaUploader.isRequestSuccess(e)) {
      this.fileState.successCount++;
      this.progressEvent('SUCCESS', {
        file
      });
      _resolve({
        data: e.target.response
      });
    } else {
      this.fileState.failCount++;
      this.progressEvent('FAIL', {
        file
      });
      _reject();
    }
  };
  xhr.onerror = () => {
    this.fileState.failCount++;
    this.progressEvent('FAIL', {
      file
    });
    _reject();
  };
  xhr.onabort = () => {
    this.fileState.abortCount++;
    _resolve({
      data: null
    });
  };
  const request = new Promise((resolve, reject) => {
    _resolve = resolve;
    _reject = reject;
    xhr.send(getXhrDataByFile(file));
  });
  return {
    xhr,
    request
  };
}


相关文章
|
6月前
|
数据采集 机器学习/深度学习 数据可视化
分享68个Python爬虫源码总有一个是你想要的
分享68个Python爬虫源码总有一个是你想要的
209 0
|
3月前
|
数据采集 前端开发 JavaScript
《花100块做个摸鱼小网站! 》第四篇—前端应用搭建和完成第一个热搜组件
本文档详细介绍了从零开始搭建一个包含前后端交互的热搜展示项目的全过程。通过本教程,读者不仅能学习到完整的项目开发流程,还能掌握爬虫技术和前后端交互的具体实践。适合有一定编程基础并对项目实战感兴趣的开发者参考。
86 1
|
3月前
|
数据采集 Java 数据库连接
《花100块做个摸鱼小网站! 》第二篇—后端应用搭建和完成第一个爬虫
本文详细介绍了一个基于Spring Boot的后端应用搭建过程,包括Maven项目结构的规划与配置、依赖管理、环境变量配置、数据库连接配置等。作者通过实际案例——一个摸鱼小网站的开发,逐步引导读者理解并实践项目的搭建流程。此外,还分享了如何利用Postman从cURL命令快速生成HTTP请求代码的方法,并演示了如何将这些代码整合进项目中,实现了一个简单的定时爬取抖音热搜数据的功能。文章不仅提供了详尽的代码示例,还附带了丰富的截图说明,非常适合希望从零开始构建Web应用的开发者参考学习。
59 3
《花100块做个摸鱼小网站! 》第二篇—后端应用搭建和完成第一个爬虫
|
3月前
|
网络安全 网络性能优化 数据中心
想要丝滑地使用ACL,少不了这篇干货~
想要丝滑地使用ACL,少不了这篇干货~
|
3月前
|
C# 开发者 Windows
勇敢迈出第一步:手把手教你如何在WPF开源项目中贡献你的第一行代码,从选择项目到提交PR的全过程解析与实战技巧分享
【8月更文挑战第31天】本文指导您如何在Windows Presentation Foundation(WPF)相关的开源项目中贡献代码。无论您是初学者还是有经验的开发者,参与这类项目都能加深对WPF框架的理解并拓展职业履历。文章推荐了一些适合入门的项目如MvvmLight和MahApps.Metro,并详细介绍了从选择项目、设置开发环境到提交代码的全过程。通过具体示例,如添加按钮点击事件处理程序,帮助您迈出第一步。此外,还强调了提交Pull Request时保持专业沟通的重要性。参与开源不仅能提升技能,还能促进社区交流。
43 0
|
3月前
|
数据采集 存储 JavaScript
打造你的第一个网页爬虫:一步步走进数据抓取的世界
【8月更文挑战第31天】在数字信息泛滥的时代,能够快速获取并利用网络数据变得尤为重要。本文将带你从零开始,用Python构建一个简单的网页爬虫。我们会一起探索请求网页、解析内容、存储数据的全过程。准备好了吗?让我们揭开数据抓取的神秘面纱,开启一段代码探险之旅!
|
6月前
|
数据采集 数据可视化 Java
分享66个Python爬虫源码总有一个是你想要的
分享66个Python爬虫源码总有一个是你想要的
213 1
|
11月前
|
测试技术 Go
【测试平台系列】第一章 手撸压力机(七)- 使用gin
今天,我们使用gin框架将压力机做成一个web服务后端。 我们引入gin框架:
【测试平台系列】第一章 手撸压力机(七)- 使用gin
|
11月前
|
存储 JSON 搜索推荐
【测试平台系列】第一章 手撸压力机(十二)-初步实现提取功能
上一章节,我们主要实现了基础的并发测试场景的能力。本章节,我们实现一下,如何对响应进行提取,使用正则/json对响应信息提取,并赋值给我们定义的变量。
|
存储 数据库 数据安全/隐私保护
我拿回属于自己的数据,怎么了?|将印象笔记导入笔记软件Notion
我拿回属于自己的数据,怎么了?|将印象笔记导入笔记软件Notion