原文转载自「刘悦的技术博客」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. tomcat 1.2 负载均衡

    实验效果:访问同一个ip或域名,轮询显示两个不同的tomcat界面, nginx服务器ip:192.168.213.4       tomcat服务器ip:192.168.213.3 实验环境:两台服 ...

  2. 【算法】基数排序(Radix Sort)(十)

    基数排序(Radix Sort) 基数排序是按照低位先排序,然后收集:再按照高位排序,然后再收集:依次类推,直到最高位.有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序.最后的次序就 ...

  3. MySQL主从配置及haproxy和keepalived搭建

    本篇文章主要介绍如何搭建MySQL主主配置.主从配置.haproxy.keepalived,已经搭建过程中的一些问题.本次运行环境是在docker中,也会介绍一些docker的知识 docker 小知 ...

  4. 130_传析阅管理系统accdb64位版本

    博客:www.jiaopengzi.com 焦棚子的文章目录 请点击下载附件 几年前笔者针对家居门店的进销存.人员管理.工资管理.任务系统.门店经营盈亏管理.销售分析.考勤请假等息息相关的业务基于Ac ...

  5. HIVE 数据分析

    题目要求: 具体操作: ①hive路径下建表:sale create table sale (day_id String, sale_nbr String, buy_nbr String, cnt S ...

  6. MongoDB 分片集群

    每日一句 Medalist don't grow on trees, you have to nurture them with love, with hard work, with dedicati ...

  7. drools中then部分的写法

    目录 1.背景 2.支持的方法 2.1 insert 插入对象到工作内存中 2.1.1 需求 2.1.2 drl文件编写 2.1.3 部分java代码编写 2.1.4 运行结果 2.1.5 结论 2. ...

  8. 【SNOI2017 DAY1】炸弹

    题意:P5024 思路:首先\(O(n^2)\)向能炸到的点连边,所以能到达的点的个数就是能到达的点的个数.然后显然要缩点+拓扑排序(我写的记搜). 然后再写一个线段树优化建图. 然后就WA了,我想了 ...

  9. docker引起服务器磁盘爆满

    服务器异常 又是开开心心打开我心爱的服务器一天: 吔!这是嘛啊?我的服务器域名访问不了了,一直转圈圈超时了,好,打开ssh远程看看,吔!!!还是访问不了,宕机了?怀着一颗憋大便的心情打开了阿里云控制面 ...

  10. SAP 日期计算

    1. CONVERSION_EXIT_IDATE_OUTPUT     INPUT:      20200601     OUTPUT:   03FEB2008 2. CONVERT_DATE_TO_ ...