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

1,428 阅读7分钟

原文转载自「刘悦的技术博客」v3u.cn/a_id_175

分片上传并不是什么新概念,尤其是大文件传输的处理中经常会被使用,在之前的一篇文章里: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://localhsot:8000/uploadfile/ 合并文件接口是:http://localhsot: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将其合并,整个过程一气呵成、行云流水、势如破竹,让人用了之后禁不住心旷神怡、把酒临风。最后奉上项目地址:gitee.com/QiHanXiBei/…

原文转载自「刘悦的技术博客」 v3u.cn/a_id_175