在奇麟大数据业务系统的开发及使用过程中,例如OBS对象存储文件管理、流计算DSC依赖管理,经常会遇到上传文件这样的基础需求,一般情况下,前端上传文件就是new FormData,然后把文件 append 进去,然后post发送给后端就完事了,但是文件越大,上传的文件也就越长,在上传过程中,经常会遇到请求超时或者阻碍用户继续操作的情况,十分影响用户体验,因为把该文件直接在一个请求体中提交,会出现一些问题,以nginx为例:

  • 其默认允许1MB以内的文件

  • 超过1MB的文件,需要设置 client_max_body_size放开体积限制

一味地只放宽Nginx的限制,会出现一些问题,最明显的问题是服务器的存储和网络带宽压力都会非常大。

以奇麟云存储OBS对象存储文件管理中使用的常规上传解决方案为例展示

// 将目标文件转为FormData数据流function uploadRequest (params) {     const file = params.file     const form = new FormData()     form.append('file', file)     const config = {           headers: {              'Content-Type': 'multipart/form-data',           },           onUploadProgress: e => {              // 上传进度百分比              if (e.total > 0) {                  e.percent = e.loaded / e.total * 100              }              params.onProgress(e)           },           timeout: 0, // 上传文件不设置超时时间     }     await request({'XXXX','post', form, config}         .then(res => {          // 上传成功             params.onSuccess(res)          })            .catch(err => {              params.onError(err)            })           .finally(() => {})}

上传文件过大的情况,很容易出现网络超时导致接口报错的情况,且用户在当前操作页面无法终止,只能持续等待直至上传操作完成

为了解决上述业务痛点,这里就需要寻求大文件上传的解决方案,核心是利用 Blob.prototype.slice 方法,和数组的 slice 方法相似,文件的 slice 方法可以返回 原文件的某个切片

提前定义好单个切片大小,将大文件切分为一个个小的切片,然后借助 http 的可并发性,同时上传多个切片。这样从原本传一个大文件,变成了并发传多个小的文件切片,可以大大减少上传时间

因为由于是并发请求传输,所以传输到服务端的切片文件顺序可能会发生变化,因此我们还需要给每个切片记录顺序

1、前端实现部分

前端使用 Vue 作为开发框架,组件库使用 Element-Plus 作为 UI 框架

1.1 上传控件

首先创建选择文件的控件并监听 change 事件,另外就是上传按钮

<template>   <div class="upload-container">    <input type="file" @change="handleFileChange" />    <el-button @click="handleUpload">upload</el-button>  </div></template><script setup>import { ref } from 'vue'const container = ref({  file: null})function handleFileChange(e) {     const [file] = e.target.files;     if (!file) return;     container.value.file = file;}async handleUpload() {  // ...}</script>

1.2 请求逻辑

request({     url,     method = "post",     data,     headers = {},     requestList   }) {     return new Promise(resolve => {       const xhr = new XMLHttpRequest();       xhr.open(method, url);       Object.keys(headers).forEach(key =>         xhr.setRequestHeader(key, headers[key])       );       xhr.send(data);       xhr.onload = e => {         resolve({           data: e.target.response         });       };     });}

1.3 上传切片

接着就要实现核心的上传功能,上传需要做两件事

  • 对文件进行切片

  • 将切片传输给服务端

<template>  <div>    <input type="file" @change="handleFileChange" />    <el-button @click="handleUpload">上传</el-button>  </div></template><script setup>import { ref } from 'vue';// 设置默认切片大小 10MB const SIZE = 10 * 1024 * 1024; const data = ref([])const container = ref({  file: null})function request() {   // ****}function handleFileChange() {   // ***}  // 生成文件切片  function createFileChunk(file, size = SIZE) {   const fileChunkList = [];    let cur = 0;    while (cur < file.size) {      fileChunkList.push({ file: file.slice(cur, cur + size) });      cur += size;    }    return fileChunkList;  }  // 上传切片  async function uploadChunks() {    const requestList = data.value      .map(({ chunk,hash }) => {        const formData = new FormData();        formData.append("chunk", chunk);        formData.append("hash", hash);        formData.append("filename", container.value.file.name);        return { formData };      })      .map(({ formData }) =>        request({          url: "http://localhost:8888",          data: formData        })      );     // 并发请求    await Promise.all(requestList);   }  async function handleUpload() {    if (!container.value.file) return;    const fileChunkList = createFileChunk(container.value.file);    data.value = fileChunkList.map(({ file },index) => ({      chunk: file,        // 文件名 + 数组下标      hash: container.value.file.name + "-" + index    }));    await uploadChunks();  }</script>

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

1.4 发送合并请求

使用整体思路中提到的第二种合并切片的方式,即前端主动通知服务端进行合并

前端发送额外的合并请求,服务端接受到请求时合并切片

<template>  <div>    <input type="file" @change="handleFileChange" />    <el-button @click="handleUpload">upload</el-button>  </div></template><script setup>import { ref } from 'vue';// 设置默认切片大小 10MB const SIZE = 10 * 1024 * 1024; const data = ref([])const container = ref({  file: null})function request() {  // ****}function handleFileChange() {  // ***}// 生成文件切片function createFileChunk(file, size = SIZE) {  const fileChunkList = [];   let cur = 0;   while (cur < file.size) {     fileChunkList.push({ file: file.slice(cur, cur + size) });     cur += size;   }   return fileChunkList;}// 上传切片async function uploadChunks() {   const requestList = data.value     .map(({ chunk,hash }) => {       const formData = new FormData();       formData.append("chunk", chunk);       formData.append("hash", hash);       formData.append("filename", container.value.file.name);       return { formData };     })     .map(({ formData }) =>       request({         url: "http://localhost:8888",         data: formData       })     );    // 并发请求   await Promise.all(requestList)  // 合并切片   await mergeRequest()}async function handleUpload() {   if (!container.value.file) return;   const fileChunkList = createFileChunk(container.value.file);   data.value = fileChunkList.map(({ file },index) => ({     chunk: file,      // 文件名 + 数组下标     hash: container.value.file.name + "-" + index   }));  await uploadChunks()  async function mergeRequest() {     await request({       url: "http://localhost:8888/mergeFile",       headers: {         "content-type": "application/json"       },       data: JSON.stringify({         filename: container.value.file.name       })     })   }}</script>

2、服务端部分,以NodeJs为例

服务端负责接受前端传输的切片,并在接收到所有切片后合并所有切片
这里又引伸出两个问题

  • 服务端合并切片的时机,即切片什么时候传输完成?

  • 如何进行切片合并,合并的规则是什么?

第一个问题需要前端配合,前端在每个切片中都携 带切片最大数量的信息,当服务端接受到这个数量的切片时自动合并。或者也可以额外发一个请求,主动通知服务端进行切片的合并
第二个问题,具体如何合并切片呢?这里可以使用 Nodejs 的 读写流(readStream/writeStream),将所有切片的流传输到最终文件的流里

使用 http 模块搭建一个简单服务端

const http = require("http");const server = http.createServer();server.on("request", async (req, res) => {  res.setHeader("Access-Control-Allow-Origin", "*");  res.setHeader("Access-Control-Allow-Headers", "*");  if (req.method === "OPTIONS") {    res.status = 200;    res.end();    return;  }});server.listen(8888, () => console.log("listening port 8888"));

2.1 接受切片

使用 multiparty 处理前端传来的 formData
在 multiparty.parse 的回调中,files 参数保存了 formData 中文件,fields 参数保存了 formData 中非文件的字段

const http = require("http");const path = require("path");const fse = require("fs-extra");const multiparty = require("multiparty");const server = http.createServer();// 大文件上传的临时存储目录const UPLOAD_DIR_NAME = path.resolve(__dirname, "..", "target");server.on("request", async (req, res) => {  res.setHeader("Access-Control-Allow-Origin", "*");  res.setHeader("Access-Control-Allow-Headers", "*");  if (req.method === "OPTIONS") {    res.status = 200;    res.end();    return;  }  const multipart = new multiparty.Form();  multipart.parse(req, async (err, fields, files) => {    if (err) {      return;    }    const [chunk] = files.chunk;    const [hash] = fields.hash;    const [filename] = fields.filename;    // 创建临时文件夹用于临时存储 chunk    // 添加 chunkDir 前缀与文件名做区分    const chunkDir = path.resolve(UPLOAD_DIR_NAME, 'chunkDir'  filename);    if (!fse.existsSync(chunkDir)) {      await fse.mkdirs(chunkDir);    }    // fs-extra 的 rename 方法 windows 平台会有权限问题    // 参考: https://github.com/meteor/meteor/issues/7852#issuecomment-255767835    await fse.move(chunk.path, `${chunkDir}/${hash}`);    res.end("received file chunks");  });});server.listen(8888, () => console.log("listening port 8888"));

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

2.2 合并切片

在接收到前端发送的合并请求后,服务端将文件夹下的所有切片进行合并

const http = require("http");const path = require("path");const fse = require("fs-extra");const server = http.createServer();const UPLOAD_DIR_NAME = path.resolve(__dirname, "..", "target");+ const resolvePost = req =>+   new Promise(resolve => {+     let chunk = "";+     req.on("data", data => {+       chunk += data;+     });+     req.on("end", () => {+       resolve(JSON.parse(chunk));+     });+   });+ // 写入文件流+ const pipeStream = (path, writeStream) =>+  new Promise(resolve => {+    const readStream = fse.createReadStream(path);+    readStream.on("end", () => {+      fse.unlinkSync(path);+      resolve();+    });+    readStream.pipe(writeStream);+  });// 合并切片+ const mergeFileChunk = async (filePath, filename, size) => {+   const chunkDir = path.resolve(UPLOAD_DIR_NAME, 'chunkDir' + filename);+   const chunkPaths = await fse.readdir(chunkDir);+   // 根据切片下标进行排序+   // 否则直接读取目录的获得的顺序会错乱+   chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);+   // 并发写入文件+   await Promise.all(+     chunkPaths.map((chunkPath, index) =>+       pipeStream(+         path.resolve(chunkDir, chunkPath),+         // 根据 size 在指定位置创建可写流+         fse.createWriteStream(filePath, {+           start: index * size,+         })+       )+     )+  );+  // 合并后删除保存切片的目录+  fse.rmdirSync(chunkDir);+};server.on("request", async (req, res) => {  res.setHeader("Access-Control-Allow-Origin", "*");  res.setHeader("Access-Control-Allow-Headers", "*");  if (req.method === "OPTIONS") {    res.status = 200;    res.end();    return;  }+   if (req.url === "/mergeFile") {+     const data = await resolvePost(req);+     const { filename,size } = data;+     const filePath = path.resolve(UPLOAD_DIR_NAME, `${filename}`);+     await mergeFileChunk(filePath, filename);+     res.end(+       JSON.stringify({+         code: 0,+         message: "file merged success"+       })+     );+   }});server.listen(8888, () => console.log("listening port 8888"));

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

async mergeRequest() {      await request({        url: "http://localhost:8888/mergeFile",        headers: {          "content-type": "application/json"        },        data: JSON.stringify({+         size: SIZE,          filename: container.value.file.name        })      });    }

其实也可以等上一个切片合并完后再合并下个切片,这样就不需要指定位置,但传输速度会降低,所以使用了并发合并的手段

接着只要保证每次合并完成后删除这个切片,等所有切片都合并完毕后最后删除切片文件夹即可

至此一个简单的大文件上传就完成了,接下来我们在此基础上扩展一些额外的功能

2.3 总进度条

将每个切片已上传的部分累加,除以整个文件的大小,就能得出当前文件的上传进度,所以这里使用 Vue 的计算属性

computed: {       uploadPercentage() {          if (!container.value.file || !data.value.length) return 0;          const loaded = data.value            .map(item => item.size * item.percentage)            .reduce((acc, cur) => acc + cur);          return parseInt((loaded / container.value.file.size).toFixed(2));        } }

经过切片逻辑改造之后,切片的每块文件都能分别统计到上传进度,如下图所示:

改造之后的大文件上传效果展示:

3. 总结

大文件上传

  • 前端上传大文件时使用 Blob.prototype.slice 将文件切片,并发上传多个切片,最后发送一个合并的请求通知服务端合并切片

  • 服务端接收切片并存储,收到合并请求后使用流将切片合并到最终文件

  • 原生 XMLHttpRequest 的 upload.onprogress 对切片上传进度的监听

  • 使用 Vue 计算属性根据每个切片的进度算出整个文件的上传进度

  • 用户等待上传时间大大缩减,改善用户的体验


更多技术干货,

请关注“360智汇云开发者”👇

360智汇云官网:https://zyun.360.cn(复制在浏览器中打开)

更多好用又便宜的云产品,欢迎试用体验~

添加工作人员企业微信👇,get更快审核通道+试用包哦~

图片

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.pswp.cn/diannao/91951.shtml
繁体地址,请注明出处:http://hk.pswp.cn/diannao/91951.shtml
英文地址,请注明出处:http://en.pswp.cn/diannao/91951.shtml

如若内容造成侵权/违法违规/事实不符,请联系英文站点网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

立创EDA中双层PCB叠层分析

立创EDA中双层PCB叠层分析 结论&#xff1a;立创EDA中的双层 PCB 叠层视图相比传统视图&#xff0c;多出一个焊盘层&#xff08;博主命名&#xff09;&#xff1b; 1. 传统双层 PCB 叠层示意图 丝印层 印刷元件标识、极性标记及厂商信息 辅助组装与后期维护 阻焊层 覆盖铜层表…

深入理解进程:从底层原理到硬件系统实战

深入理解进程&#xff1a;从底层原理到嵌入式实战&#xff08;3-4 万字详解&#xff09; 前言&#xff1a;为什么硬件开发者必须吃透进程&#xff1f; 作为嵌入式开发者&#xff0c;你可能会说&#xff1a;“我平时用的 RTOS 里只有任务&#xff08;Task&#xff09;&#xff0…

Elasticsearch 简化指南:GCP Google Compute Engine

作者&#xff1a;来自 Elastic Eduard Martin 系列内容的一部分&#xff1a;开始使用 Elasticsearch&#xff1a;GCP 想获得 Elastic 认证&#xff1f;看看下一期 Elasticsearch Engineer 培训什么时候开始&#xff01; Elasticsearch 拥有丰富的新功能&#xff0c;帮助你根据…

STM32的定时器输入捕获-超声波测距案例

STM32的定时器输入捕获-超声波测距案例 gitee代码输入捕获硬件电路案例说明主函数代码 gitee代码 https://gitee.com/xiaolixi/l-stm32/tree/master/STM32F103C8T6/2-1tem-ld-timer-input-pluse 输入捕获硬件电路 超声波测距案例说明 使用超声波测距传感器使用tim1的输入捕获…

[特殊字符] Spring Boot 常用注解全解析:20 个高频注解 + 使用场景实例

一文掌握 Spring Boot 中最常用的 20 个注解&#xff0c;涵盖开发、配置、Web、数据库、测试等场景&#xff0c;配合示例讲解&#xff0c;一站式掌握&#xff01;&#x1f4cc; 一、核心配置类注解 1. SpringBootApplication 作用&#xff1a;标记为 Spring Boot 应用的入口类&…

【工具变量】地级市城市包容性绿色增长数据(2011-2023年)

城市包容性绿色增长是指在推动城市经济增长的过程中&#xff0c;兼顾环境可持续性、社会公平和包容性发展的理念与实践。它强调在实现绿色转型和低碳发展的同时&#xff0c;保障社会各群体&#xff0c;特别是弱势群体的利益与参与权利&#xff0c;确保增长成果能够公平共享 本…

深入理解React Hooks:从使用到原理

4. 源码解析类:《深入理解React Hooks:从使用到原理》 # 深入理解React Hooks:从使用到原理🔥 **背景**: - Hooks解决了Class组件的哪些问题? - 为什么不能在循环/条件中调用Hooks?🔍 **核心原理**:### 1. Hooks链表 React内部维护一个单向链表:fiber.memoizedSta…

【云原生】Docker 部署 Elasticsearch 9 操作详解

目录 一、前言 二、Elasticsearch 9 新特性介绍 2.1 基于 Lucene 10 重大升级 2.2 Better Binary Quantization(BBQ) 2.3 Elastic Distributions of OpenTelemetry(EDOT) 2.4 LLM 可观测性 2.5 攻击发现与自动导入 2.6 ES|QL 增强 2.7 语义检索 三、基于Docker部署…

uview-ui使用u-search搜索框

1、效果图 2、带地址搜索框&#xff0c;在微信小程序线上需要开启地图定位接口&#xff0c;若没有权限则显示不了城市名&#xff0c;注意事项参考uniapp相关地图 API调用-CSDN博客 <template><view><u-sticky offset-top"-1"><u-search v-mode…

Elasticsearch+Logstash+Kibana部署

目录 一、实验准备 1.下载安装 2.下载java 2.同步主机系统时间 二、部署 1.部署elasticsearch 修改 /etc/elasticsearch/elasticsearch.yml 配置文件 修改 /etc/hosts/ 文件 启动elasticsearch 查看是否启动进程netstat -antptu | grep java 2.部署logstash 进入/et…

TEngine学习

关于静态类中的静态变量赋值&#xff1a; public static class ActorEventDefine{public static readonly int ScoreChange RuntimeId.ToRuntimeId("ActorEventDefine.ScoreChange");public static readonly int GameOver RuntimeId.ToRuntimeId("ActorEventD…

猎板:在 5G 与 AI 时代,印制线路板如何满足高性能需求

5G 与 AI 技术的深度融合&#xff0c;推动电子设备向高速传输、高算力、高集成方向发展&#xff0c;印制线路板&#xff08;PCB&#xff09;作为核心载体&#xff0c;其性能直接决定终端设备的运行效率与可靠性。猎板 PCB 聚焦 5G 通信的高频需求与 AI 算力的密集需求&#xff…

教你如何借助AI精读文献

目录1. 原文2. 对文献有一个快速的理解3. 专业术语解读4. 解答疑问5. 借助AI翻译摘要和引言部分5.1 **摘要 (Abstract)**5.2 **引言 (Introduction)**6. 介绍论文中的“Stack-Propagation”7. 查阅论文里的参考文献&#xff0c;看看他是如何在Introduction中引述研究进展文献&a…

FastAdmin框架超级管理员密码重置与常规admin安全机制解析-卓伊凡|大东家

FastAdmin框架超级管理员密码重置与常规admin安全机制解析-卓伊凡|大东家我们可以看到admin账户是不允许直接修改的&#xff0c;这也是目前fastadmin 框架不允许的&#xff0c;那么如何处理一、FastAdmin超级管理员密码重置方法当FastAdmin的超级管理员密码忘记或需要重置时&am…

我做的基础服务项目,是如何实现 API 安全与限流的(短信、邮件、文件上传、钉钉通知)

我做的基础服务项目&#xff0c;是如何实现 API 安全与限流的&#xff08;短信、邮件、文件上传、钉钉通知&#xff09;一、背景 最近我做了一个基础服务项目&#xff0c;主要对外提供短信、邮件、文件上传和钉钉通知等基础功能。这些接口是多个业务系统都要调用的&#xff0c;…

(Python)类和类的方法(基础教程介绍)(Python基础教程)

源代码&#xff1a;class Students:stats"大学"def __init__(self,name,age,sex,credit):self.namenameself.ageageself.sexsexself.creditcreditdef tell(self):return f"{self.name}说&#xff1a;你好"class Teachers(Students):stats"教师"d…

网络智能体研究综述

网络智能体研究综述1.什么是网络智能体1.1.核心特征1.2.分类方式1.2.1.按功能定位1.2.2. 按网络结构1.2.3.按应用场景1.3.典型应用场景1.4.技术基础1.5.发展趋势与挑战1.5.1.发展趋势1.5.2.核心挑战2.网络智能体盘点3.阿里的WebSailor3.1.WebSailor的主要功能和技术特点3.2.技术…

git 介绍与使用教程

Git 是一个 分布式版本控制系统&#xff0c;每个开发者都有一个完整的本地仓库&#xff08;包含完整历史记录&#xff09;&#xff0c;而远程仓库&#xff08;如 GitHub、GitLab、Gitee&#xff09;是团队共享的中央仓库。它们的关系如下&#xff1a;本地仓库&#xff08;Local…

[AI风堇]基于ChatGPT3.5+科大讯飞录音转文字API+GPT-SOVITS的模拟情感实时语音对话项目

[AI风堇]最近利用工作日的晚间和周末时间&#xff0c;我完成了一个有趣的Python编程小项目。这个项目的灵感来源于上个月在B站看到的"科技怪咖"UP主分享的一个视频&#xff0c;视频中展示了一个名为"DataMagic"的自动化数据处理工具&#xff0c;能够智能分…

物联网-规则引擎的定义

构建物联网系统中的规则引擎是一个系统性的工程&#xff0c;它需要处理来自海量设备的实时数据流&#xff0c;并根据预定义的逻辑触发动作。以下是构建一个高效、可靠、可扩展的物联网规则引擎的关键步骤和考虑因素&#xff1a; 核心目标 实时性&#xff1a; 快速处理设备事件并…