网站首页 > 技术文章 正文
大文件上传
前景提要
在工作中,经常会遇到上传文件的功能,但是当文件体积大时,如果使用把该文件直接在一个请求体中提交,会出现一些问题,以nginx为例:
- 其默认允许1MB以内的文件
- 超过1MB的文件,需要设置client_max_body_size放开体积限制
但是这样会存在一个问题,就是如果上传的文件体积很大,就会出现一些问题,最明显的问题是:
服务器的存储和网络带宽压力都会非常大
当服务器、产品、用户忍不了时,就需要对大文件上传进行优化。
1、大文件切片上传
逻辑梗概
- 将大文件分割成多个文件块
- 逐个上传文件块
- 服务端将文件块顺序合并成完整文件
优势分析
- 减轻服务器压力:如果一次性上传大文件,服务器的存储和网络带宽压力都会非常大,而通过切片,可以将这些压力分散到多个小文件中,减轻服务器的压力。
- 断点续传、错误重试:因为大文件被肢解了,如果因为一些原因中断、错误了,已经上传的部分就不用再重新上传了,只需要把后续的传上就好了。
前端部分
1.1 切文件(前端)
1.2 判定切片是否完成上传完成(前端)
- 客户端记录切片的上传状态,只需要上传未成功的切片
1.3 断点、错误续传(前端)
- 客户端上传文件时,记录已上传的切片位置
- 下次上传时,根据记录的位置,继续上传
后端部分
1.1 收切片、存切片
- 将相关切片保存在目标文件夹
1.2 合并切片
- 服务端根据切片的顺序,将切片合并成完整文件
1.3 文件是否存在校验
- 服务端根据文件Hash值、文件名,校验该文件是否已经上传
代码实现
1、搭建基础项目
服务器(基于express)
const express = require('express')
const app = express()
app.listen(3000, () => {
console.log('服务已运行:http://localhost:3000');
})
前端
基础页面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
input{
display: block;
margin: 10px 0;
}
</style>
</head>
<body>
<input type="file" id="file">
<input type="button" id="upload" value="上传">
<input type="button" id="continue" value="继续上传">
</body>
</html>
引入资源
<script type="module" src="./spark-md5.js"></script>
<script type="module" src="./operate.js"></script>
operate.js
// 获取文件域
const fileEle = document.querySelector("#file");
const uploadButton = document.querySelector("#upload");
const continueButton = document.querySelector("#continue");
uploadButton.addEventListener("click", async () => {
console.log("点击了上传按钮")
})
continueButton.addEventListener('click', async () => {
console.log("点击了继续上传按钮")
})
3、静态资源托管(server)
app.use(express.static('static'))
4、上传接口
搭建上传接口(server)
使用body-parser中间价解析请求体
// 导入中间件
const bodyParser = require('body-parser')
// 使用中间件
// 处理URL编码格式的数据
app.use(bodyParser.urlencoded({ extended: false }));
// 处理JSON格式的数据
app.use(bodyParser.json());
上传接口
app.post('/upload', (req, res) => {
res.send({
msg: '上传成功',
success: true
})
})
测试接口(前端)
// 单个文件上传
const uploadHandler = async (file) => {
fetch('http://localhost:3000/upload', {
method: "POST",
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileName: '大文件',
}),
})
}
uploadButton.addEventListener("click", async (e) => {
uploadHandler()
})
5、文件上传接口存储文件(server)
使用multer中间件处理上传文件
设置uploadFiles文件夹为文件存储路径
const multer = require('multer')
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, './uploadFiles');
},
});
const upload = multer({
storage
})
app.post('/upload', upload.single('file'), (req, res) => {
})
测试
// 单个文件上传
const uploadHandler = async (file) => {
let fd = new FormData();
fd.append('file', file);
fetch('http://localhost:3000/upload', {
method: "POST",
body: fd
})
}
uploadButton.addEventListener("click", async () => {
let file = fileEle.files[0];
uploadHandler(file)
})
6、文件切片
注意
假设切片大小为1M 保存切片顺序(为了合成大文件时正确性) 上传状态(为了断点续传、前端显示进度条)
// 使用单独常量保存预设切片大小 1MB
const chunkSize = 1024 * 1024 * 1;
// 文件切片
const createChunks = (file) => {
// 接受一个文件对象,要把这个文件对象切片,返回一个切片数组
const chunks = [];
// 文件大小.slice(开始位置,结束位置)
let start = 0;
let index = 0;
while (start < file.size) {
let curChunk = file.slice(start, start + chunkSize);
chunks.push({
file: curChunk,
uploaded: false,
chunkIndex: index,
});
index++;
start += chunkSize;
}
return chunks;
}
测试文件切片函数
// 存储当前文件所有切片
let chunks = [];
uploadButton.addEventListener("click", async () => {
let file = fileEle.files[0];
chunks = createChunks(file);
console.log(chunks);
})
注意:将来要把这些切片全部都上传到服务器,并且最后需要把这些切片合并成一个文件,且要做出文件秒传功能,需要保留当前文件的hash值和文件名,以辨别文件和合并文件。
在页面中引入spark-md5.js
<script type="module" src="./spark-md5.js"></script>
获取文件Hash值
const getHash = (file) => {
return new Promise((resolve) => {
const fileReader = new FileReader();
fileReader.readAsArrayBuffer(file);
fileReader.onload = function (e) {
let fileMd5 = SparkMD5.ArrayBuffer.hash(e.target.result);
resolve(fileMd5);
}
});
}
把文件的hash值保存在切片信息中
// 文件hash值
let fileHash = "";
// 文件名
let fileName = "";
// 创建切片数组
const createChunks = (file) => {
// 接受一个文件对象,要把这个文件对象切片,返回一个切片数组
const chunks = [];
// 文件大小.slice(开始位置,结束位置)
let start = 0;
let index = 0;
while (start < file.size) {
let curChunk = file.slice(start, start + chunkSize);
chunks.push({
file: curChunk,
uploaded: false,
fileHash: fileHash,
chunkIndex: index,
});
index++;
start += chunkSize;
}
return chunks;
}
// 上传执行函数
const uploadFile = async(file) => {
// 设置文件名
fileName = file.name;
// 获取文件hash值
fileHash = await getHash(file);
chunks = createChunks(file);
console.log(chunks);
}
7、上传逻辑修改
前端部分
单个文件上传函数修改:
插入文件名、文件Hash值、切片索引
上传成功之后修改状态标识(可用于断点续传、上传进度回显)
// 单个文件上传
const uploadHandler = (chunk) => {
return new Promise(async (resolve, reject) => {
try {
let fd = new FormData();
fd.append('file', chunk.file);
fd.append('fileHash', chunk.fileHash);
fd.append('chunkIndex', chunk.chunkIndex);
let result = await fetch('http://localhost:3000/upload', {
method: 'POST',
body: fd
}).then(res => res.json());
chunk.uploaded = true;
resolve(result)
} catch (err) {
reject(err)
}
})
}
批量上传切片
限制并发数量(减轻服务器压力)
// 批量上传切片
const uploadChunks = (chunks, maxRequest = 6) => {
return new Promise((resolve, reject) => {
if (chunks.length == 0) {
resolve([]);
}
let requestSliceArr = []
let start = 0;
while (start < chunks.length) {
requestSliceArr.push(chunks.slice(start, start + maxRequest))
start += maxRequest;
}
let index = 0;
let requestReaults = [];
let requestErrReaults = [];
const request = async () => {
if (index > requestSliceArr.length - 1) {
resolve(requestReaults)
return;
}
let sliceChunks = requestSliceArr[index];
Promise.all(
sliceChunks.map(chunk => uploadHandler(chunk))
).then((res) => {
requestReaults.push(...(Array.isArray(res) ? res : []))
index++;
request()
}).catch((err) => {
requestErrReaults.push(...(Array.isArray(err) ? err : []))
reject(requestErrReaults)
})
}
request()
})
}
抽离上传操作
// 文件上传
const uploadFile = async (file) => {
// 设置文件名
fileName = file.name;
// 获取文件hash值
fileHash = await getHash(file);
// 获取切片
chunks = createChunks(file);
try {
await uploadChunks(chunks)
} catch (err) {
return {
mag: "文件上传错误",
success: false
}
}
}
后端部分
修改上传接口,增加功能
使用一个文件Hash值同名的文件夹保存所有切片
这里使用了node内置模块path处理路径
使用fs-extra第三方模块处理文件操作
const path = require('path')
const fse = require('fs-extra')
app.post('/upload', upload.single('file'), (req, res) => {
const { fileHash, chunkIndex } = req.body;
// 上传文件临时目录文件夹
let tempFileDir = path.resolve('uploadFiles', fileHash);
// 如果当前文件的临时文件夹不存在,则创建该文件夹
if (!fse.pathExistsSync(tempFileDir)) {
fse.mkdirSync(tempFileDir)
}
// 如果无临时文件夹或不存在该切片,则将用户上传的切片移到临时文件夹里
// 如果有临时文件夹并存在该切片,则删除用户上传的切片(因为用不到了)
// 目标切片位置
const tempChunkPath = path.resolve(tempFileDir, chunkIndex);
// 当前切片位置(multer默认保存的位置)
let currentChunkPath = path.resolve(req.file.path);
if (!fse.existsSync(tempChunkPath)) {
fse.moveSync(currentChunkPath, tempChunkPath)
} else {
fse.removeSync(currentChunkPath)
}
res.send({
msg: '上传成功',
success: true
})
})
8、合并文件
编写合并接口(server)
合并成的文件名为 文件哈希值.文件扩展名
所以需要传入文件Hash值、文件名
app.get('/merge', async (req, res) => {
const { fileHash, fileName } = req.query;
res.send({
msg: `Hash:${fileHash},文件名:${fileName}`,
success: true
});
})
请求合并接口(前端)
封装合并请求函数
// 合并分片请求
const mergeRequest = (fileHash, fileName) => {
return fetch(`http://localhost:3000/merge?fileHash=${fileHash}&fileName=${fileName}`, {
method: "GET",
}).then(res => res.json());
};
在切片上传完成后,调用合并接口
// 文件上传
const uploadFile = async (file) => {
// 设置文件名
fileName = file.name;
// 获取文件hash值
fileHash = await getHash(file);
// 获取切片
chunks = createChunks(file);
try {
await uploadChunks(chunks)
await mergeRequest(fileHash, fileName)
} catch (err) {
return {
mag: "文件上传错误",
success: false
}
}
}
合并接口逻辑
1、根据文件Hash值,找到所有切片
app.get('/merge', async (req, res) => {
const { fileHash, fileName } = req.query;
// 最终合并的文件路径
const filePath = path.resolve('uploadFiles', fileHash + path.extname(fileName));
// 临时文件夹路径
let tempFileDir = path.resolve('uploadFiles', fileHash);
// 读取临时文件夹,获取所有切片
const chunkPaths = fse.readdirSync(tempFileDir);
console.log('chunkPaths:', chunkPaths);
res.send({
msg: "合并成功",
success: true
});
})
合并接口逻辑
2、遍历获取所有切片路径数组,根据路径找到切片,合并成一个文件,删除原有文件夹
app.get('/merge', async (req, res) => {
const { fileHash, fileName } = req.query;
// 最终合并的文件路径
const filePath = path.resolve('uploadFiles', fileHash + path.extname(fileName));
// 临时文件夹路径
let tempFileDir = path.resolve('uploadFiles', fileHash);
// 读取临时文件夹,获取所有切片
const chunkPaths = fse.readdirSync(tempFileDir);
console.log('chunkPaths:', chunkPaths);
// 将切片追加到文件中
let mergeTasks = [];
for (let index = 0; index < chunkPaths.length; index++) {
mergeTasks.push(new Promise((resolve) => {
// 当前遍历的切片路径
const chunkPath = path.resolve(tempFileDir, index + '');
// 将当前遍历的切片切片追加到文件中
fse.appendFileSync(filePath, fse.readFileSync(chunkPath));
// 删除当前遍历的切片
fse.unlinkSync(chunkPath);
resolve();
}))
}
await Promise.all(mergeTasks);
// 等待所有切片追加到文件后,删除临时文件夹
fse.removeSync(tempFileDir);
res.send({
msg: "合并成功",
success: true
});
})
10、断点续传
封装continueUpload方法
在continueUpload方法中,只上传 uploaded 为true的切片
修改后此功能对用户来说即是黑盒,用户只需要重复调用continueUpload方法即可
// 文件上传
const continueUpload = async (file) => {
if(chunks.length == 0 || !fileHash || !fileName){
return;
}
try {
await uploadChunks(chunks.filter(chunk => !chunk.uploaded))
await mergeRequest(fileHash, fileName)
} catch (err) {
return {
mag: "文件上传错误",
success: false
}
}
}
2、大文件秒传
逻辑梗概
- 客户端上传文件时,先提交文件的哈希值,
- 服务端根据哈希值查询文件是否已经上传,如果已上传,则直接返回已上传状态
- 客户端收到已上传状态后,直接跳过上传过程
优势分析
- 提高上传效率:秒传可以提高上传效率,因为文件已经在上传过程中被上传过了,直接返回已上传状态,省要再次上传,提高效率。
代码实现
校验接口,校验是否已经存在目标文件
逻辑:根据文件Hash值和文件名组成 “文件Hash.文件扩展名” ,以保证文件名唯一
app.get('/verify', (req, res) => {
const { fileHash, fileName } = req.query;
const filePath = path.resolve('uploadFiles', fileHash + path.extname(fileName));
const exitFile = fse.pathExistsSync(filePath);
res.send({
exitFile
})
})
校验函数
// 校验文件、文件分片是否存在
const verify = (fileHash, fileName) => {
return fetch(`http://localhost:3000/verify?fileHash=${fileHash}&fileName=${fileName}`, {
method: "GET",
}).then(res => res.json());
};
// 文件上传
const uploadFile = async (file) => {
// 设置文件名
fileName = file.name;
// 获取文件hash值
fileHash = await getHash(file);
// 校验是否已经上传该文件
let { exitFile } = await verify(fileHash, fileName);
if (exitFile) {
return {
mag: "文件已上传",
success: true
}
}
// 获取切片
chunks = createChunks(file);
try {
await uploadChunks(chunks.filter(chunk => !chunk.uploaded))
await mergeRequest(fileHash, fileName)
} catch (err) {
return {
mag: "文件上传错误",
success: false
}
}
}
3、提取为公共方法
封装
编写 bigFileUpload.js 文件,暴露uploadFile和continueUpload
// bigFileUpload.js
export default {
uploadFile,
continueUpload
}
使用
导入资源并调用
import bigUpload from './bigFileUpload.js'
uploadButton.addEventListener("click", async () => {
let file = fileEle.files[0];
bigUpload.uploadFile(file)
})
continueButton.addEventListener('click', async () => {
bigUpload.continueUpload()
})
4、可优化
前端:
封装形式可优化,采用类的方式封装,以保证数据的独立性、可定制性
切片Hash的计算可以通过抽样切片的方式来进行
...
后端:
文件Hash校验可增加用户ip地址以保证文件唯一性
待合并项可定时删除
...
欢迎大家补充!
作者:JSNoob
链接:https://juejin.cn/post/7323883238896058387
猜你喜欢
- 2024-10-10 让编辑器支持word的复制黏贴,支持截屏的黏贴
- 2024-10-10 你知道前端对图片的处理方式吗(前端实现图片编辑)
- 2024-10-10 JavaScript异步图像上传(javascript 异步操作)
- 2024-10-10 javascript对文件和进制操作的一些方法汇总
- 2024-10-10 Node + H5 实现大文件分片上传、断点续传
- 2024-10-10 input上传图片并压缩(vue,前端,js)
- 2024-10-10 leaflet地图截图批量导出(leaflet地图旋转)
- 2024-10-10 Spring WebSocket传递多媒体消息(websocket springmvc)
- 2024-10-10 JS上传文件判断文件类型(js如何判断文件是否存在)
- 2024-10-10 Web worker 技术应用与实践(web技术应用基础)
- 最近发表
- 标签列表
-
- cmd/c (57)
- c++中::是什么意思 (57)
- sqlset (59)
- ps可以打开pdf格式吗 (58)
- phprequire_once (61)
- localstorage.removeitem (74)
- routermode (59)
- vector线程安全吗 (70)
- & (66)
- java (73)
- org.redisson (64)
- log.warn (60)
- cannotinstantiatethetype (62)
- js数组插入 (83)
- resttemplateokhttp (59)
- gormwherein (64)
- linux删除一个文件夹 (65)
- mac安装java (72)
- reader.onload (61)
- outofmemoryerror是什么意思 (64)
- flask文件上传 (63)
- eacces (67)
- 查看mysql是否启动 (70)
- java是值传递还是引用传递 (58)
- 无效的列索引 (74)