文件上传,如何从普通做成亮点(大文件断点续传)

438 阅读6分钟

  最近在思考一个问题:我们大部分前端开发者都是在做业务功能需求,积累了很多的项目经验,但如何将普通的项目功能做成亮点,有深度有价值,是值得我们一直不断思考和探究的!
  比如文件上传,这个功能在普遍的业务场景都会用到。我们就来聊聊上传文件的几种实现方式,看看如何将普通功能做成项目亮点~~

以下代码案例client端是采用vue、elementui,server端是采用eggjs来实现的

一、普通版

FormData实现上传文件

html:

<input type="file" name="file" @change="uploadFile"/>

JS:

function uploadFile() {
    const form = new FormData();
    form.append('filename', this.file.name);
    form.append('file', this.file);

    axios({    
      url : "/upload",    
      type : "post",    
      data : formData, 
    }).then(data=> {    
      console.log(data)  
    }).catch(err => {
      console.log(err)
    })
}

二、优化交互体验版

2.1 上传进度条

elmentui进度条:

<el-progress :stroke-width="20"	 :percentage="uploadProgress"></el-progress>

js:

axios.post('/uploadfile', form, {
    onUploadProgress: progress => {
      this.uploadProgress = Number(((progress.loaded/progress.total)*100).toFixed(2))
    }
})

2.2 文件格式校验(文件头信息截取)

  文件格式校验,大家很容易想到根据文件后缀名进行判断。这一方式比较简单,但碰到有人恶意修改文件后缀,非法格式的文件可以通过校验上传到服务器,存在安全问题。
  因而可以选择截取文件头信息的方式进行校验,用js的slice方法截取文件头信息,转换成十六进制字符进行相应格式判断。
  下面代码是校验图片格式(pngjpggif):

// png:判断前8个字节
async isPng(file) {
    const ret = await this.blobToString(file.slice(0,8));
    const isPng = (ret == '89 50 4E 47 0D 0A 1A 0A');
    return isPng;
},
// jpg:判断文件头2个字节、文件结尾2个字节
async isJpg(file) {
    const len = file.size;
    const start = await this.blobToString(file.slice(0,2));
    const tail = await this.blobToString(file.slice(-2));
    const isJpg = (start == 'FF D8') && (tail == 'FF D9');
    return isJpg;
},
// gif:判断前6个字节
async isGif(file){
    // GIF89a 和 GIF87a(2种gif图片规范)
    // 47 49 46 38 39(37) 61
    const ret = await this.blobToString(file.slice(0,6));
    console.log(ret)
    const isGif = (ret == '47 49 46 38 39 61') || (ret == '47 49 46 38 37 61');
    return isGif;
},
// 读取文件信息转换成十六进制
async blobToString(blob){
    return new Promise(resolve=> {
      const reader = new FileReader();
      reader.onload = function() {
        const ret = reader.result.split('')
                      .map(v=> v.charCodeAt())
                      .map(v=>v.toString(16).toUpperCase())
                      .join('')
        resolve(ret)
      }
      reader.readAsBinaryString(blob);
    })
}

三、亮点版(大文件断点续传)

3.1 计算文件hash

方式一:Web Worker计算hash

  采用Web Worker,创造多线程环境,将计算md5值的任务分配给worker线程,计算完成再把结果返回给主线程作为hash值。

// webWorker多线程计算hash
async calculateHashWorker() {
  return new Promise(resolve => {
    this.worker = new Worker('/hash.js');
    this.worker.postMessage({chunks: this.chunks})
    this.worker.onmessage = e => {
      const {progress, hash} = e.data;
      this.hashProgess = Number(progress.toFixed(2))
      if(hash) {
        resolve(hash)
      }
    }
  })
}

hash.js:

// 引入spark-md5
self.importScripts('spark-md5.min.js')

self.onmessage = e => {
  // 接收主线程传递的数据
  const {chunks} = e.data;
  const spark = new self.SparkMD5.ArrayBuffer()

  let progress = 0;
  let count = 0;

  const loadNext = index => {
    const reader = new FileReader()
    reader.readAsArrayBuffer(chunks[index].file)
    reader.onload = e=> {
      count++
      spark.append(e.target.result)

      if(count == chunks.length) {
        self.postMessage({
          progress: 100,
          hash: spark.end()
        })
      }else{
        progress += 100/chunks.length
        self.postMessage({
          progress
        })
        loadNext(count)
      }
    }
  }
  loadNext(0)
}

方式二:时间切片计算hash

  采用requestIdleCallback方法将在浏览器的空闲时段内调用的函数排队。这使开发者能够在主事件循环上执行后台和低优先级工作。requestIdleCallback执行方法时,会传递deadline参数,能够知道当前帧的剩余时间,具体代码如下:

// 时间切片计算hash
async calculateHashIdle() {
  const chunks = this.chunks;
  return new Promise(resolve => {
    const spark = new SparkMD5.ArrayBuffer();
    let count = 0;

    const appendToSpark = async file => {
      return new Promise(resolve=>{
        const reader = new FileReader()
        reader.readAsArrayBuffer(file)
        reader.onload = e=>{
          spark.append(e.target.result)
          resolve()
        }
      })
    }

    const workLoop = async deadline => {
      // timeRemaining获取当前帧的剩余时间
      while(count<chunks.length && deadline.timeRemaining()>1) {
        // 有空闲时间,且有任务
        await appendToSpark(chunks[count].file);
        count++;
        if(count<chunks.length) {
          // 计算中
          this.hashProgess = Number(
            ((100*count)/chunks.length).toFixed(2)
          )
        }else {
          this.hashProgess = 100;
          resolve(spark.end())
        }
      }
      window.requestIdleCallback(workLoop)
    }
    // 浏览器一旦空闲,就会调用workLoop
    window.requestIdleCallback(workLoop)
  })
}

方式三:抽样计算hash

  计算文件md5值的作用,无非就是为了判定文件是否存在,我们可以考虑设计一个抽样的hash,牺牲一些命中率的同时,提升效率,设计思路如下:

  1. 文件切成2M的切片
  2. 第一个和最后一个切片全部内容,其他切片的取 首中尾三个地方各2个字节
  3. 合并后的内容,计算md5,称之为影分身Hash
  4. 这个hash的结果,就是文件存在,有小概率误判,但是如果不存在,是100%准的的 ,和布隆过滤器的思路有些相似, 可以考虑两个hash配合使用
  5. 自己电脑上试了下1.5G的文件,全量大概要20秒,抽样大概1秒还是很不错的, 可以先用来判断文件是不是不存在
// 抽样计算hash
async calculateHashSample(){
  // 1个G的文件,抽样后5M以内
  return new Promise(resolve=>{
    const spark = new sparkMD5.ArrayBuffer()
    const reader = new FileReader()

    const file = this.file
    const size = file.size
    const offset = 2*1024*1024
    // 第一个2M,最后一个区块数据全要
    let chunks = [file.slice(0,offset)]

    let cur = offset
    while(cur<size){
      if(cur+offset>=size){
        // 最后一个区快
        chunks.push(file.slice(cur, cur+offset))

      }else{
        // 中间的区块
        const mid = cur+offset/2
        const end = cur+offset
        chunks.push(file.slice(cur, cur+2))
        chunks.push(file.slice(mid, mid+2))
        chunks.push(file.slice(end-2, end))
      }
      cur+=offset
    }
    // 中间的,取前中后各2各字节
    reader.readAsArrayBuffer(new Blob(chunks))
    reader.onload = e=>{
      spark.append(e.target.result)
      this.hashProgress = 100
      resolve(spark.end())
    }
  })
},

抽样hash

3.2 文件切片上传

  将切割的文件chunk转换成formData数据,发起异步请求,上传到后端,具体代码如下:

// 切片上传
async uploadChunks() {
  const requests = this.chunks.map((chunk, index)=>{
    const form = new FormData();

    form.append('hash', chunk.hash)
    form.append('name', chunk.name)
    form.append('chunk', chunk.chunk)
    return {form,index: chunck.index};
  }).map(({form, index})=> axios.post('/uploadfile', form, {
      onUploadProgress: progress => {
      this.chunks[index].progress = Number(((progress.loaded/progress.total)*100).toFixed(2))
    }
  }))

  await Promise.all(requests)
},

3.3 断点续传/秒传

  上传文件前,先询问下后端文件是否存在,如果存在直接提示秒传成功。
  client端:

const {data:{uploaded, uploadedList}} = await axios.post('/checkfile', {
  hash: this.hash,
  ext: this.file.name.substring(this.file.name.lastIndexOf('.')+1)
})
if(uploaded) {
  // 秒传
  return this.$message('秒传成功')
}

  server端:

const fse = require('fs-extra')

async checkfile() {
  const { ctx } = this
  const { ext, hash } = ctx.request.body
  const filePath = path.resolve(this.config.UPLOAD_DIR, `${hash}.${ext}`)

  let uploaded = false
  let uploadedList = []
  if (fse.existsSync(filePath)) {
    // 文件存在
    uploaded = true
  } else {
    uploadedList = await this.getUploadedList(path.resolve(this.config.UPLOAD_DIR, hash))
  }
  this.success({
    uploaded,
    uploadedList,
  })
}
// 过滤.文件(如.DS_Strore)
async getUploadedList(dirPath) {
  return fse.existsSync(dirPath)
    ? (await fse.readdir(dirPath)).filter(name => name[0] !== '.')
    : []
}

  上传文件前,询问后端文件是否上传过,如果没有,是否存在切片uploadedList。断点续传实质,就是在上传切片前使用filter过滤,具体代码如下:

async uploadChunks(uploadedList=[]) {
  const requests = this.chunks
    .filter(chunk=> uploadedList.indexOf(chunk.name) == -1)
    .map((chunk, index)=>{
      const form = new FormData();

      form.append('hash', chunk.hash)
      form.append('name', chunk.name)
      form.append('chunk', chunk.chunk)
      return form;
    }).map((form, index)=> axios.post('/uploadfile', form, {
      onUploadProgress: progress => {
        this.chunks[index].progress = Number(((progress.loaded/progress.total)*100).toFixed(2))
      }
    }))

  await Promise.all(requests)
}

3.4 异步请求控制并发数量

  上传切片,如果切片过多,同时发起的异步请求就会过多。同一时间申请tcp连接过多的话,浏览器也会造成卡顿,因而需要控制异步请求并发数。
  实现思路,可以把请求放一个队列,比如并发数是4,就先同时发起4个请求,然后有请求结束了,再发起下一个请求即可,思路清楚,具体代码如下:

async sendRequest(chunks, limit=4) {
  return new Promise((resolve, reject)=> {
    const len = chunks.length;
    let count = 0;
    const start = async () => {
      const task = chunks.shift()
      if(task) {
        const {form, index} = task
        await axios.post('/uploadfile', form, {
          onUploadProgress: progress => {
            this.chunks[index].progress = Number(((progress.loaded/progress.total)*100).toFixed(2))
          }
        }) 
        if(count == len-1) {
          // 最后一任务
          resolve()
        }else{
          count++
          start()
        }
      }
    }

    while(limit>0) {
      start()
      limit-=1
    }
  })
}

3.5 并发报错重试

  上传切片可能报错(比如网速比较差的时候),在上传切片请求中可以添加报错重试的逻辑。实现思路如下:

  1. 请求出错.catch 把任务重新放在队列中
  2. 出错后进度条值progress设置为-1
  3. 请求任务定义error字段,记录报错重试的次数,当超过3次,就立即停止上传请求。

server端先模拟报错:

if(Math.random()>0.3){
    return this.ctx.status = 500
}

client端:

// 上传可能报错
// 报错之后,进度条变红,然后开始重试
// 一个切片重试失败3次,整体全部终止
async sendRequest(chunks, limit=4) {
  return new Promise((resolve, reject)=> {
    const len = chunks.length;
    let count = 0;
    let isStop = false;
    const start = async () => {
      if(isStop) {
        return
      }
      const task = chunks.shift()
      if(task) {
        const {form, index} = task
        try {
          await axios.post('/uploadfile', form, {
            onUploadProgress: progress => {
              this.chunks[index].progress = Number(((progress.loaded/progress.total)*100).toFixed(2))
            }
          })
          if(count == len-1) {
            // 最后一任务
            resolve()
          }else{
            count++
            start()
          }
        } catch (e) {
          this.chunks[index].progress = -1
          if(task.error<3) {
            task.error++
            chunks.unshift(task)
            start()
          }else{
            // 报错3次终止任务
            isStop = true;
            reject()
          }
        }
      }
    }

    while(limit>0) {
      start()
      limit-=1
    }
  })
}

3.6 文件碎片清理

  如果用户上传文件到一半就离开,这些切片文件就不存在意义,可以考虑定期清理。比如server端使用node-schedule来管理定时任务,每天扫一次目标target目录,如果文件修改时间是一个月以前的就清理删除。

小结

  • 上传小文件简单,上传大文件复杂;单机简单,分布式很难
  • 任何看似简单的功能,如果量级提升,难度就会提升 之所以分享文件上传的案例(从小文件到大文件断点续传,复杂度是逐渐加深),主要是用来梳理记录自己对上传文件问题的思考,其次是想把探究问题逐步加深的精神传递给大家~~