学成在线(第13天)在线学习 HLS
在线学习需求分析
学成在线作为在线教育网站,提供多种学习形式,包括:录播、直播、图文、社群等,学生登录进入学习中心即可
在线学习,本章节将开发录播课程的在线学习功能,需求如下:
1、学生可以在windows浏览器上在线观看视频。
2、播放器具有快进、快退、暂停等基本功能。
3、学生可以方便切换章节进行学习。
流媒体
流媒体就是将视频文件分成许多小块儿,将这些小块儿作为数据包通过网络发送出去,实现一边传输视
频 数据 包一边观看视频。
流式传输
在网络上传输音、视频信息有两个方式:下载和流式传输。
下载:就是把音、视频文件完全下载到本机后开始播放,它的特点是必须等到视频文件下载完成方可播放,
播放等待时间较长,无法去播放还未下载的部分视频。
流式传输:就是客户端通过链接视频服务器实时传输音、视频信息,实现“边下载边播放”。
流式传输包括如下两种方式:
1) 顺序流式传输
即顺序下载音、视频文件,可以实现边下载边播放,不过,用户只能观看已下载的视频内容,无法快进到未
下载的视频部分,顺序流式传输可以使用Http服务器来实现,比如Nginx、Apache等。
2)实时流式传输
实时流式传输可以解决顺序流式传输无法快进的问题,它与Http流式传输不同,它必须使用流媒体服务器并
且使用流媒体协议来传输视频,它比Http流式传输复杂。常见的实时流式传输协议有RTSP、RTMP、RSVP
等。
流媒体系统的概要结构
通过流媒体系统的概要结构学习流媒体系统的基本业务流程。
1、将原始的视频文件通过编码器转换为适合网络传输的流格式,编码后的视频直接输送给媒体服务器。
原始的视频文件通常是事先录制好的视频,比如通过摄像机、摄像头等录像、录音设备采集到的音视频文
件,体积较大,要想在网络上传输需要经过压缩处理,即通过编码器进行编码 。
2、媒体服务获取到编码好的视频文件,对外提供流媒体数据传输接口,接口协议包括 :HTTP、RTSP、
RTMP等 。
3、播放器通过流媒体协议与媒体服务器通信,获取视频数据,播放视频。
HLS是什么?
HLS的工作方式是:将视频拆分成若干ts格式的小文件,通过m3u8格式的索引文件对这些ts小文件建立索引。一般
10秒一个ts文件,播放器连接m3u8文件播放,当快进时通过m3u8即可找到对应的索引文件,并去下载对应的ts文
件,从而实现快进、快退以近实时 的方式播放视频。
IOS、Android设备、及各大浏览器都支持HLS协议。
采用 HLS方案即可实现边下载边播放,并可不用使用rtmp等流媒体协议,不用构建专用的媒体服务器,节省成本。
本项目点播方案确定为方案3。
FFmpeg 的基本使用
我们将视频录制完成后,使用视频编码软件对视频进行编码,本项目 使用FFmpeg对视频进行编码 。
下载 :ffmpeg-20180227-fa0c9d6-win64-static.zip,并解压,本教程将ffmpeg解压到了
F:\devenv\edusoft\ffmpeg-20180227-fa0c9d6-win64-static\ffmpeg-20180227-fa0c9d6-win64-static下。
将F:\devenv\edusoft\ffmpeg-20180227-fa0c9d6-win64-static\ffmpeg-20180227-fa0c9d6-win64-static\bin目
录配置在path环境变量中。
检测是否安装成功:
生成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
第二步:将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的索引
文件。
播放器
视频编码后要使用播放器对其进行解码、播放视频内容。在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万网站使用。
Nginx媒体服务器
HLS协议基于Http协议,本项目使用Nginx作为视频服务器。下图是Nginx媒体服务器的配置流程图:
1.用户打开www.xuecheng.com上边的 video.html网页
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; } }
video_server_pool的配置如下:
#媒体服务 upstream video_server_pool{ server 127.0.0.1:90 weight=10; }
测试video.js
1、编写测试页面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/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、测试
配置hosts文件,本教程开发环境使用Window10,修改C:\Windows\System32\drivers\etc\hosts文件
127.0.0.1 video.xuecheng.com
搭建学习中心前端
学成网学习中心提供学生在线学习的各各模块,上一章节测试的点播学习功能也属于学习中心的一部分,本章节将
实现学习中心点播学习的前端部分。之所以先实现前端部分,主要是因为要将video.js+vue.js集成,一部分精力还
是要放在技术研究。
先看一下界面原型,如下图,最终的目标是在此页面使用video.js播放视频。
配置域名
学习中心的二级域名为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;
}
调试视频播放页面
使用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
媒资管理
每个教学机构都可以在媒资系统管理自己的教学资源,包括:视频、教案等文件。
目前媒资管理的主要管理对象是课程录播视频,包括:媒资文件的查询、视频上传、视频删除、视频处理等。
媒资查询:教学机构查询自己所拥有的媒体文件。
视频上传:将用户线下录制的教学视频上传到媒资系统。
视频处理:视频上传成功,系统自动对视频进行编码处理。
视频删除 :如果该视频已不再使用,可以从媒资系统删除。
下边是媒资系统与其它系统的交互情况:
1、上传媒资文件
前端/客户端请求媒资系统上传文件。
文件上传成功将文件存储到媒资服务器,将文件信息存储到数据库。
2、使用媒资
课程管理请求媒资系统查询媒资信息,将课程计划与媒资信息对应、存储。
3、视频播放
用户进入学习中心请求学习服务学习在线播放视频。
学习服务校验用户资格通过后请求媒资系统获取视频地址。
业务流程
服务端需要实现如下功能:
1、上传前检查上传环境
检查文件是否上传,已上传则直接返回。
检查文件上传路径是否存在,不存在则创建。
2、分块检查
检查分块文件是否上传,已上传则返回true。
未上传则检查上传路径是否存在,不存在则创建。
3、分块上传
将分块文件上传到指定的路径。
4、合并分块
将所有分块文件合并为一个文件。
在数据库记录文件信息。
上传注册
1、配置
application.yml配置上传文件的路径:
xc‐service‐manage‐media:
upload‐location: F:/develop/video/
2、定义Dao
媒资文件管理Dao
public interface MediaFileRepository extends MongoRepository<MediaFile,String> { }
3、Service
功能:
1)检查上传文件是否存在
2)创建文件目录
@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); } }
分块检查
在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); } }
上传分块
在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; }
合并分块
在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 = mediaFileDao.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; }
Controller
@RestController @RequestMapping("/media/upload") public class MediaUploadController implements MediaUploadControllerApi { @Autowired MediaUploadService mediaUploadService; @Override @PostMapping("/register") public ResponseResult register(@RequestParam("fileMd5") String fileMd5, @RequestParam("fileName") String fileName, @RequestParam("fileSize") Long fileSize, @RequestParam("mimetype") String mimetype, @RequestParam("fileExt") String fileExt) { return mediaUploadService.register(fileMd5,fileName,fileSize,mimetype,fileExt); } @Override @PostMapping("/checkchunk") public CheckChunkResult checkchunk(@RequestParam("fileMd5") String fileMd5,@RequestParam("chunk") Integer chunk, @RequestParam("chunkSize") Integer chunkSize) { return mediaUploadService.checkchunk(fileMd5,chunk,chunkSize); } @Override @PostMapping("/uploadchunk") public ResponseResult uploadchunk(@RequestParam("file") MultipartFile file, @RequestParam("fileMd5") String fileMd5, @RequestParam("chunk") Integer chunk) { return mediaUploadService.uploadchunk(file,fileMd5,chunk); } @Override @PostMapping("/mergechunks") public ResponseResult mergechunks(@RequestParam("fileMd5") String fileMd5, @RequestParam("fileName") String fileName, @RequestParam("fileSize") Long fileSize, @RequestParam("mimetype") String mimetype, @RequestParam("fileExt") String fileExt) { return mediaUploadService.mergechunks(fileMd5,fileName,fileSize,mimetype,fileExt); } }