大文件上传是指将大于常规文件大小(通常大于 100MB 或 GB 级别)的文件从客户端上传到服务器或云存储服务的过程。由于大文件的大小和上传过程中的网络限制,它通常需要特殊的技术来确保上传的稳定性、效率和可靠性。
(1)稳定性:避免上传中断,支持断点续传。
(2)效率:提高上传速度,支持并发上传。
(3)资源优化:节省带宽和内存,减少负载。
(4)灵活性:提高文件完整性校验,灵活管理上传内容。
(1)前端切片:将文件分割成多个较小的分片。
(2)计算文件哈希值:计算整个文件的哈希值或每个分片的哈希值。
(3)上传分片:通过并发上传的方式上传每个分片。
(4)服务器接收与存储:服务器接收每个分片,并将它们存储在临时文件夹中。
(5)合并文件:当所有分片上传完成后,服务器将分片按顺序合并成一个完整的文件。
(6)文件校验:服务器验证合并后的文件完整性,确保上传文件无误。
(7)返回成功:上传和合并成功后,返回给前端通知上传成功。
用户通过 选择文件,并触发
clickFn
方法。
在 clickFn
中,文件通过 e.target.files
获取。获取到文件后,前端将文件进行分片,调用 createChunks
方法。每个分片的大小为 1MB (CHUNK_SIZE = 1024 * 1024
),然后文件被分割成多个较小的 Blob 对象。
const CHUNK_SIZE = 1024 * 1024//切片大小为1M
const fileHash = useRef('')//存储文件哈希值
const fileName = useRef('')//存储文件名称
const [progress, setProgress] = useState(0)进度条进度
// 获取文件分片
const createChunks = (file: File) => {
let cur = 0
let thunks = []
while (cur < file.size) {
const blob = file.slice(cur, cur + CHUNK_SIZE)
thunks.push(blob)
cur += CHUNK_SIZE
}
return thunks
}
在文件被分片后,前端通过 calculateHash
方法计算文件的哈希值,这个哈希值用于标识文件的唯一性。哈希值计算使用了 SparkMD5.ArrayBuffer
。
calculateHash
方法中,通过读取文件的每一个分片(包括前后两个字节和中间部分的字节),计算并返回整个文件的 MD5 哈希值。
const calculateHash = (thunks: Blob[]) => {
return new Promise(resolve => {
// 第一个和最后一个切片全部参与计算
// 中间的切片只计算前面两个字节、中间两个字节、最后两个字节
const targets: Blob[] = [] //存储所有参与计算的切片
const spark = new SparkMD5.ArrayBuffer()
const fileReader = new FileReader()
thunks.forEach((h, index) => {
if (index === 0 || index === thunks.length - 1) {
// 第一个和最后一个切片全部参与计算
targets.push(h)
} else {
targets.push(h.slice(0, 2)) //前面两个字节
targets.push(h.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2)) //中间两个字节
targets.push(h.slice(CHUNK_SIZE - 2, CHUNK_SIZE)) //最后两个字节
}
})
fileReader.readAsArrayBuffer(new Blob(targets))
fileReader.onload = (e) => {
// console.log((e.target as FileReader).result);
spark.append((e.target as FileReader).result as ArrayBuffer)
// console.log('hash:' + spark.end());
resolve(spark.end())
}
})
}
分片计算完哈希值后,前端会调用 uploadChunks
方法来上传每个分片。
在 uploadChunks
中,前端将每个分片和相关的哈希值(fileHash
和 thunkHash
)一起放入 FormData
中,并通过 fetch
发送到服务器。
并发上传:为了提高上传效率,前端限制最大并发数为 6(max = 6
),上传过程中会实时显示进度条,通过 setProgress
更新上传进度。
const uploadChunks = async (thunks: Blob[]) => {
const data = thunks.map((thunk, index) => {
return {
fileHash: fileHash.current,
thunkHash: fileHash.current + '-' + index,
thunk,
}
})
const formDatas = data.map((item) => {
const formData = new FormData()
formData.append('fileHash', item.fileHash)
formData.append('thunkHash', item.thunkHash)
formData.append('thunk', item.thunk)
// console.log(item.fileHash);
return formData
})
// console.log(formDatas);
const max = 6 //最大并发请求数
let index = 0 //
const taskPool: any = [] //请求池
let totalUploaded = 0//已上传的字节数
const totalSize = thunks.reduce((sum, thunk) => sum + thunk.size, 0)//计算所有切片的总大小
while (index < formDatas.length) {
const currentIndex = index;
const currentThunk = thunks[currentIndex];
const task = fetch('http://localhost:3000/upload', {
method: 'POST',
body: formDatas[index],
}).then((res) => {
if (res.status == 200) {
totalUploaded += currentThunk.size
const uploadProgress = ((totalUploaded / totalSize) * 100).toFixed(2)
setProgress(Number(uploadProgress))
}
})
taskPool.splice(taskPool.findIndex((item: any) => item === task))
taskPool.push(task)
if (taskPool.length === max) {
await Promise.race(taskPool)
}
index++
}
await Promise.all(taskPool)
// 通知服务器合并文件
mergeRequest()
}
一旦所有的分片上传完成,前端会调用 mergeRequest
方法通知服务器开始文件的合并操作。
const mergeRequest = () => {
fetch('http://localhost:3000/merge', {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
fileHash: fileHash.current,
fileName: fileName.current,
size: CHUNK_SIZE
})
}).then((res) => {
alert('合并成功')
})
}
服务器通过 multiparty
解析前端上传的分片,每个分片的内容会存储在 files['thunk']
中,而文件的哈希信息(fileHash
和 thunkHash
)会存储在 fields
中。
每个分片通过文件哈希(fileHash
)进行分类存储,thunkHash
用于标识每个分片。
服务器将每个分片存储到以文件哈希为名的文件夹中,存储路径为 chunkDir
。
对于每个分片,服务器会将其从临时路径移动到 chunkDir
(以 fileHash
为名的文件夹)中,文件路径为 chunkPath
。
一旦分片存储完成,服务器返回成功响应,表示该分片上传成功。
// 处理分片上传
router.post('/upload', function (req, res) {
const form = new multiparty.Form();
form.parse(req, async (err, fields, files) => {
// fields中存储的是前端传递fileHash和thunkHash
// files中存储的是上传文件的切片
if (err) {
return res.status(500).json({ ok: false, msg: "上传失败" });
}
// 获取fileHash和thunkHash
const fileHash = fields['fileHash'][0]; // 文件哈希值
const chunkHash = fields['thunkHash'][0]; // 切片哈希值
// 存放当前文件的所有切片的路径
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
// if (fse.existsSync(chunkDir)) {
// console.log('已存在');
// return
// }
// 创建存放切片的目录(如果不存在)
if (!fse.existsSync(chunkDir)) {
await fse.ensureDir(chunkDir);
}
// 将切片移动到thunkDir
const oldPath = files['thunk'][0]['path']; // 上传的临时文件路径
const chunkPath = path.resolve(chunkDir, chunkHash); // 目标路径
// 移动文件到切片目录
await fse.move(oldPath, chunkPath, { overwrite: true });
res.status(200).json({ ok: true, msg: '切片上传成功' });
});
});
上传所有分片后,前端会通知服务器进行文件合并(通过 mergeRequest
)。
服务器在 merge
路由中接收文件合并请求,首先获取文件的哈希值(fileHash
)、文件名(fileName
)以及每个分片的大小。
服务器会按顺序读取存储的所有分片,并通过 fse.createWriteStream
将每个分片内容写入到最终的文件中。
在合并过程中,服务器会删除每个已合并的分片,并最终删除存储分片的目录(chunkDir
)。
合并完成后,服务器返回合并成功的响应。
// 处理文件合并
router.post('/merge', async (req, res) => {
const { fileHash, fileName, size } = req.body;
// fileHash文件唯一id
// fileName文件名
// size切片大小
// 合成后文件路径
const filePath = path.resolve(UPLOAD_DIR, fileHash + extractExt(fileName));
// 存放切片的目录
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
if (!fse.existsSync(chunkDir)) {
return res.status(400).json({ ok: false, msg: '切片目录不存在' });
}
// 获取所有切片文件
const chunkPaths = await fse.readdir(chunkDir);
// 对切片进行排序
chunkPaths.sort((a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1]));
// 逐个合并切片
for (let i = 0; i < chunkPaths.length; i++) {
// 当前切片的路径
const chunkPath = path.resolve(chunkDir, chunkPaths[i]);
// 创建写入流,使用追加方式
const writeStream = fse.createWriteStream(filePath, { flags: "a" });
await new Promise((resolve) => {
// 异步方法,读取当前切片内容
const readStream = fse.createReadStream(chunkPath);
// 将readStream的值进行追加
readStream.pipe(writeStream);
// 监听end事件,将合并完的切片进行删除
readStream.on("end", async () => {
await fse.unlink(chunkPath); // 删除切片
resolve();
});
});
}
以上就是分片上传的流程,下面是完整代码
import { useEffect, useRef, useState } from "react"
import SparkMD5 from 'spark-md5'
export default function Index() {
const CHUNK_SIZE = 1024 * 1024//1m
const fileHash = useRef('')
const fileName = useRef('')
const [progress, setProgress] = useState(0)
// 文件分片
const createChunks = (file: File) => {
let cur = 0
let thunks = []
while (cur < file.size) {
const blob = file.slice(cur, cur + CHUNK_SIZE)
thunks.push(blob)
cur += CHUNK_SIZE
}
return thunks
}
// 计算hash值函数
const calculateHash = (thunks: Blob[]) => {
return new Promise(resolve => {
// 第一个和最后一个切片全部参与计算
// 中间的切片只计算前面两个字节、中间两个字节、最后两个字节
const targets: Blob[] = [] //存储所有参与计算的切片
const spark = new SparkMD5.ArrayBuffer()
const fileReader = new FileReader()
thunks.forEach((h, index) => {
if (index === 0 || index === thunks.length - 1) {
// 第一个和最后一个切片全部参与计算
targets.push(h)
} else {
targets.push(h.slice(0, 2)) //前面两个字节
targets.push(h.slice(CHUNK_SIZE / 2, CHUNK_SIZE / 2 + 2)) //中间两个字节
targets.push(h.slice(CHUNK_SIZE - 2, CHUNK_SIZE)) //最后两个字节
}
})
fileReader.readAsArrayBuffer(new Blob(targets))
fileReader.onload = (e) => {
// console.log((e.target as FileReader).result);
spark.append((e.target as FileReader).result as ArrayBuffer)
// console.log('hash:' + spark.end());
resolve(spark.end())
}
})
}
const mergeRequest = () => {
fetch('http://localhost:3000/merge', {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
fileHash: fileHash.current,
fileName: fileName.current,
size: CHUNK_SIZE
})
}).then((res) => {
alert('合并成功')
})
}
// 上传分片
const uploadChunks = async (thunks: Blob[]) => {
const data = thunks.map((thunk, index) => {
return {
fileHash: fileHash.current,
thunkHash: fileHash.current + '-' + index,
thunk,
}
})
const formDatas = data.map((item) => {
const formData = new FormData()
formData.append('fileHash', item.fileHash)
formData.append('thunkHash', item.thunkHash)
formData.append('thunk', item.thunk)
// console.log(item.fileHash);
return formData
})
// console.log(formDatas);
const max = 6 //最大并发请求数
let index = 0 //
const taskPool: any = [] //请求池
let totalUploaded = 0//已上传的字节数
const totalSize = thunks.reduce((sum, thunk) => sum + thunk.size, 0)//计算所有切片的总大小
while (index < formDatas.length) {
const currentIndex = index;
const currentThunk = thunks[currentIndex];
const task = fetch('http://localhost:3000/upload', {
method: 'POST',
body: formDatas[index],
}).then((res) => {
if (res.status == 200) {
totalUploaded += currentThunk.size
const uploadProgress = ((totalUploaded / totalSize) * 100).toFixed(2)
setProgress(Number(uploadProgress))
}
})
taskPool.splice(taskPool.findIndex((item: any) => item === task))
taskPool.push(task)
if (taskPool.length === max) {
await Promise.race(taskPool)
}
index++
}
await Promise.all(taskPool)
// 通知服务器合并文件
mergeRequest()
}
const clickFn = async (e: any) => {
const files = e.target.files
if (!files) return
// 读取文件
// console.log(files[0]);
fileName.current = files[0].name
// 文件分片操作
const thunks = createChunks(files[0])
// console.log(thunks);
// hash计算
const hash = await calculateHash(thunks)
fileHash.current = hash as string
// console.log(hash);
// 上传分片
uploadChunks(thunks)
}
return <>
{ clickFn(e) }} />
{progress}%
>
}
var express = require('express');
var router = express.Router();
var multiparty = require('multiparty')
var fse = require('fs-extra')
var path = require('path');
// 定义uploads目录
const UPLOAD_DIR = path.resolve(__dirname, 'uploads')
// 提取文件扩展名
const extractExt = filename => filename.slice(filename.lastIndexOf('.'))
// 处理分片上传
router.post('/upload', function (req, res) {
const form = new multiparty.Form();
form.parse(req, async (err, fields, files) => {
// fields中存储的是前端传递fileHash和thunkHash
// files中存储的是上传文件的切片
if (err) {
return res.status(500).json({ ok: false, msg: "上传失败" });
}
// 获取fileHash和thunkHash
const fileHash = fields['fileHash'][0]; // 文件哈希值
const chunkHash = fields['thunkHash'][0]; // 切片哈希值
// 存放当前文件的所有切片的路径
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
// if (fse.existsSync(chunkDir)) {
// console.log('已存在');
// return
// }
// 创建存放切片的目录(如果不存在)
if (!fse.existsSync(chunkDir)) {
await fse.ensureDir(chunkDir);
}
// 将切片移动到thunkDir
const oldPath = files['thunk'][0]['path']; // 上传的临时文件路径
const chunkPath = path.resolve(chunkDir, chunkHash); // 目标路径
// 移动文件到切片目录
await fse.move(oldPath, chunkPath, { overwrite: true });
res.status(200).json({ ok: true, msg: '切片上传成功' });
});
});
// 处理文件合并
router.post('/merge', async (req, res) => {
const { fileHash, fileName, size } = req.body;
// fileHash文件唯一id
// fileName文件名
// size切片大小
// 合成后文件路径
const filePath = path.resolve(UPLOAD_DIR, fileHash + extractExt(fileName));
// 存放切片的目录
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
if (!fse.existsSync(chunkDir)) {
return res.status(400).json({ ok: false, msg: '切片目录不存在' });
}
// 获取所有切片文件
const chunkPaths = await fse.readdir(chunkDir);
// 对切片进行排序
chunkPaths.sort((a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1]));
// 逐个合并切片
for (let i = 0; i < chunkPaths.length; i++) {
// 当前切片的路径
const chunkPath = path.resolve(chunkDir, chunkPaths[i]);
// 创建写入流,使用追加方式
const writeStream = fse.createWriteStream(filePath, { flags: "a" });
await new Promise((resolve) => {
// 异步方法,读取当前切片内容
const readStream = fse.createReadStream(chunkPath);
// 将readStream的值进行追加
readStream.pipe(writeStream);
// 监听end事件,将合并完的切片进行删除
readStream.on("end", async () => {
await fse.unlink(chunkPath); // 删除切片
resolve();
});
});
}
await fse.remove(chunkDir); // 删除存放切片的目录
res.status(200).json({ ok: true, msg: '文件合并成功' });
});
module.exports = router;