大文件分片上传就是一个契合分治算法的场景,现而今,视频文件的体积越来越大,高清视频体积大概2-4g不等,但4K视频的分辨率是标准高清的四倍,需要四倍的存储空间——只需两到三分钟的未压缩4K 电影,或者电影预告片的长度,就可以达到500GB。 8K视频文件更是大得难以想象,而现在12K正在出现,如此巨大的文件,该怎样设计一套合理的数据传输方案?这里我们以前后端分离项目为例,前端使用Vue.js3.0配合ui库Ant-desgin,后端采用并发异步框架Tornado实现大文件的分片无阻塞传输与异步IO写入服务
 

在前端对视频进行分片

首先要安装Ant-desgin:

随后,参照Ant-desgin官方文档:https://antdv.com/components/overview-cn 构建上传控件:

<a-upload @change="handleChange" :beforeUpload="beforeUpload">

            <a-button>

               <upload-outlined></upload-outlined>

               上传资质文件

            </a-button>

         </a-upload>

注意这里需要将绑定的before-upload强制返回false,设置为手动上传: 

 

beforeUpload:function() {

               return false

         },

声明分片方法:

// 分片上传方法
      handleChange: function (file) {

         console.log(file)
         // 获取文件大小    文件最小是1kb 转换成字节码1024
         var size = file.file.size
         // console.log('size', size)
         this.size = size
    
         // 定义分片大小  200kb
         var shardSize = 1024 * 200

         // 总片数  向上取整
         this.shardCount = Math.ceil(size / shardSize)
         // console.log(this.shardCount);

         // 切片操作
         for (var i = 0; i < this.shardCount; i++) {
            //开始位置      每一次循环的开始位置
            var start = i * shardSize
            // 结束位置       取最小值    如 378,0+200取200  378,200+200取378 
            var end = Math.min(size, start + shardSize)
            // console.log(start, end)
            //切片
            var shardfile = file.file.slice(start, end)
            // console.log('shardfile')
                     const axiosupload = this.axios.create({withCredentials:false});
         let data = new FormData();
         // console.log('1111111size',this.size)
         data.append('file', file);
         data.append('count', count);
         data.append('filename', filename);
         // 发起请求
         axiosupload({
            method: "POST",
            url: this.weburl + '/upload/',
            data: data
         }).then(resp => {
            console.log(resp)
            if (resp.data.errcode == 0) {
               this.finished += 1
               console.log(this.finished, this.shardCount)
               if (this.finished == this.shardCount) {
                  this.myaxios(this.weburl + '/upload/', "put", { "filename": filename, "size": this.size }).then(resp => {
                     if (resp.errcode == 0) {
                        this.websrc = this.upload_dir + filename
                        this.src = filename
                        this.finished = 0;

                        this.task(filename);
                     }
                  })
               }
            }
         })
      

         }


      },

 

 

具体分片逻辑是,大文件总体积按照单片体积的大小做除法并向上取整,获取到文件的分片个数,这里为了测试方便,将单片体积设置为200kb,可以随时做修改。

随后,分片过程中使用Math.min方法计算每一片的起始和结束位置,再通过slice方法进行切片操作,最后将分片的下标、文件名、以及分片本体异步发送到后台。
 

当分片请求成功之后,执行合并分片的操作

//发起定时任务
      task: function (filename) {
         console.log("执行---------------------")
         this.myaxios("http://127.0.0.1:8000/scheduler/", "post", { "filename": filename, "count": this.shardCount, "size": this.size, "job_id": "1" }).then(data => {

            console.log(data);

         });
      },

前端的分片逻辑就执行完成了。

后端异步IO写入

为了避免同步写入引起的阻塞,安装aiofiles库:

pip install aiofiles

aiofiles用于处理asyncio应用程序中的本地磁盘文件,配合Tornado的异步非阻塞机制,可以有效的提升文件写入效率: 

import aiofiles
# 支持协程 异步 io 非阻塞
# 超大型文件上传  分治算法  分片上传

class SliceUpload(BaseHandler):
    # 上传分片
    async def post(self):

        # 获取分片实体
        file = self.request.files["file"][0]

		# 获取下标
        count = self.get_argument("count",None)

        # 文件名
        filename = self.get_argument("filename",None)


        # 获取文件内容
        content = file["body"]


        # 异步写入
        async with aiofiles.open("./static/upload/{}_{}".format(filename,count),"wb") as file:

            # 异步
            await file.write(content)

        self.finish({"errcode":0,"msg":"分片上传成功"})

这里后端获取到分片实体、文件名、以及分片标识后,将分片文件以文件名_分片标识的格式异步写入到系统目录中,以一张378kb大小的png图片为例,分片文件应该顺序为200kb和178kb,如图所示:

vue antdesign_上传

 当分片文件都写入成功后,触发分片合并接口

class SliceUpload(BaseHandler):

    # 合并分片逻辑

    async def put(self):

        filename = self.get_argument("filename",None)

        count = 0

        # 文件体积
        size = self.get_argument("size",None)


        try:
            filesize = os.path.getsize("./static/upload/{}".format(filename))
        except Exception as e:
            print(str(e))
            filesize = 0

        print(filesize,size)

        if int(size) != filesize:

            # 异步打开文件句柄
            async with aiofiles.open("./static/upload/{}".format(filename),"ab") as file:

                while True:

                    try:

                        # 读取分片
                        shard_file = open("./static/upload/{}_{}".format(filename,count),'rb')

                        # 异步写入
                        await file.write(shard_file.read())

                        # 手动关闭句柄
                        shard_file.close()

                    except Exception as e:

                        print(str(e))
                        break

                    count = count + 1

        self.finish({"errcode":0,"msg":"合并完毕"})

这里通过文件名进行寻址,随后遍历合并,注意句柄写入模式为增量字节码写入,否则会逐层将分片文件覆盖,同时也兼具了断点续写的功能。有些逻辑会将分片个数传入后端,让后端判断分片合并个数,其实并不需要,因为如果寻址失败,会自动抛出异常并且跳出循环,从而节约了一个参数的带宽占用。

轮询服务

在真实的超大文件传输场景中,由于网络或者其他因素,很可能导致分片任务中断,此时就需要通过降级快速响应,返回托底数据,避免用户的长时间等待,这里我们使用基于Tornado的Apscheduler库来调度分片任务

pip install apscheduler
 

随后编写轮询服务文件:

# 导包
import re
from tornado.web import url
from datetime import datetime
from distutils.log import debug
from email.mime import application
from itertools import count
from numpy import size 
                        #   时间循环    回调事件
from tornado.ioloop import IOLoop,PeriodicCallback
from tornado.web import RequestHandler,Application

from apscheduler.schedulers.tornado import TornadoScheduler
import os

# 全局变量
scheduler = None
# 维护一个job列表  存在内存里
job_ids = []

# 初始化
def init_scheduler():
    # 调用全局作用域下的变量
    global scheduler
    # 实例化对象
    scheduler = TornadoScheduler()
    # 调用内置方法
    scheduler.start()
    print("定时任务开启")
    
# 声明任务 数据分片传输是否正常进行  任务id,有几次上传就有几次job
                # 文件名  大小  分片总数
def task(job_id,filename,size,count):

    size=int(size)
    count=int(count)

    filelist = os.listdir('./static/upload')
    print('文件',filelist)
    temp = []
    for i in range(count):
        print(i)
        for x in filelist:          
            if x == "{}_{}".format(filename,i) :
                print('xxxx',x)
                temp.append(x)
    print('temp',temp)
    print('count',count)
    # 从目录中读取分片的个数
    if  count != len(temp):
        print('分片未上传成功')
        return  False
    
    try:
        # 得到文件的大小
        f_size = os.path.getsize('./static/upload/{}'.format(filename))
      
    except  Exception as e:
        f_size=0
        print(e)
    if int(size) != f_size :
        print('分片合并未成功')
        return  False
    # 删除分片
    for x in range(count):
        os.remove("./static/upload/{}_{}".format(filename,x))

    # 删除定时任务
    scheduler.remove_job(str(job_id))
    job_ids.remove(job_id)

    print("分片执行完毕")
  


from base import BaseHandler
# 声明服务控制器
class SchedulerHandler(BaseHandler):
    
    # 操作任务 添加到任务队列中
    async def get(self):

        job_id = self.get_argument("job_id",None)


        filename = self.get_argument("filename",None)

        size = self.get_argument("size",None)

        count = self.get_argument("count",None)
        print('----------',job_id,filename,size,count)

        global job_ids

        if job_id not in job_ids:
            # 将此次任务的id放在任务列表中
            job_ids.append(job_id)
            print('job_id111',job_ids)
                        # 任务名称  内置的间息执行  想几秒后执行
            scheduler.add_job(task,'interval',seconds=3,id=str(job_id),args=(job_id,filename,size,count))
            print('job_id222',id)
            print("定时任务入队")
            res = {'errcode':0,'msg':'ok'}

        else:
            print('该任务已经存在')
            res = {'errcode':0,'msg':'failed'}
        self.finish(res)

if __name__ == '__main__':
    # task(1,'2.mp4','1108',6)

    # 声明路由
    routes = [url(r'/scheduler/',SchedulerHandler)]
    init_scheduler()

    # 声明tornado对象
    application = Application(routes,debug=True)
    application.listen(8000)
    IOLoop.current().start()

每一次分片接口被调用后,就建立定时任务对分片文件进行监测,如果分片成功就删除分片文件,同时删除任务。