Day13_在线学习 HLS

1 在线学习需求分析

1.1 需求描述

学成在线作为在线教育网站,提供多种学习形式,包括:录播、直播、图文、社群等,学生登录进入学习中心即可 在线学习,本章节将开发录播课程的在线学习功能,需求如下:

1、学生可以在浏览器上在线观看视频。
2、播放器具有快进、快退、暂停等基本功能。
3、学生可以方便切换章节进行学习。

什么是录播课程?

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

课程视频如何管理?

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

1.2 视频点播解决方案

1.2.1 流媒体

流媒体:流媒体(英语:Streaming media)是指将一连串的多媒体资料压缩后,经过互联网分段发送资料,在互联网上即时传输影音以供观赏的一种技术与过程,此技术使得资料数据包得以像流水一样发送,如果不使用此技术,就必须在使用前下载整个媒体文件。

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

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

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

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

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

1) 顺序流式传输
即顺序下载音、视频文件,可以实现边下载边播放,不过,用户只能观看已下载的视频内容,无法快进到未下载的视频部分,顺序流式传输可以使用Http服务器来实现,比如Nginx、Apache等。
2)实时流式传输
实时流式传输可以解决顺序流式传输无法快进的问题,它与Http流式传输不同,它必须使用流媒体服务器并且使用流媒体协议来传输视频,它比Http流式传输复杂。常见的实时流式传输协议有RTSP、RTMP、RSVP 等。

流媒体系统的概要结构

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

1、将原始的视频文件通过编码器转换为适合网络传输的流格式,编码后的视频直接输送给媒体服务器。
原始的视频文件通常是事先录制好的视频,比如通过摄像机、摄像头等录像、录音设备采集到的音视频文件,体积较大,要想在网络上传输需要经过压缩处理,即通过编码器进行编码。
2、媒体服务器获取到编码好的视频文件,对外提供流媒体数据传输接口,接口协议包括 :HTTP、RTSP、 RTMP等 。
3、播放器通过流媒体协议与媒体服务器通信,获取视频数据,播放视频。

1.2.2 点播方案

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

1、 播放器通过 http协议从http服务器上下载视频文件进行播放
问题:必须等到视频下载完才可以播放,不支持快进到某个时间点进行播放
2、 播放器通过rtmp协议连接媒体服务器以实时流方式播放视频
使用rtmp协议需要架设媒体服务器,造价高,对于直播多采用此方案。
3、 播放器使用HLS协议连接http服务器(Nginx、Apache等)实现近实时流方式播放视频
HLS协议规定:基于Http协议,视频封装格式为ts,视频的编码格式为H264,音频编码格式为MP3、AAC或者AC3。

HLS是什么?

HTTP Live Streaming(缩写是HLS)是由苹果公司提出基于HTTP的流媒体网络传输协议。是苹果公司QuickTime X和iPhone软件系统的一部分。它的工作原理是把整个流分成一个个小的基于HTTP的文件来下载,每次只下载一些。当媒体流正在播放时,客户端可以选择从许多不同的备用源中以不同的速率下载同样的资源,允许流媒体会话适应不同的数据速率。在开始一个流媒体会话时,客户端会下载一个包含元数据的扩充 M3U (m3u8) 播放列表文件,用于寻找可用的媒体流。

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

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

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

本项目点播方案确定为方案3。

2 视频编码

视频编码:

所谓视频编码方式就是指通过压缩技术,将原始视频格式的文件转换成另一种视频格式文件的方式。视频流传输中最为重要的编解码标准有国际电联的H.261、H.263、H.264,运动静止图像专家组的M-JPEG和国际标准化组织运动图像专家组的MPEG系列标准,此外在互联网上被广泛应用的还有Real-Networks的RealVideo、微软公司的WMV以及Apple公司的QuickTime等。

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

文件格式:是指.mp4、.avi、.rmvb等这些不同扩展名的视频文件的文件格式,视频文件的内容主要包括视频和音频,其文件格式是按照一定的编码格式去编码,并且按照该文件所规定的封装格式将视频、音频、字幕等信息封装在一起,播放器会根据它们的封装格式去提取出编码,然后由播放器解码,最终播放音视频。
音视频编码格式:通过音视频的压缩技术,将视频格式转换成另一种视频格式,通过视频编码实现流媒体的传输。 比如:一个.avi的视频文件原来的编码是a,通过编码后编码格式变为b,音频原来为c,通过编码后变为d。

音视频编码格式各类繁多,主要有几下几类:

MPEG系列 (由ISO[国际标准组织机构]下属的MPEG[运动图象专家组]开发 )视频编码方面主要是Mpeg1(vcd用的就是它)、Mpeg2(DVD使用)、Mpeg4(DVDRIP使用的都是它的变种,如:divx,xvid等)、Mpeg4 AVC(正热门);音频编码方面主要是MPEG Audio Layer 1/2、MPEG Audio Layer 3(大名鼎鼎的mp3)、 MPEG-2 AAC 、MPEG-4 AAC等等。注意:DVD音频没有采用Mpeg的。

H.26X系列 (由ITU[国际电传视讯联盟]主导,侧重网络传输,注意:只是视频编码) 包括H.261、H.262、 H.263、H.263+、H.263++、H.264(就是MPEG4 AVC-合作的结晶)

目前最常用的编码标准是视频H.264,音频AAC。

2.2 FFmpeg 的基本使用

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

FFmpeg是一个开放源代码的自由软件,可以运行音频和视频多种格式的录影、转换、流功能,包含了libavcodec——这是一个用于多个项目中音频和视频的解码器库,以及libavformat——一个音频与视频格式转换库。

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

官方网站:https://www.ffmpeg.org

简单的测试:/Users/XinxingWang/Development/Java/IDEA_Project/vedio/lucene.avi

将一个.avi文件转成mp4、mp3、gif等。

比如我们将lucene.avi文件转成mp4,运行如下命令:

ffmpeg -i lucene.avi lucene.mp4
转成mp3:ffmpeg -i lucene.avi lucene.mp3
转成gif:ffmpeg -i lucene.avi lucene.gif

2.2 生成m3u8/ts文件

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

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

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

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

-c:v 视频编码为x264 ,x264编码是H264的一种开源编码格式。
-s 设置分辨率
-pix_fmt yuv420p:设置像素采样方式,主流的采样方式有三种,YUV4:4:4,YUV4:2:2,YUV4:2:0,它的作用是根据采样方式来从码流中还原每个像素点的YUV(亮度信息与色彩信息)值。
-b 设置码率,-b:a和-b:v分别表示音频的码率和视频的码率,-b表示音频加视频的总码率。码率对一个视频质量有很大的作用,后边会介绍。
-r 帧率,表示每秒更新图像画面的次数,通常大于24肉眼就没有连贯与停顿的感觉了。

第二步:将mp4生成m3u8

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、音频 CD、VCD 及各类流媒体协议。(http://www.videolan.org/)

2.2.1 码率的设置

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

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

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

1*1024*1024*8/10/1024 = 819Kbps

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

3 播放器

3.1 技术选型

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

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

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

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

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

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

3.2 下载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的一个插件)

使用文档:https://docs.videojs.com

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

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

3.3 搭建媒体服务器

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

3.3.1 Nginx媒体服务器

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

1、用户打开www.xuecheng.com上边的video.html网页

在此网页中引入视频链接,视频地址指向 video.xuecheng.com

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

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

	#学成网媒体服务 
	server {
		listen	90;
		server_name localhost;

		#视频目录 
		location /video/ {
			alias /Users/XinxingWang/Development/Java/IDEA_Project/video/hls;
		}
	}

3.3.2 媒体服务器代理

媒体服务器不止一台,通过代理实现负载均衡功能,使用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:允许跨域访问的外域地址
通常允许跨域访问的站点不是一个,所以这里用map定义了多个站点。
如果允许任何站点跨域访问则设置为*,通常这是不建议的。
Access-Control-Allow-Credentials: 允许客户端携带证书访问
Access-Control-Allow-Methods:允许客户端跨域访问的方法

video_server_pool的配置如下:

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

3.4 测试video.js

参考https://github.com/videojs/videojs-contrib-hls#installation和http://jsbin.com/vokipos/8/edit?html

1、编写测试页面video.html

xc-ui-pc-static-portal工程下已经安装好插件,并且video.html代码已经书写完毕。

<!DOCTYPE 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/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/lucene.m3u8',
            type: 'application/x-mpegURL',
            withCredentials: true
        });
        player.play();
    }
</script>

</body>
</html>

2、测试

此处测试卡住了,本人电脑是MacOS,但是联网状态下Google浏览器默认不解析本地的hosts文件,导致访问www.xuecheng.com一直是别人的服务器,一直不能读取本地.m3u8文件,并且即使设置跨域,Google浏览器还是报跨域错误,不知道怎么回事,用chrome浏览器就可以正常访问,可能是电脑的代理有问题。

配置hosts文件

127.0.0.1 video.xuecheng.com

效果:

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

4 搭建学习中心前端

学成网学习中心提供学生在线学习的各各模块,上一章节测试的点播学习功能也属于学习中心的一部分,本章节将 实现学习中心点播学习的前端部分。之所以先实现前端部分,主要是因为要将video.js+vue.js集成,一部分精力还 是要放在技术研究。

4.1 界面原型

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

4.2 创建学习中心工程

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

1、从资料目录拷贝xc-ui-pc-leanring.zip 并解压到前端工程下。
2、使用webstorm创建打开xc-ui-pc-leanring目录
3、进入xc-ui-pc-leanring目录,执行cnpm install,将根据package.json的依赖配置远程下载依赖的js包。

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

4.2.1 配置域名与地址映射

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

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

        #个人中心 
        location / { 
            proxy_pass http://ucenter_server_pool; 
        }

    }

配置端口

127.0.0.1 ucenter.xuecheng.com

4.2.2 访问

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

4.3 调试视频播放页面

使用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';
export default [
  {
    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

根据具体情况修改learning_video中的m3u8地址,这里是http://video.xuecheng.com/video/lucene.m3u8

5 媒资管理

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

媒体资产管理(Media Asset Management,简称MAM)是对各种类型媒体资料数据,如视音频资料、文本文件、图表等进行全面管理的完整解决方案。其目的是将现有的影视节目进行数字化或数据化,并采用适当的方式编码,再记录到成熟稳定的媒体上,达到影视节目长期保存和重复利用的目的,以满足影视节目的制作、播出和交换的需要。

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

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

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

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

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

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

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

1、上传媒资文件

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

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

2、使用媒资

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

3、视频播放

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

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

5.1 开发环境

5.1.1 创建媒资数据库

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;

/**
 * @Author: mrt.
 * @Description:
 * @Date:Created in 2018/1/24 10:04.
 * @Modified By:
 */
@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、MongoDB创建xc_media数据库

媒资系统使用mongodb数据库存储媒资信息。

5.1.2 创建媒资服务工程

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

5.2 上传文件

5.2.1 断点续传解决方案

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

什么是断点续传:

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

如下图:

上传流程如下:

1、上传前先把文件分成块
2、一块一块的上传,上传中断后重新上传,已上传的分块则不用再上传
3、各分块上传完成最后合并文件

文件下载同理。

5.2.2 文件分块与合并

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

5.2.2.1 文件分块

文件分块的流程如下:

1、获取源文件长度
2、根据设定的分块文件的大小计算出块数
3、从源文件读数据依次向每一个块文件写数据。

package com.xuecheng.manage_media.test;

import org.junit.Test;

import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;

/**
 * @author HackerStar
 * @create 2020-08-28 11:20
 */
public class TestChunk {
    //测试文件分块方法
    @Test
    public void testChunk() throws IOException {
        File sourceFile = new File("/Users/XinxingWang/Development/Java/video/lucene.mp4");
        String chunkPath = "/Users/XinxingWang/Development/Java/video/chunk/";
        File chunkFolder = new File(chunkPath);
        if (!chunkFolder.exists()) {
            chunkFolder.mkdirs();
        }
        //分块大小 表示1M
        long chunkSize = 1024 * 1024 * 1; //1M=1024KB=1024*1024 B
        //分块数量
        double chunkNum = Math.ceil(sourceFile.length() * 1.0 / chunkSize);
        if (chunkNum <= 0) {
            chunkNum = 1;
        }
        //缓冲区大小
        byte[] b = new byte[1024 * 1024]; //M
        //使用RandomAccessFile访问文件
        RandomAccessFile raf_read = new RandomAccessFile(sourceFile, "r");
        //分块
        for (int i = 0; i < chunkNum; i++) {
            //创建分块文件
            File file = new File("file_" + i);
            boolean newFile = file.createNewFile();
            if (newFile){
                //向分块文件中写数据
                RandomAccessFile raf_write = new RandomAccessFile(file, "rw");
                int len = -1;
                while ((len = raf_read.read(b)) != -1) {
                    raf_write.write(b,0,len);
                    if(file.length()>chunkSize){
                        break;
                    }
                }
                raf_write.close();
            }
        }
        raf_read.close();
    }
}

5.2.2.2 文件合并

文件合并流程:

1、找到要合并的文件并按文件合并的先后进行排序
2、创建合并文件
3、依次从合并的文件中读取数据向合并文件写入数据
 		//测试文件合并方法
    @Test
    public void testMerge() throws IOException {
        //块文件目录
        File file = new File("/Users/XinxingWang/Development/Java/video/chunk/");
        //合并文件
        File mergeFile = new File("/Users/XinxingWang/Development/Java/video/lucene1.mp4");
        if (mergeFile.exists()) {
            mergeFile.delete();
        }
        //向分块文件中写数据
        RandomAccessFile raf_write = new RandomAccessFile(mergeFile, "rw");
        //指针指向文件顶端
        raf_write.seek(0);
        //缓冲区
        byte[] b = new byte[1024 * 1024];
        //分块列表
        File[] fileArray = file.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())) {
                    return -1;
                }
                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();
    }

5.2.3 前端页面

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

5.2.3.1 WebUploader介绍

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

常见的方案有:
1、通过Flash上传,比如SWFupload、Uploadify。
2、安装浏览器插件,变相的pc客户端,用的比较少。
3、Html5

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

本项目使用WebUploader完成大文件上传功能的开发。

WebUploader官网地址:http://fexteam.gz01.bdysite.com/webuploader/

特性如下:

使用WebUploader上传流程如下:

5.2.3.1 钩子方法

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

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

1)before-send-file
在开始对文件分块儿之前调用,可以做一些上传文件前的准备工作,比如检查文件目录是否创建完成等。
2 )before-send
在上传文件分块之前调用此方法,可以请求服务端检查分块是否存在,如果已存在则此分块儿不再上传。
3)after-send-file
在所有分块上传完成后触发,可以请求服务端合并分块文件。
WebUploader.Uploader.register({
	"before‐send‐file":"beforeSendFile", 
  "before‐send":"beforeSend", 
  "after‐send‐file":"afterSendFile" 
}
5.2.3.2 构建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// 允许在文件传输时提前把下一个文件准备好
  }
)
5.2.3.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
}
5.2.3.4 before-send

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

参考源代码如下:

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

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

参考代码如下:

type:"POST",
url:"/api/media/upload/mergechunks",
data:{
	fileMd5:this.fileMd5, 
  fileName: file.name, 
  fileSize:file.size, 
  mimetype:file.type, 
  fileExt:file.ext
}
5.2.3.6 页面效果

5.3.4 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;

/**
 * @author HackerStar
 * @create 2020-08-28 14:58
 */
@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);
}

5.3.5 媒资服务端编写

5.3.5.1 业务流程

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

1、上传前检查上传环境
检查文件是否上传,已上传则直接返回。
检查文件上传路径是否存在,不存在则创建。
2、分块检查
检查分块文件是否上传,已上传则返回true。
未上传则检查上传路径是否存在,不存在则创建。
3、分块上传
将分块文件上传到指定的路径。
4、合并分块
将所有分块文件合并为一个文件。
在数据库记录文件信息。
5.3.5.2 上传注册

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

1、配置

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

server:
  port: 31400
spring:
  application:
    name: xc-service-manage-media
  data:
    mongodb:
      uri:  mongodb://localhost:27017
      database: xc_media
eureka:
  client:
    registerWithEureka: true #服务注册开关
    fetchRegistry: true #服务发现开关
    serviceUrl: #Eureka客户端与Eureka服务端进行交互的地址,多个中间用逗号分隔
      defaultZone: ${EUREKA_SERVER:http://localhost:50101/eureka/}
  instance:
    prefer-ip-address:  true  #将自己的ip地址注册到Eureka服务中
    ip-address: ${IP_ADDRESS:127.0.0.1}
    instance-id: ${spring.application.name}:${server.port} #指定实例id
ribbon:
  MaxAutoRetries: 2 #最大重试次数,当Eureka中可以找到服务,但是服务连不上时将会重试,如果eureka中找不到服务则直接走断路器
  MaxAutoRetriesNextServer: 3 #切换实例的重试次数
  OkToRetryOnAllOperations: false  #对所有操作请求都进行重试,如果是get则可以,如果是post,put等操作没有实现幂等的情况下是很危险的,所以设置为false
  ConnectTimeout: 5000  #请求连接的超时时间
  ReadTimeout: 6000 #请求处理的超时时间
xc-service-manage-media:
  upload-location: /Users/XinxingWang/Development/Java/video/upload

2、定义Dao

媒资文件管理Dao

package com.xuecheng.manage_media.dao;

import com.xuecheng.framework.domain.media.MediaFile;
import org.springframework.data.mongodb.repository.MongoRepository;


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

3、Service

功能:

1)检查上传文件是否存在
2)创建文件目录
package com.xuecheng.manage_media.service;

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.dao.MediaFileRepository;
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;

/**
 * @author HackerStar
 * @create 2020-08-28 15:04
 */
@Service
public class MediaUploadService {
    private final static Logger LOGGER = LoggerFactory.getLogger(MediaUploadController.class);

    @Autowired
    MediaFileRepository mediaFileRepository;

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

    /**
     * 根据文件md5得到文件路径
     * * 规则:
     * * 一级目录:md5的第一个字符
     * * 二级目录:md5的第二个字符
     * * 三级目录:md5
     * * 文件名:md5+文件扩展名
     * * @param fileMd5 文件md5值
     * * @param fileExt 文件扩展名
     * * @return 文件路径
     */
    private String getFilePath(String fileMd5, String fileExt) {
        String filePath = uploadPath + "/" + fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + fileMd5 + "." + fileExt;
        return filePath;
    }

    //得到文件目录相对路径,路径中去掉根目录
    private String getFileFolderRelativePath(String fileMd5, String fileExt) {
        String filePath = fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/";
        return filePath;
    }

    //得到文件所在目录
    private String getFileFolderPath(String fileMd5) {
        String fileFolderPath = uploadPath + "/" + fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/";
        return fileFolderPath;
    }

    //创建文件目录
    private boolean createFileFold(String fileMd5) {
        //创建上传文件目录
        String fileFolderPath = getFileFolderPath(fileMd5);
        File fileFolder = new File(fileFolderPath);
        if (!fileFolder.exists()) {
            //创建文件夹
            boolean mkdirs = fileFolder.mkdirs();
            return mkdirs;
        }
        return true;
    }

    //文件上传注册
    public ResponseResult register(String fileMd5, String fileName, String fileSize, String mimetype, String fileExt) {
        //检查文件是否上传
        //1、得到文件的路径
        String filePath = getFilePath(fileMd5, fileExt);
        File file = new File(filePath);

        //2、查询数据库文件是否存在
        Optional<MediaFile> optional = mediaFileRepository.findById(fileMd5);
        //文件存在直接返回
        if (file.exists() && optional.isPresent()) {
            ExceptionCast.cast(MediaCode.UPLOAD_FILE_REGISTER_EXIST);
        }
        boolean fileFold = createFileFold(fileMd5);
        if (!fileFold) {
            //上传文件目录创建失败
            ExceptionCast.cast(MediaCode.UPLOAD_FILE_REGISTER_CREATEFOLDER_FAIL);
        }
        return new ResponseResult(CommonCode.SUCCESS);
    }
}
5.3.5.3 分块检查

在Service 中定义分块检查方法:

		//得到块文件所在目录
    private String getChunkFileFolderPath(String fileMd5) {
        String fileChunkFolderPath = getFileFolderPath(fileMd5) + "/" + "chunks" + "/";
        return fileChunkFolderPath;
    }

    //检查块文件
    public CheckChunkResult checkchunk(String fileMd5, String chunk, String chunkSize) {
        //得到块文件所在路径
        String chunkfileFolderPath = getChunkFileFolderPath(fileMd5);
        //块文件的文件名称以1,2,3..序号命名,没有扩展名
        File chunkFile = new File(chunkfileFolderPath + chunk);
        if (chunkFile.exists()) {
            return new CheckChunkResult(MediaCode.CHUNK_FILE_EXIST_CHECK, true);
        } else {
            return new CheckChunkResult(MediaCode.CHUNK_FILE_EXIST_CHECK, false);
        }
    }
5.3.5.4 上传分块

在Service 中定义分块上传分块方法:

		//块文件上传
    public ResponseResult uploadchunk(MultipartFile file, String fileMd5, String chunk) {
        if (file == null) {
            ExceptionCast.cast(MediaCode.UPLOAD_FILE_REGISTER_ISNULL);
        }
        //创建块文件目录
        boolean fileFold = createChunkFileFolder(fileMd5);
        //块文件
        File chunkfile = new File(getChunkFileFolderPath(fileMd5) + chunk);
        //上传的块文件
        InputStream inputStream = null;
        FileOutputStream outputStream = null;
        try {
            inputStream = file.getInputStream();
            outputStream = new FileOutputStream(chunkfile);
            IOUtils.copy(inputStream, outputStream);
        } catch (Exception e) {
            e.printStackTrace();
            LOGGER.error("upload chunk file fail:{}", e.getMessage());
            ExceptionCast.cast(MediaCode.CHUNK_FILE_UPLOAD_FAIL);
        } finally {
            try {
                inputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
            try {
                outputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return new ResponseResult(CommonCode.SUCCESS);
    }

    //创建块文件目录
    private boolean createChunkFileFolder(String fileMd5) {
        //创建上传文件目录
        String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
        File chunkFileFolder = new File(chunkFileFolderPath);
        if (!chunkFileFolder.exists()) {
            //创建文件夹
            boolean mkdirs = chunkFileFolder.mkdirs();
            return mkdirs;
        }
        return true;
    }
5.3.5.5 合并分块

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

1)将块文件合并
2)校验文件md5是否正确
3)向Mongodb写入文件信息
		//合并块文件
    public ResponseResult mergechunks(String fileMd5, String fileName, Long fileSize, String mimetype, String fileExt) {
        //获取块文件的路径
        String chunkfileFolderPath = getChunkFileFolderPath(fileMd5);
        File chunkfileFolder = new File(chunkfileFolderPath);
        if (!chunkfileFolder.exists()) {
            chunkfileFolder.mkdirs();
        }
        //合并文件路径
        File mergeFile = new File(getFilePath(fileMd5, fileExt));
        //创建合并文件
        //合并文件存在先删除再创建
        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 = getChunkFiles(chunkfileFolder);
        //合并文件
        mergeFile = 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);
        //文件路径保存相对路径
        mediaFile.setFilePath(getFileFolderRelativePath(fileMd5, fileExt));
        mediaFile.setFileSize(fileSize);
        mediaFile.setUploadTime(new Date());
        mediaFile.setMimeType(mimetype);
        mediaFile.setFileType(fileExt);
        //状态为上传成功
        mediaFile.setFileStatus("301002");
        MediaFile save = mediaFileRepository.save(mediaFile);
        return new ResponseResult(CommonCode.SUCCESS);
    }

    //校验文件的md5值
    private boolean checkFileMd5(File mergeFile, String md5) {
        if (mergeFile == null || StringUtils.isEmpty(md5)) {
            return false;
        }
        //进行md5校验
        FileInputStream mergeFileInputstream = null;
        try {
            mergeFileInputstream = new FileInputStream(mergeFile);
            //得到文件的md5
            String mergeFileMd5 = DigestUtils.md5Hex(mergeFileInputstream);
            //比较md5
            if (md5.equalsIgnoreCase(mergeFileMd5)) {
                return true;
            }
        } catch (Exception e) {
            e.printStackTrace();
            LOGGER.error("checkFileMd5 error,file is:{},md5 is: {}", mergeFile.getAbsoluteFile(), md5);
        } finally {
            try {
                mergeFileInputstream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        return false;
    }

    //获取所有块文件
    private List<File> getChunkFiles(File chunkfileFolder) {
        //获取路径下的所有块文件
        File[] chunkFiles = chunkfileFolder.listFiles();
        //将文件数组转成list,并排序
        List<File> chunkFileList = new ArrayList<File>();
        chunkFileList.addAll(Arrays.asList(chunkFiles));
        //排序
        Collections.sort(chunkFileList, 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 chunkFileList;
    }

    //合并文件
    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;
    }

原文地址:https://www.cnblogs.com/artwalker/p/13578521.html