前端大文件上传

最近遇见一个需要上传百兆大文件的需求,调研了七牛和腾讯云的切片分段上传功能,因此在此整理前端大文件上传相关功能的实现。

在某些业务中,大文件上传是一个比较重要的交互场景,如上传入库比较大的Excel表格数据、上传影音文件等。如果文件体积比较大,或者网络条件不好时,上传的时间会比较长(要传输更多的报文,丢包重传的概率也更大),用户不能刷新页面,只能耐心等待请求完成。

下面从文件上传方式入手,整理大文件上传的思路,并给出了相关实例代码,由于PHP内置了比较方便的文件拆分和拼接方法,因此服务端代码使用PHP进行示例编写。

本文相关示例代码位于github

大文件上传

大文件上传最主要的问题就在于:在同一个请求中,要上传大量的数据,导致整个过程会比较漫长,且失败后需要重头开始上传。试想,如果我们将这个请求拆分成多个请求,每个请求的时间就会缩短,且如果某个请求失败,只需要重新发送这一次请求即可,无需从头开始,这样是否可以解决大文件上传的问题呢?

综合上面的问题,看来大文件上传需要实现下面几个需求

  • 支持拆分上传请求(即切片)
  • 支持断点续传
  • 支持显示上传进度和暂停上传

接下来让我们依次实现这些功能,看起来最主要的功能应该就是切片了。

文件切片

编码方式上传中,在前端我们只要先获取文件的二进制内容,然后对其内容进行拆分,最后将每个切片上传到服务端即可。

在JavaScript中,文件FIle对象是Blob对象的子类,Blob对象包含一个重要的方法slice,通过这个方法,我们就可以对二进制文件进行拆分。

下面是一个拆分文件的示例

function slice(file, piece = 1024 * 1024 * 5) {
  let totalSize = file.size; // 文件总大小
  let start = 0; // 每次上传的开始字节
  let end = start + piece; // 每次上传的结尾字节
  let chunks = []
  while (start < totalSize) {
    // 根据长度截取每次需要上传的数据
    // File对象继承自Blob对象,因此包含slice方法
    let blob = file.slice(start, end); 
    chunks.push(blob)

    start = end;
    end = start + piece;
  }
  return chunks
}

将文件拆分成piece大小的分块,然后每次请求只需要上传这一个部分的分块即可

let file =  document.querySelector("[name=file]").files[0];

const LENGTH = 1024 * 1024 * 0.1;
let chunks = slice(file, LENGTH); // 首先拆分切片

chunks.forEach(chunk=>{
  let fd = new FormData();
  fd.append("file", chunk);
  post('/mkblk.php', fd)
})

服务器接收到这些切片后,再将他们拼接起来就可以了,下面是PHP拼接切片的示例代码

$filename = './upload/' . $_POST['filename'];//确定上传的文件名
//第一次上传时没有文件,就创建文件,此后上传只需要把数据追加到此文件中
if(!file_exists($filename)){
    move_uploaded_file($_FILES['file']['tmp_name'],$filename);
}else{
    file_put_contents($filename,file_get_contents($_FILES['file']['tmp_name']),FILE_APPEND);
    echo $filename;
}

测试时记得修改nginx的server配置,否则大文件可能会提示413 Request Entity Too Large的错误。

server {
	// ...
	client_max_body_size 50m;
}

上面这种方式来存在一些问题

  • 无法识别一个切片是属于哪一个切片的,当同时发生多个请求时,追加的文件内容会出错
  • 切片上传接口是异步的,无法保证服务器接收到的切片是按照请求顺序拼接的

因此接下来我们来看看应该如何在服务端还原切片。

还原切片

在后端需要将多个相同文件的切片还原成一个文件,上面这种处理切片的做法存在下面几个问题

  • 如何识别多个切片是来自于同一个文件的,这个可以在每个切片请求上传递一个相同文件的context参数
  • 如何将多个切片还原成一个文件
    • 确认所有切片都已上传,这个可以通过客户端在切片全部上传后调用mkfile接口来通知服务端进行拼接
    • 找到同一个context下的所有切片,确认每个切片的顺序,这个可以在每个切片上标记一个位置索引值
    • 按顺序拼接切片,还原成文件

上面有一个重要的参数,即context,我们需要获取为一个文件的唯一标识,可以通过下面两种方式获取

  • 根据文件名、文件长度等基本信息进行拼接,为了避免多个用户上传相同的文件,可以再额外拼接用户信息如uid等保证唯一性
  • 根据文件的二进制内容计算文件的hash,这样只要文件内容不一样,则标识也会不一样,缺点在于计算量比较大.

修改上传代码,增加相关参数

// 获取context,同一个文件会返回相同的值
function createContext(file) {
 	return file.name + file.length
}

let file = document.querySelector("[name=file]").files[0];
const LENGTH = 1024 * 1024 * 0.1;
let chunks = slice(file, LENGTH);

// 获取对于同一个文件,获取其的context
let context = createContext(file);

let tasks = [];
chunks.forEach((chunk, index) => {
  let fd = new FormData();
  fd.append("file", chunk);
  // 传递context
  fd.append("context", context);
  // 传递切片索引值
  fd.append("chunk", index + 1);
	
  tasks.push(post("/mkblk.php", fd));
});
// 所有切片上传完毕后,调用mkfile接口
Promise.all(tasks).then(res => {
  let fd = new FormData();
  fd.append("context", context);
  fd.append("chunks", chunks.length);
  post("/mkfile.php", fd).then(res => {
    console.log(res);
  });
});

mkblk.php接口中,我们通过context来保存同一个文件相关的切片

// mkblk.php
$context = $_POST['context'];
$path = './upload/' . $context;
if(!is_dir($path)){
    mkdir($path);
}
// 把同一个文件的切片放在相同的目录下
$filename = $path .'/'. $_POST['chunk'];
$res = move_uploaded_file($_FILES['file']['tmp_name'],$filename);

除了上面这种简单通过目录区分切片的方法之外,还可以将切片信息保存在数据库来进行索引。接下来是mkfile.php接口的实现,这个接口会在所有切片上传后调用

// mkfile.php
$context = $_POST['context'];
$chunks = (int)$_POST['chunks'];

//合并后的文件名
$filename = './upload/' . $context . '/file.jpg'; 
for($i = 1; $i <= $chunks; ++$i){
    $file = './upload/'.$context. '/' .$i; // 读取单个切块
    $content = file_get_contents($file);
    if(!file_exists($filename)){
        $fd = fopen($filename, "w+");
    }else{
        $fd = fopen($filename, "a");
    }
    fwrite($fd, $content); // 将切块合并到一个文件上
}
echo $filename;

这样就解决了上面的两个问题:

  • 识别切片来源
  • 保证切片拼接顺序

断点续传

即使将大文件拆分成切片上传,我们仍需等待所有切片上传完毕,在等待过程中,可能发生一系列导致部分切片上传失败的情形,如网络故障、页面关闭等。由于切片未全部上传,因此无法通知服务端合成文件。这种情况下可以通过断点续传来进行处理。

断点续传指的是:可以从已经上传部分开始继续上传未完成的部分,而没有必要从头开始上传,节省上传时间。

由于整个上传过程是按切片维度进行的,且mkfile接口是在所有切片上传完成后由客户端主动调用的,因此断点续传的实现也十分简单:

  • 在切片上传成功后,保存已上传的切片信息
  • 当下次传输相同文件时,遍历切片列表,只选择未上传的切片进行上传
  • 所有切片上传完毕后,再调用mkfile接口通知服务端进行文件合并

因此问题就落在了如何保存已上传切片的信息了,保存一般有两种策略

  • 可以通过locaStorage等方式保存在前端浏览器中,这种方式不依赖于服务端,实现起来也比较方便,缺点在于如果用户清除了本地文件,会导致上传记录丢失
  • 服务端本身知道哪些切片已经上传,因此可以由服务端额外提供一个根据文件context查询已上传切片的接口,在上传文件前调用该文件的历史上传记录

下面让我们通过在本地保存已上传切片记录,来实现断点上传的功能

 // 获取已上传切片记录
function getUploadSliceRecord(context){
  let record = localStorage.getItem(context)
  if(!record){
    return []
  }else {
    try{
      return JSON.parse(record)
    }catch(e){}
  }
}
// 保存已上传切片
function saveUploadSliceRecord(context, sliceIndex){
  let list = getUploadSliceRecord(context)
  list.push(sliceIndex)
  localStorage.setItem(context, JSON.stringify(list))
}

然后对上传逻辑稍作修改,主要是增加上传前检测是已经上传、上传后保存记录的逻辑

let context = createContext(file);
// 获取上传记录
let record = getUploadSliceRecord(context);
let tasks = [];
chunks.forEach((chunk, index) => {
  // 已上传的切片则不再重新上传
  if(record.includes(index)){
    return
  }
	
  let fd = new FormData();
  fd.append("file", chunk);
  fd.append("context", context);
  fd.append("chunk", index + 1);

  let task = post("/mkblk.php", fd).then(res=>{
    // 上传成功后保存已上传切片记录
    saveUploadSliceRecord(context, index)
    record.push(index)
  })
  tasks.push(task);
});

此时上传时刷新页面或者关闭浏览器,再次上传相同文件时,之前已经上传成功的切片就不会再重新上传了。

服务端实现断点续传的逻辑基本相似,只要在getUploadSliceRecord内部调用服务端的查询接口获取已上传切片的记录即可,因此这里不再展开。

此外断点续传还需要考虑切片过期的情况:如果调用了mkfile接口,则磁盘上的切片内容就可以清除掉了,如果客户端一直不调用mkfile的接口,放任这些切片一直保存在磁盘显然是不可靠的,一般情况下,切片上传都有一段时间的有效期,超过该有效期,就会被清除掉。基于上述原因,断点续传也必须同步切片过期的实现逻辑。

上传进度和暂停

通过xhr.upload中的progress方法可以实现监控每一个切片上传进度。

上传暂停的实现也比较简单,通过xhr.abort可以取消当前未完成上传切片的上传,实现上传暂停的效果,恢复上传就跟断点续传类似,先获取已上传的切片列表,然后重新发送未上传的切片。

由于篇幅关系,上传进度和暂停的功能这里就先不实现了。

小结

目前社区已经存在一些成熟的大文件上传解决方案,如七牛SDK腾讯云SDK等,也许并不需要我们手动去实现一个简陋的大文件上传库,但是了解其原理还是十分有必要的。

本文讨论了大文件上传的几种场景,以及大文件上传需要实现的几个功能

  • 通过Blob对象的slice方法将文件拆分成切片
  • 整理了服务端还原文件所需条件和参数,演示了PHP将切片还原成文件
  • 通过保存已上传切片的记录来实现断点续传

 

 


转载:https://juejin.im/post/5cf765275188257c6b51775f

 

 

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值