學成在線做爲在線教育網站,提供多種學習形式,包括:錄播、直播、圖文、社羣等,學生登陸進入學習中心便可
在線學習,本章節將開發錄播課程的在線學習功能,需求以下:
一、學生能夠在windows瀏覽器上在線觀看視頻。
二、播放器具備快進、快退、暫停等基本功能。
三、學生能夠方便切換章節進行學習。css
流媒體就是將視頻文件分紅許多小塊兒,將這些小塊兒做爲數據包經過網絡發送出去,實現一邊傳輸視
頻 數據 包一邊觀看視頻。html
流式傳輸
在網絡上傳輸音、視頻信息有兩個方式:下載和流式傳輸。
下載:就是把音、視頻文件徹底下載到本機後開始播放,它的特色是必須等到視頻文件下載完成方可播放,
播放等待時間較長,沒法去播放還未下載的部分視頻。
流式傳輸:就是客戶端經過連接視頻服務器實時傳輸音、視頻信息,實現「邊下載邊播放」。
流式傳輸包括以下兩種方式:
1) 順序流式傳輸
即順序下載音、視頻文件,能夠實現邊下載邊播放,不過,用戶只能觀看已下載的視頻內容,沒法快進到未
下載的視頻部分,順序流式傳輸可使用Http服務器來實現,好比Nginx、Apache等。
2)實時流式傳輸
實時流式傳輸能夠解決順序流式傳輸沒法快進的問題,它與Http流式傳輸不一樣,它必須使用流媒體服務器並
且使用流媒體協議來傳輸視頻,它比Http流式傳輸複雜。常見的實時流式傳輸協議有RTSP、RTMP、RSVP
等。前端
流媒體系統的概要結構
經過流媒體系統的概要結構學習流媒體系統的基本業務流程。vue
一、將原始的視頻文件經過編碼器轉換爲適合網絡傳輸的流格式,編碼後的視頻直接輸送給媒體服務器。
原始的視頻文件一般是事先錄製好的視頻,好比經過攝像機、攝像頭等錄像、錄音設備採集到的音視頻文
件,體積較大,要想在網絡上傳輸須要通過壓縮處理,即經過編碼器進行編碼 。
二、媒體服務獲取到編碼好的視頻文件,對外提供流媒體數據傳輸接口,接口協議包括 :HTTP、RTSP、
RTMP等 。
三、播放器經過流媒體協議與媒體服務器通訊,獲取視頻數據,播放視頻。nginx
HLS的工做方式是:將視頻拆分紅若干ts格式的小文件,經過m3u8格式的索引文件對這些ts小文件創建索引。通常
10秒一個ts文件,播放器鏈接m3u8文件播放,當快進時經過m3u8便可找到對應的索引文件,並去下載對應的ts文
件,從而實現快進、快退以近實時 的方式播放視頻。
IOS、Android設備、及各大瀏覽器都支持HLS協議。git
採用 HLS方案便可實現邊下載邊播放,並可不用使用rtmp等流媒體協議,不用構建專用的媒體服務器,節省成本。
本項目點播方案肯定爲方案3。github
咱們將視頻錄製完成後,使用視頻編碼軟件對視頻進行編碼,本項目 使用FFmpeg對視頻進行編碼 。web
下載 :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文件windows
使用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萬網站使用。
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.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>
二、測試
配置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
每一個教學機構均可以在媒資系統管理本身的教學資源,包括:視頻、教案等文件。
目前媒資管理的主要管理對象是課程錄播視頻,包括:媒資文件的查詢、視頻上傳、視頻刪除、視頻處理等。
媒資查詢:教學機構查詢本身所擁有的媒體文件。
視頻上傳:將用戶線下錄製的教學視頻上傳到媒資系統。
視頻處理:視頻上傳成功,系統自動對視頻進行編碼處理。
視頻刪除 :若是該視頻已再也不使用,能夠從媒資系統刪除。
下邊是媒資系統與其它系統的交互狀況:
一、上傳媒資文件
前端/客戶端請求媒資系統上傳文件。
文件上傳成功將文件存儲到媒資服務器,將文件信息存儲到數據庫。
二、使用媒資
課程管理請求媒資系統查詢媒資信息,將課程計劃與媒資信息對應、存儲。
三、視頻播放
用戶進入學習中心請求學習服務學習在線播放視頻。
學習服務校驗用戶資格經過後請求媒資系統獲取視頻地址。
服務端須要實現以下功能:
一、上傳前檢查上傳環境
檢查文件是否上傳,已上傳則直接返回。
檢查文件上傳路徑是否存在,不存在則建立。
二、分塊檢查
檢查分塊文件是否上傳,已上傳則返回true。
未上傳則檢查上傳路徑是否存在,不存在則建立。
三、分塊上傳
將分塊文件上傳到指定的路徑。
四、合併分塊
將全部分塊文件合併爲一個文件。
在數據庫記錄文件信息。
一、配置
application.yml配置上傳文件的路徑:
xc‐service‐manage‐media:
upload‐location: F:/develop/video/
二、定義Dao
媒資文件管理Dao
public interface MediaFileRepository extends MongoRepository<MediaFile,String> { }
三、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) { //檢查文件是否上傳 //一、獲得文件的路徑 String filePath = getFilePath(fileMd5, fileExt); File file = new File(filePath); //二、查詢數據庫文件是否存在 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; }
@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); } }