文件上传

575 阅读6分钟

校验文件格式

大部分判断文件上传的方法主要是还是第一种,然而,扩展名完全是可以随便修改的。

1.判断文件的后缀名,是否符合。这种校验方法,不准确。

2.不管是图片、视频等文件都是而二进制形式存储的,每类文件的头信息是不同的,所有我们可以通过头信息来判断是哪种类型文件

通过input的事件,传递进来的文件,构造函数是File,File也继承了Blob的属性方法。

比如我们要判断gif格式,只需要判断头6位就行了

  • 首先调用file.slice(0,6)。返回指定范围内的数据,是个blob对象
async blobToString(blob) {
    return new Promise(resolve => {
        const reader = new FileReader()
        reader.onload = function() {
            // 1.将结果转为unicode编码,2.转成16进制,再转大写
            let res = reader.result.split('')
                        .map(i => i.charCodeAt())
                        .map(j => j.toString(16).toUpperCase())
                        .join(' ')
            // 返回转换后的结果
            resolve(res)
        }
        // 调用读取文件方法
        reader.readAsBinaryString(blob)
    })
},
  • 创建一个FileReader实例去读取这个blob对象的内容,此时的内容是 "GIF89a "。

  • 先将字符分割开,charCodeAt:返回字符的Unicode 编码,返回值是 0 - 65535 之间的整数

  • 转成16进制,再转大写,最后以空格转成字符串。最终比较结果是否是 "47 49 46 38 37 61" 或者 res === "47 49 46 38 39 61"

上传文件

普通的上传文件,就是将file添加到formDate中,传给后端就ok了。 但文件要考虑的问题:

  1. 要上传的文件过大
  2. 文件上传过程中,网络错误导致上传失败怎么办
  3. 秒传等问题

文件过大就要用到切片上传,就是将文件分割成许多小块,每个小块单独上传,这样就需要知道上传了哪些切片。

如何知道服务器上有没有上传过该文件? 该文件的切片上传了多少?

这就需要一个唯一标识,就是计算文件的md5值,只要文件内容不变,md5值就不会变

计算文件md5

首先先将文件切片,就是定义一个size,用file.slice(0, size)将文件切成大小相同的切片,最后一个可能不同。

计算md5值,是非常耗时的。

new Worker

创建一个worker实例,注册传递、接收的两个监听函数。

传进去切片数组,worker每计算完一个切片或者计算完所有切片。就发给主线程发信号。这个信号有md5计算的进度,前端可用来做进度条

requestIdleCallback

react16的Filber架构就是用的这个,利用浏览器的空闲时间计算。

就是在window.requestIdleCallback传进去一个执行函数,当空闲时间大于1ms就执行这个计算函数

抽样hash计算

思路就是设定一个size,假如是2M。

  • 开头用一个size的长度去切片file,就是slice(0, size)

  • 往后每一个size的长度切片分别从前中后2个子节。大概就是slice(start, start + 2)、slice(midd, midd + 2)、slice(end - 2, end)

  • 余下的不足一个size的作为一个切片

然后把计算md5值,其实这个计算的是:前后切片、中间的一部门切片的抽样的md5值。

计算结果肯定和以上两种计算的不一样,但是这种方法不管多大的文件,大概都是当做4M左右文件的md5值,适合那种超大的文件

这种计算是损失掉一部分精度,换来的高效。如果文件的其中某几个子节改变了,计算的md5值可能不一样。当然了,这种情况极少,可以忽略了...

切片上传图片

经过以上步骤,切片、文件hash都有了

  • 将每个切片的标记一个index、name,index是切片的顺序,name是hash + index。用作唯一标识。
  • 创建一个数组,数组每一项是上传切片的请求。也就是说每个成员都是一个Promise对象
  • Promise.all(promises),并发执行。这里存在一个问题,就是虽然是并发执行n多个请求,但只是同时建立了n多个Tcp连接而已,并不是同时上传。会造成性能问题,解决反感就是下边的控制并发
  • 当切片上传完,再请求后端merge接口,传递文件名、hash值等
async mergeRequest() {
  let data = {
    ext: this.file.name.split(".").pop(),
    size: this.chunkSize,
    hash: this.hash
  }
  let res = await this.$http.post("/mergeUploadedSliceFile", data)
  if (res && res.success) {
    this.$message({
      message: res.message,
      type: 'success'
    });
  }
},
  • 后端接口接收到后,大概就是创建一个写的流,根据切片的index讲读的流依次导进去。删掉删掉所有的切片。
async mergeFile(filePath, filehash, size) {
    // 切片所在的文件夹
    let chunkdDir = path.resolve(this.config.UPLOAD_DIR, filehash)
    // 给所有切片排序 
    try {
        let chunks = await fse.readdir(chunkdDir)                
        chunks.sort((a, b) => a.split('-')[1] - b.split('-')[1])
        chunks = chunks.map(cp => path.resolve(chunkdDir, cp))
        await this.mergeChunks(chunks, filePath, size)
    } catch(err) {
        console.log("err", err);
    }
}
async mergeChunks(files, dest, size) {        
    const pipStream = (filePath, writeStream) => new Promise(resolve => {
        const readStream = fse.createReadStream(filePath)
        readStream.on('end', () => {
            fse.unlinkSync(filePath)
            resolve()
        })
        readStream.pipe(writeStream)
    })
    // 创建一个写的流,依次将读到的流导进去
    await Promise.all(
        files.map((file, index) => {
            // size 必须是整数
            let option = {
                start: index * size,
                end: (index + 1) * size
            }                
            pipStream(file, fse.createWriteStream(dest, option))
        })
    )
}

断点续传、秒传

发一个checked接口请求,带上文件的md5值,验证文件是否存在。若不存在,也有可能存在切片的文件包

  1. 如果存在就是直接提示秒传成功。
  2. 不存在,后端会回传一个切片的数组。 前端根据切片数组,修改各个切片进度条状态,过滤掉已存在的切片。只上传未上传的切片,后边上传、merge的操作和一样

控制并发上传

解决以上的Promise.all一次建立多个tcp连接的问题。比如limit是3,调用三次执行函数。

由于循环的原因,三次执行的开始点就不一样了。每次将弹出一个切片,当前切片上传完以后,再执行下一个切片。永远都是三次并发执行

return new Promise((resolve, reject) => {
    let cur = 0
    let limit = this.limit
    let len = requets.lengthz
    const startUpload = async () => {
      let task = requets.shift();
      if (task) {
        let res = await this.$http.post('/uploadSliceFile', task.formData, {
          onUploadProgress: e => {
            this.chunks[task.index]['progress'] = Number(
              ((e.loaded / e.total) * 100).toFixed(2),
            )
          },
        })
        if (cur === (len - 1)) {
          resolve()
        } else {
          cur ++
          startUpload()
        }
      }
    }
    while(limit>0) {
      startUpload()
      limit --
    }
})

报错重试上传

假设限制每个切片最多上传3次。

每个切片的添加一个err属性为0,用于记录当前切片上传失败的次数,在以上并发处理的外层,加一层try catch。当有切片返回接口报错的时候

  • 当没有超过3次时,err+1,将当前任务添加到切片数组的开头,重新执行,同时更新进度条
  • 已经3次了,直接reject

前端项目地址

后端项目地址

本文使用 mdnice 排版