原文转载自「刘悦的技术博客」https://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将其合并,整个过程一气呵成、行云流水、势如破竹,让人用了之后禁不住心旷神怡、把酒临风。最后奉上项目地址:https://gitee.com/QiHanXiBei/fastapi_blog

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

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

  1. php 文件上传类,功能相当齐全,留作开发中备用吧。

    收藏一个经典好用的php 文件上传类,功能相当齐全,留作开发中备用吧. 好东西,大家都喜欢,才是真的好,哈哈!!! <?php  /**   * 文件上传类   */  class upload ...

  2. DJANGO和UIKIT结合,作一个有进度条的无刷新上传功能

    以前作的上传,在糙了,所以在用户体验上改进一下. 同时,结合DJANGO作定位上传. 这其中分两步进行,第一次上传到TMP目录下, 第二次,将TMP下的文件转移到标准目录下. form.py file ...

  3. DolphinScheduler 功能开发:⼯作流级别任务空跑(后端),测试工作流是否正确执行...

    点击上方 蓝字关注我们 ✎ 编 者 按 在今年由中国科学院软件研究所主办的开源软件所供应链点亮计划-开源之夏活动中,有不少小伙伴提交了关于 DolphinScheduler 的项目,本期是来自成都信息 ...

  4. 《.Net 的冰与火之歌》寄雁传书,你必须知道的C#参数知识大盘点

    引言 参数,也叫参变量,是一个变量.在方法签名中随处可见,实现了不同方法间对于数据的寄雁传书,基本上充斥在代码的各个角落里.在方法签名或者原型中,方法名称后的括号包含方法的参数及其类型的完整列表.参数 ...

  5. fedora环境安装webkit支持作爬虫下载解析JS

    环境: 我使用的fedora19.1-xfce版本,属于redhat系的桌面环境. 1.安装 webkit源码安装webkit失败,这里提供的是yum安装方式. a.查看当前yum库中的webkit资 ...

  6. IOS--工作总结--post上传文件(以流的方式上传)

    1.添加协议 <NSURLConnectionDelegate> 2.创建 @property (nonatomic,retain) NSURLConnection* aSynConnec ...

  7. zookeeper-操作与应用场景-《每日五分钟搞定大数据》

    Zookeeper作为一个分布式协调系统提供了一项基本服务:分布式锁服务,分布式锁是分布式协调技术实现的核心内容.像配置管理.任务分发.组服务.分布式消息队列.分布式通知/协调等,这些应用实际上都是基 ...

  8. vue.js 作一个用户表添加页面----初级

    使用vue.js 制作一个用户表添加页面,实际上是把原来需要使用js写的部分,改写成vue.js的格式 首先,想象一下,先做思考,我们要添加用户表,设涉及到哪些数据,一个是用户id,一个是用户名,一个 ...

  9. 用Python的requests库作接口测试——上传文件

    POST一个多部分编码(Multipart-Encoded)的文件 Requests使得上传多部分编码文件变得很简单: >>> url = 'http://httpbin.org/p ...

随机推荐

  1. (原创)[C#] MEF 主程序与插件加载不同版本的DLL

    一.前言 MEF(Managed Extensibility Framework),是轻量级的插件框架.使用简单,功能强大.详细介绍见MSDN,本文不再赘述. 在使用MEF时,会遇到这样一种场景: 主 ...

  2. TKE qGPU 通过 CRD 管理集群 GPU 卡资源

    作者 刘旭,腾讯云高级工程师,专注容器云原生领域,有多年大规模 Kubernetes 集群管理经验,现负责腾讯云 GPU 容器的研发工作. 背景 目前 TKE 已提供基于 qGPU 的算力/显存强隔离 ...

  3. Go到底能不能实现安全的双检锁?

    不安全的双检锁 从其他语言转入Go语言的同学经常会陷入一个思考:如何创建一个单例? 有些同学可能会把其它语言中的双检锁模式移植过来,双检锁模式也称为懒汉模式,首次用到的时候才创建实例.大部分人首次用G ...

  4. Flink中如何实现一个自定义MetricReporter

    什么是 Metrics 在 flink 任务运行的过程中,用户通常想知道任务运行的一些基本指标,比如吞吐量.内存和 cpu 使用情况.checkpoint 稳定性等等.而通过 flink metric ...

  5. 苹果宣布 2022 年 Apple 设计大奖得主

    Apple 今日举办了年度 Apple 设计大奖颁奖仪式,表彰 12 款出类拔萃的 app 与游戏佳作.今年的获奖者包括来自全球各地的开发者.他们通过 app 呈现锐意创新.别出心裁的优美设计体验,以 ...

  6. docker-compose 搭建 Prometheus+Grafana监控系统

    有关监控选型之前有写过一篇文章: 监控系统选型,一文轻松搞定! 监控对象 Linux服务器 Docker Redis MySQL 数据采集 1).prometheus: 采集数据 2).node-ex ...

  7. Java常用类-包装类

    包装类 ​ Java中的基本类型功能简单,不具备对象的特性,为了使基本类型具备对象的特性,所以出现了包装类,就可以像操作对象一样操作基本类型数据;包装类不是为了取代基本数据类型,而是在数据类型需要使用 ...

  8. Kubernetes-23:详解如何将CPU Manager做到游刃有余

    k8s中为什么要用CPU Manager? 默认情况下,kubelet 使用CFS配额来执行 Pod 的 CPU 约束.Kubernetes的Node节点会运行多个Pod,其中会有部分的Pod属于CP ...

  9. MYSQL中IF IN语句

    以下代码摘自后台管理系统中的一部分SQL语句: 当取数状态为1或2时,才展示取数时间,否则,取数时间展示为空 当申报状态为2.3.4或5时,才展示申报时间,否则,申报时间展示为空 select A.Q ...

  10. Tapdata 实时数据中台在智慧教育中的实践

      摘要:随着教育信息化的推进,智慧校园建设兴起,但在实施过程中面临数据孤岛.应用繁多.数据再利用等方面挑战,而 Tapdata 的实时数据中台解决方案,能够高效地解决智慧校园实施中的基础数据问题. ...