记录--经常被cue大文件上传,忍不住试一下

news/发布时间2024/8/24 18:59:09

这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助

大文件上传主要步骤:

  1. 获取文件对象,切分文件
  2. 根据文件切片,计算文件唯一hash值
  3. 上传文件切片,服务端保存起来
  4. 合并文件切片,前端发送合并请求,服务端将文件切片合并为原始文件
  5. 秒传,对于已经存在的分片,可以前端发个请求获取已经上传的文件切片信息,前端判断已经上传的切片不再发送切片上传请求;或者后端验证已经存在的切片,直接返回成功结果,后端不再重复写入保存
  6. 暂停上传,使用axios的取消请求
  7. 继续上传,跟秒传逻辑一样,先发个请求验证,已经上传的切片不再重复发请求,将没有上传的切片继续上传

技术栈:

包管理工具:

  • pnpm

前端:

  • vue 3.3.11
  • vite
  • axios
  • spark-md5:根据文件内容生成唯一hash值

后端:

  • node
  • koa
  • @koa/router
  • koa-body 解析请求体,包括json、form-data等
  • @koa/cors:解决跨域

1、文件分片

先用vite搭一个vue3项目 pnpm create vite

首先拿到上传的文件,通过 <input type="file"/>change事件拿到File文件对象,File继承自Blob,可以调用Blob的实例方法,然后用slice方法做分割;

这篇文章介绍了 JS中的二进制对象:Blob、File、ArrayBuffer,及转换处理:FileReader、URL.createObjectURL

// App.vue
<script setup>
import { ref } from "vue";
import { createChunks } from "./utils";// 保存切片
const fileChunks = ref([]);function handleFileChange(e) {// 获取文件对象const file = e.target.files[0];if (!file) {return;}fileChunks.value = createChunks(file);console.log(fileChunks.value);
}
</script><template><input type="file" @change="handleFileChange" /><button>上传</button>
</template>// utils.js// 默认每个切片3MB
const CHUNK_SIZE = 3 * 1024 * 1024;export function createChunks(file, size = CHUNK_SIZE) {const chunks = [];for (let i = 0; i < file.size; i += size) {chunks.push(file.slice(i, i + size));}return chunks;
}

2、计算hash

上传文件给服务器,要区分一下不同文件,对于服务端已经存在的文件切片,前端不需要重复上传,服务器不需要重复处理,节约性能。要做到区分不同文件,就需要给每个文件一个唯一标识,用 spark-md5 这个库来根据文件内容生成唯一hash值,安装 pnpm add spark-md5

// App.vue
<script setup>
import { ref } from "vue";
import { createChunks, calculateFileHash } from "./utils";const fileChunks = ref([]);async function handleFileChange(e) {const file = e.target.files[0];if (!file) {return;}fileChunks.value = createChunks(file);const sT = Date.now();const hash = await calculateFileHash(fileChunks.value);console.log(Date.now() - sT); //测试一下计算hash耗时
}</script>
// utils.js
import SparkMD5 from "spark-md5";export function calculateFileHash(chunkList) {return new Promise((resolve) => {const spark = new SparkMD5.ArrayBuffer();// FileReader读取文件内容const reader = new FileReader();reader.readAsArrayBuffer(new Blob(chunkList));// 读取成功回调reader.onload = (e) => {spark.append(e.target.result);resolve(spark.end());};});
}

上面calculateFileHash这个函数计算hash使用文件所有切片内容,如果文件很大,将会非常耗时,测试了一个526MB的文件,需要6813ms左右,为了保证所有切片都参与计算,也不至于太耗时,采取下面这种方式:

  • 第一个和最后一个切片全部计算
  • 其他切片取前、中、后两个字节参与计算

这种方式可能会损失一点准确性,如果计算出来的hash变了,就重新上传呗

// utils.js
const CHUNK_SIZE = 3 * 1024 * 1024;export function calculateFileHash(chunkList) {return new Promise((resolve) => {const spark = new SparkMD5.ArrayBuffer();const reader = new FileReader();// 抽取chunkconst chunks = [];for (let i = 0; i < chunkList.length; i++) {const chunk = chunkList[i];if (i === 0 || i === chunkList.length - 1) {chunks.push(chunk);} else {chunks.push(chunk.slice(0, 2));chunks.push(chunk.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2));chunks.push(chunk.slice(CHUNK_SIZE - 2, CHUNK_SIZE));}}reader.readAsArrayBuffer(new Blob(chunks));reader.onload = (e) => {spark.append(e.target.result);resolve(spark.end());};});
}

再次传同一个文件测试,只需要975ms左右

3、上传切片

前端逻辑:

这里要考虑一个问题,如果一个大文件切成了几十上百个切片,这时如果同时发送,浏览器负担很重,浏览器默认允许同时建立 6 个 TCP 持久连接,也就是说同一个域名同时能支持6个http请求,多余的会排队。这里就需要控制一下并发请求数量,设置为同时发送6个

// App.vue
<script setup>
import {createChunks,calculateFileHash,createFormData,concurrentChunksUpload,
} from "./utils";async function uploadChunks() {const hash = await calculateFileHash(fileChunks.value);// 利用计算的文件hash构造formDataconst dataList = createFormData(fileChunks.value, hash);// 切片上传请求await concurrentChunksUpload(dataList);
}
</script<template><input type="file" @change="handleFileChange" /><button @click="uploadChunks()">上传</button>
</template>
// utils.jsconst CHUNK_SIZE = 10 * 1024 * 1024;
const BASE_URL = "http://localhost:2024";// 根据切片的数量组装相同数量的formData
export function createFormData(fileChunks, hash) {return fileChunks.map((chunk, index) => ({fileHash: hash,chunkHash: `${hash}-${index}`,chunk,})).map(({ fileHash, chunkHash, chunk }) => {const formData = new FormData();formData.append("fileHash", fileHash);formData.append("chunkHash", chunkHash);formData.append(`chunk-${chunkHash}`, chunk);return formData;});
}// 默认最大同时发送6个请求
export function concurrentChunksUpload(dataList, max = 6) {return new Promise((resolve) => {if (dataList.length === 0) {resolve([]);return;}const dataLength = dataList.length;// 保存所有成功结果const results = [];// 下一个请求let next = 0;// 请求完成数量let finished = 0;async function _request() {// next达到dataList个数,就停止if (next === dataLength) {return;}const i = next;next++;const formData = dataList[i];const url = `${BASE_URL}/upload-chunks`;try {const res = await axios.post(url, formData);results[i] = res.data;finished++;// 所有切片上传成功返回if (finished === dataLength) {resolve(results);}_request();} catch (err) {console.log(err);}}// 最大并发数如果大于formData个数,取最小数const minTimes = Math.min(max, dataLength);for (let i = 0; i < minTimes; i++) {_request();}});
}

后端逻辑:

浏览器跨域问题及几种常见解决方案:CORS,JSONP,Node代理,Nginx反向代理 ,分析如何解决浏览器跨域

const path = require("path");
const fs = require("fs");
const Koa = require("koa");
const KoaRouter = require("@koa/router");
const cors = require("@koa/cors");
const { koaBody } = require("koa-body");const app = new Koa();
const router = new KoaRouter();
// 保存切片目录
const chunksDir = path.resolve(__dirname, "../chunks");//cors解决跨域
app.use(cors()); 
app.use(router.routes()).use(router.allowedMethods());
app.listen(2024, () => console.log("Koa文件服务器启动"));// 中间件:处理multipart/form-data,切片写入磁盘
const uploadKoaBody = koaBody({multipart: true,formidable: {// 设置保存切片的文件夹uploadDir: chunksDir,// 在保存到磁盘前回调onFileBegin(name, file) {if (!fs.existsSync(chunksDir)) {fs.mkdirSync(chunksDir);}// 切片重命名file.filepath = `${chunksDir}/${name}`;},},
});// 上传chunks切片接口
router.post("/upload-chunks", uploadKoaBody, (ctx) => {ctx.body = { code: 200, msg: "文件上传成功" };
});

4、合并切片

前端逻辑:

当所有切片上传成功,发送合并请求

// App.vue
<script setup>
import {createChunks,calculateFileHash,createFormData,concurrentChunksUpload,mergeChunks
} from "./utils";async function uploadChunks() {const hash = await calculateFileHash(fileChunks.value);// 利用计算的文件hash构造formDataconst dataList = createFormData(fileChunks.value, hash);// 切片上传请求await concurrentChunksUpload(dataList);// 等所有chunks发送完毕,发送合并请求mergeChunks(originFile.value.name);
}
</script<template><input type="file" @change="handleFileChange" /><button @click="uploadChunks()">上传</button>
</template>

// utils.jsexport function mergeChunks(filename) {return axios.post(BASE_URL + "/merge-chunks", { filename, size: CHUNK_SIZE });
}

后端逻辑:

  • fs.readdirSync(path[, options]):同步读取给定目录的内容,返回一个数组,其中包含目录中的所有文件名或对象
  • fs.existsSync(path):判断路径是否存在
  • fs.mkdirSync(path[, options]):同步地创建目录
  • fs.createWriteStream(path[, options]):创建文件可写流
  • fs.createReadStream(path[, options]):创建文件可读流
// 合并chunks接口
router.post("/merge-chunks", koaBody(), async (ctx) => {const { filename, size } = ctx.request.body;await mergeChunks(filename, size);ctx.body = { code: 200, msg: "合并成功" };
});// 合并 chunks
async function mergeChunks(filename, size) {// 读取chunks目录中的文件名const chunksName = fs.readdirSync(chunksDir);if (!chunksName.length) return;// 保证切片合并顺序chunksName.sort((a, b) => a.split("-")[2] - b.split("-")[2]);// 提前创建要写入的static目录const fileDir = path.resolve(__dirname, "../static");if (!fs.existsSync(fileDir)) {fs.mkdirSync(fileDir);}// 最后写入的文件路径const filePath = path.resolve(fileDir, filename);const pipeStreams = chunksName.map((chunkName, index) => {const chunkPath = path.resolve(chunksDir, chunkName);// 创建写入流const writeStream = fs.createWriteStream(filePath, { start: index * size });return createPipeStream(chunkPath, writeStream);});await Promise.all(pipeStreams);// 全部写完,删除chunks切片目录fs.rmdirSync(chunksDir);
}// 创建管道流写入
function createPipeStream(chunkPath, writeStream) {return new Promise((resolve) => {const readStream = fs.createReadStream(chunkPath);readStream.pipe(writeStream);readStream.on("end", () => {// 写完一个chunk,就删除fs.unlinkSync(chunkPath);resolve();});});
}

5、秒传文件

对于已经上传的文件,服务端这边可以判断,直接返回成功结果,不做重复保存的处理,节省时间;也可以前端先发一个请求获取已经上传的文件切片,就不再重复发送切片上传请求

服务端逻辑加一个中间件做判断:

// 中间件,已经存在的切片,直接返回成功结果
async function verifyChunks(ctx, next) {// 前端把切片hash放到请求路径上带过来const chunkName = ctx.request.querystring.split("=")[1];const chunkPath = path.resolve(chunksDir, chunkName);if (fs.existsSync(chunkPath)) {ctx.body = { code: 200, msg: "文件已上传" };} else {await next();}
}// 上传chunks切片接口
router.post("/upload-chunks", verifyChunks, uploadKoaBody, (ctx) => {ctx.body = { code: 200, msg: "文件上传成功" };
});
前端这边修改一下请求路径,带个参数过去
export function concurrentChunksUpload(dataList, max = 6) {return new Promise((resolve) => {//...const formData = dataList[i];const chunkName = `chunk-${formData.get("chunkHash")}`;const url = `${BASE_URL}/upload-chunks?chunkName=${chunkName}`;//...});
}

6、暂停上传

前端逻辑

axios中可以使用同一个 cancel token 取消多个请求

<script setup>
import axios from "axios";const CancelToken = axios.CancelToken;
let axiosSource = CancelToken.source();function pauseUpload() {axiosSource.cancel?.();
}async function uploadChunks(existentChunks = []) {const hash = await calculateFileHash(fileChunks.value);const dataList = createFormData(fileChunks.value, hash, existentChunks);await concurrentChunksUpload(axiosSource.token, dataList);// 等所有chunks发送完毕,发送合并请求mergeChunks(originFile.value.name);
}
</script><template><input type="file" @change="handleFileChange" /><button @click="uploadChunks()">上传</button><button @click="pauseUpload">暂停</button>
</template>

 

// utils.jsexport function concurrentChunksUpload(sourceToken, dataList, max = 6) {return new Promise((resolve) => {//...const res = await axios.post(url, formData, {cancelToken: sourceToken,});//...});
}

7、继续上传

前端逻辑

要调用CancelToken.source()重新生成一个suource,发请求获取已经上传的chunks,过滤一下,不再重复发送,前面的秒传是在服务端判断的,也可以按这个逻辑来,已经上传的不重复发请求

<script setup>
import { getExistentChunks } from "./utils";async function continueUpload() {const { data } = await getExistentChunks();uploadChunks(data);
}// existentChunks 默认空数组
async function uploadChunks(existentChunks = []) {const hash = await calculateFileHash(fileChunks.value);// existentChunks传入过滤已经上传的切片const dataList = createFormData(fileChunks.value, hash, existentChunks);// 重新生成sourceaxiosSource = CancelToken.source();await concurrentChunksUpload(axiosSource.token, dataList);// 等所有chunks发送完毕,发送合并请求mergeChunks(originFile.value.name);
}</script><template><input type="file" @change="handleFileChange" /><button @click="uploadChunks()">上传</button><button @click="pauseUpload">暂停</button><button @click="continueUpload">继续</button>
</template>

 

// utils.js
export function createFormData(fileChunks, hash, existentChunks) {const existentChunksName = existentChunks// 如果切片有损坏,切片大小可能就不等于CHUNK_SIZE,重新传// 最后一张切片大小大概率是不等的.filter((item) => item.size === CHUNK_SIZE).map((item) => item.filename);return fileChunks.map((chunk, index) => ({fileHash: hash,chunkHash: `${hash}-${index}`,chunk,})).filter(({ chunkHash }) => {// 同时过滤掉已经上传的切片return !existentChunksName.includes(`chunk-${chunkHash}`);}).map(({ fileHash, chunkHash, chunk }) => {const formData = new FormData();formData.append("fileHash", fileHash);formData.append("chunkHash", chunkHash);formData.append(`chunk-${chunkHash}`, chunk);return formData;});
}export function getExistentChunks() {return axios.post(BASE_URL + "/existent-chunks");
}

后端逻辑

// 获取已经上传的切片接口
router.post("/existent-chunks", (ctx) => {if (!fs.existsSync(chunksDir)) {ctx.body = [];return;}ctx.body = fs.readdirSync(chunksDir).map((filename) => {return {// 切片名:chunk-tue234wdhfjksd211tyf3234-1filename,// 切片大小size: fs.statSync(`${chunksDir}/${filename}`).size,};});
});

最后

  • 多次尝试一个23MB的pdf和一个536MB的mp4,重复几次点暂停和继续,最后都可以打开
  • 如有问题,请不吝指教,学习一下

本文转载于:

https://juejin.cn/post/7317704519160528923

如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。

 

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

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

相关文章

debezium+kafka实现mysql数据同步(debezium-connector-mysql)

1.情景展示 在企业当中,往往会存在不同数据库之间的表的数据需要保持一致的情况(数据同步)。 如何将A库a表的数据同步至B库a表当中呢?(包含:新增、修改和删除) 往往不仅仅需要保持数据的一致性,还要保证数据的即时性,即:A库a表的数据发生变化后,B库a表也能立刻同步变…

数据结构实验代码分享 - 3

哈夫曼编码/ 译码系统(树应用) [问题描述] 任意给定一个仅由 26 个大写英文字母组成的字符序列,根据哈夫曼编码算法,求得每个字符的哈夫曼编码。 要求: 1)输入一个由 26 个英文字母组成的字符串,请给出经过哈夫曼编码后的编码序列及其编码程度。(编码) 2)采用上一问题…

leetcode 2706 购买两块巧克力

leetcode 2706 购买两块巧克力题目: 2706 购买两块巧克力 思路:找两个最小值。 分情况讨论代码 class Solution:def buyChoco(self, prices: List[int], money: int) -> int:# 遍历一遍,找2个最小值# 找一个最小值我们都会。# 找次小值,就分两种情况,假设minPrice是最小…

开发商城小程序具有哪些模块和功能?(临沂软件定制开发-艾思软件)

随着移动互联网的发展,微信小程序已经成为了企业、商家和开发者的重要工具。商城小程序作为微信小程序的一种类型,为商家提供了一个全新的销售渠道。本文将详细介绍商城小程序的模块和功能,并附带相关代码。 一、商城小程序的模块首页模块:展示商城的热门商品、优惠活动等信…

01.Shiro基础概念以及快速入门

概述 Apache Shiro 是一个功能强大且灵活的开源安全框架,可以干净地处理身份验证,授权,企业会话 Management 和加密。 Apache Shiro 的首要目标是易于使用和理解。安全有时可能非常复杂,甚至会很痛苦,但不一定如此。框架应尽可能掩盖复杂性,并公开干净直观的 API,以简化…

H5前端特殊艺术字体文件太大,可通过font-spider压缩

原理: 1.爬行本地 html 文档,分析所有 css 语句 2.记录@font-face语句声明的字体,并且记录使用该字体的 css 选择器 3.通过 css 选择器的规则查找当前 html 文档的节点,记录节点上的文本 4.找到字体文件并删除没被使用的字符 5.编码成跨平台使用的字体格式 简而言之:就是爬…

H-ui JQuery 给单选按纽赋值不生效

H-ui JQuery 给单选按纽赋值不生效 $("#sex-1").attr(checked,true)原因,iradio-blue 样式的原因 把下面代码注释掉就可以了 $(.skin-minimal input).iCheck({checkboxClass: icheckbox-blue,radioClass: iradio-blue,increaseArea: 20% });<div class="fo…

dockerfile多阶段构建最小镜像

如何将Go项目与Docker结合实现高效部署 原创 云原生Go 源自开发者 2023-12-29 07:00 发表于广东 听全文源自开发者 专注于提供关于Go语言的实用教程、案例分析、最新趋势,以及云原生技术的深度解析和实践经验分享。 56篇原创内容公众号在现代软件开发中,使用Docker部署应用程…

Spring Boot邮件发送教程:步步为营,轻松实现图片附件邮件!

通过Spring Boot构建一个功能强大的邮件发送应用程序,重点是实现发送包含图片附件的邮件。我将逐步介绍添加必要的依赖、创建邮件服务类和控制器的步骤,并提供了具体的示例源代码。跟随这个简单而清晰的教程,您将能够轻松地集成邮件发送功能到您的Spring Boot应用中。 步骤 …

汇编-压缩BCD的算术运算

(这里讨论的指令仅适用于32位模式下的编程。)压缩二进制编码的十进制整数,或者称为压缩的BCD整数, 在每个字节中存放两个十进制数字。回忆一下在第1章中讲到的关于二进制编码的十进制整数的内容。为了简化代码编写, 我们只使用无符号BCD数。数值以小端序存放,最低十进制数字…

InterSystems 数据库的存储过程存在哪里

我们都知道 InterSystems 的 Studio 可以创建存储过程。但这个存储过程我们保存的时候是保存在哪里? 存储逻辑 如果我们在 Studio 创建存储过程的话,存储过程是存储在数据库上面的。 本地文件夹中是没有存储的。 选择系统下面的存储过程,然后选择 Go 去查看系统中存储的存储…

自动查询12306余票,结果以txt形式放到nginx网站目录下

1 #!/bin/bash2 3 # yum install glibc-common jq4 5 6 date=2024-01-017 from=BJP8 to=HBB9 10 echo -en "$date from $from to $to \n查询时间:$(date)\n\n" > /usr/share/nginx/html/tmp.txt 11 echo -en "1(硬座) | 2(软座) | 3(硬卧) | 4…

羽毛球比赛规则

from random import random print(学号后两位:47) print(22信计1晁丽 ,2022310143047) def first(): print("这个程序模拟两个选手A和B的羽毛球竞技比赛") print("程序运行需要A和B的能力值(以0到1之间的小数表示)") def second(): a = float(input(&q…

西游记jieba分词

import jiebatxt = open("西游记.txt", "r", encoding=utf-8).read()words = jieba.lcut(txt) # 使用精确模式对文本进行分词counts = {} # 通过键值对的形式存储词语及其出现的次数for word in words:if len(word) == 1:continueelif word in ["大…

ArcGIS打开工具箱未响应问题

有时,打开工具箱的工具时,出现未响应的情况,主要以下规律: (1)所有工具都可能出现这种情况,与工具的功能无关; (2)不是每一次都会出现这样的情况; (3)从目录窗口中打开工具会出现这种情况,从ArcToolbox窗口打开不会出现。不知道是什么原因,遇到这种情况,一般把…

网络层路由技术

网络层路由技术 1、移动承载网络中的网络层协议 2G/3G的业务模式更多的是点到点的模式,直接实现基站和基站控制器之间的数据传递。 在4G LTE业务中, 第一,核心网侧的服务器集中化部署,基站允许在某个业务服务器出现故障之后同另外的服务器进行通信。第二,基站和基站之间的…

策略梯度

策略梯度呢,顾名思义,策略就是一个状态或者是action的分布,梯度就是我们的老朋友,梯度上升或者梯度下降。 就是说,J函数的自变量是西塔,然后对J求梯度,进而去更新西塔,比如说,J西塔,是一个该策略下预测状态值,也可以说是策略值,那么我们当然希望这个策略值越大越好…

javaCC链2

cc2链 pom.xml配置<dependency><groupId>org.apache.commons</groupId><artifactId>commons-collections4</artifactId><version>4.0</version></dependency><dependency><groupId>org.javassist</groupId>…

JavaWeb - Day13 - 事务管理、AOP(基础、进阶、案例)

01. 事务管理-事务回顾-spring事务管理 1.1 事务回顾 在数据库阶段我们已学习过事务了,我们讲到: 事务是一组操作的集合,它是一个不可分割的工作单位。事务会把所有的操作作为一个整体,一起向数据库提交或者是撤销操作请求。所以这组操作要么同时成功,要么同时失败。 怎么…

【 python 】《 Anaconda安装与操作 》

安装包下载 1)官网下载地址:https://www.anaconda.com/download2)其他版本下载地址:repo.anaconda.com/archive/详细安装步骤 1、双击运行安装程序,点击Next2、点击 I Agree3、点击 Next4、选择安装路径,确保空间足够即可,然后点击Next5、勾选两个框,设置环境变量以及设…
推荐文章