(1).背景:
- AI 产品方面的,会涉及到用户自定义模型, 经常会遇到一些问题
- 网络断开之后, 之前传的没了
- 传着传着,网络波动了, 结果都没了
- 关机了,想接着传,做不到
(2)专业术语:
- 断点续传
- 断开重连重传
- 切片上传
(3)方案:
- 前端将文件切片,并发上传,后端接受切片并保存,最后合并切片
- 前端将文件切片,并发上传,后端接受切片并保存,前端定时检查上传进度,如果发现上传失败,则重新上传
- 前端将文件切片,并发上传,后端接受切片并保存,前端定时检查上传进度,如果发现上传失败,则重新上传,同时记录上传进度,下次上传时从上次失败的位置开始
(4)原理:
- 分片上传的原理就像是把一个大蛋糕切成小块一样。
- 首页,将要上传的文件分成很多小块,每个小块的大小相同,比如每块大小 5MB, 然后,前端会依次上传这些小块到服务器。上传的时候,可以同时上传多个小块,也可以一个一个上传,这取决于你的网络带宽和服务器性能。上传每个小块后,服务器会保存这些小块,并记录他们的顺序和位置。最后,当所有的小块都上传完成后,服务器会把这些小块按照正确的顺序合并成一个完整的文件。
<template>
<div>
<h1>大文件上传</h1>
<input type="file" @change="handleUpload" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import SparkMD5 from 'spark-md5'
const fileHash = ref('')
const fileName = ref('')
// 1MB = 1024KB = 1024 * 1024B
const CHUNK_SIZE = 1024 * 1024 // 1M
// 文件分片的操作
const createChunks = file => {
let cur = 0
let chunks = []
while (cur < file.size) {
const blob = file.slice(cur, cur + CHUNK_SIZE)
chunks.push(blob)
cur += CHUNK_SIZE
}
return chunks
}
const calculateHash = chunks => {
return new Promise(resolve => {
// 1. 第一个和最后一个切片全部参与计算
// 2. 中间的切片只计算 前面两个字节,中间的字节,和最后两个字节
const targets = [] // 参与计算 hash 的切片
const spark = new SparkMD5.ArrayBuffer()
const fileReader = new FileReader()
chunks.forEach((chunk, index) => {
if (index === 0 || index === chunks.length - 1) {
targets.push(chunk)
} else {
targets.push(chunk.slice(0, 2)) // 前面两个字节
targets.push(chunk.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2)) // 中间的字节
targets.push(chunk.slice(CHUNK_SIZE - 2, CHUNK_SIZE)) // 最后两个字节
}
})
fileReader.readAsArrayBuffer(new Blob(targets))
fileReader.onload = e => {
spark.append(e.target.result)
const hash = spark.end() // 拿到 hash 值
resolve(hash)
}
})
}
const mergeRequest = () => {
fetch('http://localhost:3000/merge', {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
fileHash: fileHash.value,
fileName: fileName.value,
size: CHUNK_SIZE
})
}).then(res => {
alert('合并成功了~~')
})
}
const uploadChunks = async (chunks, existChunks) => {
const data = chunks.map((chunk, index) => {
return {
fileHash: fileHash.value,
chunkHash: fileHash.value + '-' + index,
chunk
}
})
const formDatas = data
.filter( item => !existChunks.includes(item.chunkHash))
.map(item => {
const formData = new FormData()
formData.append('fileHash', item.fileHash)
formData.append('chunkHash', item.chunkHash)
formData.append('chunk', item.chunk)
return formData
})
// console.log(formDatas);
const max = 6 // 最大并发数
let index = 0
const taskPool = [] // 请求池
while (index < formDatas.length) {
const task = fetch('http://localhost:3000/upload', {
method: 'POST',
body: formDatas[index]
})
taskPool.splice(taskPool.findIndex(item => item === task))
taskPool.push(task)
if (taskPool.length >= max) {
await Promise.race(taskPool)
}
index++
}
await Promise.all(taskPool)
// 通知服务器合并切片
mergeRequest()
}
const verify = () => {
return fetch('http://localhost:3000/verify', {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
fileHash: fileHash.value,
fileName: fileName.value
})
}).then(res => res.json())
.then(res => {
return res
})
}
const handleUpload = async e => {
// 注意: e.target.files 伪数组
const files = e.target.files
if (!files) return
// 读取选择的文件
const file = files[0]
fileName.value = file.name
// 文件分片
const chunks = createChunks(file)
// console.log(chunks);
// 计算hash 值
const hash = await calculateHash(chunks)
// console.log(hash);
fileHash.value = hash
// 效验hash 值 是否已经上传过 , 必须等有 hash 值之后才能效验
const data = await verify()
console.log(data);
if(!data.data.isUploaded) {
alert('妙传: 上传成功了~')
return
}
// 上传分片
uploadChunks(chunks, data.data.existChunks)
}
/**
* 文件分片
* 核心用 Blob 对象的 slice 方法, 在前面一步获取选择的文件一个 File对象, 它继承于 Blob 对象,所以可以使用 slice 方法对文件其进行分片
* let blob = instanceOfBlob.slice(start, end, contentType)
* start 和 end 代表 Blob里的下标,表示被拷贝进新的 Bob 的字节的起始位置和结束位置。contentType 会给新的 Bob 赋予一个新的文
* 档类型,在这里我们用不到。接下来就来使用slice方法来实现下对文件的分片。
*
* hash 计算
* 在向服务器发送文件之前,怎么区分不同的文件呢? 如果根据文件名区分的话可以吗?
* 答案: 不行,因为文件名是可以重复的,也可以修改的。所以我们需要根据文件内容来区分不同的文件,这里就需要用到 hash 算法了。
* 可以根据 文件内容生成一个唯一的 hash 值,来区分不同的文件。 文件内容变化, hash 值也会变化。 而且通过这个办法,还可以 实现妙传的功能。
* 妙传: 就是说,服务器上传文件请求的时候,要先判断下对应文件的hash值有没有记录, 如果 A 和 B 先后 上传了同一个文件,
* 那么这两份文件对应的 hash 值是相同的,当A上传的时候会根据文件内容生成一个对应的 bash 值,然后在服务器上就会有一个对应的文件,
* B再上传的时候,服务器就会发现这个文件的 hash 值之前已经有记录了,说明之前已经上传过相同内容的文件了,
* 所以就不用处理B的这个上传请求了,给用户的感觉就像是实现了秒传。
* 那么怎么计算文件的hash值呢?可以通过一个工具:spark-md5,所以我们得先安装它。
* 在上一步获取到了文件的所有切片,我们就可以用这些切片来算该文件的hash 值,但是如果一个文件特别大,每个切片的所有内容都参与计算的话会很耗时间,所有我们可以采取以下策略:
1.第一个和最后一个切片的内容全部参与计算
2.中间剩余的切片我们分别在前面、后面和中间取2个字节参与计算
这样就既能保证所有的切片参与了计算,也能保证不耗费很长的时间
* 文件上传
* 前端实现
* 前面已经完成了上传的前置操作,接下来就来看下如何去上传这些切片
我们以1G的文件来分析,假如每个分片的大小为1M,那么总的分片数将会是1024个,如果我们同时发送这1024个分片,浏览器肯定处理不了,原因是切片文件过多,浏览器一次性创建了太多的请求。这是没有必要的,拿 chrome 浏览器来说,默认的并发数量只有6,过多的请求并不会提升上传速度,反而是给浏览器带来了巨大的负担。因此,我们有必要限制前端请求个数。
怎么做呢,我们要创建最大并发数的请求,比如6个,那么同一时刻我们就允许浏览器只发送6个请求,其中一个请求有了返回的结果后我们再发起一个新的请求,依此类推,直至所有的请求发送完毕。
上传文件时一般还要用到 Formpata 对象,需要将我们要传递的文件还有额外信息放到这个 Formpata 对象里面
文件合并
上一步我们已经实现了将所有切片上传到服务器了,上传完成之后,我们就可以将所有的切片合并成一个完整的文件了,下面就一块来实现下。
前端实现 : 前端只需要向服务器发送一个合并的请求,并且为了区分要合并的文件,需要将文件的hash值给传过去
注意: 已经大文件上传的分片上传的基本功能了,但是没有考虑到如果上传相同的文件的情况,而且如果中间网络断了,
就得重新上传,这些情况在大文件上传中都是需要考虑的。
妙传 & 断点续传
我们在上面有提到,如果内容相同的文件进行hash计算时,对应的hash值应该是一样的,
而且我们在服务器上给上传的文件命名的时候就是用对应的hash值命名的,所以在上传之前是不是可以加一个判断,
如果有对应的这个文件,就不用再重复上传了,直接告诉用户上传成功,
给用户的感觉就像是实现了秒传。接下来,就来看下如何实现的。
前端实现
前端在上传之前,需要将对应文件的hash值告诉服务器,看看服务器上有没有对应的这个文件,
如果有,就直接返回,不执行上传分片的操作了。
*/
</script>
<style scoped></style>
后端
const express = require("express");
const path = require("path");
const multiparty = require("multiparty");
const fse = require("fs-extra");
const cors = require("cors");
const bodyParser = require("body-parser");
const app = express();
app.use(bodyParser.json());
app.use(cors());
// 提取文件名后缀
const getSuffix = (fileName) => {
return fileName.substring(fileName.lastIndexOf("."));
};
const UPLOAD_DIR = path.resolve(__dirname, "uploads");
app.post("/upload", (req, res) => {
const form = new multiparty.Form();
form.parse(req, async (err, fields, files) => {
if (err) {
res.status(401).json({
ok: false,
message: "上传失败",
});
return;
}
console.log("fields", fields);
console.log("files", files);
const fileHash = fields.fileHash[0];
const chunkHash = fields.chunkHash[0];
// 临时存放文件路径
const chunkPath = path.resolve(UPLOAD_DIR, fileHash);
if (!fse.existsSync(chunkPath)) {
await fse.mkdir(chunkPath);
}
const oldPath = files.chunk[0].path;
// 将切片放到这个
await fse.move(oldPath, path.resolve(chunkPath, chunkHash));
res.status(200).json({
ok: true,
message: "上传成功",
});
});
});
app.post("/merge", async (req, res) => {
const { fileHash, fileName, size } = req.body;
// 如果已经存在该文件,就没必要合并了
const filePath = path.resolve(
UPLOAD_DIR,
`${fileHash}${getSuffix(fileName)}`
);
if (fse.existsSync(filePath)) {
res.status(200).json({
ok: true,
message: "合并成功",
});
return;
}
// 如果不存在,则合并
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
if (!fse.existsSync(chunkDir)) {
res.status(400).json({
ok: false,
message: "合并失败,请重新上传",
});
return;
}
// 真正的合并操作
const chunkPaths = await fse.readdir(chunkDir);
chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
const list = chunkPaths.map((chunkName, index) => {
return new Promise(async (resolve) => {
const chunkPath = path.resolve(chunkDir, chunkName);
const readStream = fse.createReadStream(chunkPath);
const writeStream = fse.createWriteStream(filePath, {
start: index * size,
end: (index + 1) * size,
});
readStream.on("end", async () => {
await fse.unlink(chunkPath);
resolve();
});
// 将切片写入到文件中
readStream.pipe(writeStream);
});
});
// 等待所有切片合并完成
await Promise.all(list);
// 删除临时文件夹
await fse.remove(chunkDir);
res.status(200).json({
ok: true,
message: "合并成功",
});
});
app.post("/verify", async (req, res) => {
const { fileHash, fileName } = req.body;
const filePath = path.resolve(
UPLOAD_DIR,
`${fileHash}${getSuffix(fileName)}`
);
// 返回服务器已经存在的切片
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
let chunkPaths = [];
if (fse.existsSync(chunkDir)) {
chunkPaths = await fse.readdir(chunkDir);
}
if (fse.existsSync(filePath)) {
res.status(200).json({
ok: true,
data: {
isUploaded: false,
},
});
} else {
// 如果不存在,则需要重新上传
res.status(200).json({
ok: true,
data: {
isUploaded: true,
existChunks: chunkPaths,
},
});
}
});
app.listen(3000, () => {
console.log(`服务器启动成功:http://localhost:3000`);
});