聚是一团火散作满天星,前端Vue.js+elementUI结合后端FastAPI实现大文件分片上传

简介: 分片上传并不是什么新概念,尤其是大文件传输的处理中经常会被使用,在之前的一篇文章里:[python花式读取大文件(10g/50g/1t)遇到的性能问题(面试向)](https://v3u.cn/a_id_97)我们讨论了如何读写超大型文件,本次再来探讨一下如何上传超大型文件,其实原理都是大同小异,原则就是化整为零,将大文件进行分片处理,切割成若干小文件,随后为每个分片创建一个新的临时文件来保存其内容,待全部分片上传完毕后,后端再按顺序读取所有临时文件的内容,将数据写入新文件中,最后将临时文件再删掉。

分片上传并不是什么新概念,尤其是大文件传输的处理中经常会被使用,在之前的一篇文章里:python花式读取大文件(10g/50g/1t)遇到的性能问题(面试向)我们讨论了如何读写超大型文件,本次再来探讨一下如何上传超大型文件,其实原理都是大同小异,原则就是化整为零,将大文件进行分片处理,切割成若干小文件,随后为每个分片创建一个新的临时文件来保存其内容,待全部分片上传完毕后,后端再按顺序读取所有临时文件的内容,将数据写入新文件中,最后将临时文件再删掉。大体流程请见下图:

其实现在市面上有很多前端的三方库都集成了分片上传的功能,比如百度的WebUploader,遗憾的是它已经淡出历史舞台,无人维护了。现在比较推荐主流的库是vue-simple-uploader,不过饿了么公司开源的elementUI市场占有率还是非常高的,但其实大家所不知道的是,这个非常著名的前端UI库也已经许久没人维护了,Vue3.0版本出来这么久了,也没有做适配,由此可见大公司的开源产品还是需要给业务让步。本次我们利用elementUI的自定义上传结合后端的网红框架FastAPI来实现分片上传。

首先前端需要安装需要的库:

npm install element-ui --save  
npm install spark-md5 --save  
npm install axios --save

随后在入口文件main.js中进行配置:

import ElementUI from 'element-ui'  
import 'element-ui/lib/theme-chalk/index.css'  
Vue.use(ElementUI)  
  
import Axios from 'axios'  
Vue.prototype.axios = Axios;  
  
import QS from 'qs'  
Vue.prototype.qs = QS;

配置好之后,设计方案,前端通过elementUI上传时,通过分片大小的阈值对文件进行切割,并且记录每一片文件的切割顺序(chunk),在这个过程中,通过SparkMD5来计算文件的唯一标识(防止多个文件同时上传的覆盖问题identifier),在每一次分片文件的上传中,会将分片文件实体,切割顺序(chunk)以及唯一标识(identifier)异步发送到后端接口(fastapi),后端将chunk和identifier结合在一起作为临时文件写入服务器磁盘中,当前端将所有的分片文件都发送完毕后,最后请求一次后端另外一个接口,后端将所有文件合并。

根据方案,前端建立chunkupload.js文件:

import SparkMD5 from 'spark-md5'

//错误信息  
function getError(action, option, xhr) {  
    let msg  
    if (xhr.response) {  
        msg = `${xhr.response.error || xhr.response}`  
    } else if (xhr.responseText) {  
        msg = `${xhr.responseText}`  
    } else {  
        msg = `fail to post ${action} ${xhr.status}`  
    }  
    const err = new Error(msg)  
    err.status = xhr.status  
    err.method = 'post'  
    err.url = action  
    return err  
}  
// 上传成功完成合并之后,获取服务器返回的json  
function getBody(xhr) {  
    const text = xhr.responseText || xhr.response  
    if (!text) {  
        return text  
    }  
    try {  
        return JSON.parse(text)  
    } catch (e) {  
        return text  
    }  
}  
  
// 分片上传的自定义请求,以下请求会覆盖element的默认上传行为  
export default function upload(option) {  
    if (typeof XMLHttpRequest === 'undefined') {  
        return  
    }  
    const spark = new SparkMD5.ArrayBuffer()// md5的ArrayBuffer加密类  
    const fileReader = new FileReader()// 文件读取类  
    const action = option.action // 文件上传上传路径  
    const chunkSize = 1024 * 1024 * 1 // 单个分片大小,这里测试用1m  
    let md5 = ''// 文件的唯一标识  
    const optionFile = option.file // 需要分片的文件  
    let fileChunkedList = [] // 文件分片完成之后的数组  
    const percentage = [] // 文件上传进度的数组,单项就是一个分片的进度  
  
    // 文件开始分片,push到fileChunkedList数组中, 并用第一个分片去计算文件的md5  
    for (let i = 0; i < optionFile.size; i = i + chunkSize) {  
        const tmp = optionFile.slice(i, Math.min((i + chunkSize), optionFile.size))  
        if (i === 0) {  
            fileReader.readAsArrayBuffer(tmp)  
        }  
        fileChunkedList.push(tmp)  
    }  
  
    // 在文件读取完毕之后,开始计算文件md5,作为文件唯一标识  
    fileReader.onload = async (e) => {  
        spark.append(e.target.result)  
        md5 = spark.end() + new Date().getTime()  
        console.log('文件唯一标识--------', md5)  
        // 将fileChunkedList转成FormData对象,并加入上传时需要的数据  
        fileChunkedList = fileChunkedList.map((item, index) => {  
            const formData = new FormData()  
            if (option.data) {  
                // 额外加入外面传入的data数据  
                Object.keys(option.data).forEach(key => {  
                    formData.append(key, option.data[key])  
                })  
                // 这些字段看后端需要哪些,就传哪些,也可以自己追加额外参数  
                formData.append(option.filename, item, option.file.name)// 文件  
                formData.append('chunkNumber', index + 1)// 当前文件块  
                formData.append('chunkSize', chunkSize)// 单个分块大小  
                formData.append('currentChunkSize', item.size)// 当前分块大小  
                formData.append('totalSize', optionFile.size)// 文件总大小  
                formData.append('identifier', md5)// 文件标识  
                formData.append('filename', option.file.name)// 文件名  
                formData.append('totalChunks', fileChunkedList.length)// 总块数  
            }  
            return { formData: formData, index: index }  
        })  
  
        // 更新上传进度条百分比的方法  
        const updataPercentage = (e) => {  
            let loaded = 0// 当前已经上传文件的总大小  
            percentage.forEach(item => {  
                loaded += item  
            })  
            e.percent = loaded / optionFile.size * 100  
            option.onProgress(e)  
        }  
  
        // 创建队列上传任务,limit是上传并发数,默认会用两个并发  
        function sendRequest(chunks, limit = 2) {  
            return new Promise((resolve, reject) => {  
                const len = chunks.length  
                let counter = 0  
                let isStop = false  
                const start = async () => {  
                    if (isStop) {  
                        return  
                    }  
                    const item = chunks.shift()  
                    console.log()  
                    if (item) {  
                        const xhr = new XMLHttpRequest()  
                        const index = item.index  
                        // 分片上传失败回调  
                        xhr.onerror = function error(e) {  
                            isStop = true  
                            reject(e)  
                        }  
                        // 分片上传成功回调  
                        xhr.onload = function onload() {  
                            if (xhr.status < 200 || xhr.status >= 300) {  
                                isStop = true  
                                reject(getError(action, option, xhr))  
                            }  
                            if (counter === len - 1) {  
                                // 最后一个上传完成  
                                resolve()  
                            } else {  
                                counter++  
                                start()  
                            }  
                        }  
                        // 分片上传中回调  
                        if (xhr.upload) {  
                            xhr.upload.onprogress = function progress(e) {  
                                if (e.total > 0) {  
                                    e.percent = e.loaded / e.total * 100  
                                }  
                                percentage[index] = e.loaded  
                                console.log(index)  
                                updataPercentage(e)  
                            }  
                        }  
                        xhr.open('post', action, true)  
                        if (option.withCredentials && 'withCredentials' in xhr) {  
                            xhr.withCredentials = true  
                        }  
                        const headers = option.headers || {}  
                        for (const item in headers) {  
                            if (headers.hasOwnProperty(item) && headers[item] !== null) {  
                                xhr.setRequestHeader(item, headers[item])  
                            }  
                        }  
                        // 文件开始上传  
                        xhr.send(item.formData);  
                    }  
                }  
                while (limit > 0) {  
                    setTimeout(() => {  
                        start()  
                    }, Math.random() * 1000)  
                    limit -= 1  
                }  
            })  
        }  
  
        try {  
            // 调用上传队列方法 等待所有文件上传完成  
            await sendRequest(fileChunkedList,2)  
            // 这里的参数根据自己实际情况写  
            const data = {  
                identifier: md5,  
                filename: option.file.name,  
                totalSize: optionFile.size  
            }  
            // 给后端发送文件合并请求  
            const fileInfo = await this.axios({  
                method: 'post',  
                url: 'http://localhost:8000/mergefile/',  
                data: this.qs.stringify(data)  
            }, {  
                 headers: {  
                        "Content-Type": "multipart/form-data"  
                    }  
            }).catch(error => {  
                console.log("ERRRR:: ", error.response.data);  
  
            });  
  
            console.log(fileInfo);  
  
            if (fileInfo.data.code === 200) {  
                const success = getBody(fileInfo.request)  
                option.onSuccess(success)  
                return  
            }  
        } catch (error) {  
            option.onError(error)  
        }  
    }  
}

之后建立upload.vue模板文件,并且引入自定义上传控件:

<template>  
  <div>  
  
  
  <el-upload  
    :http-request="chunkUpload"  
    :ref="chunkUpload"  
    :action="uploadUrl"  
    :data="uploadData"  
    :on-error="onError"  
    :before-remove="beforeRemove"  
    name="file" >  
  
    <el-button size="small" type="primary">点击上传</el-button>  
  
  </el-upload>  
  
  
  
</div>  
    
</template>  
  
  
   
<script>  
  
  
//js部分  
import chunkUpload from './chunkUpload'  
export default {  
  data() {  
    return {  
      uploadData: {  
        //这里面放额外携带的参数  
      },  
      //文件上传的路径  
      uploadUrl: 'http://localhost:8000/uploadfile/', //文件上传的路径  
      chunkUpload: chunkUpload // 分片上传自定义方法,在头部引入了  
    }  
  },  
  methods: {  
    onError(err, file, fileList) {  
      this.$store.getters.chunkUploadXhr.forEach(item => {  
        item.abort()  
      })  
      this.$alert('文件上传失败,请重试', '错误', {  
        confirmButtonText: '确定'  
      })  
    },  
    beforeRemove(file) {  
      // 如果正在分片上传,则取消分片上传  
      if (file.percentage !== 100) {  
        this.$store.getters.chunkUploadXhr.forEach(item => {  
          item.abort()  
        })  
      }  
    }  
  }  
}  
  
  
  
</script>  
   
<style>  
  
  
  
</style>

此时启动前端的vue.js服务:

npm run dev

页面效果见下图:

前端搞定了,下面我们来编写接口,后端的任务相对简单,利用FastAPI接收分片文件、分片顺序以及唯一标识,并且将文件临时写入到服务器中,当最后一个分片文件完成上传后,第二个接口负责按照分片顺序合并所有文件,合并成功后再删除临时文件,用来节约空间,先安装依赖的三方库

pip3 install python-multipart

当然了,由于是前后端分离项目,别忘了设置一下跨域,编写main.py:

from uploadfile import router  
from fastapi import FastAPI, Request  
from fastapi.responses import HTMLResponse  
from fastapi.staticfiles import StaticFiles  
from fastapi.templating import Jinja2Templates  
from model import database  
from fastapi.middleware.cors import CORSMiddleware  
  
app = FastAPI()  
  
origins = [  
    "*"  
]  
app.add_middleware(  
    CORSMiddleware,  
    allow_origins=origins,  
    allow_credentials=True,  
    allow_methods=["*"],  
    allow_headers=["*"],  
)  
  
  
app.mount("/static", StaticFiles(directory="static"), name="static")  
  
templates = Jinja2Templates(directory="templates")  
  
app.include_router(router)  
  
  
@app.on_event("startup")  
async def startup():  
    await database.connect()  
  
  
@app.on_event("shutdown")  
async def shutdown():  
    await database.disconnect()  
  
  
  
@app.get("/")  
def read_root():  
    return {"Hello": "World"}

然后编写uploadfile.py:

@router.post("/uploadfile/")  
async def uploadfile(file: UploadFile = File(...), chunkNumber: str = Form(...), identifier: str = Form(...)):  
  
    task = identifier          # 获取文件唯一标识符  
    chunk = chunkNumber        # 获取该分片在所有分片中的序号  
    filename = '%s%s' % (task,chunk)           # 构成该分片唯一标识符  
    contents = await file.read() #异步读取文件  
    with open('./static/upload/%s' % filename, "wb") as f:  
        f.write(contents)  
    print(file.filename)  
    return {"filename": file.filename}  
  
  
@router.post("/mergefile/")  
async def uploadfile(identifier: str = Form(...), filename: str = Form(...)):  
  
    target_filename = filename  # 获取上传文件的文件名  
    task = identifier              # 获取文件的唯一标识符  
    chunk = 1                                       # 分片序号  
    with open('./static/upload/%s' % target_filename, 'wb') as target_file:  # 创建新文件  
        while True:  
            try:  
                filename = './static/upload/%s%d' % (task,chunk)  
                # 按序打开每个分片  
                source_file = open(filename, 'rb')  
                # 读取分片内容写入新文件  
                target_file.write(source_file.read())  
                source_file.close()  
            except IOError:  
                break  
            chunk += 1  
            os.remove(filename)  
    return {"code":200}

值得一提的是这里我们使用UploadFile来定义文件参数,它的优势在于在接收存储文件过程中如果文件过大超过了内存限制就会存储在硬盘中,相当灵活,同时配合await关键字异步读取文件内容,提高了性能和效率。

启动后端服务测试一下效果:

uvicorn main:app --reload

可以看到,当我们上传一张2.9m的图片时,前端会根据设置好的的分片阈值将该图片切割为四份,传递给后端接口uploadfile后,后端在根据参数用接口mergefile将其合并,整个过程一气呵成、行云流水、势如破竹,让人用了之后禁不住心旷神怡、把酒临风。

相关文章
|
10天前
|
机器学习/深度学习 自然语言处理 前端开发
前端神经网络入门:Brain.js - 详细介绍和对比不同的实现 - CNN、RNN、DNN、FFNN -无需准备环境打开浏览器即可测试运行-支持WebGPU加速
本文介绍了如何使用 JavaScript 神经网络库 **Brain.js** 实现不同类型的神经网络,包括前馈神经网络(FFNN)、深度神经网络(DNN)和循环神经网络(RNN)。通过简单的示例和代码,帮助前端开发者快速入门并理解神经网络的基本概念。文章还对比了各类神经网络的特点和适用场景,并简要介绍了卷积神经网络(CNN)的替代方案。
|
10天前
|
移动开发 前端开发 JavaScript
前端实训,刚入门,我用原生技术(H5、C3、JS、JQ)手写【网易游戏】页面特效
于辰在大学期间带领团队参考网易游戏官网的部分游戏页面,开发了一系列前端实训作品。项目包括首页、2021校园招聘页面和明日之后游戏页面,涉及多种特效实现,如动态图片切换和人物聚合效果。作品源码已上传至CSDN,视频效果可在CSDN预览。
17 0
前端实训,刚入门,我用原生技术(H5、C3、JS、JQ)手写【网易游戏】页面特效
|
15天前
|
JavaScript 前端开发 开发者
前端框架对比:Vue.js与Angular的优劣分析与选择建议
【10月更文挑战第27天】在前端开发领域,Vue.js和Angular是两个备受瞩目的框架。本文对比了两者的优劣,Vue.js以轻量级和易上手著称,适合快速开发小型到中型项目;Angular则由Google支持,功能全面,适合大型企业级应用。选择时需考虑项目需求、团队熟悉度和长期维护等因素。
21 1
|
16天前
|
JavaScript 前端开发 API
前端框架对比:Vue.js与Angular的优劣分析与选择建议
【10月更文挑战第26天】前端技术的飞速发展让开发者在构建用户界面时有了更多选择。本文对比了Vue.js和Angular两大框架,介绍了它们的特点和优劣,并给出了在实际项目中如何选择的建议。Vue.js轻量级、易上手,适合小型项目;Angular结构化、功能强大,适合大型项目。
16 1
|
26天前
|
前端开发 JavaScript 安全
JavaScript前端开发技术
JavaScript(简称JS)是一种广泛使用的脚本语言,特别在前端开发领域,它几乎成为了网页开发的标配。从简单的表单验证到复杂的单页应用(SPA),JavaScript都扮演着不可或缺的角色。
19 3
|
29天前
|
JavaScript UED
Vue + ElementUI 实现动态添加和删除表单项的多层嵌套表单
【10月更文挑战第5天】本示例展示了如何在 Vue.js 中使用 Element UI 组件实现动态添加和删除嵌套表单项。该表单包含设备信息、设备部位及其对应的任务列表,支持用户动态添加设备部位和任务,并提供相应的表单验证规则。
118 0
Vue + ElementUI 实现动态添加和删除表单项的多层嵌套表单
|
19天前
|
前端开发 JavaScript UED
"前端小技巧大揭秘:JS如何将后台时间戳秒变亲切小时前、分钟前,让用户秒懂,提升互动体验!"
【10月更文挑战第23天】在Web开发中,将后台返回的时间戳转换为“小时前”、“分钟前”、“刚刚”等友好的时间描述是常见需求。本文介绍如何用JavaScript实现这一功能,通过计算当前时间和时间戳的差值,返回相应的描述,提升用户体验。
25 1
|
29天前
|
存储 前端开发 NoSQL
拿下奇怪的前端报错(四):1比特丢失导致的音视频播放时长无限增长-浅析http分片传输核心和一个坑点
在一个使用MongoDB GridFS存储文件的项目中,音频和视频文件在大部分设备上播放时长显示为无限,而单独播放则正常。经调查发现,问题源于HTTP Range请求的处理不当,导致最后一个字节未被正确返回。通过调整请求参数,使JavaScript/MongoDB的操作范围与HTTP Range一致,最终解决了这一问题。此案例强调了对HTTP协议深入理解及跨系统集成时注意细节的重要性。
|
30天前
|
JavaScript 前端开发 应用服务中间件
vue前端开发中,通过vue.config.js配置和nginx配置,实现多个入口文件的实现方法
vue前端开发中,通过vue.config.js配置和nginx配置,实现多个入口文件的实现方法
137 0
|
30天前
|
前端开发 JavaScript 程序员
【从前端入门到全栈】Node.js 之核心概念
【从前端入门到全栈】Node.js 之核心概念