微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现

知识点概览

为了方便后续回顾该项目时能够清晰的知道本章节讲了哪些内容,并且能够从该章节的笔记中得到一些帮助,所以在完成本章节的学习后在此对本章节所涉及到的知识点进行总结概述。

本章节为【学成在线】项目的 day13 的内容

  • FFmpeg 的基本使用
  • 使用 m3u8video.js技术实现视频的在线播放
  • 搭建媒资服务工程实现文件的分块储存

目录

内容会比较多,小伙伴们可以根据目录进行按需查阅。

文章目录

  • 知识点概览
  • 目录
  • 一、在线学习需求分析
    • 0x01 需求描述
    • 0x02 视频点播解决方案
      • 流媒体方案
      • 点播方案
  • 二、视频编码
    • 0x01 视频编码格式
    • 0x02 FFmpeg 的基本使用
    • 0x03 生成 m3u8/ts 文件
      • 码率的设置
  • 三、播放器
    • 0x01 技术选型
    • 0x02 下载 video.js
    • 0x03 搭建媒体播放器
      • 配置 Nginx媒体服务器
      • 配置媒体服务器代理
    • 0x04 测试 video.js
  • 四、搭建学习中心前端
    • 0x01 界面原型
    • 0x02 创建学习中心工程
      • 配置域名
      • 访问测试
    • 0x03 调试视频播放页面
  • 五、媒资管理
    • 0x01 需求分析
    • 0x02 开发环境搭建
      • 创建媒资数据库
      • 创建媒资服务工程
    • 0x03 上传文件
      • 断点续传解决方案
      • 文件分块与合并
        • 1、分块
        • 2、合并
      • 前端页面
        • 1、WebUploader 介绍
        • 2、钩子方法
        • 3、构建 WebUploader
        • 3、before-send-file
        • 4、before-send
        • 5、after-send-file
        • 6、页面效果
      • Api接口
      • 媒资服务端编写
        • 1、业务流程
        • 2、上传注册
        • 3、分块检查
        • 4、上传分块
        • 5、合并分块
        • 6、Controller
        • 7、测试
    • 0x04 完整的代码
      • MediaUploadService
      • MediaUploadServiceImpl
  • 认识作者

一、在线学习需求分析

0x01 需求描述

学成在线作为在线教育网站,提供多种学习形式,包括:录播、直播、图文、社群等,学生登录进入学习中心即可

在线学习,本章节将开发录播课程的在线学习功能,需求如下:

1、学生可以在 windows 浏览器上在线观看视频。

2、播放器具有快进、快退、暂停等基本功能。

3、学生可以方便切换章节进行学习。

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第1张图片

什么是录播课程?

录播课程就是提供录制好课程视频,供用户在线点播,反复学习。

课程视频如何管理?

媒资管理系统专门来管理课程视频,用户视频文件上传到媒资系统,并对视频进行编码处理。

0x02 视频点播解决方案

流媒体方案

详细参考:https://baike.baidu.com/item/%E6%B5%81%E5%AA%92%E4%BD%93/98740?fr=aladdin

概括理解:流媒体就是将视频文件分成许多小块儿,将这些小块儿作为数据包通过网络发送出去,实现一边传输视
频 数据 包一边观看视频。

  • 流式传输

    在网络上传输音、视频信息有两个方式:下载 和 流式传输。

    下载:就是把音、视频文件完全下载到本机后开始播放,它的特点是必须等到视频文件下载完成方可播放,播放等待时间较长,无法去播放还未下载的部分视频。

    流式传输:就是客户端通过链接视频服务器实时传输音、视频信息,实现 “边下载边播放”。

    流式传输包括如下两种方式:

    • 1) 顺序流式传输

      即顺序下载音、视频文件,可以实现边下载边播放,不过,用户只能观看已下载的视频内容,无法快进到未下载的视频部分,顺序流式传输可以使用 Http 服务器来实现,比如 NginxApache 等。

    • 2)实时流式传输

      实时流式传输可以解决顺序流式传输无法快进的问题,它与Http流式传输不同,它必须使用流媒体服务器并且使用流媒体协议来传输视频,它比 Http 流式传输复杂。常见的实时流式传输协议有RTSPRTMPRSVP 等。

  • 流媒体系统的概要结构

    通过流媒体系统的概要结构,学习流媒体系统的基本业务流程。

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第2张图片

1、将原始的视频文件通过编码器转换为适合网络传输的流格式,编码后的视频直接输送给媒体服务器。
原始的视频文件通常是事先录制好的视频,比如通过摄像机、摄像头等录像、录音设备采集到的音视频文
件,体积较大,要想在网络上传输需要经过压缩处理,即通过编码器进行编码 。

2、媒体服务获取到编码好的视频文件,对外提供流媒体数据传输接口,接口协议包括 :HTTPRTSPRTMP 等 。

3、播放器通过流媒体协议与媒体服务器通信,获取视频数据,播放视频。

点播方案

本项目包括点播和直播两种方式,我们先调研一下几个点播的方案,如下:

  1. 播放器通过 http 协议从 http 服务器上下载视频文件进行播放

    问题:必须等到视频下载完才可以播放,不支持快进到某个时间点进行播放

  2. 播放器通过 rtmp 协议连接媒体服务器以实时流方式播放视频

    使用rtmp协议需要架设媒体服务器,造价高,对于直播多采用此方案。

  3. 播放器使用 HLS 协议连接 http 服务器(NginxApache等)实现近实时流方式播放视频

    HLS协议规定:基于 Http 协议,视频封装格式为 ts,视频的编码格式为 H264,音频编码格式为MP3AAC或者 AC-3

那么 HLS 是什么?

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第3张图片

HLS 的工作方式是:将视频拆分成若干 ts 格式的小文件,通过 m3u8 格式的索引文件对这些 ts 小文件建立索引。一般 10 秒一个 ts 文件,播放器连接 m3u8 文件播放,当快进时通过 m3u8 即可找到对应的索引文件,并去下载对应的 ts 文件,从而实现快进、快退以近实时的方式播放视频。

IOS、Android 设备、及各大浏览器都支持 HLS 协议。

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第4张图片

详细参考:https://baike.baidu.com/item/HLS/8328931?fr=aladdin

采用 HLS 方案即可实现边下载边播放,并可不用使用 rtmp 等流媒体协议,不用构建专用的媒体服务器,节省成本。所以本项目点播方案确定为方案 3

二、视频编码

0x01 视频编码格式

先来看一下百度百科中的简介

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第5张图片

详情参考 :https://baike.baidu.com/item/%E8%A7%86%E9%A2%91%E7%BC%96%E7%A0%81/839038

首先我们要分清文件格式和编码格式:

文件格式:是指 .mp4.avi.rmvb 等 这些不同扩展名的视频文件的文件格式 ,视频文件的内容主要包括视频和音频,其文件格式是按照一 定的编码格式去编码,并且按照该文件所规定的封装格式将视频、音频、字幕等信息封装在一起,播放器会根据它们的封装格式去提取出编码,然后由播放器解码,最终播放音视频。

音视频编码格式:通过音视频的压缩技术,将视频格式转换成另一种视频格式,通过视频编码实现流媒体的传输。

比如:一个 .avi 的视频文件原来的编码是 a,通过编码后编码格式变为 b,音频原来为 c,通过编码后变为d

0x02 FFmpeg 的基本使用

我们将视频录制完成后,使用视频编码软件对视频进行编码,本项目 使用 FFmpeg 对视频进行编码。

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第6张图片

FFmpeg 被许多开源项目采用,QQ影音、暴风影音、VLC 等。

下载:FFmpeg https://www.ffmpeg.org/download.html#build-windows

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第7张图片

下载完成后,将 ffmpeg 解压到磁盘下,设置环境变量 FFMPEG_HOME 的值为 ffmpeg 的安装目录

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第8张图片

%FFMPEG_HOME%/bin 添加到环境变量Path

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第9张图片

0x03 生成 m3u8/ts 文件

使用 ffmpeg 生成 m3u8 的步骤如下:

第一步:先将 avi 视频转成 mp4

ffmpeg.exe -i lucene.avi -c:v libx264 -s 1280x720 -pix_fmt yuv420p -b:a 63k -b:v 753k -r 18 lucene.mp4

下面把各参数意思大概讲讲,大概了解意思即可,不再此展开流媒体专业知识的讲解。

  • -c:v 视频编码为 x264x264 编码是 H264 的一种开源编码格式。

  • -s 设置分辨率

  • -pix_fmt yuv420p:设置像素采样方式,主流的采样方式有三种,YUV4:4:4YUV4:2:2YUV4:2:0,它的作用是根据采样方式来从码流中还原每个像素点的YUV(亮度信息与色彩信息)值。

  • -b 设置码率,-b:a-b:v 分别表示音频的码率和视频的码率,-b 表示音频加视频的总码率。码率对一个视频质量有很大的作用,后边会介绍。

  • -r:帧率,表示每秒更新图像画面的次数,通常大于 24 肉眼就没有连贯与停顿的感觉了。

第二步:将 mp4 生成 m3u8

mkdir hls
ffmpeg -i lucene.mp4 -hls_time 10 -hls_list_size 0 -hls_segment_filename ./hls/lucene_%05d.ts ./hls/lucene.m3u8
  • -hls_time 设置每片的长度,单位为秒

  • -hls_list_size n: 保存的分片的数量,设置为 0 表示保存所有分片

  • -hls_segment_filename :段文件的名称,%05d 表示 5 位数字

    生成的效果是:将 lucene.mp4 视频文件每 10 秒生成一个 ts 文件,最后生成一个 m3u8 文件,m3u8 文件是 ts 的索引文件。

使用 VLC 打开 m3u8 文件,测试播放效果,VLC 是一款自由、开源的跨平台多媒体播放器及框架,可播放大多数多媒体文件,以及 DVD、音频 CDVCD 及各类流媒体协议。(http://www.videolan.org/)

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第10张图片

如果这里出现无法播放的情况,请将 FFmpegVLC 播放器更新到最新版本。

码率的设置

码率又叫比特率即每秒传输的 bit 数,单位为 bps(Bit Per Second),码率越大传送数据的速度越快。

码率的计算公式是:文件大小(转成 bit)/ 时长(秒)/1024 = kbps 即每秒传输千位数

例如一个 1M 的视频,它的时长是 10s,它的码率等于 1*1024*1024*8/10/1024 = 819Kbps

码率设置到多少才能达到最好,通过根据个人的经验或参考一些视频网台给出的参考,下图是优酷对码率的要求:

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第11张图片

如果要将视频上传到优酷则必须按照上面的要求,如果是自己搭建视频服务器,码率设置不易过大,最终达到的视频清晰度满足业务需求即可。

三、播放器

0x01 技术选型

视频编码后要使用播放器对其进行解码、播放视频内容。在 web 应用中常用的播放器有 flash 播放器、H5 播放器或浏览器插件播放器,其中以 flashH5 播放器最常见。

flash 播放器:缺点是需要在客户机安装 Adobe Flash Player 播放器,优点是 flash 播放器已经很成熟了,并且浏览器对 flash 支持也很好。

H5播放器:基于 h5 自带 video 标签进行构建,优点是大部分浏览器支持 H5,不用再安装第三方的flash 播放器,并且随着前端技术的发展,h5 技术会越来越成熟。

本项目采用H5播放器,使用 Video.js 开源播放器。

Video.js 是一款基于 HTML5 世界的网络视频播放器。它支持 HTML5Flash 视频,它支持在台式机和移动设备上播放视频。这个项目于 2010 年中开始,目前已在 40 万网站使用。

官方地址:http://videojs.com/

0x02 下载 video.js

Video.js: https://github.com/videojs/video.js

videojs-contrib-hls: https://github.com/videojs/videojs-contrib-hls#installation(videojs-contrib-hls是播放 hls 的一个插件)

使用文档:http://docs.videojs.com/tutorial-videojs_.html

本教程使用 video.js 6.7.3 版本,videojs-contrib-hls 5.14.1 版本。

下载上边两个文件,为了测试需求将其放在门户工程的 plugins 目录中。

0x03 搭建媒体播放器

正常使用 video.js 播放视频是通过一个网页,用户通过浏览器打开网页去播放视频,网页和视频都从web服务器请求,通常视频的 url 地址使用单独的域名。

配置 Nginx媒体服务器

HLS 协议基于 Http 协议,本项目使用 Nginx 作为视频服务器。下图是 Nginx 媒体服务器的配置流程图:

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第12张图片

1、用户打开www.xuecheng.com上边的 video.html 网页 ,在此网页中引入视频链接,视频地址指向video.xuecheng.com

2、video.xuecheng.com 进行负载均衡处理,将视频请求转发到媒体服务器

根据上边的流程,我们在媒体服务器上安装 Nginx,并配置如下:

#学成网媒体服务
server {
     
    listen 90;
    server_name localhost;
    #视频目录
    location /video/ {
     
        alias F:/develop/video/;
    }
}

配置媒体服务器代理

媒体服务器不止一台,通过代理实现负载均衡功能,使用 Nginx 作为媒体服务器的代理,此代理服务器作为 video.xuecheng.com 域名服务器。

配置 video.xuecheng.com 虚拟主机:

注意:这里我们开发环境中代理服务器和媒体服务器在同一台服务器,使用同一个Nginx

#学成网媒体服务代理
map $http_origin $origin_list{
     
    default http://www.xuecheng.com;
    "~http://www.xuecheng.com" http://www.xuecheng.com;
    "~http://ucenter.xuecheng.com" http://ucenter.xuecheng.com;
} 
#学成网媒体服务代理
server {
     
    listen 80;
    server_name video.xuecheng.com;
    location /video {
     
        proxy_pass http://video_server_pool;
        add_header Access-Control-Allow-Origin $origin_list;
        #add_header Access-Control-Allow-Origin *;
        add_header Access-Control-Allow-Credentials true;
        add_header Access-Control-Allow-Methods GET;
    }
}

cors跨域参数:

  • Access-Control-Allow-Origin:允许跨域访问的外域地址

  • Access-Control-Allow-Credentials: 允许客户端携带证书访问

  • Access-Control-Allow-Methods:允许客户端跨域访问的方法

通常允许跨域访问的站点不是一个,所以这里用 map 定义了多个站点。

如果允许任何站点跨域访问则设置为 *,通常这是不建议的。

video_server_pool 的配置如下:

#媒体服务
upstream video_server_pool{
     
    server 127.0.0.1:90 weight=10;
}

配置 hosts 文件,本教程开发环境使用 Windows10,修改 hosts 文件,路径为 C:\Windows\System32\drivers\etc\hosts

127.0.0.1 video.xuecheng.com

0x04 测试 video.js

参考文档如下:

https://github.com/videojs/videojs-contrib-hls#installation

http://jsbin.com/vokipos/8/edit?html,output

1、编写测试页面 video.html


<html lang="en">
    <head>
        <meta http-equiv="content-type" content="text/html; charset=utf-8" />
        <title>视频播放title>
        <link href="/plugins/videojs/video-js.css" rel="stylesheet">
    head>
    <body>
        <video id=example-video width=800 height=600 class="video-js vjs-default-skin vjs-big-play-
                                                            centered" controls poster="http://127.0.0.1:90/video/add.jpg">
            <source
                    src="http://video.xuecheng.com/video/hls/lucene.m3u8"
                    type="application/x-mpegURL">
        video>
        <input type="button" onClick="switchvideo()" value="switch"/>
        <script src="/plugins/videojs/video.js">script>
        <script src="/plugins/videojs/videojs-contrib-hls.js">script>
        <script>
            var player = videojs('example-video');
            //player.play();
            //切换视频
            function switchvideo(){
      
                player.src({
      
                    src: 'http://video.xuecheng.com/video/hls/lucene.m3u8',
                    type: 'application/x-mpegURL',
                    withCredentials: true
                });
                player.play();
            }
        script>
    body>
html>

2、测试

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第13张图片

点击 switch 测试切换视频功能。

四、搭建学习中心前端

0x01 界面原型

先看一下界面原型,如下图,最终的目标是在此页面使用 video.js 播放视频。

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第14张图片

0x02 创建学习中心工程

学习中心的用户是学生,为了便于系统维护和扩展,单独创建学习中心工程:

1、从资料目录拷贝 xc-ui-pc-leanring.zip 并解压到 xc-ui-pc-leanring 目录。

2、使用 webstorm 创建打开 xc-ui-pc-leanring 目录

3、进入 xc-ui-pc-leanring 目录,执行 cnpm install,将根据 package.json 的依赖配置远程下载依赖的 js 包。

创建完成,xc-ui-pc-leanring工程如下:

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第15张图片

配置域名

学习中心的二级域名为 ucenter.xuecheng.com,我们在 nginx 中配置 ucenter 虚拟主机。

#学成网用户中心
server {
     
    listen 80;
    server_name ucenter.xuecheng.com;
    #个人中心
    location / {
     
        proxy_pass http://ucenter_server_pool;
    }
} 
#前端ucenter
upstream ucenter_server_pool{
     
    #server 127.0.0.1:7081 weight=10;
    server 127.0.0.1:13000 weight=10;
}

在根服务下添加一个 /plugins/ 站点

# 根服务
server{
     
    listen       80;
    server_name  www.xuecheng.com;
    ssi on;
    ssi_silent_errors on;
    location /plugins/ {
           
        # 跨域参数
        alias  E:/Project/XueChengOnline/xcEduUI01/xc-ui-pc-static-portal/plugins/;   
        add_header Access-Control-Allow-Origin http://ucenter.xuecheng.com; 
        add_header Access-Control-Allow-Credentials true;          
        add_header Access-Control-Allow-Methods GET;        
    }
    .....
}

访问测试

启动工程,看到下边的界面说明本工程创建完成

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AtcW0UdA-1595567116697)(https://qnoss.codeyee.com/20200704_13/image16)]

0x03 调试视频播放页面

使用 vue-video-player 组件将 video.js 集成到 vue.js 中,本项目使用 vue-video-player实现video.js 播放。

组件地址:https://github.com/surmon-china/vue-video-player

上面的 xc-ui-pc-learning 工程已经添加 vue-video-player 组件,我们在 vue 页面直接使用即可。

前边我们已经测试通过 video.js,下面我们直接在 vue 页面中使用 vue-video-player 完成视频播放。

导入 learning_video.vue 页面到 course 模块下。

配置路由

import learning_video from '@/module/course/page/learning_video.vue';
{
     
    path: '/learning/:courseId/:chapter',
    component: learning_video,
    name: '录播视频学习',
    hidden: false,
    iconCls: 'el‐icon‐document'
}

预览效果

请求:http://ucenter.xuecheng.com/#/learning/1/2

第一个参数:courseId,课程id,这里是测试页面效果随便输入一个 ID 即可,这里输入1

第二个参数:chapter,课程计划id,这里是测试页面效果随便输入一个 ID 即可,这里输入2

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第16张图片

五、媒资管理

前边章节完成在线视频播放,如何实现点击课程计划播放视频呢,课程视频如何管理呢?

本节开始将对课程视频进行管理。

0x01 需求分析

媒资管理系统是每个在线教育平台所必须具备的,百度百科对它的定义如下:

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第17张图片

每个教学机构都可以在媒资系统管理自己的教学资源,包括:视频、教案等文件。

目前媒资管理的主要管理对象是课程录播视频,包括:媒资文件的查询、视频上传、视频删除、视频处理等。

  • 媒资查询:教学机构查询自己所拥有的媒体文件。

  • 视频上传:将用户线下录制的教学视频上传到媒资系统。

  • 视频处理:视频上传成功,系统自动对视频进行编码处理。

  • 视频删除 :如果该视频已不再使用,可以从媒资系统删除。

下边是媒资系统与其它系统的交互情况:

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第18张图片

1、上传媒资文件

前端/客户端请求媒资系统上传文件。

文件上传成功将文件存储到媒资服务器,将文件信息存储到数据库。

2、使用媒资

课程管理请求媒资系统查询媒资信息,将课程计划与媒资信息对应、存储。

3、视频播放

用户进入学习中心请求学习服务学习在线播放视频。

学习服务校验用户资格通过后请求媒资系统获取视频地址。

0x02 开发环境搭建

创建媒资数据库

1、媒资文件信息

package com.xuecheng.framework.domain.media;

import lombok.Data;
import lombok.ToString;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;

import java.util.Date;

@Data
@ToString
@Document(collection = "media_file")
public class MediaFile {
     
    /*
    文件id、名称、大小、文件类型、文件状态(未上传、上传完成、上传失败)、上传时间、视频处理方式、视频处理状态、hls_m3u8,hls_ts_list、课程视频信息(课程id、章节id)
     */
    @Id
    //文件id
    private String fileId;
    //文件名称
    private String fileName;
    //文件原始名称
    private String fileOriginalName;
    //文件路径
    private String filePath;
    //文件url
    private String fileUrl;
    //文件类型
    private String fileType;
    //mimetype
    private String mimeType;
    //文件大小
    private Long fileSize;
    //文件状态
    private String fileStatus;
    //上传时间
    private Date uploadTime;
    //处理状态
    private String processStatus;
    //hls处理
    private MediaFileProcess_m3u8 mediaFileProcess_m3u8;

    //tag标签用于查询
    private String tag;

}

2、创建 xc_media 数据库

媒资系统使用 mongodb 数据库存储媒资信息,再创建集合 media_file

创建媒资服务工程

媒资管理的相关功能单独在媒资服务中开发,下边创建媒资服务工程(xc-service-manage-media)。
媒资服务的配置与 cms 类似,导入 资料/xc-service-manage-media 工程,工程结构如下:

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第19张图片

0x03 上传文件

断点续传解决方案

通常视频文件都比较大,所以对于媒资系统上传文件的需求要满足大文件 的上传要求。http 协议本身对上传文件大小没有限制,但是客户的网络环境质量、电脑硬件环境等参差不齐,如果一个大文件快上传完了网断了,电断了没有上传完成,需要客户重新上传,这是致命的,所以对于大文件上传的要求最基本的是断点续传。

什么是断点续传?

引用百度百科:断点续传指的是在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为的划分为几个部分,每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传下载未完成的部分,而没有必要从头开始上传下载,断点续传可以提高节省操作时间,提高用户体验性。

如下图:

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第20张图片

上传流程如下:

1、上传前先把文件分成块

2、一块一块的上传,上传中断后重新上传,已上传的分块则不用再上传

3、各分块上传完成最后合并文件

文件下载则同理。

文件分块与合并

为了更好的理解文件分块上传的原理,下边用java代码测试文件的分块与合并。

1、分块

文件分块的流程如下:

1、获取源文件长度

2、根据设定的分块文件的大小计算出块数

3、从源文件读数据,再依次向每一个块文件写入数据。

单元测试代码如下

/**
     * 测试文件分块
     */
@Test
public void testChunk() throws IOException {
     
    String chunkPath = "E:/Project/XueChengOnline/xcEduUI01/xuecheng/video/";
    String fileName = "lucene.mp4";
    File sourceFile = new File(chunkPath + fileName);
    File chunkFolder = new File(chunkPath + fileName + ".chunk");
    if(!chunkFolder.exists()){
     
        chunkFolder.mkdir();
    }

    //分块大小
    long chunkSize = 1024*1024*1;
    //分块数量
    //Math.ceil向上取整,例如 12.1=13,12.8=13
    long chunkNum = (long) Math.ceil((sourceFile.length() * 1.0) / chunkSize);
    chunkNum = chunkNum <= 0 ? 1 : chunkNum;
    //缓冲区大小
    byte[] byte_cache = new byte[1024];
    //使用RandomAccessFile访问文件
    RandomAccessFile rafRead = new RandomAccessFile(sourceFile, "r");
    //分块
    for (int i = 0; i < chunkNum; i++) {
     
        //创建分块文件
        File chunkFile = new File(chunkFolder.getPath() + "/" + i);
        boolean newFile = chunkFile.createNewFile();
        if (newFile){
     
            //向分块文件中写入数据
            RandomAccessFile raf_write = new RandomAccessFile(chunkFile, "rw");
            int len = -1;
            //读取到-1则表示读取完成
            len = rafRead.read(byte_cache);
            while (len != -1){
     
                raf_write.write(byte_cache,0, len);
                //读取到预期块大小时结束
                if (chunkFile.length() >= chunkSize){
     
                    break;
                }
            }
            raf_write.close();
        }
    }
    rafRead.close();
}

2、合并

文件合并流程:

1、找到要合并的文件并按文件合并的先后进行排序。

2、创建合并文件

3、依次从合并的文件中读取数据向合并文件写入数

//测试文件合并方法
@Test
public void testMerge() throws IOException {
     
    //块文件目录
    File chunkFolder = new File("F:/develop/ffmpeg/chunk/");
    //合并文件
    File mergeFile = new File("F:/develop/ffmpeg/lucene1.mp4");
    if(mergeFile.exists()){
     
        mergeFile.delete();
    } 
    //创建新的合并文件
    mergeFile.createNewFile();
    //用于写文件
    RandomAccessFile raf_write = new RandomAccessFile(mergeFile, "rw");
    //指针指向文件顶端
    raf_write.seek(0);
    //缓冲区
    byte[] b = new byte[1024];
    //分块列表
    File[] fileArray = chunkFolder.listFiles();
    // 转成集合,便于排序
    List<File> fileList = new ArrayList<File>(Arrays.asList(fileArray));
    // 从小到大排序
    Collections.sort(fileList, new Comparator<File>() {
     
        @Override
        public int compare(File o1, File o2) {
     
            if (Integer.parseInt(o1.getName()) < Integer.parseInt(o2.getName())) {
     
                return1;
            } 
            return 1;
        }
    });
    //合并文件
    for(File chunkFile:fileList){
     
        RandomAccessFile raf_read = new RandomAccessFile(chunkFile,"rw");
        int len =1;
        while((len=raf_read.read(b))!=1){
     
            raf_write.write(b,0,len);
        } 
        raf_read.close();
    } 
    raf_write.close();
}

前端页面

上传文件的页面内容参考:资料/upload.vue 文件

1、WebUploader 介绍

如何在web页面实现断点续传?

常见的方案有:

1、通过 Flash 上传,比如 SWFuploadUploadify

2、安装浏览器插件,变相的pc客户端,用的比较少。

3、HTML5

随着 HTML5 的流行,本项目采用 HTML5 完成文件分块上传。

本项目使用 WebUploader 完成大文件上传功能的开发,WebUploader 官网地址:
http://fexteam.gz01.bdysite.com/webuploader/

WebUploader 简介

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wXWLwmLa-1595567116713)(https://qnoss.codeyee.com/20200704_13/image23)]

WebUploader 的特性

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3pG0sVSF-1595567116716)(https://qnoss.codeyee.com/20200704_13/image24)]

使用 WebUploader 的上传流程如下:

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第21张图片

2、钩子方法

webuploader 中提供很多钩子方法,下边列出一些重要的:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YFlMfWdv-1595567116719)(https://qnoss.codeyee.com/20200704_13/image26)]

本项目使用如下钩子方法:

  • before-send-file

    在开始对文件分块儿之前调用,可以做一些上传文件前的准备工作,比如检查文件目录是否创建完成等

  • before-send

    在上传文件分块之前调用此方法,可以请求服务端检查分块是否存在,如果已存在则此分块儿不再上传。

  • after-send-file

    在所有分块上传完成后触发,可以请求服务端合并分块文件。

注册钩子方法源代码:

WebUploader.Uploader.register({
     
    "before‐send‐file":"beforeSendFile",
    "before‐send":"beforeSend",
    "after‐send‐file":"afterSendFile"
}

3、构建 WebUploader

使用 webUploader 前需要创建webUploader 对象。

指定上传分块的地址:/api/media/upload/uploadchunk

// 创建uploader对象,配置参数
this.uploader = WebUploader.create(
    {
     
        swf:"/static/plugins/webuploader/dist/Uploader.swf",//上传文件的flash文件,浏览器不支持h5时启动
        flash
        server:"/api/media/upload/uploadchunk",//上传分块的服务端地址,注意跨域问题
        fileVal:"file",//文件上传域的name
        pick:"#picker",//指定选择文件的按钮容器
        auto:false,//手动触发上传
        disableGlobalDnd:true,//禁掉整个页面的拖拽功能
        chunked:true,// 是否分块上传
        chunkSize:1*1024*1024, // 分块大小(默认5M)
        threads:3, // 开启多个线程(默认3个)
        prepareNextFile:true// 允许在文件传输时提前把下一个文件准备好
    }
)

3、before-send-file

文件开始上传前前端请求服务端准备上传工作。

参考源代码如下:

type:"POST",
url:"/api/media/upload/register",
data:{
     
    // 文件唯一表示
    fileMd5:this.fileMd5,
    fileName: file.name,
    fileSize:file.size,
    mimetype:file.type,
    fileExt:file.ext
}

4、before-send

上传分块前前端请求服务端校验分块是否存在。

参考源代码如下:

type:"POST",
url:"/api/media/upload/checkchunk",
data:{
     
    // 文件唯一表示
    fileMd5:this.fileMd5,
    // 当前分块下标
    chunk:block.chunk,
    // 当前分块大小
    chunkSize:block.end‐block.start
}

5、after-send-file

在所有分块上传完成后触发,可以请求服务端合并分块文件

参考代码如下:

type:"POST",
url:"/api/media/upload/checkchunk",
data:{
     
// 文件唯一表示
fileMd5:this.fileMd5,
// 当前分块下标
chunk:block.chunk,
// 当前分块大小
chunkSize:block.end‐block.start
}

6、页面效果

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第22张图片

定义文件上传的Api接口,此接收是前端 WebUploader 调用服务端的接口。

编写此接口需要参数前端 WebUploader 应用代码。

Api接口

定义文件上传的 Api 接口,此接收是前端 WebUploader 调用服务端的接口。

编写此接口需要参数前端 WebUploader 应用代码。

package com.xuecheng.api.media;

import com.xuecheng.framework.domain.media.response.CheckChunkResult;
import com.xuecheng.framework.model.response.ResponseResult;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.multipart.MultipartFile;

@Api(value = "媒资管理接口", description = "媒资管理接口,提供文件上传,文件处理等接口")
public interface MediaUploadControllerApi {
     
    @ApiOperation("文件上传注册")
    public ResponseResult register(String fileMd5,
                                   String fileName,
                                   Long fileSize,
                                   String mimetype,
                                   String fileExt);
    @ApiOperation("分块检查")
    public CheckChunkResult checkchunk(String fileMd5,
                                       Integer chunk,
                                       Integer chunkSize);
    @ApiOperation("上传分块")
    public ResponseResult uploadchunk(MultipartFile file,
                                      Integer chunk,
                                      String fileMd5);
    @ApiOperation("合并文件")
    public ResponseResult mergechunks(String fileMd5,
                                      String fileName,
                                      Long fileSize,
                                      String mimetype,
                                      String fileExt);
}

媒资服务端编写

1、业务流程

服务端需要实现如下功能:

1)上传前检查上传环境

检查文件是否上传,已上传则直接返回。

检查文件上传路径是否存在,不存在则创建。

2)分块检查

检查分块文件是否上传,已上传则返回 true

未上传则检查上传路径是否存在,不存在则创建。

3)分块上传

将分块文件上传到指定的路径。

4)合并分块

将所有分块文件合并为一个文件,在数据库记录文件信息。

2、上传注册

由于上传过程复杂,开发时按业务流程分别实现。

1、配置

application.yml 配置上传文件的路径:

xc‐service‐manage‐media:
  # 媒体文件存放路径
  upload‐location: F:/develop/video/

2、定义DAO

public interface MediaFileRepository extends MongoRepository<MediaFile,String> {
     
}

3、编写Service

功能:

1)检查上传文件是否存在

2)创建文件目录

package com.xuecheng.manage_media.service.impl;

import com.xuecheng.framework.domain.media.MediaFile;
import com.xuecheng.framework.domain.media.response.MediaCode;
import com.xuecheng.framework.exception.ExceptionCast;
import com.xuecheng.framework.model.response.CommonCode;
import com.xuecheng.framework.model.response.ResponseResult;
import com.xuecheng.manage_media.controller.MediaUploadController;
import com.xuecheng.manage_media.dao.MediaFileRepository;
import com.xuecheng.manage_media.service.MediaUploadService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.io.File;
import java.util.Optional;

@Service
public class MediaUploadServiceImpl implements MediaUploadService {
     
    private final static Logger LOGGER = LoggerFactory.getLogger(MediaUploadController.class);

    @Autowired
    MediaFileRepository mediaFileRepository;

    //上传文件根目录
    @Value("${xc‐service‐manage‐media.upload‐location}")
    String uploadPath;

   /**
     * 检查文件信息是否已经存在本地以及mongodb内,其中一者不存在则重新注册
     * @param fileMd5 文件md5值
     * @param fileExt 文件扩展名
     * @return 文件路径
     */
    @Override
    public ResponseResult register(String fileMd5, String fileName, Long fileSize, String mimetype, String fileExt) {
     
        //1.检查文件在磁盘上是否存在
        //2.检查文件信息在mongodb上是否存在

        //获取文件所属目录以及文件路径
        String fileFloderPath = this.getFileFloderPath(fileMd5);
        String filePath = this.getFileFullPath(fileMd5, fileExt);
        File file = new File(filePath);
        boolean exists = file.exists();

        //查询mongodb上的文件信息
        Optional<MediaFile> optional = mediaFileRepository.findById(fileMd5);
        if(exists && optional.isPresent()){
     
            ExceptionCast.cast(MediaCode.UPLOAD_FILE_REGISTER_EXIST);
        }
        //其中一者不存在则重新注册文件信息
        File fileFloder = new File(fileFloderPath);
        if(!fileFloder.exists()){
     
            //创建文件目录
            fileFloder.mkdirs();
        }
        return new ResponseResult(CommonCode.SUCCESS);
    }

    
    /**
     * 根据文件md5得到文件的所属目录
     * 规则:
     * 一级目录:md5的第一个字符
     * 二级目录:md5的第二个字符
     * 三级目录:md5
     */
    private String getFileFloderPath(String fileMd5){
     
        String floderPath = uploadPath + "/" + fileMd5.substring(0,1) + "/" + fileMd5.substring(1,2) + "/" + fileMd5  + "/";
        return floderPath;
    }

    /**
     * 获取全文件路径
     * 文件名:md5+文件扩展名
     */
    private String getFileFullPath(String fileMd5, String fileExt){
     
        String floderPath = this.getFileFloderPath(fileMd5);
        String filePath = floderPath + fileMd5 + "." + fileExt;
        return filePath;
    }
    
    /**
     * 获取文件路径
     * 文件名:md5+文件扩展名
     */
    private String getFilePath(String fileMd5, String fileExt){
     
        String filePath = "/" + fileMd5.substring(0,1) + "/" + fileMd5.substring(1,2) + "/" + fileMd5 + "/";
        return filePath;
    }
}

3、分块检查

在 Service 中定义分块检查方法

/**
 * 检查文件块是否存在
 * @param fileMd5 文件md5
 * @param chunk 块编号
 * @param chunkSize 块大小
 * @return CheckChunkResult
 */
@Override
public CheckChunkResult checkChunk(String fileMd5, Integer chunk, Integer chunkSize) {
     
    //获取文件块路径
    String chunkFloder = this.getChunkFloderPath(fileMd5);
    File chunkFile = new File(chunkFloder + chunk);
    if(chunkFile.exists()){
     
        return new CheckChunkResult(CommonCode.SUCCESS, true);
    }

    return new CheckChunkResult(CommonCode.SUCCESS, false);
}

//获取文件块路径
private String getChunkFloderPath(String fileMd5) {
     
    //获取分块文件所属目录
    String fileFloderPath = this.getFileFloderPath(fileMd5);
    String chunkFloder = fileFloderPath + "chunk/";
    File fileChunkFloder = new File(chunkFloder);
    //如果分块所属目录不存在则创建
    if(!fileChunkFloder.exists()){
     
        fileChunkFloder.mkdirs();
    }
    return chunkFloder;
}

4、上传分块

/**
     * 上传分块文件
     * @param file 上传的文件
     * @param chunk 分块号
     * @param fileMd5 文件MD5
     * @return
     */
@Override
public ResponseResult uploadChunk(MultipartFile file, Integer chunk, String fileMd5) {
     
    //获取分块文件所属目录
    String chunkFloder = this.getChunkFloderPath(fileMd5);
    InputStream inputStream = null;
    FileOutputStream fileOutputStream = null;
    try {
     
        inputStream = file.getInputStream();
        fileOutputStream = new FileOutputStream(chunkFloder + chunk);
        IOUtils.copy(inputStream,fileOutputStream);
    } catch (IOException e) {
     
        //文件保存失败
        e.printStackTrace();
        LOGGER.error("upload chunk file fail:{}",e.getMessage());
        ExceptionCast.cast(MediaCode.CHUNK_FILE_UPLOAD_FAIL);
    }
    return new ResponseResult(CommonCode.SUCCESS);
}

5、合并分块

Service 中定义分块合并分块方法,功能如下:

1)将块文件合并

2)校验文件 md5 是否正确

3)向 Mongodb 写入文件信息

/**
     * 合并文件块信息
     * @param fileMd5 文件MD5
     * @param fileName 文件名称
     * @param fileSize 文件大小
     * @param mimetype 文件类型
     * @param fileExt 文件拓展名
     * @return ResponseResult
     */
@Override
public ResponseResult mergeChunks(String fileMd5, String fileName, Long fileSize, String mimetype, String fileExt) {
     
    //获取文件块路径
    String chunkFloderPath = getChunkFloderPath(fileMd5);
    //合并文件路径
    String fileFullPath = this.getFileFullPath(fileMd5, fileExt);
    File mergeFile = new File(fileFullPath);
    //创建合并文件,如果存在则先删除再创建
    if(mergeFile.exists()){
     
        mergeFile.delete();
    }
    boolean newFile = false;
    try {
     
        newFile = mergeFile.createNewFile();
    } catch (IOException e) {
     
        e.printStackTrace();
        LOGGER.error("mergechunks..create mergeFile fail:{}",e.getMessage());
    }
    if(!newFile){
     
        //文件创建失败
        ExceptionCast.cast(MediaCode.MERGE_FILE_CREATEFAIL);
    }

    //获取块文件列表,此列表是已经排序好的
    List<File> chunkFiles = this.getChunkFiles(chunkFloderPath);

    //合并文件
    mergeFile = this.mergeFile(mergeFile, chunkFiles);
    if(mergeFile == null){
     
        ExceptionCast.cast(MediaCode.MERGE_FILE_FAIL);
    }

    //校验文件
    boolean checkResult = this.checkFileMd5(mergeFile, fileMd5);
    if(!checkResult){
     
        ExceptionCast.cast(MediaCode.MERGE_FILE_CHECKFAIL);
    }

    //将文件信息保存到数据库
    MediaFile mediaFile = new MediaFile();
    mediaFile.setFileId(fileMd5);
    mediaFile.setFileName(fileMd5+"."+fileExt);
    mediaFile.setFileOriginalName(fileName);

    //文件路径保存相对路径
    String filePath = this.getFilePath(fileMd5,fileExt);
    mediaFile.setFilePath(this.getFilePath(fileMd5,fileExt));
    mediaFile.setFileUrl(filePath + fileName + "." + fileExt);
    mediaFile.setFileSize(fileSize);
    mediaFile.setUploadTime(new Date());
    mediaFile.setMimeType(mimetype);
    mediaFile.setFileType(fileExt);

    //状态为上传成功
    mediaFile.setFileStatus("301002");
    MediaFile save = mediaFileRepository.save(mediaFile);

    //向MQ发送视频处理消息
    this.sendProcessVideoMsg(fileMd5);

    return new ResponseResult(CommonCode.SUCCESS);

}

//校验文件MD5
private boolean checkFileMd5(File mergeFile, String fileMd5) {
     
    if(mergeFile == null || StringUtils.isEmpty(fileMd5)){
     
        return false;
    }
    //进行md5校验
    try {
     
        FileInputStream fileInputStream = new FileInputStream(mergeFile);
        //得到文件的MD5
        String md5Hex = DigestUtils.md5Hex(fileInputStream);
        //比较两个MD5值
        if(md5Hex.equalsIgnoreCase(fileMd5)){
     
            return true;
        }
    } catch (FileNotFoundException e) {
     
        e.printStackTrace();
        LOGGER.error("未找到该文件 {}",e.getMessage());
    } catch (IOException e) {
     
        e.printStackTrace();
    }

    return false;
}

//合并文件
private File mergeFile(File mergeFile, List<File> chunkFiles) {
     
    try {
     
        //创建写文件对象
        RandomAccessFile raf_write = new RandomAccessFile(mergeFile,"rw");
        //遍历分块文件开始合并
        //读取文件缓冲区
        byte[] b = new byte[1024];
        for(File chunkFile:chunkFiles){
     
            RandomAccessFile raf_read = new RandomAccessFile(chunkFile,"r");
            int len = -1;
            //读取分块文件
            while((len = raf_read.read(b))!= -1){
     
                //向合并文件中写数据
                raf_write.write(b,0,len);
            } 
            raf_read.close();
        } 
        raf_write.close();
    } catch (Exception e) {
     
        e.printStackTrace();
        LOGGER.error("merge file error:{}",e.getMessage());
        return null;
    } 
    return mergeFile;
}

//获取块文件列表
private List<File> getChunkFiles(String chunkFloderPath) {
     
    //块文件目录
    File chunkFolder = new File(chunkFloderPath);
    //分块文件列表
    File[] fileArray = chunkFolder.listFiles();
    //将分块列表转为集合,便于排序
    ArrayList<File> fileList = new ArrayList<>(Arrays.asList(fileArray));
    //从小到大排序,按名称升序
    Collections.sort(fileList, new Comparator<File>() {
     
        @Override
        public int compare(File o1, File o2) {
     
            //比较两个文件的名称
            if (Integer.parseInt(o1.getName()) < Integer.parseInt(o2.getName())) {
     
                return -1;
            }
            return 1;
        }
    });
    return fileList;
}

6、Controller

package com.xuecheng.manage_media.controller;

import com.xuecheng.api.media.MediaUploadControllerApi;
import com.xuecheng.framework.domain.media.response.CheckChunkResult;
import com.xuecheng.framework.model.response.ResponseResult;
import com.xuecheng.manage_media.service.MediaUploadService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/media/upload")
public class MediaUploadController implements MediaUploadControllerApi {
     

    @Autowired
    MediaUploadService mediaUploadService;

    @PostMapping("/register")
    @Override
    public ResponseResult register(String fileMd5, String fileName, Long fileSize, String mimetype, String fileExt) {
     
        return mediaUploadService.register(fileMd5,fileName,fileSize,mimetype,fileExt);
    }

    @PostMapping("/checkchunk")
    @Override
    public CheckChunkResult checkChunk(String fileMd5, Integer chunk, Integer chunkSize) {
     
        return mediaUploadService.checkChunk(fileMd5,chunk,chunkSize);
    }

    @PostMapping("/uploadchunk")
    @Override
    public ResponseResult uploadChunk(MultipartFile file, Integer chunk, String fileMd5) {
     
            return mediaUploadService.uploadChunk(file, chunk, fileMd5);
    }

    @PostMapping("/mergechunks")
    @Override
    public ResponseResult mergeChunks(String fileMd5, String fileName, Long fileSize, String mimetype, String fileExt) {
     
        return mediaUploadService.mergeChunks(fileMd5,fileName,fileSize,mimetype,fileExt);
    }
}

7、测试

建议第一次测试时使用调试模式,在每个模块下都打一个断点进行单步调试,逐步的执行代码,以便检查代码的逻辑。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-htVHhNFC-1595567116728)(https://qnoss.codeyee.com/20200704_13/image28)]

我们在前端工程选择一个文件进行上传,点击开始上传

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第23张图片

开始上传后,我们可以看到文件所属的 chunk 目录下在不断的生成块文件

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第24张图片

块文件全部生成后,会自动调用合并的接口,将所有块文件合并成单个文件

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第25张图片

上传成功页面提示

微服务[学成在线] day13:使用FFmpeg进行格式转换以及m3u8文件生成、文件分块上传接口实现_第26张图片

0x04 完整的代码

MediaUploadService

package com.xuecheng.manage_media.service;

import com.xuecheng.framework.domain.media.response.CheckChunkResult;
import com.xuecheng.framework.model.response.ResponseResult;
import org.springframework.web.multipart.MultipartFile;

/**
 * 媒体文件上传服务
 */
public interface MediaUploadService {
     
    /**
     * 文件信息校验并注册
     * @param fileMd5 文件md5值
     * @param fileName 文件名称
     * @param fileSize 文件大小
     * @param mimetype 文件类型
     * @param fileExt 文件扩展名
     * @return 通用的响应信息
     */
    ResponseResult register(String fileMd5, String fileName, Long fileSize, String mimetype, String fileExt);

    /**
     * 检查文件块是否存在
     * @param fileMd5 文件md5
     * @param chunk 块编号
     * @param chunkSize 块大小
     * @return 检查文件块信息的响应
     */
    CheckChunkResult checkChunk(String fileMd5,Integer chunk,Integer chunkSize);

    /**
     * 上传分块文件
     * @param file 上传的文件
     * @param chunk 分块号
     * @param fileMd5 文件MD5
     * @return ResponseResult
     */
    ResponseResult uploadChunk(MultipartFile file,Integer chunk,String fileMd5);

    /**
     * 合并文件块
     * @param fileMd5 文件MD5
     * @param fileName 文件名称
     * @param fileSize 文件大小
     * @param mimetype 文件类型
     * @param fileExt 文件拓展名
     * @return ResponseResult
     */
    ResponseResult mergeChunks(String fileMd5,String fileName,Long fileSize,String mimetype,String fileExt);

}

MediaUploadServiceImpl

package com.xuecheng.manage_media.service.impl;

import com.alibaba.fastjson.JSON;
import com.netflix.discovery.converters.Auto;
import com.xuecheng.framework.domain.media.MediaFile;
import com.xuecheng.framework.domain.media.response.CheckChunkResult;
import com.xuecheng.framework.domain.media.response.MediaCode;
import com.xuecheng.framework.exception.ExceptionCast;
import com.xuecheng.framework.model.response.CommonCode;
import com.xuecheng.framework.model.response.ResponseResult;
import com.xuecheng.manage_media.config.RabbitMQConfig;
import com.xuecheng.manage_media.controller.MediaUploadController;
import com.xuecheng.manage_media.dao.MediaFileRepository;
import com.xuecheng.manage_media.service.MediaUploadService;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;

import javax.jws.Oneway;
import java.io.*;
import java.util.*;

@Service
class MediaUploadServiceImpl implements MediaUploadService {
     
    private final static Logger LOGGER = LoggerFactory.getLogger(MediaUploadController.class);

    @Autowired
    MediaFileRepository mediaFileRepository;

    //上传文件根目录
    @Value("${xc-service-manage-media.upload-location}")
    String uploadPath;

   /**
     * 检查文件信息是否已经存在本地以及mongodb内,其中一者不存在则重新注册
     * @param fileMd5 文件md5值
     * @param fileExt 文件扩展名
     * @return 文件路径
     */
    @Override
    public ResponseResult register(String fileMd5, String fileName, Long fileSize, String mimetype, String fileExt) {
     
        //1.检查文件在磁盘上是否存在
        //2.检查文件信息在mongodb上是否存在

        //获取文件所属目录以及文件路径
        String fileFloderPath = this.getFileFloderPath(fileMd5);
        String filePath = this.getFileFullPath(fileMd5, fileExt);
        File file = new File(filePath);
        boolean exists = file.exists();

        //查询mongodb上的文件信息
        Optional<MediaFile> optional = mediaFileRepository.findById(fileMd5);
        if(exists && optional.isPresent()){
     
            ExceptionCast.cast(MediaCode.UPLOAD_FILE_REGISTER_EXIST);
        }
        //其中一者不存在则重新注册文件信息
        File fileFloder = new File(fileFloderPath);
        if(!fileFloder.exists()){
     
            //创建文件目录
            fileFloder.mkdirs();
        }
        return new ResponseResult(CommonCode.SUCCESS);
    }

    /**
     * 检查文件块是否存在
     * @param fileMd5 文件md5
     * @param chunk 块编号
     * @param chunkSize 块大小
     * @return CheckChunkResult
     */
    @Override
    public CheckChunkResult checkChunk(String fileMd5, Integer chunk, Integer chunkSize) {
     
        //获取文件块路径
        String chunkFloder = this.getChunkFloderPath(fileMd5);
        File chunkFile = new File(chunkFloder + chunk);
        if(chunkFile.exists()){
     
            return new CheckChunkResult(CommonCode.SUCCESS, true);
        }

        return new CheckChunkResult(CommonCode.SUCCESS, false);
    }

    /**
     * 上传分块文件
     * @param file 上传的文件
     * @param chunk 分块号
     * @param fileMd5 文件MD5
     * @return
     */
    @Override
    public ResponseResult uploadChunk(MultipartFile file, Integer chunk, String fileMd5) {
     
        //获取分块文件所属目录
        String chunkFloder = this.getChunkFloderPath(fileMd5);
        InputStream inputStream = null;
        FileOutputStream fileOutputStream = null;
        try {
     
            inputStream = file.getInputStream();
            fileOutputStream = new FileOutputStream(chunkFloder + chunk);
            IOUtils.copy(inputStream,fileOutputStream);
        } catch (IOException e) {
     
            //文件保存失败
            e.printStackTrace();
            LOGGER.error("upload chunk file fail:{}",e.getMessage());
            ExceptionCast.cast(MediaCode.CHUNK_FILE_UPLOAD_FAIL);
        }
        return new ResponseResult(CommonCode.SUCCESS);
    }

    /**
     * 合并文件块信息
     * @param fileMd5 文件MD5
     * @param fileName 文件名称
     * @param fileSize 文件大小
     * @param mimetype 文件类型
     * @param fileExt 文件拓展名
     * @return ResponseResult
     */
    @Override
    public ResponseResult mergeChunks(String fileMd5, String fileName, Long fileSize, String mimetype, String fileExt) {
     
        //获取文件块路径
        String chunkFloderPath = getChunkFloderPath(fileMd5);
        //合并文件路径
        String fileFullPath = this.getFileFullPath(fileMd5, fileExt);
        File mergeFile = new File(fileFullPath);
        //创建合并文件,如果存在则先删除再创建
        if(mergeFile.exists()){
     
            mergeFile.delete();
        }
        boolean newFile = false;
        try {
     
            newFile = mergeFile.createNewFile();
        } catch (IOException e) {
     
            e.printStackTrace();
            LOGGER.error("mergechunks..create mergeFile fail:{}",e.getMessage());
        }
        if(!newFile){
     
            //文件创建失败
            ExceptionCast.cast(MediaCode.MERGE_FILE_CREATEFAIL);
        }

        //获取块文件列表,此列表是已经排序好的
        List<File> chunkFiles = this.getChunkFiles(chunkFloderPath);

        //合并文件
        mergeFile = this.mergeFile(mergeFile, chunkFiles);
        if(mergeFile == null){
     
            ExceptionCast.cast(MediaCode.MERGE_FILE_FAIL);
        }

        //校验文件
        boolean checkResult = this.checkFileMd5(mergeFile, fileMd5);
        if(!checkResult){
     
            ExceptionCast.cast(MediaCode.MERGE_FILE_CHECKFAIL);
        }

        //将文件信息保存到数据库
        MediaFile mediaFile = new MediaFile();
        mediaFile.setFileId(fileMd5);
        mediaFile.setFileName(fileMd5+"."+fileExt);
        mediaFile.setFileOriginalName(fileName);

        //文件路径保存相对路径
        String filePath = this.getFilePath(fileMd5,fileExt);
        mediaFile.setFilePath(this.getFilePath(fileMd5,fileExt));
        mediaFile.setFileUrl(filePath + fileName + "." + fileExt);
        mediaFile.setFileSize(fileSize);
        mediaFile.setUploadTime(new Date());
        mediaFile.setMimeType(mimetype);
        mediaFile.setFileType(fileExt);

        //状态为上传成功
        mediaFile.setFileStatus("301002");
        MediaFile save = mediaFileRepository.save(mediaFile);

        //向MQ发送视频处理消息
        this.sendProcessVideoMsg(fileMd5);

        return new ResponseResult(CommonCode.SUCCESS);

    }

    //视频处理路由
    @Value("${xc-service-manage-media.mq.routingkey-media-video}")
    public String routingkey_media_video;

    @Autowired
    RabbitTemplate rabbitTemplate;

    //向MQ发送视频处理消息
    private ResponseResult sendProcessVideoMsg(String mediaId){
     
        Optional<MediaFile> optional = mediaFileRepository.findById(mediaId);
        if(!optional.isPresent()){
     
            return new ResponseResult(CommonCode.FAIL);
        }
        MediaFile mediaFile = optional.get();
        //发送视频处理消息
        Map<String,String> msgMap = new HashMap<>();
        msgMap.put("mediaId",mediaId);
        //发送的消息
        String msg = JSON.toJSONString(msgMap);
        try {
     
            this.rabbitTemplate.convertAndSend(RabbitMQConfig.EX_MEDIA_PROCESSTASK,routingkey_media_video,msg);
            LOGGER.info("send media process task msg:{}",msg);
        }catch (Exception e){
     
            e.printStackTrace();
            LOGGER.info("send media process task error,msg is:{},error:{}",msg,e.getMessage());
            return new ResponseResult(CommonCode.FAIL);
        }
        return new ResponseResult(CommonCode.SUCCESS);
    }

    //校验文件MD5
    private boolean checkFileMd5(File mergeFile, String fileMd5) {
     
        if(mergeFile == null || StringUtils.isEmpty(fileMd5)){
     
            return false;
        }
        //进行md5校验
        try {
     
            FileInputStream fileInputStream = new FileInputStream(mergeFile);
            //得到文件的MD5
            String md5Hex = DigestUtils.md5Hex(fileInputStream);
            //比较两个MD5值
            if(md5Hex.equalsIgnoreCase(fileMd5)){
     
                return true;
            }
        } catch (FileNotFoundException e) {
     
            e.printStackTrace();
            LOGGER.error("未找到该文件 {}",e.getMessage());
        } catch (IOException e) {
     
            e.printStackTrace();
        }

        return false;
    }
    //合并文件
    private File mergeFile(File mergeFile, List<File> chunkFiles) {
     
        try {
     
            //创建写文件对象
            RandomAccessFile raf_write = new RandomAccessFile(mergeFile,"rw");
            //遍历分块文件开始合并
            //读取文件缓冲区
            byte[] b = new byte[1024];
            for(File chunkFile:chunkFiles){
     
                RandomAccessFile raf_read = new RandomAccessFile(chunkFile,"r");
                int len = -1;
                //读取分块文件
                while((len = raf_read.read(b))!= -1){
     
                    //向合并文件中写数据
                    raf_write.write(b,0,len);
                } 
                raf_read.close();
            } 
            raf_write.close();
        } catch (Exception e) {
     
            e.printStackTrace();
            LOGGER.error("merge file error:{}",e.getMessage());
            return null;
        } 
        return mergeFile;
    }

    //获取块文件列表
    private List<File> getChunkFiles(String chunkFloderPath) {
     
        //块文件目录
        File chunkFolder = new File(chunkFloderPath);
        //分块文件列表
        File[] fileArray = chunkFolder.listFiles();
        //将分块列表转为集合,便于排序
        ArrayList<File> fileList = new ArrayList<>(Arrays.asList(fileArray));
        //从小到大排序,按名称升序
        Collections.sort(fileList, new Comparator<File>() {
     
            @Override
            public int compare(File o1, File o2) {
     
                //比较两个文件的名称
                if (Integer.parseInt(o1.getName()) < Integer.parseInt(o2.getName())) {
     
                    return -1;
                }
                return 1;
            }
        });
        return fileList;
    }

    //获取文件块路径
    private String getChunkFloderPath(String fileMd5) {
     
        //获取分块文件所属目录
        String fileFloderPath = this.getFileFloderPath(fileMd5);
        String chunkFloder = fileFloderPath + "chunk/";
        File fileChunkFloder = new File(chunkFloder);
        //如果分块所属目录不存在则创建
        if(!fileChunkFloder.exists()){
     
            fileChunkFloder.mkdirs();
        }
        return chunkFloder;
    }



    /**
     * 根据文件md5得到文件的所属目录
     * 规则:
     * 一级目录:md5的第一个字符
     * 二级目录:md5的第二个字符
     * 三级目录:md5
     */
    private String getFileFloderPath(String fileMd5){
     
            String floderPath = uploadPath + "/" + fileMd5.substring(0,1) + "/" + fileMd5.substring(1,2) + "/" +fileMd5  + "/";
            return floderPath;
    }

    /**
     * 获取全文件路径
     * 文件名:md5+文件扩展名
     */
    private String getFileFullPath(String fileMd5, String fileExt){
     
        String floderPath = this.getFileFloderPath(fileMd5);
        String filePath = floderPath + fileMd5 + "." + fileExt;
        return filePath;
    }
    /**
     * 获取文件路径
     * 文件名:md5+文件扩展名
     */
    private String getFilePath(String fileMd5, String fileExt){
     
        String filePath = "/" + fileMd5.substring(0,1) + "/" + fileMd5.substring(1,2) + "/" + fileMd5 + "/";
        return filePath;
    }
}

认识作者

作者: LCyee ,全干型代码

自建博客:https://www.codeyee.com

记录学习以及项目开发过程中的笔记与心得,记录认知迭代的过程,分享想法与观点。

CSDN 博客:https://blog.csdn.net/codeyee

记录和分享一些开发过程中遇到的问题以及解决的思路。

欢迎加入微服务练习生的队伍,一起交流项目学习过程中的一些问题、分享学习心得等,不定期组织一起刷题、刷项目,共同见证成长。

你可能感兴趣的:(微服务项目学习,java,ffmpeg,spring,boot)