在線學習需求分析
學成在線作為在線教育網站,提供多種學習形式,包括:錄播、直播、圖文、社群等,學生登錄進入學習中心即可
在線學習,本章節將開發錄播課程的在線學習功能,需求如下:
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); } }