前言
前端向后端发送文件数据时一般会限制文件大小,普通的文件数据如 word, pdf, 图片、文本等数据时一般较小无需考虑什么上传速度,但是一些大文件(音频、视频媒体数据等等)数据因为体积大,上传时如果直接发送后台可能导致请求延时长,一旦请求失败意味着重新上传文件,非常的耗费资源。
所以说何不将单个请求拆分为多个请求,将单个文件拆分多个文件(chunk包),上传时每个请求负责一个chunk,后台负责接收所有的chunk,并且合并chunk组成一个完整文件呢
这样即使发送的多个请求中有出现问题没有发送成功的,那也可以在下次请求中继续补发缺失的chunk, 这样就可以避免发送重复的chunk包了
思路
简单说一下实现思路(仅代表个人的)
测试服务端构建
- 暴露请求校验文件完整性的api接口,需要校验文件是否存在目录,没有则创建目录,并且返回空的已存在chunk目录, 若是存在目录则返回目录中存在的chunk文件列表以用于前端筛查已上传的chunk包
- 暴露请求上传chunk包的api接口, 在上传时校验是否存在已经合并好的文件,有,则返回响应,提示文件已上传,无, 则向目标文件夹中写入对应chunk(校验请求中创建的)
- 暴露请求合并chunk包的api接口,先校验是否有合并文件, 有,返回文件对应的文件静态地址, 无, 则重命名头部文件,排序筛选后, 向头部文件中依次追加读取的文件,然后delete追加的文件。 追加合并完成后返回预览文件地址
more······
前端构建大概流程
- 初始化后,通过input:file可以获取到二进制File类型(extend Blob)的数据
- 进行文件类型/大小等验证
- 验证通过后,如果用户选择上传,则按照标准将文件进行分割,得到blob文件数组chunks
- 将文件chunks包装为FormData(表单数据)数组(使用生成hash来对每个chunk命名),用于发送后台
- 请求后台验证服务,筛选出未上传的chunk添加到上传的chunk数组中 (注意筛查是通过排除chunkname实现的)
- 筛查完成后将每一个chunk发送到后台,没有发送顺序要求
- 若是所有chunk都发送完成则请求合并chunks的api,合并成功后即可拿到响应的内容
more ...
补充
- 前后端关于chunk名称需统一, 后面的演示项目将使用MD5对文件进行命名,优点是只有文件内容改变时才会导致hash值的变化,每一个chunk包的命名将以
[hash]_(chunk序号).[ext]的方式,注意序号必须严格依照分割顺序(避免后台合并错误) - 有需要可前端实现上传进度的监视,即(已上传chunk / 所有上传chunk)
- 后台合并文件后的文件名依照
[hash]/[hash].[ext]格式, 每一个chunk文件以[hash]/[hash]_(序号).[ext] - 后台接口在请求时需要携带文件key(hash值) 用于锁定目标文件
more ....
实现
这里以上传视频为例
服务端核心
- 校验文件(校验是否存在合并的文件, 返回已上传chunkname)
// 校验文件完整 -- 返回已创建好的chunk
app.post('/validationFile', async (req, res) => {
try {
let { key } = req.body;
// 权限校验 ````
// 携带值的校验
if (!key) {
res.status(400).json({ code: 500, msg: 'err', data: '未包含有效hash key' })
return
}
// 判断是否存在文件夹
let dir = resolve(__dirname, 'video', key)
let result = await isExist(dir)
// 存在则返回 已存在的chunk
if (result) {
// 检验已上传chunk
// 读取所有在文件夹下的chunk
let result = await readdirFile(dir)
// 筛查(未合并的)
let fList = result.filter(child => {
let reg = new RegExp(/_(\d+)\.mp4$/)
return reg.test(child)
})
res.status(200).json({ code: 200, msg: 'ok', data: fList })
return;
}
// 不存在则创建文件夹
mkdirSync(dir)
// 返回空数组
res.status(200).json({ code: 200, msg: 'ok', data: [] })
} catch (error) {
res.status(500).json({ code: 500, msg: 'err', data: error.message })
}
})
- 上传chunks api
// 上传chunks包
app.post('/upload-video', async (req, res) => {
let { fileName, type, totalSize, size, hash } = req.body;
let { file } = req.files;
// 上传无文件时
if (!file) res.status(400).send('未接收到上传的文件')
// 校验文件上传类型
if (type != 'video/mp4') res.status(400).send('上传文件类型有误')
// 拼接文件名
let FileName = fileName
// 直接添加chunk
try {
// 检索是否已有合并好的chunk
let existPath = resolve(__dirname, './Video', hash, `${hash}.mp4`)
let isResult = await isExist(existPath)
if (isResult) {
//存在
// 直接响应已存在
res.status(200).json({ code: 200, msg: 'ok', data: '文件已存在' })
} else {
// 不存在
// 执行操作
// 获取文件路径
let filePath = resolve(__dirname, './Video', hash, FileName)
// 写入文件
// 添加chunk
await writeFilePromiseIfy(filePath, file.data)
res.status(200).json({ code: 200, msg: 'ok', data: 'null' })
}
} catch (error) {
res.status(500).json({ code: 500, msg: 'no', data: error })
}
})
- 合并chunks接口
// 合并切片api
app.post('/merge', async (req, res) => {
try {
let { key } = req.body;
// 权限校验
// 参数校验
if (!key) {
res.status(400).json({ code: 500, msg: 'err', data: '未包含有效hash key' })
return
}
// 检验是否有合并文件
let searchPath = resolve(__dirname, 'video', key, `${key}.mp4`)
let result = await isExist(searchPath)
// 存在合并文件 则直接返回合并地址
if (result) {
res.status(200).json({ code: 200, msg: 'ok', data: `http://127.0.0.1:${PROT}/${key}/${key}.mp4` })
return;
}
// 不存在则读取文件下所有的文件 用于合并
// 文件所在路径
let path = resolve(__dirname, 'Video', key)
// 读取所有文件
let fileList = await readdirFile(path)
// 获取追加目标文件路径
let targetFilePath = resolve(__dirname, 'video', `${key}/${key}.mp4`)
// 对文件进行排序
let sortArr = fileList.sort((a, b) => {
let reg = new RegExp(/_(\d+)\.mp4$/)
if (reg.test(a) && reg.test(b)) {
let start = reg.exec(a)[1],
end = reg.exec(b)[1];
return Number(start) - Number(end)
}
})
// 重命名文件位置
let renamePath = resolve(__dirname, 'video', `${key}/${sortArr[0]}`)
// 新文件位置
let newFilePath = resolve(__dirname, 'video', `${key}/${key}.mp4`)
// 重命名文件
await renameFile(renamePath, newFilePath)
// 过滤出需要合并的文件
let newFileList = sortArr.filter(child => {
let reg = new RegExp(/_1.mp4$/)
return child.search('_') != -1 && !reg.test(child)
})
// 对需要合并的文件进行操作
await deepFileHandler(newFileList, key, targetFilePath)
setTimeout(() => {
// 响应
res.status(200).json({ code: 200, msg: 'ok', data: `http://127.0.0.1:${PROT}/${key}/${key}.mp4` })
}, 1000)
} catch (error) {
res.status(500).json({ code: 500, msg: 'err', data: error.message })
}
})
- 工具函数
/**
* @description 递归追加文件
* @param {string[]} appChildList -追加的文件列表
* @param {string} key
* @param {string} path 追加的目标文件
*/
async function deepFileHandler(appChildList, key, path) {
try {
if (appChildList.length) {
// 读取文件路径
let appChildPath = resolve(__dirname, 'video', key, `${appChildList[0]}`)
let data = await readFilePromise(appChildPath)
// 追加文件
await mergeFile(path, data)
// 追加成功删除文件
await removeFile(appChildPath)
appChildList.shift()
deepFileHandler(appChildList, key, path)
} else {
return Promise.resolve('ok')
}
// 删除文件
} catch (error) {
return Promise.reject(error.message)
}
}
/**
* @param {string} appchild - 追加的文件路径
*/
function readFilePromise(appChildPath) {
// 读取文件
return new Promise((res, rej) => {
readFile(appChildPath, (err, data) => {
if (err) {
rej(err.message)
return;
}
res(data)
})
})
}
/**
* @param {string} path - 追加的文件路径
* @param {Buffer} data - 追加的内容
*/
function mergeFile(path, data) {
// 读取文件
return new Promise((res, rej) => {
appendFile(path, data, (err, result) => {
if (err) {
rej(err.message)
return;
}
res(result)
})
})
}
/**
* 删除文件
* @param {string} path - 删除的文件路径
*/
function removeFile(path) {
return new Promise((res, rej) => {
unlink(path, err => {
if (err) {
rej(err.message)
return;
}
res()
})
})
}
/**
* @description 重命名文件
* @param {string} renamePath - 重命名文件路径
* @param {string} newFilePath - 新文件地址(名称)
* @returns {Promise}
*/
function renameFile(renamePath, newFilePath) {
return new Promise((res, rej) => {
rename(renamePath, newFilePath, (err) => {
if (err) {
res(err.message)
return
}
res('ok')
})
})
}
/**
* @description 检验某个文件/文件夹是否存在
* @param {string} path 检测的地址
* @returns {Promise<boolean>} 返回是否存在
*/
function isExist(path) {
return new Promise((res, rej) => {
access(path, err => {
if (err) res(false)
else res(true)
})
})
}
/**
* @description 写入文件
* @param {string} filePath 写入文件位置
* @param {nuknown} fileData 写入文件内容
*/
function writeFilePromiseIfy(filePath, fileData) {
return new Promise((res, rej) => {
writeFile(filePath, fileData, err => {
if (err) rej(err)
else res('ok')
})
})
}
/**
* @description 读取文件下的所有文件
* @param {string} dir 读取的文件夹
* @returns {Promise<string[]>} 返回读取的文件列表
*/
function readdirFile(dir) {
return new Promise((res, rej) => {
readdir(dir, (err, data) => {
if (err) {
rej(err)
return
}
res(data)
})
})
}
前端核心
-
获取到上传文件,且通过校验
-
对文件进行分割, 得到blob数组
注意分割要求, 先按照 1 * 1024 * 1024 (1MB) 每个chunk包大小分割,一旦chunk数量超过50个就将文件分割为50个chunk包
// 每一个chunk包大小
let chunkSize = 1 * 1024 * 1024 // 1mb
// 点击上传文件后触发
async function upload(): Promise<void> {
// 文件
let fileContent = inputFile.files[0];
// 用于接收返回的blob数据对象
let blobs = []
// 按照1mb chunk 大小分割的包数
let chunks = Math.ceil(fileContent.size / chunkSize as number)
// 若是包数大于等于 50个以上
// 限制发送的包数量为 50
if (chunks >= 50) {
chunkSize = fileContent.size / 50
}
// 文件切片方法
deepFileSliceMethod(fileContent, chunkSize, 0, blobs)
}
/**
* @description 递归分割文件
* @param {File} file 分割文件对象
* @param {number} size 分割文件大小
* @param {number} start 切割文件的起点
* @param { Blob[] } chunks 分割文件的块组
* @returns {void}
*/
function deepFileSliceMethod(file: File, size: number, start: number, returnChucks: Blob[]): void {
// 1、判断文件是否满足切割要求
if (file.size <= size) returnChucks.push(file);
else if (start >= file.size) return;
else {
// 2、执行代码分割
let fileChuck = file.slice(start, start + size)
// 3、添加到chunks
returnChucks.push(fileChuck)
// 4、继续分割
deepFileSliceMethod(file, size, start + size, returnChucks);
};
}
3、对文件chunk进行formData包装,使其具有一些标识(name, size等)
import md5 from 'js-md5'
// 点击上传文件后触发
async function upload(): Promise<void> {
······
// 文件切片方法
deepFileSliceMethod(fileContent, chunkSize, 0, blobs)
let type = fileContent.type // 文件类型
let name = fileContent.name // 文件名称
let totalSize = fileContent.size // 文件大小(总)
// 生成formdata chunk组
let formDatas = formDataChunckMethod(blobs, name, { type, totalSize });
}
/**
* @description 递归获取到formData 格式数组
* @param {Blob[]} blobs 二进制文件切割后的数组
* @param {string} chunkname 切片名称
* @param {ParamInt} Params 固定添加的属性
* @returns {FormData[]} 返回formdata格式数组
*/
interface ParamInt {
[propName: string]: any
}
function formDataChunckMethod(blobs: Blob[], chunkname: string, Params: ParamInt): FormData[] {
return blobs.map((child, index) => {
// 1 创建formdata
let fd: FormData = new FormData()
for (const key in Params) {
if (Object.prototype.hasOwnProperty.call(Params, key)) {
fd.append(key, Params[key]);
}
}
// 文件
fd.append('file', child);
// 文件名称 (依照划分顺序重命名)
fd.append('fileName', `${md5(chunkname)}_${index + 1}.mp4`)
fd.append('hash', `${md5(chunkname)}`)
fd.append('size', `${child.size}`);
return fd
})
}
得到
- 请求校验,得到已经上传的chunkname
// 点击上传文件后触发
async function upload(): Promise<void> {
······
let type = fileContent.type // 文件类型
let name = fileContent.name // 文件名称
let totalSize = fileContent.size // 文件大小(总)
// 生成formdata chunk组
let formDatas = formDataChunckMethod(blobs, name, { type, totalSize });
// 断点续传前期校验
let result = await validationFile(name);
}
// 验证是否已经上传文件
/**
* @param {string} key --文件名称·hash
*/
async function validationFile(key: string): Promise<{ code: number, msg: string, data: string[] }> {
try {
let result = await fetch(UPLOAD_BASE_URL + 'validationFile', {
method: 'POST',
body: JSON.stringify({ key: md5(key) }),
headers: {
"Content-Type": "application/json;charset=UTF-8",
"Authorization": "eefasfafa"
}
})
return Promise.resolve(result.json())
} catch (error) {
return Promise.reject(error)
}
}
初次请求, 得到空数组
前一次请求失败的情况,(即缺失部分数据包)
这里可以发现上一次请求未上传成功的有(2、5)两个数据包(chunk)
- 根据后台已有的chunks 得到需要上传的chunks
// 断点续传前期校验
let result = await validationFile(name);
// 筛选出不需要再次上传的chunk
if (result.data.length) {
// 从formdata 中拿到chunk名
let totalList = formDatas.map(child => {
return child.get('fileName') as string
})
// 筛选符合条件的chunksname(未上传的chunk包名称)
let uploadChunksName = filterChunks(totalList, result.data)
// 过滤出符合条件(需要上传)chunks formdata
let uploadChunks = formDatas.filter(child => {
if (uploadChunksName.includes(child.get('fileName') as string)) {
return child
}
})
// 后续操作
return
}
······
/**
* @description 筛选出后端已存在的chunk -- 不上传
* @param {string[]} totalChunks 总的chunk
* @param {string[]} newChunks 需要筛选掉的chunk
* @returns {string[]} 返回需要上传的chunk列表
*/
function filterChunks(totalChunks: string[], newChunks: string[]): string[] {
return totalChunks.filter(child => {
return !newChunks.includes(child)
})
}
uploadChunks 中包含的是需要上传的formdata, 而uploadChunksName中是需要上传的chunks名称
- 得到上传chunks后即可调用上传接口
// 获取到fd 数组 chunk 分批次发送请求到服务器
uploadFileChunk(formDatas, name)
/**
* @description:上传分割好的文件chunk
* @param {FormData[]} fdLists formdata 文件chunk
* @param {string} fileName 文件hash 名
*/
function uploadFileChunk(fdLists: FormData[], fileName: string): void {
fdLists.forEach(async (child) => {
try {
// 设置异步上传
let result = await fetch(UPLOAD_BASE_URL + 'upload-video', {
body: child,
method: 'post',
}).then(data => {
return data.json()
})
if (result.code == '200' && result.msg == 'ok') {
// 切片上传成功
chunkUploadSuccess(fileName);
}
} catch (error) {
info.innerText = INFO.UPLOAD_FAIL + `(${error.message})`
}
})
}
初次上传后
上传详情
后台(未合并)
- 上传完成后调用合并file接口(这里合并完成会返回合并文件静态访问地址)
/**
* @param { string } fileName
* @returns {void}
*/
function chunkUploadSuccess(fileName: string): void {
// 上传成功则 记录上传成功值 ++
uploadedChunks++
// 全部上传完成后调用合并切片事件
if (uploadedChunks >= totalChunks) {
merge(fileName)
}
}
// 请求合并chunk
/**
* @param {string} key --文件名称·hash
*/
async function merge(key: string): Promise<void> {
try {
let result = await fetch(UPLOAD_BASE_URL + 'merge', {
method: 'POST',
body: JSON.stringify({ key: md5(key) }),
headers: {
"Content-type": "application/json;charset=utf-8"
}
})
······
} catch (error) {
······
}
}
得到合并响应
得到预览(测试--可以播放)
后台(合并后)
再次上传
补充
- 测试请求取消情况
// 用于管理所有请求状态 -- 主要用于取消
let controler = new AbortController()
// 请求唯一标识
let requestId: AbortSignal | null = null;
····
// 上传chunk请求
// 设置异步上传
let result = await fetch(UPLOAD_BASE_URL + 'upload-video', {
body: child,
method: 'post',
signal: requestId // 用于统一管理请求
}).then(data => {
return data.json()
})
// 取消请求
function aboutEvent(): void {
controler.abort()
}
效果
可以发现后台是未合并的chunk 且只有36个(总56),
继续上传原视频(前端没有再次发送所有包)
校验请求返回列表返回已存在chunk
后台完成文件合并
--- end ---