指尖上的记忆指尖上的记忆
首页
  • 基础
  • Laravel框架
  • Symfony框架
  • 基础
  • Gin框架
  • 基础
  • Spring框架
  • 命令
  • Nginx
  • Ai
  • Deploy
  • Docker
  • K8s
  • Micro
  • RabbitMQ
  • Mysql
  • PostgreSsql
  • Redis
  • MongoDb
  • Html
  • Js
  • 前端
  • 后端
  • Git
  • 知识扫盲
  • Golang
🌟 gitHub
首页
  • 基础
  • Laravel框架
  • Symfony框架
  • 基础
  • Gin框架
  • 基础
  • Spring框架
  • 命令
  • Nginx
  • Ai
  • Deploy
  • Docker
  • K8s
  • Micro
  • RabbitMQ
  • Mysql
  • PostgreSsql
  • Redis
  • MongoDb
  • Html
  • Js
  • 前端
  • 后端
  • Git
  • 知识扫盲
  • Golang
🌟 gitHub

最近在做视频上传的功能,遇到大视频上传的问题,主要是测试服务器限流了,所以可能会有超时的问题,所以决定搞个分片上传 主要工作包括如下两大块:

  • 前端
    1.文件格式校验
    2.文件切片、md5计算
    3.发起检查请求,把当前文件的hash发送给服务端,检查是否有相同hash的文件
    4.上传进度计算
    5.上传完成后通知后端合并切片
  • 后端
    1.检查接收到的hash是否有相同的文件,并通知前端当前hash是否有未完成的上传
    2.接收切片
    3.合并所有切片

代码如下:
index.html.twig

{% extends 'backend.html.twig' %}

{% block title %}course management{% endblock %}
{% block stylesheets %}
    {{ encore_entry_link_tags('upload_splices') }}
{% endblock %}
{% block content %}
    <div id="app">
        <div class="row">
            <div class="col-12">
                hello
            </div>
        </div>
        <div class="row">
            <div class="col-12">
                <upload-splice
                        :upload_url="`{{ upload_url }}`"
                        :check_url="`{{ check_url }}`"
                        :merge_url="`{{ merge_url }}`"
                        :read_url="`{{ read_url }}`"
                ></upload-splice>
            </div>
        </div>
    </div>
{% endblock %}

{% block javascripts %}
    {{ encore_entry_script_tags('upload_splices') }}
{% endblock %}

UploadSplice.vue,需要 yarn add spark-md5 这个包

<template>
    <div>
        <div>
            <el-upload class="upload-demo" action="#" :on-change="uploadFile" :show-file-list="true" :file-list="fileList" :auto-upload="false" ref="uploadfile" :limit="1">
                <el-button size="small" type="primary" :loading="loadingFile">上传文件</el-button>
            </el-upload>
        </div>
        <div>
            <Button @click="getVideoStream">读取数据</Button>
        </div>

        <Video :src="playUrl" v-if="playUrl" controls="controls"></Video>
    </div>
</template>

<script>
import SparkMD5 from "spark-md5";
import axios from 'axios'
const chunkSize = 5 * 1024 * 1024;//定义分片的大小 暂定为5M,方便测试
export default {
    name: 'UploadSplice',
    components: {},
    props: {
        upload_url: {
            type: String,
            default: ''
        },
        check_url: {
            type: String,
            default: ''
        },
        merge_url: {
            type: String,
            default: ''
        },
        read_url: {
            type: String,
            default: ''
        }
    },
    data() {
        return {
            fileList: [],
            loadingFile: false,
            playUrl: ''
        }
    },
    watch: {},
    computed: {},
    methods: {
        /**
         * 上传文件
         */
        async uploadFile(File) {
            this.loadingFile = true
            var self = this
            //获取用户选择的文件
            const file = File.raw
            this.currentFile = file
            //文件大小(大于100m再分片哦,否则直接走普通文件上传的逻辑就可以了,这里只实现分片上传逻辑)
            const fileSize = File.size
            // 放入文件列表
            this.fileList = [{ "name": File.name }]
            // 可以设置大于多少兆可以分片上传,否则走普通上传
            if (fileSize <= chunkSize) {
                console.log("上传的文件大于10m才能分片上传")
            }
            //计算当前选择文件需要的分片数量
            const chunkCount = Math.ceil(fileSize / chunkSize)
            console.log("文件大小:", (File.size / 1024 / 1024) + "Mb", "分片数:", chunkCount)
            //获取文件md5,这个fileMd5 很重要,每个视频只会生成一个相同(不管请求多少次)的fileMd5,可以通过这个md5字符串来区分视频,将各自的分片放到各自的md5目录下
            const fileMd5 = await this.getFileMd5(file, chunkCount);
            console.log("文件md5:", fileMd5)

            console.log("向后端请求本次分片上传初始化")

            const initUploadParams = {
                "identifier": fileMd5, //文件的md5
                "filename": File.name, //文件名
                "totalChunks": chunkCount, //分片的总数量
            }

            //
            axios.post(this.check_url, initUploadParams).then(async (resp) => {
                if (resp.status === 200) {
                    if (resp.data.code === 0) {
                        // 获取后端返回的已上传分片数字的数组
                        // var uploaded = res.data.uploaded
                        // 定义分片开始上传的序号
                        // 由于是顺序上传,可以判断后端返回的分片数组的长度,为0则说明文件是第一次上传,分片开始序号从0开始
                        // 如果分片数组的长度不为0,我们取最后一个序号作为开始序号
                        // var num = uploaded.length == 0 ? 0 : uploaded[uploaded.length - 1]
                        let num = 0
                        console.log(num, '分片开始序号')
                        // 当前为顺序上传方式,若要测试并发上传,请将103 行 await 修饰符删除即可
                        // 循环调用上传
                        for (let i = num; i < chunkCount; i++) {
                            //分片开始位置
                            let start = i * chunkSize
                            //分片结束位置
                            let end = Math.min(fileSize, start + chunkSize)
                            //取文件指定范围内的byte,从而得到分片数据
                            let _chunkFile = File.raw.slice(start, end)
                            console.log(_chunkFile) // 打印出来是一个Blob 对象,包括 size和type信息
                            console.log("开始上传第" + i + "个分片")
                            let formdata = new FormData()
                            formdata.append('identifier', fileMd5)
                            formdata.append('filename', File.name)
                            formdata.append('totalChunks', chunkCount)
                            formdata.append('chunkNumber', i)
                            formdata.append('totalSize', fileSize)
                            formdata.append('file', _chunkFile)

                            // 通过await实现顺序上传
                            await this.getMethods(formdata)
                        }
                        // 文件上传完毕,请求后端合并文件并传入参数
                        self.composeFile(fileMd5, File.name, chunkCount)
                    } else {
                        this.open4(resp.data.msg)
                    }
                }
            }).catch(function (error) {
                console.log(error)
            })
        },
        /**
         * 上传文件方法
         * @param formdata 上传文件的参数
         */
        getMethods(formdata) {
            return new Promise((resolve, reject) => {
                axios.post(this.upload_url, formdata).then((resp) => {
                    if (resp.status === 200) {
                        if (resp.data.code === 0) {
                            console.log(resp.data.data)
                            console.log('ok')

                            resolve();
                        } else {
                            this.open4(resp.data.msg)
                        }
                    }
                }).catch(function (error) {
                    console.log(error)
                })

            });
        },
        /**
         * 获取文件MD5
         * @param file
         * @param chunkCount
         * @returns {Promise<unknown>}
         */
        getFileMd5(file, chunkCount) {
            return new Promise((resolve, reject) => {
                let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
                let chunks = chunkCount;
                let currentChunk = 0;
                let spark = new SparkMD5.ArrayBuffer();
                let fileReader = new FileReader();

                fileReader.onload = function (e) {
                    spark.append(e.target.result);
                    currentChunk++;
                    if (currentChunk < chunks) {
                        loadNext();
                    } else {
                        let md5 = spark.end();
                        resolve(md5);
                    }
                };
                fileReader.onerror = function (e) {
                    reject(e);
                };
                function loadNext() {
                    let start = currentChunk * chunkSize;
                    let end = start + chunkSize;
                    if (end > file.size) {
                        end = file.size;
                    }
                    fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
                }
                loadNext();
            });
        },
        /**
         * 请求后端合并文件
         * @param fileMd5 文件md5
         * @param fileName 文件名称
         * @param count 文件分片总数
         */
        composeFile(fileMd5, fileName, count) {
            console.log("开始请求后端合并文件")
            let data = {
                "identifier": fileMd5, //文件的md5
                "filename": fileName, //文件名
                "totalChunks": count //分片的总数量
            }

            axios.post(this.merge_url, data).then((resp) => {
                if (resp.status === 200) {
                    if (resp.data.code === 0) {
                        this.loadingFile = false
                        this.$refs.uploadfile.clearFiles()
                    } else {
                        this.open4(resp.data.msg)
                    }
                }
            }).catch(function (error) {
                console.log(error)
            })
        },
        getVideoStream(){
            axios.get(this.read_url).then((resp) => {
                if (resp.status === 200) {
                    if (resp.data.code === 0) {
                        //本来想将后台的blob在前端转成 url的,但是由于当前的项目环境,访问起来是有问题的
                        //下面是两种转换方法,第二种已被chrome浏览器弃用了
                        //后来还是想在后台合并完blob文件以后,能不能转为mp4,现在是可以的,不知道为什么第一个分片为mp4格式,导致最后的合并blob 也成了mp4格式,算是歪打正着吧

                        // let binaryData = [];
                        // binaryData.push('http://academy.web.test/'+resp.data.data.video);
                        // this.playUrl = window.URL.createObjectURL(new Blob(binaryData));

                        // let url = window.URL.createObjectURL('http://academy.web.test/'+resp.data.data.video);

                        this.playUrl = resp.data.data.video

                        console.log(this.playUrl)
                    } else {
                        this.open4(resp.data.msg)
                    }
                }
            }).catch(function (error) {
                console.log(error)
            })
        }
    },
    created() { },
    mounted() { }
}
</script>
<style lang="less" scoped>
</style>

UploadSpliceController.php

<?php

namespace App\Controller\Admin;

use App\Service\FileService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

class UploadSpliceController extends AbstractController
{
    #[Route('/admin/upload/list', name: 'admin_upload_list')]
    public function index(Request $request): Response
    {
        return $this->render('admin/upload/index.html.twig', [
            'upload_url' => $this->generateUrl('admin_upload_splice', [], UrlGeneratorInterface::ABSOLUTE_URL),
            'check_url'  => $this->generateUrl('admin_upload_check', [], UrlGeneratorInterface::ABSOLUTE_URL),
            'merge_url'  => $this->generateUrl('admin_merge_splice', [], UrlGeneratorInterface::ABSOLUTE_URL),
            'read_url'   => $this->generateUrl('admin_blob_read', [], UrlGeneratorInterface::ABSOLUTE_URL),
        ]);
    }

    #[Route('/admin/upload/check', name: 'admin_upload_check')]
    public function checkExist(): Response
    {
        return $this->json([
            'code' => 0,
            'msg'  => 'success',
            'data' => [],
        ]);
    }

    #[Route('/admin/upload/splice', name: 'admin_upload_splice')]
    public function uploadSplice(Request $request, FileService $fileService): Response
    {
        $file        = $request->files->get('file');
        $fileName    = $request->get('filename');
        $chunkNumber = $request->get('chunkNumber');

        $date = (new \DateTime())->format('Y-m-d');
        $ext  = $file->getClientOriginalExtension() ?: 'mp4';
        $name = $file->getClientOriginalName();

        //测试分片数据类型
//        $pathName = $file->getPathname();
//        $finfo = finfo_open(FILEINFO_MIME); // 返回 mime 类  需要在copmposer.json添加 "ext-fileinfo": "*" 拓展才能使用
//        $reallyType = finfo_file($finfo, $pathName);//得到文件类型的字符串,这个是获取到资源的真实类型
//        $mime = $file->getClientMimeType();

//        if ($chunkNumber == 0){
//            dd([$reallyType, $mime]);
//        }
//
//        if ($chunkNumber == 2){
//            dd([$reallyType, $mime]);
//        }


        if ($name) {
            $filename = $name;
        } else {
            $filename = md5(microtime()) . '.' . $ext;
        }

        $destDir = 'upload' . '/video' . '/' . $date . '/' . $chunkNumber . '/';
        try {
            $file->move($destDir, $filename);
        } catch (\Throwable $throwable) {
            $this->json([
                'code' => 1001,
                'msg'  => $throwable->getMessage(),
                'data' => [],
            ]);
        }

        $filePath = $destDir . $filename;
        return $this->json([
            'code' => 0,
            'msg'  => 'success',
            'data' => ['url' => $filePath, 'title' => $fileName],
        ]);
    }

    #[Route('/admin/merge/splice', name: 'admin_merge_splice')]
    public function mergeSplice(Request $request): Response
    {
        $params   = json_decode($request->getContent(), true);
        $filename = $params['filename'];
        $totalChunks = $params['totalChunks'];

        $date    = (new \DateTime())->format('Y-m-d');
        $allPath = 'upload' . '/video' . '/' . $date . '/all/' . $filename;

        // 从2个开始读取,第一个(0)的格式一直为mp4 不知道为什么
        for ($i = 0; $i < $totalChunks; $i++) {
            $cacheFile = fopen('upload/video/2023-04-20/' . $i . '/blob', 'rb');
            $content   = fread($cacheFile, 5 * 1024 * 1024);
            file_put_contents($allPath, $content, FILE_APPEND);
        }

        return $this->json([
            'code' => 0,
            'msg'  => 'success',
            'data' => [],
        ]);
    }

    #[Route('/admin/blob/read', name: 'admin_blob_read')]
    public function readData()
    {
        return $this->json([
            'code' => 0,
            'msg'  => 'success',
            'data' => ['video' => 'http://academy.web.test/upload/video/2023-04-20/all/test4.mp4'], //这里返回一个固定的视频地址
        ]);
    }
}

目前还没有做好的是,分片上传的时候,怎么计算上传的进度。这个md5校验还是很重要的,防止误传或者文件修改问题,导致数据错乱

补充:获取上传文件的真实格式,原因是我发现第一个分片的格式和后面所有的分片都不一样,第一个分片会保留原文件的一些元数据,比如 原文件是一个视频,那么第一个分片就是Mp4格式;而其它分片确实Blob格式,这样也有一个好处就是,可以直接把第一个分片和后面其他的 分片追加起来,就正好又是一个完整的原文件,如所示视频的话,可以直接file_put_contents为一个视频文件,还是很好用的。

使用:
$pathName = $file->getPathname(); //临时文件路径
$finfo = finfo_open(FILEINFO_MIME); // 返回 mime 类
$reallyType = finfo_file($finfo, $pathName); //得到文件类型的字符串,这个是获取到资源的真实类型
$mime = $file->getClientMimeType();


if ($chunkNumber == 0){
    dd([$reallyType, $mime]);
}
if ($chunkNumber == 1){
    dd([$reallyType, $mime]);
}
        
第一个分片的类型:
array:2 [
  0 => "video/mp4; charset=binary"   //real
  1 => "application/octet-stream"    //mime
]

第二个分片以后都是下面的类型:
array:2 [
  0 => "application/octet-stream; charset=binary"   //real
  1 => "application/octet-stream"   //mime
]