依星源码资源网,依星资源网

 找回密码
 立即注册

QQ登录

只需一步,快速开始

【好消息,好消息,好消息】VIP会员可以发表文章赚积分啦 !
查看: 239|回复: 0

字节跳动面试官:请你实现一个大文件上传和断点续传

[复制链接] 主动推送

1万

主题

1万

帖子

1万

积分

管理员

Rank: 9Rank: 9Rank: 9

积分
12016
发表于 2024-8-29 17:35:40 | 显示全部楼层 |阅读模式
字节跳动面试官:请你实现一个大文件上传和断点续传
前言这段时间面试官都挺忙的,频频出现在博客文章标题,虽然我不是特别想蹭热度,但是实在想不到好的标题了-。-,蹭蹭就蹭蹭
事实上我在面试的时候确实被问到了这个问题,而且是一道在线 coding 的编程题,当时虽然思路正确,可惜最终也并不算完全答对
结束后花了一段时间整理了下思路,那么究竟该如何实现一个大文件上传,以及在上传中如何实现断点续传的功能呢?
本文将从零搭建前端和服务端,实现一个大文件上传和断点续传的 demo
前端:Vue@2 + Element-ui
服务端:Nodejs@14 + multiparty
大文件上传整体思路前端前端大文件上传网上的大部分文章已经给出了解决方案,核心是利用 Blob.prototype.slice 方法,和数组的 slice 方法相似,文件的 slice 方法可以返回原文件的某个切片
预先定义好单个切片大小,将文件切分为一个个切片,然后借助 http 的可并发性,同时上传多个切片。这样从原本传一个大文件,变成了并发传多个小的文件切片,可以大大减少上传时间
另外由于是并发,传输到服务端的顺序可能会发生变化,因此我们还需要给每个切片记录顺序
服务端服务端负责接受前端传输的切片,并在接收到所有切片后合并所有切片
这里又引伸出两个问题
  • 何时合并切片,即切片什么时候传输完成
  • 如何合并切片
第一个问题需要前端配合,前端在每个切片中都携带切片最大数量的信息,当服务端接受到这个数量的切片时自动合并。或者也可以额外发一个请求,主动通知服务端进行切片的合并
第二个问题,具体如何合并切片呢?这里可以使用 Nodejs 的 读写流(readStream/writeStream),将所有切片的流传输到最终文件的流里
talk is cheap,show me the code,接着我们用代码实现上面的思路
前端部分前端使用 Vue 作为开发框架,对界面没有太大要求,原生也可以,考虑到美观使用 Element-ui 作为 UI 框架
上传控件首先创建选择文件的控件并监听 change 事件,另外就是上传按钮

  1. <template>
  2.    <div>
  3.     <input type="file" @change="handleFileChange" />
  4.     <el-button @click="handleUpload">upload</el-button>
  5.   </div>
  6. </template>

  7. <script>
  8. export default {
  9.   data: () => ({
  10.     container: {
  11.       file: null
  12.     }
  13.   }),
  14.   methods: {
  15.      handleFileChange(e) {
  16.       const [file] = e.target.files;
  17.       if (!file) return;
  18.       Object.assign(this.$data, this.$options.data());
  19.       this.container.file = file;
  20.     },
  21.     async handleUpload() {}
  22.   }
  23. };
  24. </script>
复制代码

字节跳动面试官:请你实现一个大文件上传和断点续传

字节跳动面试官:请你实现一个大文件上传和断点续传

请求逻辑
考虑到通用性,这里没有用第三方的请求库,而是用原生 XMLHttpRequest 做一层简单的封装来发请求
  1. request({
  2.       url,
  3.       method = "post",
  4.       data,
  5.       headers = {},
  6.       requestList
  7.     }) {
  8.       return new Promise(resolve => {
  9.         const xhr = new XMLHttpRequest();
  10.         xhr.open(method, url);
  11.         Object.keys(headers).forEach(key =>
  12.           xhr.setRequestHeader(key, headers[key])
  13.         );
  14.         xhr.send(data);
  15.         xhr.onload = e => {
  16.           resolve({
  17.             data: e.target.response
  18.           });
  19.         };
  20.       });
  21.     }
复制代码
上传切片
接着实现比较重要的上传功能,上传需要做两件事
  • 对文件进行切片
  • 将切片传输给服务端
  1. <template>
  2.   <div>
  3.     <input type="file" @change="handleFileChange" />
  4.     <el-button @click="handleUpload">上传</el-button>
  5.   </div>
  6. </template>

  7. <script>
  8. + // 切片大小
  9. + // the chunk size
  10. + const SIZE = 10 * 1024 * 1024;

  11. export default {
  12.   data: () => ({
  13.     container: {
  14.       file: null
  15.     },
  16. +   data: []
  17.   }),
  18.   methods: {
  19.     request() {},
  20.     handleFileChange() {},
  21. +    // 生成文件切片
  22. +    createFileChunk(file, size = SIZE) {
  23. +     const fileChunkList = [];
  24. +      let cur = 0;
  25. +      while (cur < file.size) {
  26. +        fileChunkList.push({ file: file.slice(cur, cur + size) });
  27. +        cur += size;
  28. +      }
  29. +      return fileChunkList;
  30. +    },
  31. +   // 上传切片
  32. +    async uploadChunks() {
  33. +      const requestList = this.data
  34. +        .map(({ chunk,hash }) => {
  35. +          const formData = new FormData();
  36. +          formData.append("chunk", chunk);
  37. +          formData.append("hash", hash);
  38. +          formData.append("filename", this.container.file.name);
  39. +          return { formData };
  40. +        })
  41. +        .map(({ formData }) =>
  42. +          this.request({
  43. +            url: "http://localhost:3000",
  44. +            data: formData
  45. +          })
  46. +        );
  47. +      // 并发请求
  48. +      await Promise.all(requestList);
  49. +    },
  50. +    async handleUpload() {
  51. +      if (!this.container.file) return;
  52. +      const fileChunkList = this.createFileChunk(this.container.file);
  53. +      this.data = fileChunkList.map(({ file },index) => ({
  54. +        chunk: file,
  55. +        // 文件名 + 数组下标
  56. +        hash: this.container.file.name + "-" + index
  57. +      }));
  58. +      await this.uploadChunks();
  59. +    }
  60.   }
  61. };
  62. </script>
复制代码


当点击上传按钮时,调用 createFileChunk 将文件切片,切片数量通过文件大小控制,这里设置 10MB,也就是说一个 100 MB 的文件会被分成 10 个 10MB 的切片
createFileChunk 内使用 while 循环和 slice 方法将切片放入 fileChunkList 数组中返回
在生成文件切片时,需要给每个切片一个标识作为 hash,这里暂时使用文件名 + 下标,这样后端可以知道当前切片是第几个切片,用于之后的合并切片
随后调用 uploadChunks 上传所有的文件切片,将文件切片,切片 hash,以及文件名放入 formData 中,再调用上一步的 request 函数返回一个 proimise,最后调用 Promise.all 并发上传所有的切片
发送合并请求使用整体思路中提到的第二种合并切片的方式,即前端主动通知服务端进行合并
前端发送额外的合并请求,服务端接受到请求时合并切片

  1. <template>
  2.   <div>
  3.     <input type="file" @change="handleFileChange" />
  4.     <el-button @click="handleUpload">upload</el-button>
  5.   </div>
  6. </template>

  7. <script>
  8. export default {
  9.   data: () => ({
  10.     container: {
  11.       file: null
  12.     },
  13.     data: []
  14.   }),
  15.   methods: {
  16.     request() {},
  17.     handleFileChange() {},
  18.     createFileChunk() {},
  19.     async uploadChunks() {
  20.       const requestList = this.data
  21.         .map(({ chunk,hash }) => {
  22.           const formData = new FormData();
  23.           formData.append("chunk", chunk);
  24.           formData.append("hash", hash);
  25.           formData.append("filename", this.container.file.name);
  26.           return { formData };
  27.         })
  28.         .map(({ formData }) =>
  29.           this.request({
  30.             url: "http://localhost:3000",
  31.             data: formData
  32.           })
  33.         );
  34.       await Promise.all(requestList);
  35. +     // 合并切片
  36. +     await this.mergeRequest();
  37.     },
  38. +    async mergeRequest() {
  39. +      await this.request({
  40. +        url: "http://localhost:3000/merge",
  41. +        headers: {
  42. +          "content-type": "application/json"
  43. +        },
  44. +        data: JSON.stringify({
  45. +          filename: this.container.file.name
  46. +        })
  47. +      });
  48. +    },   
  49.     async handleUpload() {}
  50.   }
  51. };
  52. </script>
复制代码



服务端部分
使用 http 模块搭建一个简单服务端
  1. const http = require("http");
  2. const server = http.createServer();

  3. server.on("request", async (req, res) => {
  4.   res.setHeader("Access-Control-Allow-Origin", "*");
  5.   res.setHeader("Access-Control-Allow-Headers", "*");
  6.   if (req.method === "OPTIONS") {
  7.     res.status = 200;
  8.     res.end();
  9.     return;
  10.   }
  11. });

  12. server.listen(3000, () => console.log("listening port 3000"));
复制代码
接受切片
使用 multiparty 处理前端传来的 formData
在 multiparty.parse 的回调中,files 参数保存了 formData 中文件,fields 参数保存了 formData 中非文件的字段
  1. const http = require("http");
  2. const path = require("path");
  3. + const fse = require("fs-extra");
  4. + const multiparty = require("multiparty");

  5. const server = http.createServer();
  6. + // 大文件存储目录
  7. + const UPLOAD_DIR = path.resolve(__dirname, "..", "target");

  8. server.on("request", async (req, res) => {
  9.   res.setHeader("Access-Control-Allow-Origin", "*");
  10.   res.setHeader("Access-Control-Allow-Headers", "*");
  11.   if (req.method === "OPTIONS") {
  12.     res.status = 200;
  13.     res.end();
  14.     return;
  15.   }

  16. +  const multipart = new multiparty.Form();

  17. +  multipart.parse(req, async (err, fields, files) => {
  18. +    if (err) {
  19. +      return;
  20. +    }
  21. +    const [chunk] = files.chunk;
  22. +    const [hash] = fields.hash;
  23. +    const [filename] = fields.filename;
  24. +    // 创建临时文件夹用于临时存储 chunk
  25. +    // 添加 chunkDir 前缀与文件名做区分
  26. +    const chunkDir = path.resolve(UPLOAD_DIR, 'chunkDir' + filename);

  27. +    if (!fse.existsSync(chunkDir)) {
  28. +      await fse.mkdirs(chunkDir);
  29. +    }

  30. +    // fs-extra 的 rename 方法 windows 平台会有权限问题
  31. +    // @see https://github.com/meteor/meteor/issues/7852#issuecomment-255767835
  32. +    await fse.move(chunk.path, `${chunkDir}/${hash}`);
  33. +    res.end("received file chunk");
  34. +  });
  35. });

  36. server.listen(3000, () => console.log("listening port 3000"));
复制代码

字节跳动面试官:请你实现一个大文件上传和断点续传

字节跳动面试官:请你实现一个大文件上传和断点续传
查看 multiparty 处理后的 chunk 对象,path 是存储临时文件的路径,size 是临时文件大小,在 multiparty 文档中提到可以使用 fs.rename(这里换成了 fs.remove, 因为 fs-extra 的 rename 方法在 windows 平台存在权限问题
在接受文件切片时,需要先创建临时存储切片的文件夹,以 chunkDir 作为前缀,文件名作为后缀
由于前端在发送每个切片时额外携带了唯一值 hash,所以以 hash 作为文件名,将切片从临时路径移动切片文件夹中,最后的结果如下

字节跳动面试官:请你实现一个大文件上传和断点续传

字节跳动面试官:请你实现一个大文件上传和断点续传


合并切片
在接收到前端发送的合并请求后,服务端将文件夹下的所有切片进行合并
  1. const http = require("http");
  2. const path = require("path");
  3. const fse = require("fs-extra");

  4. const server = http.createServer();
  5. const UPLOAD_DIR = path.resolve(__dirname, "..", "target");

  6. + const resolvePost = req =>
  7. +   new Promise(resolve => {
  8. +     let chunk = "";
  9. +     req.on("data", data => {
  10. +       chunk += data;
  11. +     });
  12. +     req.on("end", () => {
  13. +       resolve(JSON.parse(chunk));
  14. +     });
  15. +   });

  16. + // 写入文件流
  17. + const pipeStream = (path, writeStream) =>
  18. +  new Promise(resolve => {
  19. +    const readStream = fse.createReadStream(path);
  20. +    readStream.on("end", () => {
  21. +      fse.unlinkSync(path);
  22. +      resolve();
  23. +    });
  24. +    readStream.pipe(writeStream);
  25. +  });

  26. // 合并切片
  27. + const mergeFileChunk = async (filePath, filename, size) => {
  28. +   const chunkDir = path.resolve(UPLOAD_DIR, 'chunkDir' + filename);
  29. +   const chunkPaths = await fse.readdir(chunkDir);
  30. +   // 根据切片下标进行排序
  31. +   // 否则直接读取目录的获得的顺序会错乱
  32. +   chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
  33. +   // 并发写入文件
  34. +   await Promise.all(
  35. +     chunkPaths.map((chunkPath, index) =>
  36. +       pipeStream(
  37. +         path.resolve(chunkDir, chunkPath),
  38. +         // 根据 size 在指定位置创建可写流
  39. +         fse.createWriteStream(filePath, {
  40. +           start: index * size,
  41. +         })
  42. +       )
  43. +     )
  44. +  );
  45. +  // 合并后删除保存切片的目录
  46. +  fse.rmdirSync(chunkDir);
  47. +};

  48. server.on("request", async (req, res) => {
  49.   res.setHeader("Access-Control-Allow-Origin", "*");
  50.   res.setHeader("Access-Control-Allow-Headers", "*");
  51.   if (req.method === "OPTIONS") {
  52.     res.status = 200;
  53.     res.end();
  54.     return;
  55.   }

  56. +   if (req.url === "/merge") {
  57. +     const data = await resolvePost(req);
  58. +     const { filename,size } = data;
  59. +     const filePath = path.resolve(UPLOAD_DIR, `${filename}`);
  60. +     await mergeFileChunk(filePath, filename);
  61. +     res.end(
  62. +       JSON.stringify({
  63. +         code: 0,
  64. +         message: "file merged success"
  65. +       })
  66. +     );
  67. +   }

  68. });

  69. server.listen(3000, () => console.log("listening port 3000"));
复制代码
由于前端在发送合并请求时会携带文件名,服务端根据文件名可以找到上一步创建的切片文件夹
接着使用 fs.createWriteStream 创建一个可写流,可写流文件名就是上传时的文件名
随后遍历整个切片文件夹,将切片通过 fs.createReadStream 创建可读流,传输合并到目标文件中
值得注意的是每次可读流都会传输到可写流的指定位置,这是通过 createWriteStream 的第二个参数 start 控制的,目的是能够并发合并多个可读流至可写流中,这样即使并发时流的顺序不同,也能传输到正确的位置
所以还需要让前端在请求的时候提供之前设定好的 size 给服务端,服务端根据 size 指定可读流的起始位置

  1.    async mergeRequest() {
  2.       await this.request({
  3.         url: "http://localhost:3000/merge",
  4.         headers: {
  5.           "content-type": "application/json"
  6.         },
  7.         data: JSON.stringify({
  8. +         size: SIZE,
  9.           filename: this.container.file.name
  10.         })
  11.       });
  12.     },
复制代码

字节跳动面试官:请你实现一个大文件上传和断点续传

字节跳动面试官:请你实现一个大文件上传和断点续传

其实也可以等上一个切片合并完后再合并下个切片,这样就不需要指定位置,但传输速度会降低,所以使用了并发合并的手段
接着只要保证每次合并完成后删除这个切片,等所有切片都合并完毕后最后删除切片文件夹即可

字节跳动面试官:请你实现一个大文件上传和断点续传

字节跳动面试官:请你实现一个大文件上传和断点续传
至此一个简单的大文件上传就完成了,接下来我们再此基础上扩展一些额外的功能
显示上传进度条上传进度分两种,一个是每个切片的上传进度,另一个是整个文件的上传进度,而整个文件的上传进度是基于每个切片上传进度计算而来,所以我们先实现单个切片的进度条
单个切片进度条XMLHttpRequest 原生支持上传进度的监听,只需要监听 upload.onprogress 即可,我们在原来的 request 基础上传入 onProgress 参数,给 XMLHttpRequest 注册监听事件

  1. // xhr
  2.     request({
  3.       url,
  4.       method = "post",
  5.       data,
  6.       headers = {},
  7. +     onProgress = e => e,
  8.       requestList
  9.     }) {
  10.       return new Promise(resolve => {
  11.         const xhr = new XMLHttpRequest();
  12. +       xhr.upload.onprogress = onProgress;
  13.         xhr.open(method, url);
  14.         Object.keys(headers).forEach(key =>
  15.           xhr.setRequestHeader(key, headers[key])
  16.         );
  17.         xhr.send(data);
  18.         xhr.onload = e => {
  19.           resolve({
  20.             data: e.target.response
  21.           });
  22.         };
  23.       });
  24.     }
复制代码
由于每个切片都需要触发独立的监听事件,所以需要一个工厂函数,根据传入的切片返回不同的监听函数
在原先的前端上传逻辑中新增监听函数部分
  1.     // 上传切片,同时过滤已上传的切片
  2.     async uploadChunks(uploadedList = []) {
  3.       const requestList = this.data
  4. +       .map(({ chunk,hash,index }) => {
  5.           const formData = new FormData();
  6.           formData.append("chunk", chunk);
  7.           formData.append("hash", hash);
  8.           formData.append("filename", this.container.file.name);
  9. +         return { formData,index };
  10.         })
  11. +       .map(({ formData,index }) =>
  12.           this.request({
  13.             url: "http://localhost:3000",
  14.             data: formData,
  15. +           onProgress: this.createProgressHandler(this.data[index]),
  16.           })
  17.         );
  18.       await Promise.all(requestList);
  19.       await this.mergeRequest();
  20.     },
  21.     async handleUpload() {
  22.       if (!this.container.file) return;
  23.       const fileChunkList = this.createFileChunk(this.container.file);
  24.       this.data = fileChunkList.map(({ file },index) => ({
  25.         chunk: file,
  26. +       index,
  27.         hash: this.container.file.name + "-" + index
  28. +       percentage:0
  29.       }));
  30.       await this.uploadChunks();
  31.     }   
  32. +   createProgressHandler(item) {
  33. +      return e => {
  34. +        item.percentage = parseInt(String((e.loaded / e.total) * 100));
  35. +      };
  36. +   }
复制代码
每个切片在上传时都会通过监听函数更新 data 数组对应元素的 percentage 属性,之后把将 data 数组放到视图中展示即可
总进度条
将每个切片已上传的部分累加,除以整个文件的大小,就能得出当前文件的上传进度,所以这里使用 Vue 的计算属性
  1.   computed: {
  2.        uploadPercentage() {
  3.           if (!this.container.file || !this.data.length) return 0;
  4.           const loaded = this.data
  5.             .map(item => item.size * item.percentage)
  6.             .reduce((acc, cur) => acc + cur);
  7.           return parseInt((loaded / this.container.file.size).toFixed(2));
  8.         }
  9. }
复制代码
最终展示如下

字节跳动面试官:请你实现一个大文件上传和断点续传

字节跳动面试官:请你实现一个大文件上传和断点续传
断点续传断点续传的原理在于前端/服务端需要记住已上传的切片,这样下次上传就可以跳过之前已上传的部分,有两种方案实现记忆的功能
  • 前端使用 localStorage 记录已上传的切片 hash
  • 服务端保存已上传的切片 hash,前端每次上传前向服务端获取已上传的切片
第一种是前端的解决方案,第二种是服务端,而前端方案有一个缺陷,如果换了个浏览器就失去了记忆的效果,所以这里选后者
生成 hash无论是前端还是服务端,都必须要生成文件和切片的 hash,之前我们使用文件名 + 切片下标作为切片 hash,这样做文件名一旦修改就失去了效果,而事实上只要文件内容不变,hash 就不应该变化,所以正确的做法是根据文件内容生成 hash,所以我们修改一下 hash 的生成规则

webpack 的产物 contenthash 也是基于这个思路实现的
这里用到另一个库 spark-md5,它可以根据文件内容计算出文件的 hash 值
另外考虑到如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会引起 UI 的阻塞,导致页面假死状态,所以我们使用 web-worker 在 worker 线程计算 hash,这样用户仍可以在主界面正常的交互
由于实例化 web-worker 时,参数是一个 js 文件路径且不能跨域,所以我们单独创建一个 hash.js 文件放在 public 目录下,另外在 worker 中也是不允许访问 dom 的,但它提供了importScripts 函数用于导入外部脚本,通过它导入 spark-md5

  1. // /public/hash.js

  2. // 导入脚本
  3. self.importScripts("/spark-md5.min.js");

  4. // 生成文件 hash
  5. self.onmessage = e => {
  6.   const { fileChunkList } = e.data;
  7.   const spark = new self.SparkMD5.ArrayBuffer();
  8.   let percentage = 0;
  9.   let count = 0;
  10.   const loadNext = index => {
  11.     const reader = new FileReader();
  12.     reader.readAsArrayBuffer(fileChunkList[index].file);
  13.     reader.onload = e => {
  14.       count++;
  15.       spark.append(e.target.result);
  16.       if (count === fileChunkList.length) {
  17.         self.postMessage({
  18.           percentage: 100,
  19.           hash: spark.end()
  20.         });
  21.         self.close();
  22.       } else {
  23.         percentage += 100 / fileChunkList.length;
  24.         self.postMessage({
  25.           percentage
  26.         });
  27.         // calculate recursively
  28.         loadNext(count);
  29.       }
  30.     };
  31.   };
  32.   loadNext(0);
  33. };
复制代码
在 worker 线程中,接受文件切片 fileChunkList,利用 fileReader 读取每个切片的 ArrayBuffer 并不断传入 spark-md5 中,每计算完一个切片通过 postMessage 向主线程发送一个进度事件,全部完成后将最终的 hash 发送给主线程
spark-md5 文档中要求传入所有切片并算出 hash 值,不能直接将整个文件放入计算,否则即使不同文件也会有相同的 hash
接着编写主线程与 worker 线程通讯的逻辑

  1. +    // 生成文件 hash(web-worker)
  2. +    calculateHash(fileChunkList) {
  3. +      return new Promise(resolve => {
  4. +        // 添加 worker 属性
  5. +        this.container.worker = new Worker("/hash.js");
  6. +        this.container.worker.postMessage({ fileChunkList });
  7. +        this.container.worker.onmessage = e => {
  8. +          const { percentage, hash } = e.data;
  9. +          this.hashPercentage = percentage;
  10. +          if (hash) {
  11. +            resolve(hash);
  12. +          }
  13. +        };
  14. +      });
  15.     },
  16.     async handleUpload() {
  17.       if (!this.container.file) return;
  18.       const fileChunkList = this.createFileChunk(this.container.file);
  19. +     this.container.hash = await this.calculateHash(fileChunkList);
  20.       this.data = fileChunkList.map(({ file },index) => ({
  21. +       fileHash: this.container.hash,
  22.         chunk: file,
  23.         hash: this.container.file.name + "-" + index,
  24.         percentage:0
  25.       }));
  26.       await this.uploadChunks();
  27.     }   
复制代码
主线程使用 postMessage 给 worker 线程传入所有切片 fileChunkList,并监听 worker 线程发出的 postMessage 事件拿到文件 hash
加上显示计算 hash 的进度条,看起来像这样

字节跳动面试官:请你实现一个大文件上传和断点续传

字节跳动面试官:请你实现一个大文件上传和断点续传
至此前端需要将之前用文件名作为 hash 的地方改写为 worker 返回的 hash

字节跳动面试官:请你实现一个大文件上传和断点续传

字节跳动面试官:请你实现一个大文件上传和断点续传

服务端则使用固定前缀 + hash 作为切片文件夹名,hash + 下标作为切片名,hash + 扩展名作为文件名

字节跳动面试官:请你实现一个大文件上传和断点续传

字节跳动面试官:请你实现一个大文件上传和断点续传


文件秒传在实现断点续传前先简单介绍一下文件秒传
所谓的文件秒传,即在服务端已经存在了上传的资源,所以当用户再次上传时会直接提示上传成功
文件秒传需要依赖上一步生成的 hash,即在上传前,先计算出文件 hash,并把 hash 发送给服务端进行验证,由于 hash 的唯一性,所以一旦服务端能找到 hash 相同的文件,则直接返回上传成功的信息即可


  1. +    async verifyUpload(filename, fileHash) {
  2. +       const { data } = await this.request({
  3. +         url: "http://localhost:3000/verify",
  4. +         headers: {
  5. +           "content-type": "application/json"
  6. +         },
  7. +         data: JSON.stringify({
  8. +           filename,
  9. +           fileHash
  10. +         })
  11. +       });
  12. +       return JSON.parse(data);
  13. +     },
  14.    async handleUpload() {
  15.       if (!this.container.file) return;
  16.       const fileChunkList = this.createFileChunk(this.container.file);
  17.       this.container.hash = await this.calculateHash(fileChunkList);
  18. +     const { shouldUpload } = await this.verifyUpload(
  19. +       this.container.file.name,
  20. +       this.container.hash
  21. +     );
  22. +     if (!shouldUpload) {
  23. +       this.$message.success("skip upload:file upload success");
  24. +       return;
  25. +    }
  26.      this.data = fileChunkList.map(({ file }, index) => ({
  27.         fileHash: this.container.hash,
  28.         index,
  29.         hash: this.container.hash + "-" + index,
  30.         chunk: file,
  31.         percentage: 0
  32.       }));
  33.       await this.uploadChunks();
  34.     }   
复制代码
秒传其实就是给用户看的障眼法,实质上根本没有上传

字节跳动面试官:请你实现一个大文件上传和断点续传

字节跳动面试官:请你实现一个大文件上传和断点续传

服务端的逻辑非常简单,新增一个验证接口,验证文件是否存在即可
  1. + // 提取后缀名
  2. + const extractExt = filename =>
  3. +  filename.slice(filename.lastIndexOf("."), filename.length);
  4. const UPLOAD_DIR = path.resolve(__dirname, "..", "target");

  5. const resolvePost = req =>
  6.   new Promise(resolve => {
  7.     let chunk = "";
  8.     req.on("data", data => {
  9.       chunk += data;
  10.     });
  11.     req.on("end", () => {
  12.       resolve(JSON.parse(chunk));
  13.     });
  14.   });

  15. server.on("request", async (req, res) => {
  16.   if (req.url === "/verify") {
  17. +    const data = await resolvePost(req);
  18. +    const { fileHash, filename } = data;
  19. +    const ext = extractExt(filename);
  20. +    const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${ext}`);
  21. +    if (fse.existsSync(filePath)) {
  22. +      res.end(
  23. +        JSON.stringify({
  24. +          shouldUpload: false
  25. +        })
  26. +      );
  27. +    } else {
  28. +      res.end(
  29. +        JSON.stringify({
  30. +          shouldUpload: true
  31. +        })
  32. +      );
  33. +    }
  34.   }
  35. });

  36. server.listen(3000, () => console.log("listening port 3000"));
复制代码
暂停上传讲完了生成 hash 和文件秒传,回到断点续传
断点续传顾名思义即断点 + 续传,所以我们第一步先实现“断点”,也就是暂停上传
原理是使用 XMLHttpRequest 的 abort 方法,可以取消一个 xhr 请求的发送,为此我们需要将上传每个切片的 xhr 对象保存起来,我们再改造一下 request 方法

  1.    request({
  2.       url,
  3.       method = "post",
  4.       data,
  5.       headers = {},
  6.       onProgress = e => e,
  7. +     requestList
  8.     }) {
  9.       return new Promise(resolve => {
  10.         const xhr = new XMLHttpRequest();
  11.         xhr.upload.onprogress = onProgress;
  12.         xhr.open(method, url);
  13.         Object.keys(headers).forEach(key =>
  14.           xhr.setRequestHeader(key, headers[key])
  15.         );
  16.         xhr.send(data);
  17.         xhr.onload = e => {
  18. +          // 将请求成功的 xhr 从列表中删除
  19. +          if (requestList) {
  20. +            const xhrIndex = requestList.findIndex(item => item === xhr);
  21. +            requestList.splice(xhrIndex, 1);
  22. +          }
  23.           resolve({
  24.             data: e.target.response
  25.           });
  26.         };
  27. +        // 暴露当前 xhr 给外部
  28. +        requestList?.push(xhr);
  29.       });
  30.     },
复制代码



这样在上传切片时传入 requestList 数组作为参数,request 方法就会将所有的 xhr 保存在数组中了


字节跳动面试官:请你实现一个大文件上传和断点续传

字节跳动面试官:请你实现一个大文件上传和断点续传


每当一个切片上传成功时,将对应的 xhr 从 requestList 中删除,所以 requestList 中只保存正在上传切片的 xhr
之后新建一个暂停按钮,当点击按钮时,调用保存在 requestList 中 xhr 的 abort 方法,即取消并清空所有正在上传的切片

  1. handlePause() {
  2.     this.requestList.forEach(xhr => xhr?.abort());
  3.     this.requestList = [];
  4. }
复制代码

字节跳动面试官:请你实现一个大文件上传和断点续传

字节跳动面试官:请你实现一个大文件上传和断点续传

点击暂停按钮可以看到 xhr 都被取消了


字节跳动面试官:请你实现一个大文件上传和断点续传

字节跳动面试官:请你实现一个大文件上传和断点续传


恢复上传之前在介绍断点续传的时提到使用第二种服务端存储的方式实现续传
由于当文件切片上传后,服务端会建立一个文件夹存储所有上传的切片,所以每次前端上传前可以调用一个接口,服务端将已上传的切片的切片名返回,前端再跳过这些已经上传切片,这样就实现了“续传”的效果
而这个接口可以和之前秒传的验证接口合并,前端每次上传前发送一个验证的请求,返回两种结果
  • 服务端已存在该文件,不需要再次上传
  • 服务端不存在该文件或者已上传部分文件切片,通知前端进行上传,并把已上传的文件切片返回给前端
所以我们改造一下之前文件秒传的服务端验证接口


  1. const extractExt = filename =>
  2.   filename.slice(filename.lastIndexOf("."), filename.length);
  3. const UPLOAD_DIR = path.resolve(__dirname, "..", "target");

  4. const resolvePost = req =>
  5.   new Promise(resolve => {
  6.     let chunk = "";
  7.     req.on("data", data => {
  8.       chunk += data;
  9.     });
  10.     req.on("end", () => {
  11.       resolve(JSON.parse(chunk));
  12.     });
  13.   });
  14.   
  15. + // 返回已上传的所有切片名
  16. + const createUploadedList = async fileHash =>
  17. +   fse.existsSync(path.resolve(UPLOAD_DIR, fileHash))
  18. +    ? await fse.readdir(path.resolve(UPLOAD_DIR, fileHash))
  19. +    : [];

  20. server.on("request", async (req, res) => {
  21.   if (req.url === "/verify") {
  22.     const data = await resolvePost(req);
  23.     const { fileHash, filename } = data;
  24.     const ext = extractExt(filename);
  25.     const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${ext}`);
  26.     if (fse.existsSync(filePath)) {
  27.       res.end(
  28.         JSON.stringify({
  29.           shouldUpload: false
  30.         })
  31.       );
  32.     } else {
  33.       res.end(
  34.         JSON.stringify({
  35.           shouldUpload: true,
  36. +         uploadedList: await createUploadedList(fileHash)
  37.         })
  38.       );
  39.     }
  40.   }
  41. });

  42. server.listen(3000, () => console.log("listening port 3000"));
复制代码
接着回到前端,前端有两个地方需要调用验证的接口
  • 点击上传时,检查是否需要上传和已上传的切片
  • 点击暂停后的恢复上传,返回已上传的切片
新增恢复按钮并改造原来上传切片的逻辑
  1. <template>
  2.   <div id="app">
  3.       <input
  4.         type="file"
  5.         @change="handleFileChange"
  6.       />
  7.        <el-button @click="handleUpload">upload</el-button>
  8.        <el-button @click="handlePause" v-if="isPaused">pause</el-button>
  9. +      <el-button @click="handleResume" v-else>resume</el-button>
  10.       //...
  11.     </div>
  12. </template>

  13. +   async handleResume() {
  14. +      const { uploadedList } = await this.verifyUpload(
  15. +        this.container.file.name,
  16. +        this.container.hash
  17. +      );
  18. +      await this.uploadChunks(uploadedList);
  19.     },
  20.     async handleUpload() {
  21.       if (!this.container.file) return;
  22.       const fileChunkList = this.createFileChunk(this.container.file);
  23.       this.container.hash = await this.calculateHash(fileChunkList);
  24. +     const { shouldUpload, uploadedList } = await this.verifyUpload(
  25. +       this.container.file.name,
  26. +       this.container.hash
  27. +     );
  28. +     if (!shouldUpload) {
  29. +       this.$message.success("skip upload:file upload success");
  30. +       return;
  31. +     }
  32.       this.data = fileChunkList.map(({ file }, index) => ({
  33.         fileHash: this.container.hash,
  34.         index,
  35.         hash: this.container.hash + "-" + index,
  36.         chunk: file,
  37.         percentage: 0
  38.       }));
  39. +      await this.uploadChunks(uploadedList);
  40.     },
  41.     // 上传切片,同时过滤已上传的切片
  42. +   async uploadChunks(uploadedList = []) {
  43.       const requestList = this.data
  44. +       .filter(({ hash }) => !uploadedList.includes(hash))
  45.         .map(({ chunk, hash, index }) => {
  46.           const formData = new FormData();
  47.           formData.append("chunk", chunk);
  48.           formData.append("hash", hash);
  49.           formData.append("filename", this.container.file.name);
  50.           formData.append("fileHash", this.container.hash);
  51.           return { formData, index };
  52.         })
  53.         .map(({ formData, index }) =>
  54.           this.request({
  55.             url: "http://localhost:3000",
  56.             data: formData,
  57.             onProgress: this.createProgressHandler(this.data[index]),
  58.             requestList: this.requestList
  59.           })
  60.         );
  61.       await Promise.all(requestList);
  62. +     // 之前上传的切片数量 + 本次上传的切片数量 = 所有切片数量时合并切片
  63. +     if (uploadedList.length + requestList.length === this.data.length) {
  64.          await this.mergeRequest();
  65. +     }
  66.     }
复制代码

字节跳动面试官:请你实现一个大文件上传和断点续传

字节跳动面试官:请你实现一个大文件上传和断点续传
这里给原来上传切片的函数新增 uploadedList 参数,即上图中服务端返回的切片名列表,通过 filter 过滤掉已上传的切片,并且由于新增了已上传的部分,所以之前合并接口的触发条件做了一些改动
到这里断点续传的功能基本完成了
进度条改进虽然实现了断点续传,但还需要修改一下进度条的显示规则,否则在暂停上传/接收到已上传切片时的进度条会出现偏差
单个切片进度条由于在点击上传/恢复上传时,会调用验证接口返回已上传的切片,所以需要将已上传切片的进度变成 100%

  1.    async handleUpload() {
  2.       if (!this.container.file) return;
  3.       const fileChunkList = this.createFileChunk(this.container.file);
  4.       this.container.hash = await this.calculateHash(fileChunkList);
  5.       const { shouldUpload, uploadedList } = await this.verifyUpload(
  6.         this.container.file.name,
  7.         this.container.hash
  8.       );
  9.       if (!shouldUpload) {
  10.         this.$message.success("skip upload:file upload success");
  11.         return;
  12.       }
  13.       this.data = fileChunkList.map(({ file }, index) => ({
  14.         fileHash: this.container.hash,
  15.         index,
  16.         hash: this.container.hash + "-" + index,
  17.         chunk: file,
  18. +       percentage: uploadedList.includes(index) ? 100 : 0
  19.       }));
  20.       await this.uploadChunks(uploadedList);
  21.     },
复制代码
uploadedList 会返回已上传的切片,在遍历所有切片时判断当前切片是否在已上传列表里即可
总进度条
之前说到总进度条是一个计算属性,根据所有切片的上传进度计算而来,这就遇到了一个问题

字节跳动面试官:请你实现一个大文件上传和断点续传

字节跳动面试官:请你实现一个大文件上传和断点续传
点击暂停会取消并清空切片的 xhr 请求,此时如果已经上传了一部分,就会发现文件进度条有倒退的现象

字节跳动面试官:请你实现一个大文件上传和断点续传

字节跳动面试官:请你实现一个大文件上传和断点续传
当点击恢复时,由于重新创建了 xhr 导致切片进度清零,所以总进度条就会倒退
解决方案是创建一个“假”的进度条,这个假进度条基于文件进度条,但只会停止和增加,然后给用户展示这个假的进度条
这里我们使用 Vue 的监听属性
  1.   data: () => ({
  2. +    fakeUploadPercentage: 0
  3.   }),
  4.   computed: {
  5.     uploadPercentage() {
  6.       if (!this.container.file || !this.data.length) return 0;
  7.       const loaded = this.data
  8.         .map(item => item.size * item.percentage)
  9.         .reduce((acc, cur) => acc + cur);
  10.       return parseInt((loaded / this.container.file.size).toFixed(2));
  11.     }
  12.   },  
  13.   watch: {
  14. +    uploadPercentage(now) {
  15. +      if (now > this.fakeUploadPercentage) {
  16. +        this.fakeUploadPercentage = now;
  17. +      }
  18.     }
  19.   },
复制代码
当 uploadPercentage 即真的文件进度条增加时,fakeUploadPercentage 也增加,一旦文件进度条后退,假的进度条只需停止即可
至此一个大文件上传 + 断点续传的解决方案就完成了
总结大文件上传
  • 前端上传大文件时使用 Blob.prototype.slice 将文件切片,并发上传多个切片,最后发送一个合并的请求通知服务端合并切片
  • 服务端接收切片并存储,收到合并请求后使用流将切片合并到最终文件
  • 原生 XMLHttpRequest 的 upload.onprogress 对切片上传进度的监听
  • 使用 Vue 计算属性根据每个切片的进度算出整个文件的上传进度
断点续传
  • 使用 spark-md5 根据文件内容算出文件 hash
  • 通过 hash 可以判断服务端是否已经上传该文件,从而直接提示用户上传成功(秒传)
  • 通过 XMLHttpRequest 的 abort 方法暂停切片的上传
  • 上传前服务端返回已经上传的切片名,前端跳过这些切片的上传


源代码
源代码增加了一些按钮的状态,交互更加友好,文章表达比较晦涩的地方可以跳转到源代码查看
反馈的问题
部分功能由于不方便测试,这里列出评论区收集到的一些问题,有兴趣的朋友可以提出你的想法/写个 demo 进一步交流

没有做切片上传失败的处理
使用 web socket 由服务端发送进度信息
打开页面没有自动获取上传切片,而需要主动再次上传一次后才显示
















相关帖子

扫码关注微信公众号,及时获取最新资源信息!下载附件优惠VIP会员5折;永久VIP免费
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

免责声明:
1、本站提供的所有资源仅供参考学习使用,版权归原著所有,禁止下载本站资源参与商业和非法行为,请在24小时之内自行删除!
2、本站所有内容均由互联网收集整理、网友上传,并且以计算机技术研究交流为目的,仅供大家参考、学习,请勿任何商业目的与商业用途。
3、若您需要商业运营或用于其他商业活动,请您购买正版授权并合法使用。
4、论坛的所有内容都不保证其准确性,完整性,有效性,由于源码具有复制性,一经售出,概不退换。阅读本站内容因误导等因素而造成的损失本站不承担连带责任。
5、用户使用本网站必须遵守适用的法律法规,对于用户违法使用本站非法运营而引起的一切责任,由用户自行承担
6、本站所有资源来自互联网转载,版权归原著所有,用户访问和使用本站的条件是必须接受本站“免责声明”,如果不遵守,请勿访问或使用本网站
7、本站使用者因为违反本声明的规定而触犯中华人民共和国法律的,一切后果自己负责,本站不承担任何责任。
8、凡以任何方式登陆本网站或直接、间接使用本网站资料者,视为自愿接受本网站声明的约束。
9、本站以《2013 中华人民共和国计算机软件保护条例》第二章 “软件著作权” 第十七条为原则:为了学习和研究软件内含的设计思想和原理,通过安装、显示、传输或者存储软件等方式使用软件的,可以不经软件著作权人许可,不向其支付报酬。若有学员需要商用本站资源,请务必联系版权方购买正版授权!
10、本网站如无意中侵犯了某个企业或个人的知识产权,请来信【站长信箱312337667@qq.com】告之,本站将立即删除。
郑重声明:
本站所有资源仅供用户本地电脑学习源代码的内含设计思想和原理,禁止任何其他用途!
本站所有资源、教程来自互联网转载,仅供学习交流,不得商业运营资源,不确保资源完整性,图片和资源仅供参考,不提供任何技术服务。
本站资源仅供本地编辑研究学习参考,禁止未经资源商正版授权参与任何商业行为,违法行为!如需商业请购买各资源商正版授权
本站仅收集资源,提供用户自学研究使用,本站不存在私自接受协助用户架设游戏或资源,非法运营资源行为。
 
在线客服
点击这里给我发消息 点击这里给我发消息 点击这里给我发消息
售前咨询热线
312337667

微信扫一扫,私享最新原创实用干货

QQ|免责声明|小黑屋|依星资源网 ( 鲁ICP备2021043233号-3 )|网站地图

GMT+8, 2024-11-24 01:37

Powered by Net188.com X3.4

邮箱:312337667@qq.com 客服QQ:312337667(工作时间:9:00~21:00)

快速回复 返回顶部 返回列表