文件传输和下载
(1)前端请求:正常发送资源获取请求,但要设置 resType: 'blob'
(2)后端返回:res.download(文件路径,文件名,(err)=>{})
const loadDataAll = async () => {
// 服务器请求
const res = await axios({
method: 'get',
url: 'http://localhost:3000/data/getTblAll',
resType: 'blob'
});
const text = await res.data.text();
// 解析
dataAll = d3.csvParse(text,d3.autoType);
await processData(); //去筛选和渲染
}
exports.getTblAll = async function (req,res){
const filePath = path.join( dataPath,'xxx.csv');
res.download(filePath, 'xxx.csv', (err) => {
if (err) {
res.status(500).send('Error downloading file');
}
});
}
(3)前端下载
- 上述请求文件
- 将 blob 解析成 本地 url ---
createObjectURL - 创建 a 标签,将 url 赋值给 a.href
- 设置 a 标签的 download 属性 + 设置文件名
- 模拟点击 a 标签
// 下载方法2:利用a标签
let downloadWithA = () => {
axios.get("http://localhost:3000/data/getFeatFile?filename="+ downloadFilename.value,{
responseType: "blob"})
.then((res) => {
//2、创建blob对象的本地ur1
let blobURL = URL.createObjectURL(res.data);
//3、创建标签,设置href
let link = document.createElement("a")
link.href = blobURL
//4、设置down1oad
link.download = downloadFilename.value //自定义下载后的文件名
link.style.display = "none";
//5、模拟点击
link.click();
window.URL.revokeObjectURL(blobURL) //移除url 释放内存
})
}
// 下载方法 1
let downloadFile = () => {
window.open(
"http://localhost:5173/public/downloadFile/" + downloadFilename.value,
'_self'
);
}
文件上传(断点续传/秒传)
前端
1 从文件上传表单获取 file 对象
<input type="file" name="file" multiple
@change="handleUpload"
/>
<script>
let handleUpload = async (e) => {
const files = e.target.files //多文件伪数组
const file = files[0]
//file.name,file.size
</script>
在 el-upload 中
<!--
action:指定上传地址
http-request:覆盖掉默认上传逻辑
-->
<el-upload
action="#"
:http-request="handleUpload">
<script>
let handleUpload = async (options) => {
const file = options.file
// ...
</script>
2 对 file对象进行循环切割,得到一个分片数组
while(cur < file.size) {
fileChunks.push(file.slice(cur, cur + CHUNK_SIZE))
cur += CHUNK_SIZE
}
3 对分片执行 hash 计算
根据分片/文件内容计算 hash,用于唯一标识
使用 sparkMD5 计算 hash
策略:如果每个分片都计算 hash 很耗时,因此:
- 首尾分片全部计算
- 中间分片分别在前后和中间各取2个字节参与计算
const calculateHash = async (fileChunks) => {
return new Promise(resolve => {
//确定参与计算hash的分片
const unHashedChunks = []
fileChunks.forEach((chunk, index) => {
if (index === 0 || index === fileChunks.length - 1) {
// 1.首尾分片
unHashedChunks.push(chunk)
} else {
// 2. 中间分片
unHashedChunks.push(chunk.slice(0,2)) // 前面 2 字节
unHashedChunks.push(chunk.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2)) // 中间 2 字节
unHashedChunks.push(chunk.slice(CHUNK_SIZE - 2, CHUNK_SIZE)) // 后面 2 字节
}
})
// sparkMD5计算hash
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
// 读取文件/分片,追加到 spark 实例
fileReader.readAsArrayBuffer(new Blob(unHashedChunks));
fileReader.onload = (event) => {
spark.append(event?.target?.result);
resolve(spark.end()); //返回hash值
};
})
}
4 判断是否秒传
将 文件名+文件hash 发送到服务器,服务返回
shouldUpload(是否需要上传) ,用于秒传uploadedChunksHash(目前已上传的分片的hash),用于断点续传。
//判断是否秒传
const verifyUpload = async() => {
return axios.post('http://localhost:3000/file/verifyUpload', {
fileName: fileName.value,
fileHash: fileHash.value
}, {
headers: {
'Content-Type': 'application/json',
}
})
.then((res) => res.data) //服务器是否有该文件
}
5 并行上传发分片
5.1 设置分片信息:文件 hash + 分片 hash + 分片(file 对象)+ 分片大小
5.2 断点续传:过滤掉已经上传的分片(根据uploadedChunksHash)
5.3 构造 formData:为每个待上传的 chunk 构造 formData 对象
5.4 并行 + 循环发送分片:每次同时发 6 个请求 ,即 6 个分片
- 构建每个分片的请求 task ---> 已完成的删除,未完成的加入
- 构建一个 taskPool 池,池中任务到一定数量则用 await Promise.race 暂时阻塞
/** 5.分片上传 */
const uploadChunks = async(chunks,uploadedChunksHash=[]) => {
// 5.1 设置分片信息
const chunksInfo = chunks.map((chunk, index) => {
return {
fileHash: fileHash.value, // 区分不同的切片
chunkHash: fileHash.value + '-' + index, //分片hash值 = hash-index
chunk: chunk,
size: chunk.size,
}
})
// 5.3 为每个待上传的 chunk 构造 formData 对象
const formDatas = chunksInfo
// 5.2 断点续传:过滤掉已经上传的分片
.filter((chunk,index) => {
return !uploadedChunksHash.includes(chunk.chunkHash)
})
.map((item) => {
const formData = new FormData()
formData.append('chunk', item.chunk) // 切片文件
formData.append('chunkHash', item.chunkHash) // 切片文件的 hash
formData.append('fileName', fileName.value) // 大文件的文件名
formData.append('fileHash', fileHash.value) // 大文件的hash
return formData
})
// 5.4 并行+循环发送
const max = 6 //最大并发请求数
let index = 0
const taskPool = [] //请求队列
// 循环发送分片们
while(index < formDatas.length){
const task = fetch('http://localhost:3000/file/uploadFile',{
method: 'POST',
body: formDatas[index],
})
// 已完成的 task 从任务队列里移除;完成的则加入
task.then(()=>{
taskPool.splice(taskPool.findIndex((item) => item === task))
})
taskPool.push(task)
// 任务队列里的任务数量到达最大并行数时,阻塞住代码,
// 等到请求池中至少一个任务完成了,代码才能继续往下执行
if(taskPool.length === max){
await Promise.race(taskPool)
}
index++
}
// 以防万一,让所有请求在此处都能确保完成
await Promise.all(taskPool)
// 通知服务器合并分片
mergeChunk()
}
// 前端发送一个合并切片的请求
const mergeChunk = () => {
//发送合并请求
fetch('http://localhost:3000/file/mergeChunk',{
method:'POST',
headers:{
'Content-Type':'application/json'
},
body: JSON.stringify({
size: CHUNK_SIZE,
fileHash: fileHash.value,
fileName: fileName.value,
})
})
.then((res)=> res.json() )
.then(() => {
ElMessage({
message: 'Merge Successfully',
type: 'success',
})
})
}
后端
1 接受上传的分片
1.1 利用 multiparty 解析 formData
1.2 创建切片们存储的临时文件夹:文件 hash 为名
1.3 把文件切片从默认存放地移动到临时文件夹中
1.4 响应 200 ok
const form = new multiparty.Form()
form.parse(req, async function (err, fields, files) {
/** Fields: {
chunkHash: [ '20809a412c59ad1c19e0330d759761d4-1' ],
fileName: [ 'CHI 可视化 2.pdf' ],
fileHash: [ '20809a412c59ad1c19e0330d759761d4' ]
}
Files: {
chunk: [
{
fieldName: 'chunk',
originalFilename: 'blob',
path: '/var/folders/v9/pn7gpmpj51s0brm3hnj4r8rc0000gn/T/ZWwHz6ow8NuzPA089MTOX8YS',
headers: [Object],
size: 600084
}
]
}*/
// ① 解析 formData
const chunkHash = fields['chunkHash'][0]
const fileHash = fields['fileHash'][0]
// ② 创建切片们存储的临时文件夹:文件 hash 为名
const chunkDir = path.resolve(UPLOAD_DIR, fileHash)
if(!fs.existsSync(chunkDir)){ // 切片目录不存在,创建
await fs.mkdirs(chunkDir)
}
// ③ 把文件切片移动到自己创建的临时文件夹中
const oldPath = files.chunk[0].path //系统默认存放地
await fs.move(oldPath,path.resolve(chunkDir,chunkHash))
// ④
res.status(200).json({...})
}
2 合并分片
2.1 设置后端文件名:fileNameBack = 文件hash + 后缀
2.2 根据下标对分片们排序
2.3 合并分片:利用 fs 对象的读写流
createReadStream 根据分片路径读取一个分片
createWriteStream 将它拼入最终的 fileNameBack 文件
所有分片读写完毕后,响应 200,删除临时文件夹
// ① 后端文件名:fileNameBack = 文件hash + 后缀
const fileNameBack = path.resolve(UPLOAD_DIR, fileHash + extractExt(fileName))
// ② 根据下标对分片们排序
chunkHashs.sort((a, b) => {
return a.split('-')[1] - b.split('-')[1]
})
// ③ 合并分片
// 遍历chunkHashs,对每个分片进行操作:
const readStream = fs.createReadStream(chunkPath)
const writeStream = fs.createWriteStream(fileNameBack, {
start: index * size,
end: (index + 1) * size
})
readStream.on('end', async () => {
await fs.unlinkSync(chunkPath) //写完后移除该分片
})
readStream.pipe(writeStream)
//合并结束,响应 200,删除临时文件夹
3 秒传/断点续传
判断文件是否已经存在,返回 shouldUpload:true/false
断点续传:返回已存在的切片 hash uploadedChunks: [chunk1hash,chunk2hash...]
// 秒传
if (fs.existsSync(fileNameBack)) {
// 如果文件已存在,秒传成功,无需重复上传
res.status(200).json({...shouldUpload: false... }
})
else {
res.status(200).json({...shouldUpload: false,uploadedChunks: chunkHashs... }
}