欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

捣鼓系列:前端大文件上传

程序员文章站 2022-03-04 09:41:14
某一天,在逛某金的时候突然看到这篇文章,前端大文件上传,之前也研究过类似的原理,但是一直没能亲手做一次,始终感觉有点虚,最近花了点时间,精(熬)心(夜)准(肝)备(爆)了个例子,来和大家分享。 本文代码:github 问题 Knowing the time available to provide ......

某一天,在逛某金的时候突然看到这篇文章,,之前也研究过类似的原理,但是一直没能亲手做一次,始终感觉有点虚,最近花了点时间,精(熬)心(夜)准(肝)备(爆)了个例子,来和大家分享。

本文代码:github

捣鼓系列:前端大文件上传

问题

knowing the time available to provide a response can avoid problems with timeouts. current implementations select times between 30 and 120 seconds

如果一个文件太大,比如音视频数据、下载的excel表格等等,如果在上传的过程中,等待时间超过30 ~ 120s,服务器没有数据返回,就有可能被认为超时,这是上传的文件就会被中断。

另外一个问题是,在大文件上传的过程中,上传到服务器的数据因为服务器问题或者其他的网络问题导致中断、超时,这是上传的数据将不会被保存,造成上传的浪费。

原理

大文件上传利用将大文件分片的原则,将一个大文件拆分成几个小的文件分别上传,然后在小文件上传完成之后,通知服务器进行文件合并,至此完成大文件上传。

这种方式的上传解决了几个问题:

  • 文件太大导致的请求超时
  • 将一个请求拆分成多个请求(现在比较流行的浏览器,一般默认的数量是6个,),增加并发数,提升了文件传输的速度
  • 小文件的数据便于服务器保存,如果发生网络中断,下次上传时,已经上传的数据可以不再上传

实现

文件分片

file接口是基于blob的,因此我们可以将上传的文件对象使用slice方法 进行分割,具体的实现如下:

export const slice = (file, piece = chunk_size) => {
  return new promise((resolve, reject) => {
    let totalsize = file.size;
    const chunks = [];
    const blobslice = file.prototype.slice || file.prototype.mozslice || file.prototype.webkitslice;
    let start = 0;
    const end = start + piece >= totalsize ? totalsize : start + piece;

    while (start < totalsize) {
        const chunk = blobslice.call(file, start, end);
        chunks.push(chunk);

        start = end;
        const end = start + piece >= totalsize ? totalsize : start + piece;
    }
    
    resolve(chunks);
  });
};

然后将每个小的文件,使用表单的方式上传

_chunkuploadtask(chunks) {
    for (let chunk of chunks) {
        const fd = new formdata();
        fd.append('chunk', chunk);

        return axios({
          url: '/upload',
          method: 'post',
          data: fd,
        })
          .then((res) => res.data)
          .catch((err) => {});
    }
}

后端采用了express,接收文件采用了[multer](https://github.com/expressjs/multer)这个 库

multer上传的的方式有single、array、fields、none、any,做单文件上传,采用singlearray皆可,使用比较简便,通过req.filereq.files来拿到上传文件的信息

另外需要通过disk storage来定制化上传文件的文件名,保证在每个上传的文件chunk都是唯一的。

const storage = multer.diskstorage({
  destination: uploadtmp,
  filename: (req, file, cb) => {
    // 指定返回的文件名,如果不指定,默认会随机生成
    cb(null, file.fieldname);
  },
});
const multerupload = multer({ storage });

// router
router.post('/upload', multerupload.any(), uploadservice.uploadchunk);

// service
uploadchunk: async (req, res) => {
  const file = req.files[0];
  const chunkname = file.filename;

  try {
    const checksum = req.body.checksum;
    const chunkid = req.body.chunkid;

    const message = messages.success(modules.upload, actions.upload, chunkname);
    logger.info(message);
    res.json({ code: 200, message });
  } catch (err) {
    const errmessage = messages.fail(modules.upload, actions.upload, err);
    logger.error(errmessage);
    res.json({ code: 500, message: errmessage });
    res.status(500);
  }
}

上传的文件会被保存在uploads/tmp下,这里是由multer自动帮我们完成的,成功之后,通过req.files能够获取到文件的信息,包括chunk的名称、路径等等,方便做后续的存库处理。

为什么要保证chunk的文件名唯一?

  • 因为文件名是随机的,代表着一旦发生网络中断,如果上传的分片还没有完成,这时数据库也不会有相应的存片记录,导致在下次上传的时候找不到分片。这样的后果是,会在tmp目录下存在着很多游离的分片,而得不到删除。
  • 同时在上传暂停的时候,也能根据chunk的名称来删除相应的临时分片(这步可以不需要,multer判断分片存在的时候,会自动覆盖)

如何保证chunk唯一,有两个办法,

  • 在做文件切割的时候,给每个chunk生成文件指纹 (chunkmd5)
  • 通过整个文件的文件指纹,加上chunk的序列号指定(filemd5 + chunkindex
// 修改上述的代码
const chunkname = `${chunkindex}.${filemd5}.chunk`;
const fd = new formdata();
fd.append(chunkname, chunk);

至此分片上传就大致完成了。

文件合并

文件合并,就是将上传的文件分片分别读取出来,然后整合成一个新的文件,比较耗io,可以在一个新的线程中去整合。

for (let chunkid = 0; chunkid < chunks; chunkid++) {
  const file = `${uploadtmp}/${chunkid}.${checksum}.chunk`;
  const content = await fspromises.readfile(file);
  logger.info(messages.success(modules.upload, actions.get, file));
  try {
    await fspromises.access(path, fs.constants.f_ok);
    await appendfile({ path, content, file, checksum, chunkid });
    if (chunkid === chunks - 1) {
        res.json({ code: 200, message });
    }
  } catch (err) {
    await createfile({ path, content, file, checksum, chunkid });
  }
}

promise.all(tasks).then(() => {
  // when status in uploading, can send /makefile request
  // if not, when status in canceled, send request will delete chunk which has uploaded.
  if (this.status === filestatus.uploading) {
    const data = { chunks: this.chunks.length, filename, checksum: this.checksum };
    axios({
      url: '/makefile',
      method: 'post',
      data,
    })
      .then((res) => {
        if (res.data.code === 200) {
          this._setdoneprogress(this.checksum, filestatus.done);
          toastr.success(`file ${filename} upload successfully!`);
        }
      })
      .catch((err) => {
        console.error(err);
        toastr.error(`file ${filename} upload failed!`);
      });
  }
});
  • 首先使用access判断分片是否存在,如果不存在,则创建新文件并读取分片内容
  • 如果chunk文件存在,则读取内容到文件中
  • 每个chunk读取成功之后,删除chunk

这里有几点需要注意:

  • 如果一个文件切割出来只有一个chunk,那么就需要在createfile的时候进行返回,否则请求一直处于pending状态。

    await createfile({ path, content, file, checksum, chunkid });
    
    if (chunks.length === 1) {
      res.json({ code: 200, message });
    }
    
  • makefile之前务必要判断文件是否是上传状态,不然在cancel的状态下,还会继续上传,导致chunk上传之后,chunk文件被删除,但是在数据库中却存在记录,这样合并出来的文件是有问题的。

文件秒传

捣鼓系列:前端大文件上传

如何做到文件秒传,思考三秒,公布答案,3. 2. 1.....,其实只是个障眼法。

为啥说是个障眼法,因为根本就没有传,文件是从服务器来的。这就有几个问题需要弄清楚,

  • 怎么确定文件是服务器中已经存在了的?
  • 文件的上传的信息是保存在数据库中还是客户端?
  • 文件名不相同,内容相同,应该怎么处理?

问题一:怎么判断文件已经存在了?

可以为每个文件上传生成对应的指纹,但是如果文件太大,客户端生成指纹的时间将大大增加,怎么解决这个问题?

还记得之前的slice,文件切片么?大文件不好做,同样的思路,切成小文件,然后计算md5值就好了。这里使用这个库来生成文件hash。改造上面的slice方法。

export const checksum = (file, piece = chunk_size) => {
  return new promise((resolve, reject) => {
    let totalsize = file.size;
    let start = 0;
    const blobslice = file.prototype.slice || file.prototype.mozslice || file.prototype.webkitslice;
    const chunks = [];
    const spark = new sparkmd5.arraybuffer();
    const filereader = new filereader();

    const loadnext = () => {
      const end = start + piece >= totalsize ? totalsize : start + piece;
      const chunk = blobslice.call(file, start, end);

      start = end;
      chunks.push(chunk);
      filereader.readasarraybuffer(chunk);
    };

    filereader.onload = (event) => {
      spark.append(event.target.result);

      if (start < totalsize) {
        loadnext();
      } else {
        const checksum = spark.end();
        resolve({ chunks, checksum });
      }
    };

    filereader.onerror = () => {
      console.warn('oops, something went wrong.');
      reject();
    };

    loadnext();
  });
};

问题二:文件的上传的信息是保存在数据库中还是客户端?

文件上传的信息最好是保存在服务端的数据库中(客户端可以使用indexdb),这样做有几个优点,

  • 数据库服务提供了成套的crud,方便数据的操作
  • 当用户刷新浏览器之后,或者更换浏览器之后,文件上传的信息不会丢失

这里主要强调的是第二点,因为第一条客户端也可以做