在线教育之学习中心搭建
一、搭建学习中心前端
腾讯课堂网学习中心提供学生在线学习的各各模块,上一章节测试的点播学习功能也属于学习中心的一部分,本章 节将实现学习中心点播学习的前端部分。之所以先实现前端部分,主要是因为要将video.js+vue.js集成,一部分精 力还是要放在技术研究。
4.1 界面原型
先看一下界面原型,如下图,最终的目标是在此页面使用video.js播放视频

4.2 创建学习中心工程
学习中心的用户是学生,为了便于系统维护和扩展,单独创建学习中心工程:
-
从资料目录拷贝yh-ui-pc-leanring.zip 并解压到yh-ui-pc-leanring目录。
-
使用webstorm创建打开yh-ui-pc-leanring目录
-
进入yh-ui-pc-leanring目录,执行cnpm install,将根据package.json的依赖配置远程下载依赖的 js 包。创建完成,yh-ui-pc-leanring工程如下:
工程中没有依赖库,需要cnpm install下载

4.2.1 配置域名
学习中心的二级域名为ucenter.lxw.com,我们在nginx中配置ucenter虚拟主机
#腾讯课堂网用户中心
server {
listen 80;
server_name ucenter.lxw.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;
}
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
上面的 yh-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.lxw.com/#/learning/1/2
第一个参数:courseId,课程id,这里是测试页面效果随便输入一个ID即可,这里输入1 第二个参数:chapter,课程计划id,这里是测试页面效果随便输入一个ID即可,这里输入2

二、媒资管理
前边章节完成在线视频播放,如何实现点击课程计划播放视频呢,课程视频如何管理呢? 本节开始将对课程视频进行管理。
5.1需求分析
媒资管理系统是每个在线教育平台所必须具备的,百度百科对它的定义如下

每个教学机构都可以在媒资系统管理自己的教学资源,包括:视频、教案等文件。 目前媒资管理的主要管理对象是课程录播视频,包括:媒资文件的查询、视频上传、视频删除、视频处理等。 媒资查询:教学机构查询自己所拥有的媒体文件。
视频上传:将用户线下录制的教学视频上传到媒资系统。 视频处理:视频上传成功,系统自动对视频进行编码处理。 视频删除 :如果该视频已不再使用,可以从媒资系统删除。下边是媒资系统与其它系统的交互情况

1、上传媒资文件 前端/客户端请求媒资系统上传文件。 文件上传成功将文件存储到媒资服务器,将文件信息存储到数据库。 2、使用媒资 课程管理请求媒资系统查询媒资信息,将课程计划与媒资信息对应、存储。 3、视频播放 用户进入学习中心请求学习服务学习在线播放视频。 学习服务校验用户资格通过后请求媒资系统获取视频地址
5.2 开发环境
5.2.1 创建媒资数据库
媒资mongo数据库:yh_media下建立集合media_file
-
媒资文件信息
@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;
} -
创建yh_media数据库
媒资系统使用mongodb数据库存储媒资信息
5.2.2 创建媒资服务工程
媒资管理的相关功能单独在媒资服务中开发,下边创建媒资服务工程(yh-service-manage- media)。媒资服务的配置与cms类似,导入 “资料”--》yh-service-manage-media工程,工程结构

5.3上传文件
5.3.1 断点续传解决方案
通常视频文件都比较大,所以对于媒资系统上传文件的需求要满足大文件的上传要求。http协议本身对上传文件大 小没有限制,但是客户的网络环境质量、电脑硬件环境等参差不齐,如果一个大文件快上传完了网断了,电断了没 有上传完成,需要客户重新上传,这是致命的,所以对于大文件上传的要求最基本的是断点续传。 什么是断点续传:
引用百度百科:断点续传指的是在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为的划分为几个部分,每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传下载未完成的部分,而没有必要从头开始上传下载,断点续传可以提高节省操作时间,提高用户体验性。
如下图:

上传流程如下: 1、上传前先把文件分成块 2、一块一块的上传,上传中断后重新上传,已上传的分块则不用再上传 3、各分块上传完成最后合并文件
文件下载则同理。
5.3.2 文件分块与合并
为了更好的理解文件分块上传的原理,下边用java代码测试文件的分块与合并。
5.3.2.1文件分块
文件分块的流程如下:
1、获取源文件长度 2、根据设定的分块文件的大小计算出块数 3、从源文件读数据依次向每一个块文件写数据
//测试文件分块方法
@Test
public void testChunk() throws IOException {
File sourceFile = new File("F:/develop/ffmpeg/mybatis.mp4");
String chunkPath = "F:/develop/ffmpeg/chunk/";
File chunkFolder = new File(chunkPath);
if(!chunkFolder.exists()){
chunkFolder.mkdirs();
}
//分块大小
long chunkSize = 512*1024*1;
//分块数量
long chunkNum = (long) Math.ceil(sourceFile.length() * 1.0 / chunkSize );
if(chunkNum<=0){
chunkNum = 1;
}
//缓冲区大小
byte[] b = new byte[1024];
//使用RandomAccessFile访问文件
RandomAccessFile raf_read = new RandomAccessFile(sourceFile, "r");
//分块
for(int i=0;i<chunkNum;i++){
//创建分块文件
File file = new File(chunkPath+i);//F:/develop/ffmpeg/chunk/0
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.3.2.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/1.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())) {
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.3.3 前端页面
上传文件的页面内容参考:“资料”--》upload.vue文件
5.3.3.1 WebUploader介绍
如何在web页面实现断点续传?
常见的方案有: 1、通过Flash上传,比如SWFupload、Uploadify。
2、安装浏览器插件,变相的pc客户端,用的比较少。 3、Html5
随着 html5 的流行,本项目采用 Html5 完成文件分块上传。
本项目使用WebUploader完成大文件上传功能的开发,WebUploader官网地址: http://fex.baidu.com/webuploader/

特性如下

使用 WebUploader 上传流程如下

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

本项目使用如下钩子方法:
-
before-send-file 在开始对文件分块儿之前调用,可以做一些上传文件前的准备工作,比如检查文件目录是否创建完成等。
-
before-send 在上传文件分块之前调用此方法,可以请求服务端检查分块是否存在,如果已存在则此分块儿不再上传。
-
after-send-file 在所有分块上传完成后触发,可以请求服务端合并分块文件。
注册钩子方法源代码
WebUploader.Uploader.register({
"before‐send‐file":"beforeSendFile",
"before‐send":"beforeSend",
"after‐send‐file":"afterSendFile"
}
5.3.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.3.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.3.3.4 before-send
上传分块前前端请求服务端校验分块是否存在。 参考源代码如下
type:"POST",
url:"/api/media/upload/checkchunk",
data:{
// 文件唯一表示
fileMd5:this.fileMd5,
// 当前分块下标
chunk:block.chunk,
// 当前分块大小
chunkSize:block.end‐block.start
}
5.3.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.3.3.6 页面效果

5.3.4 Api 接口
定义文件上传的Api接口,此接收是前端WebUploader调用服务端的接口。 编写此接口需要参数前端WebUploader应用代码
@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配置上传文件的路径
yh‐service‐manage‐media:
upload‐location: F:/develop/video/
2、媒资文件管理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("${yh‐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 = 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;
}
5.3.5.6 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);
}
}
- 点赞
- 收藏
- 关注作者
评论(0)