聚是一团火散作满天星,前端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>

这里定义的后端上传接口是:http://localhost:8000/uploadfile/ 合并文件接口是:http://localhost:8000/mergefile/

此时启动前端的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将其合并,整个过程一气呵成、行云流水、势如破竹,让人用了之后禁不住心旷神怡、把酒临风。最后奉上项目地址:https://gitee.com/QiHanXiBei/fastapi\_blog

相关文章
|
14天前
|
前端开发 JavaScript 网络协议
前端最常见的JS面试题大全
【4月更文挑战第3天】前端最常见的JS面试题大全
33 5
|
30天前
|
JavaScript 前端开发 Java
纯前端JS实现人脸识别眨眨眼张张嘴案例
纯前端JS实现人脸识别眨眨眼张张嘴案例
41 0
|
17天前
|
人工智能 前端开发 JavaScript
【前端设计】HTML+CSS+JavaScript基本特性
【前端设计】HTML+CSS+JavaScript基本特性
|
1月前
|
JSON JavaScript 前端开发
Node.js:前端开发的后端利器
Node.js作为一种运行在服务器端的JavaScript环境,为前端开发者打开了后端开发的大门。它以其高效的事件驱动、非阻塞I/O模型以及强大的npm生态,使得前端开发者能够轻松构建服务器端应用,实现前后端的全栈开发。本文将探讨Node.js的核心优势、应用场景以及在前端开发中的重要性。
|
1月前
|
前端开发 JavaScript 数据可视化
JavaScript:前端开发的灵魂与动力
JavaScript(JS)作为前端开发的核心语言,赋予了网页交互性和动态性。通过JS,开发者可以实现复杂的用户交互、数据操作以及动画效果,为用户带来丰富而流畅的网页体验。本文将介绍JS的基本概念、应用场景以及在前端开发中的重要性。
|
1月前
|
机器学习/深度学习 人工智能 前端开发
【前端】JavaScript简介
【前端】JavaScript简介
17 0
|
1月前
uni-app 63egg.js后端用户上线和下线深度剖析
uni-app 63egg.js后端用户上线和下线深度剖析
16 1
|
1月前
|
Web App开发 JavaScript 前端开发
深入浅出:Node.js 在后端开发中的应用与实践
【2月更文挑战第13天】本文旨在探讨Node.js这一流行的后端技术如何在现代Web开发中被应用以及它背后的核心优势。通过深入分析Node.js的非阻塞I/O模型、事件驱动机制和单线程特性,我们将揭示其在处理高并发场景下的高效性能。同时,结合实际开发案例,本文将展示如何利用Node.js构建高性能、可扩展的后端服务,以及在实际项目中遇到的挑战和解决方案。此外,我们还将讨论Node.js生态系统中的重要工具和库,如Express.js、Koa.js等,它们如何帮助开发者快速搭建和部署应用。通过本文的探讨,读者将获得对Node.js在后端开发中应用的深入理解,以及如何有效利用这一技术来提升开发效率
|
1月前
|
Rust 前端开发 JavaScript
前端技术的未来:WebAssembly与JavaScript的融合之路
【2月更文挑战第12天】本文旨在探讨WebAssembly(以下简称Wasm)与JavaScript(以下简称JS)的结合如何为前端开发带来革命性变化。传统上,JS一直是前端开发的核心,但随着Wasm的出现,我们看到了前端性能和功能的新天地。文章首先介绍Wasm的基本概念,然后分析其对前端开发的影响,包括性能提升、新功能实现以及开发模式的变化。最后,探讨了Wasm与JS融合的未来趋势,以及这种融合如何推动前端技术的进步。
|
1月前
|
Rust 前端开发 JavaScript
探索前端技术的未来:WebAssembly与JavaScript的融合之路
【2月更文挑战第12天】 随着Web技术的不断进步,前端开发正迎来一场革命性变革。本文将深入探讨WebAssembly(以下简称Wasm)与JavaScript(以下简称JS)的结合如何为前端开发带来前所未有的性能提升与新的编程模式。我们将从两者的基本概念入手,探索它们各自的优势与局限,然后深入分析Wasm和JS协同工作时能够解锁的潜力,最后展望这一技术趋势如何塑造未来的前端开发生态。本文旨在为前端开发者提供洞见,帮助他们理解并准备好迎接这一即将到来的技术浪潮。