关于文件上传的分析与实践

166 阅读12分钟

(1)文件上传都发生了啥

在浏览器看来,其实就是 提交表单-编码-解码-保存至数据库 这样一个过程

选择编码

默认的表单提交的编码格式是 application/x-www-form-urlencoded ,但用这个编码传文件会不现实,因为这个编码会对非ASCII码的字符做转义处理,传输文件时,面对一大堆的二进制文件,这个编码就显得很力不从心,他也不支持多文件传输。非要用他做数据上传的话,传递给后台的也是文件名,并没有内容。

所以我们转而使用 multipart/form-data 的编码。这个编码会把表单分成多个部分,每个部分有一个在表单中“不可能出现”的字符来分隔,每个部分都有自己的头信息和数据内容(可以是文本或二进制数据,也可以是文件或表单字段的值。),他还支持多文件多表单数据的传输,也支持断点续传和上传进度显示等功能。

最后的数据样子(后台不分析的情况下)就是第一行和最后一行是boundary,第二行是Content-Disposition包含一些文件名等基本信息,第三行是content-type:text/html,后面就是文件内容。

为什么要编码

就是为了后台跟前台进行更好的交互,统一编码能省很多事,编码的意义就是

  1. 便于后端能够实现一套对应的解析规则
  2. 传递的数据规则里包含所传递文件的基本信息 ,如文件名与文件类型,便后端写出正确格式的文件

后台是怎么解析的

答案很简单,去掉第一个和最后一个,拿到第二行和第三行,再拿到数据内容,存到数据库就行。

 /**
  * @step1 过滤第一行
  * @step2 过滤最后一行
  * @step3 过滤最先出现Content-Disposition的一行
  * @step4 过滤最先出现Content-Type:的一行
  */
 const decodeContent = content => {
     let lines = content.split('\n');
     const findFlagNo = (arr, flag) => arr.findIndex(o => o.includes(flag));
     // 查找 ----- Content-Disposition Content-Type 位置并且删除
     const startNo = findFlagNo(lines, '------');
     lines.splice(startNo, 1);
     const ContentDispositionNo = findFlagNo(lines, 'Content-Disposition');
     lines.splice(ContentDispositionNo, 1);
     const ContentTypeNo = findFlagNo(lines, 'Content-Type');
     lines.splice(ContentTypeNo, 1);
     // 最后的 ----- 要在数组末往前找
     const endNo = lines.length - findFlagNo(lines.reverse(), '------') - 1;
     // 先反转回来
     lines.reverse().splice(endNo, 1);
     return Buffer.from(lines.join('\n'));
   }

不编码会怎么样

也就是 file格式直接表单上

file格式是blob格式的子类,后台什么处理都不做,将拿到的数据写入到txt文件中,直接打开就能拿到最原始的文件。

两者的不同在于 File 对象下会多出一些文件相关的属性,而 Blob 对象则只是基本的 size 与 type 属性。当打印 arrayBuffer 函数的返回值时发现其内容也是完全一致的

arrayBuffer :上面这些数字其实就是文件的内容,大家都知道数据是 0,1 组成的世界,而 ArrayBuffer 则是更多的数字来体现的数据世界,它和二进制的目的是一样的,它被用来表示通用的、固定长度的原始二进制数据缓冲区。说到这里则必须要提起一个新的概念,浏览器的提供的 Blob 接口。

其实我认为用application/json也可以传,也就是以文本形式传输。例如我们知道了文件是以二进制的形式存在,application/json 是以文本形式进行传输,那么某种意义上我们确实可以将文件转成例如文本形式的 Base64 形式。但是呢,你转成这样的形式,后端也需要按照你这样传输的形式,做特殊的解析。并且文本在传输过程中是相比二进制效率低的,那么对于我们动辄几十 M 几百 M 的文件来说是速度是更慢的。

(2)大文件上传

首先介绍文件有几种上传方式以及他们对于大文件上传的优缺点分析:

介绍几种上传方式

1.表单formdata上传

指定表单的提交内容类型为 enctype="multipart/form-data",表明表单需要上传二进制数据。form 表单上传大文件时,很容易遇见服务器超时的问题。

通过 xhr,前端也可以进行异步上传文件的操作,一般有下面两个思路。

2.formData格式上传

new formData()

常用的方式

3.base64编码上传

之前做图片封面上传的时候就是用base64,服务器也只要解码一下然后保存就行了。

base64 编码的缺点在于其体积比原图片更大(因为 Base64 将三个字节转化成四个字节,因此编码后的文本,会比原文本大出三分之一左右),对于体积很大的文件来说,上传和解析的时间会明显增加。

4. iframe 无跳转刷新页面

低版本的浏览器(如 IE)上,xhr 是不支持直接上传 formdata 的,因此只能用 form 来上传文件,而 form 提交本身会进行页面跳转,这是因为 form 表单的 target 属性导致的,其取值有

  • _self,默认值,在相同的窗口中打开响应页面
  • _blank,在新窗口打开
  • _parent,在父窗口打开
  • _top,在最顶层的窗口打开
  • framename,在指定名字的 iframe 中打开

如果需要让用户体验异步上传文件的感觉,可以通过 framename 指定 iframe 来实现。把 form 的 target 属性设置为一个看不见的 iframe,那么返回的数据就会被这个 iframe 接受,因此只有该 iframe 会被刷新,至于返回结果,也可以通过解析这个 iframe 内的文本来获取。

大文件上传

上面的方式都无法避免大文件上传的请求超时痛点

在同一个请求中,要上传大量的数据,导致整个过程会比较漫长,且失败后需要重头开始上传

思路:

拆分成多个请求,有一个失败重启这一个就行了,不用重头开始。

前端切片

切片:文件 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
 }

之后上传的话直接把这个chunks数组拿过来放formdata 里传过去就行,不不不要把每个子项传过去,一整个传过去不行

 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', fd)
 })

存在以下问题:

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

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

还原切片

在前端上传文件时,文件会被切割成若干个分片并上传给后端,后端需要将这些分片的数据合并成一个完整的文件。一般的做法是:

  1. 为每个分片创建一个唯一的标识符,以便在存储和读取时能够找到对应的分片。这个标识符通常是由文件名、分片序号、文件大小和文件类型等组合而成的。
  2. 将每个分片的数据存储到一个临时目录中。在存储时,可以以上述生成的唯一标识符来作为文件名,以便后续读取时能够找到对应的分片。
  3. 当所有分片上传完成后,后端需要读取这些分片并将它们合并成一个完整的文件。一般的做法是,先获取所有分片在磁盘中存储的路径,然后按照分片的序号依次读取每个分片的数据,并将其写入到一个新的文件中,直到所有分片的数据都被写入到新文件中
  4. 最后,删除临时目录和分片文件。完成文件合并后,这些分片文件就可以删除了,以释放磁盘空间。

下面是nodejs实现的代码

const fs = require('fs');
const path = require('path');
const { promisify } = require('util');
const readdir = promisify(fs.readdir);
const writeFile = promisify(fs.writeFile);

// 获取指定目录下的所有文件名
async function getFiles(dir) {
  const files = await readdir(dir);
  return files.map(file => path.join(dir, file));
}

// 将所有分片文件的数据合并成一个完整的文件
async function mergeFile(dir, filename) {
  // 获取所有分片文件的路径
  const files = await getFiles(dir);
  // 按照分片序号排序
  files.sort((a, b) => parseInt(a.split('.')[1]) - parseInt(b.split('.')[1]));
  // 创建一个可写流
  const writeStream = fs.createWriteStream(filename);
  // 逐一读取分片文件的数据,并写入到完整文件中
  for (const file of files) {
    const data = fs.readFileSync(file);
    writeStream.write(data);
  }
  // 关闭可写流
  writeStream.end();
  // 删除临时目录和分片文件
  await promisify(fs.rmdir)(dir, { recursive: true });
}

在这段代码中,通过 getFiles 获取指定目录下的所有文件名,并通过 sort 方法按照分片序号进行排序。接着,创建一个可写流,逐一读取分片文件的数据,并使用 write 写入到完整文件中,最后关闭可写流,文件合并完成,临时目录和分片文件也被删除掉了。

需要考虑的问题:

  • 如何识别多个切片是来自于同一个文件的

    • 这个可以在每个切片请求上传递一个相同文件的 context 参数来作标识
  • 如何将多个切片还原成一个文件

    • 可以让前端再发一次请求通知服务器可以拼接了
    • 找到同一个 context 下的所有切片,确认每个切片的顺序,这个可以在每个切片上标记一个位置索引值
    • 按顺序拼接切片,还原成文件
关于第一个识别是否同一个文件(文件是否更新)的问题

context 可以取下面两个值

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

下面的代码跟上面不一样的地方在于:

  1. 添加context(文件名+文件长度)
  2. 保存切片的顺序(chunk在chunks中的索引值)
  3. 往请求列表里添加每一个切片的上传请求。(task.push(post("/mkblk.php", fd)))
  4. 使用 promise.all 实现并发上传。
 // 获取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);
   });
 });
关于第二个如何还原文件的问题

在前端上传文件时,文件会被切割成若干个分片并上传给后端,后端需要将这些分片的数据合并成一个完整的文件。一般的做法是:

  1. 为每个分片创建一个唯一的标识符,以便在存储和读取时能够找到对应的分片。这个标识符通常是由文件名、分片序号、文件大小和文件类型等组合而成的。
  2. 将每个分片的数据存储到一个临时目录中。在存储时,可以以上述生成的唯一标识符来作为文件名,以便后续读取时能够找到对应的分片。
  3. 当所有分片上传完成后,后端需要读取这些分片并将它们合并成一个完整的文件。一般的做法是,先获取所有分片在磁盘中存储的路径,然后按照分片的序号依次读取每个分片的数据,并将其写入到一个新的文件中,直到所有分片的数据都被写入到新文件中。
  4. 最后,删除临时目录和分片文件。完成文件合并后,这些分片文件就可以删除了,以释放磁盘空间。

在 /mkblk.php 接口中,他的工作是个卸货工人,就是把切片放到同一个目录下面

 // 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']; // 切片的总个数 chunks.length
 ​
 //合并后的文件名
 // 读取在mkblk.php创建的用来存放分片的临时目录(临时仓库)。
 $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;

这段代码没有采用 Stream 流的方式来避免内存占用过高的问题。同时它也没有对上传的文件做任何类型和大小的校验,这是需要注意的地方,比如文件类型错误或文件大小过大等校验。实际应用中需要根据需求做相应的调整。

但不管怎么说,这段代码达到了之前的要求

  • 识别切片来源(context)
  • 保证切片拼接顺序(每个chunk在chunks中的索引说)

(3)断点续传

简单来说,断点续传的要点在于保存每个chunk的context(标识)值。

前端如果发现这个chunk被发送过(context值在record里被找到了),就跳过这次的chunk,遇到后面发现没上传过的,就继续跟之前的一样。

实现断点续传有下面几个思考点来做到断点续传

  • 在切片上传成功后,保存已上传的切片信息
  • 当下次传输相同文件时,遍历切片列表,只选择未上传的切片进行上传
  • 所有切片上传完毕后,再调用 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 的接口,放任这些切片一直保存在磁盘显然是不可靠的,一般情况下,切片上传都有一段时间的有效期,超过该有效期,就会被清除掉。基于上述原因,断点续传也必须同步切片过期的实现逻辑。

参考文章:juejin.cn/post/684490…