上传模块说明文档

@author: Pober Wong
@time: 2018-08-15

背景

2017.03 月是我入职嘉云升科技的第一个月。正逢公司核心项目 —— PGX-Cloud(本项目大致是一个以上传实验数据文件为起点,最终得到测序分析结果并配合各项数据供机构使用的平台)的第一次大版本升级,经过技术评估发现原有的上传模块设计架构不足以再支撑新的产品迭代(用系统架构方面的术语叫熵值已经达到了当前架构的临界点),所以为了实现细致程度接近百度云盘等成熟的上传模块,不得不进行第一次部分模块的重写。

原模块回顾

原有的模块只有两个部分:
视图

  1. 负责所有的工作,包括了视图的交互展示、数据的存储。
  2. 关于数据,使用若干个数组进行组织维护文件状态。上传的过程中直接操作维护文件数组所对应的状态数组,相关其他操作也都是直接操作对应的数组。
  3. 关于请求,则是直接使用 Promise 链进行操作,且有冗余的 Promise 封装,没有做异常处理。
  4. 关于进度,人为设定进度,和真实进度不符,容易出现不可预知的错误

工具类
各种请求的封装,以及 MD5 分片计算和分片上传的切片封装。

架构设计

UI 需要的信息

1. 文件的信息:进度、状态。  
2. 小组的信息:进度、状态、几乎都是由组内所维护的文件来统计出来的。  
3. 组件的信息:由于需要不断更新组件的进度状态,因此需要将触发组件更新的粒度控制到最合理的范围,避免组件渲染性能爆炸。  

逻辑控制

1. 上传单个文件:整个流程大致是十多个步骤,主要包含了计算 HASH 值、签名、切片、再签名、上传、校验、最后确认再校验。  
2. 失败重传机制:1 中的每一个步骤都需要控制好合理的小任务组,以此来进行小范围的重试,而不至于 `重试 === 重新上传`  
2. 对于小组而言:每个组在上传完成之前,都可以继续增删文件,小组的状态是由组内每一个文件的状态综合计算出的一个结果,因此小组需要对组内内容的变化做出正确、及时响应。

业务实体类 Model
文件、小组、后续接触到还有不同的医疗方案(这个可以归结到大的任务),这三个 Model 浑然天成,而且基于渲染粒度控制的考虑,更需要以这三个方面来切分 Model。

Model、Controller 以及 View 三层结构是最适合的选择,便毫不犹豫地使用了最经典的架构 —— MVC(Model-View-Controller)。
image

为了保证项目中数据的流向是可寻的,所有数据通信在本项目中都保持单向的。即:

  1. Model 对 Model 数据的修改,只能发生在 Controller 层中
  2. View 只做展示,不对数据进行任何操作,View 层的指令(dom 所响应的用户操作)交给 Controller 层控制
  3. Controller 作为用户指令的接受器,负责数据集实例的初始化,接受用户行为,对数据进行操作,同时还有核心的上传功能,而整个上传流程中对会对 Model 实例进行修改。

项目结构介绍

// 目录结构
UploadModal
| index // 入口页面,主要包含了选择文件功能
| GroupList // 选择文件页面的左侧小组列表
| UploadProgressModal // 上传中的弹框(核心操作的部分)
| QuitModal // 退出弹框
| DeleteModal // 删除文件弹框
| FailedModal // 上传失败弹框
|
└─── tools
| | uploadChunks // 分片上传单个文件的整个流程
| | uploadRequest // 对所有青云请求以及服务端请求|的封装
| | uploadTools // 分片计算文件 MD5 值,边读取边上传启动任务,重试机制,修正进度
|
└─── stores
| index // 可以批量生成以多个子 store 为内容的大 store
| File // File Model
| Group // Group Model
| TaskModel // 任务模型,承载着核心的 Controller,连接着 tools 里的一些功能模块
| manager // 组织多个子项目同时存在,以 tasks 为存储载体

目录额外说明

  1. 在该 uploadTools 文件中,本没有 边读取边上传启动任务,最早是单纯的切片工作,将整个文件按照一定大小切分到数组中,然后返回给 uploadChunks 模块中,以递归的方式集中上传所有的二进制单片文件。到后来发生了上传大型文件(3GB 以上)时,会出现 Chrome 进程占据过大内存而导致浏览器崩溃的问题,就意识到不能够将整个文件通过 FileReader 全部读入内存再集中处理。
    于是从切片入手,每切完一片,就立即开始调用 uploadChunks 中上传单片的流程,再通过 onFinish 回调获知结果,并决定是否开始通过 FileReader 来读取下一片。因此,将启动整个文件的上传任务放到 uploadTools 中看起来有点不合理。

  2. 模块根目录的所有文件均为 UI 层相关的文件,主要由 indexUploadProgressModal 两部分组成,为了维护方便,将其他提示性的 UI Modal 放到了与之同级的位置,或许可以将它们一同放入以上二者之一。

View 层

由于涉及到同一个页面,多个状态量(每个文件各自的进度、状态)需要同时更新的问题,如果使用过度得调用 setState 来更新页面(因为 setState 在更新页面的时候会以深度遍历的方式来更新当前虚拟 Dom 树下的所有节点,抛开利用 diff 算法来降低真实 Dom 开销的问题,单以更新虚拟 Dom 的代价来讲,就已经是非常高了)。而使用 PureComponent 和 shouldComponentUpdate 来控制对对应叶子组件的渲染也是非常繁琐的,于是采取了 MobX 来解决渲染的问题,只要控制好 observable 对象和订阅其的 Component粒度,就可以实现以最小的渲染成本来实现页面的更新,同时多项内容同时渲染而带来对共同的部分重复渲染的问题

如 UploadProgressModal 中:

const ObserverProgress = observer(({task, index}) => {
let info = task.type === TASK_TYPE.file ? task.progress + "%" : task.successCount + "/" + task.size
return (
<div className="progress-container">
{task.type === TASK_TYPE.file && task.isQuickUpload &&
<img className="quick-label" src={lightning} alt='' />
}
<span className="progress-bar">
<Progress
title={task.type === TASK_TYPE.file
? (task.isError ? task.errorInfo : null)
: task.statusText
}
percent={task.progress}
showInfo={false} strokeWidth={5} status={task.status === INACTIVE ? ACTIVE : task.status} />
</span>
<span>{info}</span>
</div>
)
})

这里没有使用装饰器的原因是装饰器本身不能直接支持方法,但 Stateless 组件作为无状态组件,可以提供更简单单向的组件状态管理,因此只好使用了高阶组件的方式来解决问题。
对 Progress 进度条的管理,observable 渲染单位还是以每一个单独的任务 task(这里也就是 File Model 对象)为触发者。这里存在一个问题,就是但凡文件的任何状态发生变化,都会触发对应的 ObserverProgress 或者其他订阅了 ObserverXXX 组件但没有用到对应变化属性的情况下都会触发渲染。开源社区提供了 mobx-devtools 工具,可以实时地将 observable 数据变化引起对应发生渲染的组件以绿框的形式展示出来。

TODO:将类似 OberverProgress 这样的组件所订阅的 observable 属性细化到最小,如 progress, 这里很简单,只需要将传入的 task 更改为 task.progress,不过要处理好额外信息(如:info、isQuickUpload 等信息的处理)。可以更极端一些,将所有与 UI 渲染有关的属性全部拆为独立的组件,这样就不会因为引起无关的渲染。

Model 层

File

  1. type:标识该文件为哪种,因为最终 Group 和 File 在 UI 层都会被当作 task 来处理,因此需要这样一个字段来区分标记
  2. errorInfo:记录发生错误的原因(后加)
  3. file:文件对象本身
  4. isQuickUpload:是否标记为快速上传
  5. reference: 在计算样本中,是否为参照样本
  6. progress:当前上传进度
  7. status:当前文件的状态,未开始 || 上传中 || 成功 || 失败

作为最基础的 Model,主要还是以存储基础信息和一些 getter 和 setter 为主,唯一不同的点就是在更新进度的同时更新了当前状态,以应对在个别情况下发生进度更新而状态标记未更新的问题(在排查清楚后,可以去掉)

Group

这个 Model 相对 File 应该是一个更加抽象的概念,因此其内部所有的信息应该由其所维护的文件来决定,因此基本都是由 computed getter 组成。

  1. groupInfo: 组的相关信息(如名称,id 等)
  2. files: 隶属于该组的所有文件对象
  3. progress getter: 计算当前组的实际进度,计算方式:

    1. 将单文件的进度 * 每一个文件的大小所占全组文件大小的比例,得到当前文件在全组中的真实进度。
    2. 将所有小的进度累加求和
      这样计算出来的进度总是依赖于每个文件的进度和状态
  4. status getter: 状态判断策略:

    1. 状态同样包含:未开始 || 上传中 || 成功 || 失败
    2. 如果第一个文件都是未激活状态,全组则标记为未激活
    3. 对组内文件进行遍历,在 O(n) 时间复杂度的前提下,实现对另外三种存在优先级状态的推算:
      1. 存在正在上传中的文件,则认为全组正处于激活状态
      2. 同时不断的累计成功文件的数量
      3. 循环结束,表明在已经开始上传的情况下组内不存在正在上传中的文件,进一步表明本组已经处理完毕,就剩下成功和存在异常两种情况,成功数量和总文件数的对比可得出结果
  5. unReachable getter: 对该组是否可删除(可根据产品需求变化)

  6. switchReference: 该样本是否为参照样本,相当于以 O(1) 的方式实现了一个单选功能

Controller

manager

因为单个医疗方案中存在多个项目,而每个项目理论上存在同时上传的问题,因此需要一个任务管理器,这里以 key-value 的形式存储,同时要保证对同一个项目多次使用的是同一个任务。

TaskModel

  1. 对组的增删、组内文件的增删和更新
  2. 重试机制和 UI 的连接
  3. 上传文件核心机制:
    1. 计算文件整体和分片的 Hash 值(md5 算法)
    2. 使用文件名称 + Hash 值从服务端获取经加密的签名值
    3. 使用文件对应的业务逻辑信息(所属组,项目,大小,上一步所预留的业务逻辑文件名等信息)向服务端初始化对应的业务逻辑
    4. 如果发现 3 返回的信息里告知前端该文件已经上传过青云对象存储,则直接执行确认上传逻辑,向服务端发起上传完毕信号
    5. 如果发现是新文件,则向青云服务端发起初始化存储对象的请求
    6. 启动分片上传
      1. 切片
      2. 上传,完毕后再执行 1,直到切完整个文件为止
        1. 校验当前读取到片的 Hash 值是否为初始化时的 MD5,防止中途文件异常或被篡改。如果异常,则直接终止该文件的上传
        2. 带着 3.2 和 3.3 以及 3.5 的信息,再配合当前片的 MD5,向服务端请求加密后的 sign_string
        3. 带上 sign_string 作为青云的 Authorization 开始上传数据
    7. 分片结束后,重新计算当前文件的 Hash 值,并再次校验,文件是否正确(其实此时不用,如果在保证每一片正确性的情况下,这样做是画蛇添足)
    8. 带着 3.5 中青云初始化存储的 ID 向服务端再次请求上传结束的 sign_string
    9. 向青云确认本文件已经上传完毕
    10. 带着服务端 3.3 中初始化业务存储的 ID 向服务端最终确认文件上传完毕

AzfTaskModel

由于 Azf 基因疾病检测需要参照样本,所以需要在上传之前将样本中标记为参照的样本优先上传,否则在计算后续样本的时候找不到参照物而计算失败。

所以需要在上传之前对每个小组内的参照文件进行调整,这里需要用到继承的重写特性(划重点:使用箭头函数声明在 this 实例上的函数是不可以被重写的,只有原型上的才可以,为此踩了不少坑)

流程控制

流程控制主要由 Promise 链来实现:
关于重试机制

1. 如果是小范围内的重试,则可以通过嵌套模块的方式,在嵌套的 Promise 子链中即使捕获异常触发重试  
2. 如果是涉及到整个文件的重试,可以通过在主链末尾进行捕获异常,则对整个文件进行重试  

由于整个 Controller 中文件的上传核心流程全由 Promise 实现,因此此处需要比较扎实的 ES6 Promise 基础

结语

整个重构之路艰辛无比,将 1500 行左右面向过程简易版本的上传模块 1.0 在未增加代码量的情况下,重构为面向对象,低渲染成本,高可拓展,拥有更多新功能的 2.0。也是我和同事第一次接触上传文件相关的业务模块,不论是在 Promise、 MobX,还是在对组件的抽象能力上都有了长足的进步和历炼。

砥砺前行,永不言弃

donation