[前端]大文件上传之分片上传、断点续传

847 阅读10分钟

前言

前端向后端发送文件数据时一般会限制文件大小,普通的文件数据如 word, pdf, 图片、文本等数据时一般较小无需考虑什么上传速度,但是一些大文件(音频、视频媒体数据等等)数据因为体积大,上传时如果直接发送后台可能导致请求延时长,一旦请求失败意味着重新上传文件,非常的耗费资源。

所以说何不将单个请求拆分为多个请求,将单个文件拆分多个文件(chunk包),上传时每个请求负责一个chunk,后台负责接收所有的chunk,并且合并chunk组成一个完整文件呢

这样即使发送的多个请求中有出现问题没有发送成功的,那也可以在下次请求中继续补发缺失的chunk, 这样就可以避免发送重复的chunk包了

思路

简单说一下实现思路(仅代表个人的)

  • 测试服务端构建
  1. 暴露请求校验文件完整性的api接口,需要校验文件是否存在目录,没有则创建目录,并且返回空的已存在chunk目录, 若是存在目录则返回目录中存在的chunk文件列表以用于前端筛查已上传的chunk包
  2. 暴露请求上传chunk包的api接口, 在上传时校验是否存在已经合并好的文件,有,则返回响应,提示文件已上传,无, 则向目标文件夹中写入对应chunk(校验请求中创建的)
  3. 暴露请求合并chunk包的api接口,先校验是否有合并文件, 有,返回文件对应的文件静态地址, 无, 则重命名头部文件,排序筛选后, 向头部文件中依次追加读取的文件,然后delete追加的文件。 追加合并完成后返回预览文件地址

more······

  • 前端构建大概流程
  1. 初始化后,通过input:file可以获取到二进制File类型(extend Blob)的数据
  2. 进行文件类型/大小等验证
  3. 验证通过后,如果用户选择上传,则按照标准将文件进行分割,得到blob文件数组chunks
  4. 将文件chunks包装为FormData(表单数据)数组(使用生成hash来对每个chunk命名),用于发送后台
  5. 请求后台验证服务,筛选出未上传的chunk添加到上传的chunk数组中 (注意筛查是通过排除chunkname实现的)
  6. 筛查完成后将每一个chunk发送到后台,没有发送顺序要求
  7. 若是所有chunk都发送完成则请求合并chunks的api,合并成功后即可拿到响应的内容

more ...

  • 补充
  1. 前后端关于chunk名称需统一, 后面的演示项目将使用MD5对文件进行命名,优点是只有文件内容改变时才会导致hash值的变化,每一个chunk包的命名将以[hash]_(chunk序号).[ext]的方式,注意序号必须严格依照分割顺序(避免后台合并错误)
  2. 有需要可前端实现上传进度的监视,即(已上传chunk / 所有上传chunk)
  3. 后台合并文件后的文件名依照[hash]/[hash].[ext]格式, 每一个chunk文件以[hash]/[hash]_(序号).[ext]
  4. 后台接口在请求时需要携带文件key(hash值) 用于锁定目标文件

more ....

实现

这里以上传视频为例

服务端核心

  1. 校验文件(校验是否存在合并的文件, 返回已上传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 })
    }
})
  1. 上传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 })
    }
})
  1. 合并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 })
    }
})
  1. 工具函数
/**
 * @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)
        })
    })
}

前端核心

  1. 获取到上传文件,且通过校验 QQ截图20221222155533.png

  2. 对文件进行分割, 得到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);
        };
    }

QQ截图20221222155533.png

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
        })
    }

得到

QQ截图20221222155533.png

  1. 请求校验,得到已经上传的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)
        }
    }

初次请求, 得到空数组

QQ截图20221222155533.png

前一次请求失败的情况,(即缺失部分数据包)

QQ截图20221222155533.png

这里可以发现上一次请求未上传成功的有(2、5)两个数据包(chunk)

  1. 根据后台已有的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名称

  1. 得到上传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})`
        }
    })
}

初次上传后

QQ截图20221222155533.png

上传详情

QQ截图20221222164034.png

后台(未合并)

QQ截图20221222155533.png

  1. 上传完成后调用合并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) {
           ······
        }
    }

得到合并响应 QQ截图20221222155533.png 得到预览(测试--可以播放)

QQ截图20221222155533.png

后台(合并后) QQ截图20221222155533.png

再次上传

QQ截图20221222155533.png

补充

  • 测试请求取消情况
// 用于管理所有请求状态 -- 主要用于取消
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()
    }

效果

QQ截图20221222155533.png

可以发现后台是未合并的chunk 且只有36个(总56),

QQ截图20221222155533.png

继续上传原视频(前端没有再次发送所有包)

QQ截图20221222155533.png

校验请求返回列表返回已存在chunk QQ截图20221222155533.png

后台完成文件合并

QQ截图20221222155533.png

--- end ---