Js+Video,分片上传、断点续传

7,398 阅读4分钟

前言

项目开发中,涉及文件上传的问题,针对于性能以及技术实现上,高频出现的一个名词---分片,以及根据实际项目需求可能还会涉及到断点续传的操作;

今天咱们就来聊一下,分片以及断点续传的那些事~

方案分析

  • 背景

    业务需求,video上传,涉及整个file过大的情况,动则几个G甚至十几个G的情况,需要切割分片上传;

    分片:顾名思义,将大片切割成性能优化的小片,以便于服务的接收;

    其后,当分片上传的问题解决后,视频的完整可合并性以及前后端分片的同步逻辑就是我们下一步需要考量的问题了;

  • 分片流程以及流程说明

    1. 校验--文件资源大小、格式限制 ~ 限定资源大小是否可控
    2. 视频标识--生成文件唯一 id(MD5) ~ 常规项目应脱离用户态,以每一个资源为唯一 id,避免资源耦合滥用
    3. 分片--片尺寸切割、片数 ~ 限定每片的大小、以及总片数
    4. 鉴权 ~ 当前视频状态,已被上传,或需要续传,或可以正常上传
    5. 鉴权续传(异常分片,走断点续传) ~ 可能涉及该视频续传,所以需要以鉴权获取已上传的分片数和原视频尽享对比,再次上传;
    6. 依次上传分片 ~ 成功delete 失败暂存
  • 断点上传

    1. 分片失败后,立即上传(即失败分片数被暂存) ~ 正常流程,失败的片数,可暂存变量体系,然后进入续传的等待操作区
  • 断点续传

    1. 一段时间后或刷新页面丢失此文件状态时处理上传 ~ 非正常情况,本地变量丢失,需重新读取片数并筛选未上传的片数

具体逻辑流程

阅读建议:以下代码并不能代表完整流程,只是将方案的核心方法简化罗列了一下,所以建议大家fileOutput文件入口看步骤,了解下思路目的就达到了;当然有意向的小伙伴理清步骤后,可以看下每个步骤具体方法实现;

  • Html

    <label for="upvideo"></label>
    <input accept="audio/mp4, video/mp4" style="display:none" id="upvideo" onchange="fileOutput" type="file" />
    注:label for 上传样式
    
  • Script ---> File入口

    参数配置区域
    this.limitSize // 文件大小
    this.fileArr // 文件分片数组 可用于暂存分片数
    async fileOutput (e) {
    // 文件输出口
    // 第一步 校验文件
    // 针对格式大小的初步过滤
    await this._fileCheck(e) // 文件格式 大小校验
    
    // 第二步 视频标识
    // 每个视频需要生成唯一id,这里以每个视频前5m作为标识
    const mdskey = await this._getMd5(e.target.files[0], 1024 * 1024 * 5) // md5 视频唯一标识
    
    // 第三步 分片
    // 获取分片数 分片文件 可配置每片的大小
    // 这里以每片5M为例,当然片数的大小取决于服务器的一个负载能力以及后期用户网速问题,所以建议每片不要过大,10m左右即可,这里咱们以5M为例
    const fileArr = await this._getVideoFilsGrouping(e.target.files[0], 1024 * 1024 * 5) // 分片
    
    // 第四步 校验视频状态
    // 以md5标识验证当前传输视频 是归属于续传还是正常流程 下面为模拟api请求
    const { data } = await this.$fetch.demo({total: Object.keys(fileArr).length, md5: mdskey}) // 授权 data 已上传的分片
    
    // 第五步 删除分片
    // 上述如果该视频存在续传情况 要相应对比 删除多余分片
    await this._deleteKey(result, data)
    
    // 第六步 上传分片 data(可重新定义变量,也可直接使用,建议重新定义,结构清晰)  
    this._forEachUp(data)
    // 涉及断点上传 重新在走 this.forEachUp(data) 即可
    // 不建议无线递归次上传方法,交互最好有个异常提示框让用户选择是否 断点上传,即主动非被动;
    }
    

    详细Function ---> 每个Funtion做了什么事?

  • _fileCheck

    _fileCheck (e) {
    // 文件格式 大小校验
    return new Promise((resolve, reject) => {
     if (!(e.target.files[0] && e.target.files[0].type)) {
     // 视频有效性验证
       document.getElementById('upvideo').value = null
       return
     }
     if (this.limitSize < blob.size) {
       // 最大不能超过多少
       return
     }
     resolve(1)
    })
    }
    
  • _getMd5

    _getMd5 (blob, size) {
     // md5 对应文件唯一标识
     // size 默认视频前5M作为此视频唯一标识,理论上MD5可加密任意字符,但是你要相信对于js来说一定有性能瓶颈,所以不 建议过大,如果需求需要,也尽量建议不要超过30m
     return new Promise((resolve, reject) => {
     if (!blob) return
     blob = blob.slice(0, size, 'video/mp4')
     const reader = new FileReader()
     reader.readAsArrayBuffer(blob)
     reader.onload = (el) => {
      /* --- 将 Unicode 编码转为一个字符 area --- */
      var binary = ''
      var bytes = new Uint8Array(el.target.result)
      var length = bytes.byteLength
      for(var i = 0; i< length; i ++){
        binary += String.fromCharCode(bytes[i])
      }
      /* --- 将 Unicode 编码转为一个字符 area --- */
      const md5str = CryptoJS.MD5(CryptoJS.enc.Latin1.parse(binary)).toString()
      resolve(md5str)
    }
    })
    }
    
     知识点:
     1. 加密方式非唯一,仅前后端统一即可;
     2. 上述为buffer ---> 8位转码字符 ---> 字符集Latin1编码 ---> MD5;
     3. 上述binary最终其实就是一串二进制编码,整个编码区其实可以被 reader.readAsBinaryString(blob)   替换,但由于readAsBinaryString是非标,而且12年已经被移出W3C草案,所以不建议使用,但不代表一定不能用,兼容问题自行斟酌;
    
  • _getVideoFilsGrouping

    // 得到视频流分组
    _getVideoFilsGrouping (blob, size) {
    return new Promise((resolve, reject) => {
      const len = size || 1024 * 1024 // 2 = 1021 * 1024
      const frist = parseInt(blob.size / len) // 分组 正常格式长度数量
      const second = blob.size % len ? 1 : 0 // 分组 含有剩余分至1组
      const allnum = frist + second // 总分组量
      this.allnum = allnum
      let fileObject = {} // 数据流详细分组容器
      if (allnum) {
        if (frist) {
          for (let i = 1; i <= allnum; i++) {
            fileObject[i] = blob.slice((i - 1) * len, len + len * (i - 1), 'video/mp4')
          }
        } else {
          fileObject[1] = blob
        }
      }
      resolve(fileObject)
    })
    }
    注意点: 因本身视频分片不存在特殊标识,亦考虑性能,前后端每片以顺序作为每片标识
    
  • _deleteKey

    _deleteKey (result, data) {
    // 删除已上传分片
    return new Promise((resolve, reject) => {
       if (data.slices && data.slices.length) {
          if (data.slices.length !== this.allnum) {
            data.slices.forEach(ele => {
              delete result[ele]
              if (ele === data.slices[data.slices.length -1]) {
                resolve(1)
              }
            })
          } else {
            resolve(1)
          }
        } else {
          resolve(1)
        }
    })
    

    }

  • _forEachUp

    // 上传
    async _forEachUp (result) {
     // 这里仅是一个简单的上传示例
     // 实际业务需求可能涉及更多业务样式等逻辑
    const keys = Object.keys(result) || []
    const lastKey = keys.length && keys[keys.length - 1]
    for (let i in result) {
      // param 上传所需的参数
      // lastKey === i 是否是最后一个 如果是最后一个并且存在上传失败的分片 将触发断点上传 并且当最后一个分片上传完成之后 要有个变量数组暂存剩余分片的数量
      // result和i的作用 当前分片上传成功 delete result[i]
      await this.$fetch.demo('url', param, lastKey === i, result, i)
    }
    }
    

小结

通过上述算是了解到一个比较经典的分片续传流程,除了断点、续传、MD5、编码等特性,就整体架构来说,视频层,我们看到整个流程是完全脱离于用户态的概念,即视频独立,这样前后端服务会非常灵活,减少了和业务性耦合,并且还能有效避免同一个视频被滥用的情况,当然具体业务具体分析,并非一家之言就能概括,就和上方阐述的一些方式方法一样,条条大路通罗马,不限定路数,但每一条路都又有不一样的体验,欢迎大家在评论区,多多评论交流!