目录
一、媒资需求分析
二、搭建Nacos
三、分布式文件系统介绍
四、上传图片
1.需求分析 ,上传课程图片总体上包括两部分:
2.环境准备
3.接口定义
4.数据模型开发
5.业务层开发
五、上传视频
1.文件分块的流程如下:
2.文件合并流程:
3.测试文件分块、合并的测试代码
4.视频上传流程
5.接口定义,分别定义了:
6.业务层接口定义
7.实现业务层service方法
8. 分块文件清理问题
六、视频处理
1.需求分析
2.执行流程
3.环境搭建
4.视频处理方案
5.添加待处理任务
6.查询待处理任务
7.开始执行任务
8.更新任务状态
9.视频处理
10.启动任务调度
11.其它问题。
七、绑定媒资
1.业务流程分析
2.接口定义
3.在内容管理模块定义请求参数模型类型:
4.在TeachplanService中定义绑定接口
5.实现Service接口
目前媒资管理的主要管理对象是视频、图片、文档等,包括:媒资文件的查询、文件上传、视频处理等。
媒资查询:教学机构查询自己所拥有的媒资信息。
文件上传:包括上传图片、上传文档、上传视频。
视频处理:视频上传成功,系统自动对视频进行编码处理。
文件删除:教学机构删除自己上传的媒资文件。
略(在后续的网关工程会详细介绍)
简单理解为:一个计算机无法存储海量的文件,通过网络将若干计算机组织起来共同去存储海量的文件,去接收海量用户的请求,这些组织起来的计算机通过网络进行通信。
本项目采用MinIO构建分布式文件系统,MinIO 是一个非常轻量的服务,可以很简单的和其他应用的结合使用,MinIO集群采用去中心化共享架构,每个结点是对等关系,通过Nginx可对MinIO进行负载均衡访问。
1.1、上传课程图片前端请求媒资管理服务将文件上传至分布式文件系统,并且在媒资管理数据库保存文件信息。
1.2、上传图片成功保存图片地址到课程基本信息表中。
2.1、首先在minio配置bucket,bucket名称为:mediafiles,并设置bucket的权限为公开。
2.2、在nacos配置中minio的相关信息,进入media-service-dev.yaml:
minio:
endpoint: http://192.168.101.65:9000
accessKey: minioadmin
secretKey: minioadmin
bucket:
files: mediafiles
videofiles: video
2.3、在media-service工程编写minio的配置类:
package com.xuecheng.media.config;
import io.minio.MinioClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MinioConfig {
@Value("${minio.endpoint}")
private String endpoint;
@Value("${minio.accessKey}")
private String accessKey;
@Value("${minio.secretKey}")
private String secretKey;
@Bean
public MinioClient minioClient() {
MinioClient minioClient =
MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
return minioClient;
}
}
package com.xuecheng.media.api;
import com.xuecheng.base.model.PageParams;
import com.xuecheng.base.model.PageResult;
import com.xuecheng.media.model.dto.QueryMediaParamsDto;
import com.xuecheng.media.model.dto.UploadFileParamsDto;
import com.xuecheng.media.model.dto.UploadFileResultDto;
import com.xuecheng.media.model.po.MediaFiles;
import com.xuecheng.media.service.MediaFileService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
@Api(value = "媒资文件管理接口", tags = "媒资文件管理接口")
@RestController
public class MediaFilesController {
@Autowired
MediaFileService mediaFileService;
@ApiOperation("上传图片")
@RequestMapping(value = "/upload/coursefile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public UploadFileResultDto upload(@RequestPart("filedata") MultipartFile filedata,
@RequestParam(value = "objectName", required = false) String objectName) throws IOException {
//准备上传文件的信息
UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();
//原始文件名称
uploadFileParamsDto.setFilename(filedata.getOriginalFilename());
//文件大小
uploadFileParamsDto.setFileSize(filedata.getSize());
//文件类型
uploadFileParamsDto.setFileType("001001");
//创建一个临时文件
File tempFile = File.createTempFile("minio", ".temp");
filedata.transferTo(tempFile);
Long companyId = 1232141425L;
//文件路径
String localFilePath = tempFile.getAbsolutePath();
//调用service上传图片
UploadFileResultDto uploadFileResultDto = mediaFileService.uploadFile(companyId, uploadFileParamsDto, localFilePath, objectName);
return uploadFileResultDto;
}
}
4.1、定义文件上传的响应模型类
package com.xuecheng.media.model.dto;
import com.xuecheng.media.model.po.MediaFiles;
import lombok.Data;
import lombok.ToString;
@Data
@ToString
public class UploadFileResultDto extends MediaFiles {
}
4.2、定义文件上传的接收数据模型类
package com.xuecheng.media.model.dto;
import lombok.Data;
import lombok.ToString;
@Data
@ToString
public class UploadFileParamsDto {
/**
* 文件名称
*/
private String filename;
/**
* 文件类型(文档,音频,视频)
*/
private String fileType;
/**
* 文件大小
*/
private Long fileSize;
/**
* 标签
*/
private String tags;
/**
* 上传人
*/
private String username;
/**
* 备注
*/
private String remark;
}
5.1业务层的接口开发
package com.xuecheng.media.service;
import com.xuecheng.base.model.PageParams;
import com.xuecheng.base.model.PageResult;
import com.xuecheng.base.model.RestResponse;
import com.xuecheng.media.model.dto.QueryMediaParamsDto;
import com.xuecheng.media.model.dto.UploadFileParamsDto;
import com.xuecheng.media.model.dto.UploadFileResultDto;
import com.xuecheng.media.model.po.MediaFiles;
import io.minio.UploadObjectArgs;
import org.springframework.web.bind.annotation.RequestBody;
import java.io.File;
import java.util.List;
public interface MediaFileService {
//根据媒资id查询文件信息
MediaFiles getFileById(String mediaId);
/**
* 上传文件
* @param companyId 机构id
* @param uploadFileParamsDto 文件信息
* @param localFilePath 文件本地路径
* @param objectname 如果传入objectname要按objectname的目录去存储,如果不传就按年月日目录结构去存储
* @return UploadFileResultDto
*/
public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, String localFilePath,String objectname);
}
5.2、实现业务层的service接口
package com.xuecheng.media.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.j256.simplemagic.ContentInfo;
import com.j256.simplemagic.ContentInfoUtil;
import com.xuecheng.base.exception.XueChengPlusException;
import com.xuecheng.base.model.PageParams;
import com.xuecheng.base.model.PageResult;
import com.xuecheng.base.model.RestResponse;
import com.xuecheng.media.mapper.MediaFilesMapper;
import com.xuecheng.media.mapper.MediaProcessMapper;
import com.xuecheng.media.model.dto.QueryMediaParamsDto;
import com.xuecheng.media.model.dto.UploadFileParamsDto;
import com.xuecheng.media.model.dto.UploadFileResultDto;
import com.xuecheng.media.model.po.MediaFiles;
import com.xuecheng.media.model.po.MediaProcess;
import com.xuecheng.media.service.MediaFileService;
import io.minio.*;
import io.minio.errors.*;
import io.minio.messages.DeleteError;
import io.minio.messages.DeleteObject;
import io.minio.messages.DeletedObject;
import io.minio.messages.Item;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.*;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Slf4j
@Service
public class MediaFileServiceImpl implements MediaFileService {
@Autowired
MediaFilesMapper mediaFilesMapper;
@Autowired
MinioClient minioClient;
@Autowired
MediaFileService currentProxy;
@Autowired
MediaProcessMapper mediaProcessMapper;
//存储普通文件
@Value("${minio.bucket.files}")
private String bucket_mediafiles;
//存储视频
@Value("${minio.bucket.videofiles}")
private String bucket_video;
@Override
public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, String localFilePath,String objectName) {
//文件名
String filename = uploadFileParamsDto.getFilename();
//先得到扩展名
String extension = filename.substring(filename.lastIndexOf("."));
//得到mimeType
String mimeType = getMimeType(extension);
//子目录
String defaultFolderPath = getDefaultFolderPath();
//文件的md5值
String fileMd5 = getFileMd5(new File(localFilePath));
if(StringUtils.isEmpty(objectName)){
//使用默认年月日去存储
objectName = defaultFolderPath+fileMd5+extension;
}
//上传文件到minio
boolean result = addMediaFilesToMinIO(localFilePath, mimeType, bucket_mediafiles, objectName);
if(!result){
XueChengPlusException.cast("上传文件失败");
}
//入库文件信息
MediaFiles mediaFiles = currentProxy.addMediaFilesToDb(companyId, fileMd5, uploadFileParamsDto, bucket_mediafiles, objectName);
if(mediaFiles==null){
XueChengPlusException.cast("文件上传后保存信息失败");
}
//准备返回的对象
UploadFileResultDto uploadFileResultDto = new UploadFileResultDto();
BeanUtils.copyProperties(mediaFiles,uploadFileResultDto);
return uploadFileResultDto;
}
}
//根据扩展名获取mimeType
//根据扩展名获取mimeType
private String getMimeType(String extension){
if(extension == null){
extension = "";
}
//根据扩展名取出mimeType
ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(extension);
String mimeType = MediaType.APPLICATION_OCTET_STREAM_VALUE;//通用mimeType,字节流
if(extensionMatch!=null){
mimeType = extensionMatch.getMimeType();
}
return mimeType;
}
//将文件上传到MINIO
/**
* 将文件上传到minio
* @param localFilePath 文件本地路径
* @param mimeType 媒体类型
* @param bucket 桶
* @param objectName 对象名
* @return
*/
public boolean addMediaFilesToMinIO(String localFilePath,String mimeType,String bucket, String objectName){
try {
UploadObjectArgs uploadObjectArgs = UploadObjectArgs.builder()
.bucket(bucket)//桶
.filename(localFilePath) //指定本地文件路径
.object(objectName)//对象名 放在子目录下
.contentType(mimeType)//设置媒体文件类型
.build();
//上传文件
minioClient.uploadObject(uploadObjectArgs);
log.debug("上传文件到minio成功,bucket:{},objectName:{},错误信息:{}",bucket,objectName);
return true;
} catch (Exception e) {
e.printStackTrace();
log.error("上传文件出错,bucket:{},objectName:{},错误信息:{}",bucket,objectName,e.getMessage());
}
return false;
}
//将文件信息添加到文件表
/**
* @description 将文件信息添加到文件表
* @param companyId 机构id
* @param fileMd5 文件md5值
* @param uploadFileParamsDto 上传文件的信息
* @param bucket 桶
* @param objectName 对象名称
*/
@Transactional
public MediaFiles addMediaFilesToDb(Long companyId,String fileMd5,UploadFileParamsDto uploadFileParamsDto,String bucket,String objectName){
//将文件信息保存到数据库
MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
if(mediaFiles == null){
mediaFiles = new MediaFiles();
BeanUtils.copyProperties(uploadFileParamsDto,mediaFiles);
//文件id
mediaFiles.setId(fileMd5);
//机构id
mediaFiles.setCompanyId(companyId);
//桶
mediaFiles.setBucket(bucket);
//file_path
mediaFiles.setFilePath(objectName);
//file_id
mediaFiles.setFileId(fileMd5);
//url
mediaFiles.setUrl("/"+bucket+"/"+objectName);
//上传时间
mediaFiles.setCreateDate(LocalDateTime.now());
//状态
mediaFiles.setStatus("1");
//审核状态
mediaFiles.setAuditStatus("002003");
//插入数据库
int insert = mediaFilesMapper.insert(mediaFiles);
if(insert<=0){
log.debug("向数据库保存文件失败,bucket:{},objectName:{}",bucket,objectName);
return null;
}
//记录待处理任务
addWaitingTask(mediaFiles);
return mediaFiles;
}
return mediaFiles;
}
注意:这里的事务控制要使用代理对象调用addMediaFilesToDb方法,且方法上添加@Transactional注解。这里的记录待处理任务可以暂时注释,后面运用任务调度是才使用。
通常视频文件都比较大,所以对于媒资系统上传文件的需求要满足大文件的上传要求,这里我们用到断点续传的技术来上传视频。
1.1、获取源文件长度。
1.2、根据设定的分块文件的大小计算出块数。
1.3、从源文件读数据依次向每一个块文件写数据。
2.1、找到要合并的文件并按文件合并的先后进行排序。
2.2、创建合并文件。
2.3、依次从合并的文件中读取数据向合并文件写入数。
package com.xuecheng.media;
import javafx.print.Collation;
import org.apache.commons.codec.digest.DigestUtils;
import org.junit.jupiter.api.Test;
import java.io.*;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
public class BigFileTest {
//分块测试
@Test
public void testChunk() throws IOException {
//源文件
File sourceFile = new File("D:\\develop\\upload\\1.项目背景.mp4");
//分块文件存储路径
String chunkFilePath = "D:\\develop\\upload\\chunk\\";
//分块文件大小
int chunkSize = 1024 * 1024 * 5;
//分块文件个数
int chunkNum = (int) Math.ceil(sourceFile.length() * 1.0 / chunkSize);
//使用流从源文件读数据,向分块文件中写数据
RandomAccessFile raf_r = new RandomAccessFile(sourceFile, "r");
//缓存区
byte[] bytes = new byte[1024];
for (int i = 0; i < chunkNum; i++) {
File chunkFile = new File(chunkFilePath + i);
//分块文件写入流
RandomAccessFile raf_rw = new RandomAccessFile(chunkFile, "rw");
int len = -1;
while ((len=raf_r.read(bytes))!=-1){
raf_rw.write(bytes,0,len);
if(chunkFile.length()>=chunkSize){
break;
}
}
raf_rw.close();
}
raf_r.close();
}
//将分块进行合并
@Test
public void testMerge() throws IOException {
//块文件目录
File chunkFolder = new File("D:\\develop\\upload\\chunk");
//源文件
File sourceFile = new File("D:\\develop\\upload\\1.项目背景.mp4");
//合并后的文件
File mergeFile = new File("D:\\develop\\upload\\1.项目背景_2.mp4");
//取出所有分块文件
File[] files = chunkFolder.listFiles();
//将数组转成list
List filesList = Arrays.asList(files);
//对分块文件排序
Collections.sort(filesList, new Comparator() {
@Override
public int compare(File o1, File o2) {
return Integer.parseInt(o1.getName())-Integer.parseInt(o2.getName());
}
});
//向合并文件写的流
RandomAccessFile raf_rw = new RandomAccessFile(mergeFile, "rw");
//缓存区
byte[] bytes = new byte[1024];
//遍历分块文件,向合并 的文件写
for (File file : filesList) {
//读分块的流
RandomAccessFile raf_r = new RandomAccessFile(file, "r");
int len = -1;
while ((len=raf_r.read(bytes))!=-1){
raf_rw.write(bytes,0,len);
}
raf_r.close();
}
raf_rw.close();
//合并文件完成后对合并的文件md5校验
FileInputStream fileInputStream_merge = new FileInputStream(mergeFile);
FileInputStream fileInputStream_source = new FileInputStream(sourceFile);
String md5_merge = DigestUtils.md5Hex(fileInputStream_merge);
String md5_source = DigestUtils.md5Hex(fileInputStream_source);
if(md5_merge.equals(md5_source)){
System.out.println("文件合并成功");
}
}
}
4.1、前端对文件进行分块。
4.2、前端上传分块文件前请求媒资服务检查文件是否存在,如果已经存在则不再上传。
4.3、如果分块文件不存在则前端开始上传
4.4、前端请求媒资服务上传分块。
4.5、媒资服务将分块上传至MinIO。
4.6、前端将分块上传完毕请求媒资服务合并分块。
4.7、媒资服务判断分块上传完成则请求MinIO合并文件。
4.8、合并完成校验合并后的文件是否完整,如果不完整则删除文件。
文件上传前检查文件 checkfile
分块文件上传前的检测 checkchunk
上传分块文件 uploadchunk
合并文件 mergechunks
package com.xuecheng.media.api;
import com.xuecheng.base.model.RestResponse;
import com.xuecheng.media.model.dto.UploadFileParamsDto;
import com.xuecheng.media.service.MediaFileService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
@Api(value = "大文件上传接口", tags = "大文件上传接口")
@RestController
public class BigFilesController {
@Autowired
MediaFileService mediaFileService;
@ApiOperation(value = "文件上传前检查文件")
@PostMapping("/upload/checkfile")
public RestResponse checkfile(
@RequestParam("fileMd5") String fileMd5
) throws Exception {
RestResponse booleanRestResponse = mediaFileService.checkFile(fileMd5);
return booleanRestResponse;
}
@ApiOperation(value = "分块文件上传前的检测")
@PostMapping("/upload/checkchunk")
public RestResponse checkchunk(@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunk") int chunk) throws Exception {
RestResponse booleanRestResponse = mediaFileService.checkChunk(fileMd5,chunk);
return booleanRestResponse;
}
@ApiOperation(value = "上传分块文件")
@PostMapping("/upload/uploadchunk")
public RestResponse uploadchunk(@RequestParam("file") MultipartFile file,
@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunk") int chunk) throws Exception {
//创建一个临时文件
File tempFile = File.createTempFile("minio", ".temp");
file.transferTo(tempFile);
//文件路径
String localFilePath = tempFile.getAbsolutePath();
RestResponse restResponse = mediaFileService.uploadChunk(fileMd5, chunk, localFilePath);
return restResponse;
}
@ApiOperation(value = "合并文件")
@PostMapping("/upload/mergechunks")
public RestResponse mergechunks(@RequestParam("fileMd5") String fileMd5,
@RequestParam("fileName") String fileName,
@RequestParam("chunkTotal") int chunkTotal) throws Exception {
Long companyId = 1232141425L;
//文件信息对象
UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();
uploadFileParamsDto.setFilename(fileName);
uploadFileParamsDto.setTags("视频文件");
uploadFileParamsDto.setFileType("001002");
RestResponse restResponse = mediaFileService.mergechunks(1232141425L, fileMd5, chunkTotal, uploadFileParamsDto);
return restResponse;
}
}
在MediaFileService接口中添加以下方法
/**
* @param fileMd5 文件的md5
* @return com.xuecheng.base.model.RestResponse false不存在,true存在
* @description 检查文件是否存在
*/
public RestResponse checkFile(String fileMd5);
/**
* @param fileMd5 文件的md5
* @param chunkIndex 分块序号
* @return com.xuecheng.base.model.RestResponse false不存在,true存在
* @description 检查分块是否存在
*/
public RestResponse checkChunk(String fileMd5, int chunkIndex);
/**
* @param fileMd5 文件md5
* @param chunk 分块序号
* @param localChunkFilePath 分块文件本地路径
* @return com.xuecheng.base.model.RestResponse
* @description 上传分块
*/
public RestResponse uploadChunk(String fileMd5, int chunk, String localChunkFilePath);
/**
* @param companyId 机构id
* @param fileMd5 文件md5
* @param chunkTotal 分块总和
* @param uploadFileParamsDto 文件信息
* @return com.xuecheng.base.model.RestResponse
* @description 合并分块
*/
public RestResponse mergechunks(Long companyId, String fileMd5, int chunkTotal, UploadFileParamsDto uploadFileParamsDto);
7.1、文件上传前检查文件 checkfile,在文件表存在,并且在文件系统存在,此文件才存在
@Override
public RestResponse checkFile(String fileMd5) {
//先查询数据库
MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
if(mediaFiles!=null){
//桶
String bucket = mediaFiles.getBucket();
//objectname
String filePath = mediaFiles.getFilePath();
//如果数据库存在再查询 minio
GetObjectArgs getObjectArgs = GetObjectArgs.builder()
.bucket(bucket)
.object(filePath)
.build();
//查询远程服务获取到一个流对象
try {
FilterInputStream inputStream = minioClient.getObject(getObjectArgs);
if(inputStream!=null){
//文件已存在
return RestResponse.success(true);
}
} catch (Exception e) {
e.printStackTrace();
}
}
//文件不存在
return RestResponse.success(false);
}
7.2、分块文件上传前的检测 checkchunk
@Override
public RestResponse checkChunk(String fileMd5, int chunkIndex) {
//根据md5得到分块文件所在目录的路径
String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
//如果数据库存在再查询 minio
GetObjectArgs getObjectArgs = GetObjectArgs.builder()
.bucket(bucket_video)
.object(chunkFileFolderPath+chunkIndex)
.build();
//查询远程服务获取到一个流对象
try {
FilterInputStream inputStream = minioClient.getObject(getObjectArgs);
if(inputStream!=null){
//文件已存在
return RestResponse.success(true);
}
} catch (Exception e) {
e.printStackTrace();
}
//文件不存在
return RestResponse.success(false);
}
//得到分块文件的目录
private String getChunkFileFolderPath(String fileMd5) {
return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + "chunk" + "/";
}
7.3、上传分块文件 uploadchunk
@Override
public RestResponse uploadChunk(String fileMd5, int chunk, String localChunkFilePath) {
//分块文件的路径
String chunkFilePath = getChunkFileFolderPath(fileMd5) + chunk;
//获取mimeType
String mimeType = getMimeType(null);
//将分块文件上传到minio
boolean b = addMediaFilesToMinIO(localChunkFilePath, mimeType, bucket_video, chunkFilePath);
if(!b){
return RestResponse.validfail(false,"上传分块文件失败");
}
//上传成功
return RestResponse.success(true);
}
7.4、合并文件 mergechunks
@Override
public RestResponse mergechunks(Long companyId, String fileMd5, int chunkTotal, UploadFileParamsDto uploadFileParamsDto) {
//分块文件所在目录
String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
//找到所有的分块文件
List sources = Stream.iterate(0, i -> ++i).limit(chunkTotal).map(i -> ComposeSource.builder().bucket(bucket_video).object(chunkFileFolderPath + i).build()).collect(Collectors.toList());
//源文件名称
String filename = uploadFileParamsDto.getFilename();
//扩展名
String extension = filename.substring(filename.lastIndexOf("."));
//合并后文件的objectname
String objectName = getFilePathByMd5(fileMd5, extension);
//指定合并后的objectName等信息
ComposeObjectArgs composeObjectArgs = ComposeObjectArgs.builder()
.bucket(bucket_video)
.object(objectName)//合并后的文件的objectname
.sources(sources)//指定源文件
.build();
//===========合并文件============
//报错size 1048576 must be greater than 5242880,minio默认的分块文件大小为5M
try {
minioClient.composeObject(composeObjectArgs);
} catch (Exception e) {
e.printStackTrace();
log.error("合并文件出错,bucket:{},objectName:{},错误信息:{}",bucket_video,objectName,e.getMessage());
return RestResponse.validfail(false,"合并文件异常");
}
//===========校验合并后的和源文件是否一致,视频上传才成功===========
//先下载合并后的文件
File file = downloadFileFromMinIO(bucket_video, objectName);
try(FileInputStream fileInputStream = new FileInputStream(file)){
//计算合并后文件的md5
String mergeFile_md5 = DigestUtils.md5Hex(fileInputStream);
//比较原始md5和合并后文件的md5
if(!fileMd5.equals(mergeFile_md5)){
log.error("校验合并文件md5值不一致,原始文件:{},合并文件:{}",fileMd5,mergeFile_md5);
return RestResponse.validfail(false,"文件校验失败");
}
//文件大小
uploadFileParamsDto.setFileSize(file.length());
}catch (Exception e) {
return RestResponse.validfail(false,"文件校验失败");
}
//==============将文件信息入库============
MediaFiles mediaFiles = currentProxy.addMediaFilesToDb(companyId, fileMd5, uploadFileParamsDto, bucket_video, objectName);
if(mediaFiles == null){
return RestResponse.validfail(false,"文件入库失败");
}
//==========清理分块文件=========
clearChunkFiles(chunkFileFolderPath,chunkTotal);
return RestResponse.success(true);
}
/**
* 清除分块文件
* @param chunkFileFolderPath 分块文件路径
* @param chunkTotal 分块文件总数
*/
private void clearChunkFiles(String chunkFileFolderPath,int chunkTotal){
Iterable objects = Stream.iterate(0, i -> ++i).limit(chunkTotal).map(i -> new DeleteObject(chunkFileFolderPath+ i)).collect(Collectors.toList());;
RemoveObjectsArgs removeObjectsArgs = RemoveObjectsArgs.builder().bucket(bucket_video).objects(objects).build();
Iterable> results = minioClient.removeObjects(removeObjectsArgs);
//要想真正删除
results.forEach(f->{
try {
DeleteError deleteError = f.get();
} catch (Exception e) {
e.printStackTrace();
}
});
}
/**
* 从minio下载文件
* @param bucket 桶
* @param objectName 对象名称
* @return 下载后的文件
*/
public File downloadFileFromMinIO(String bucket,String objectName){
//临时文件
File minioFile = null;
FileOutputStream outputStream = null;
try{
InputStream stream = minioClient.getObject(GetObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.build());
//创建临时文件
minioFile=File.createTempFile("minio", ".merge");
outputStream = new FileOutputStream(minioFile);
IOUtils.copy(stream,outputStream);
return minioFile;
} catch (Exception e) {
e.printStackTrace();
}finally {
if(outputStream!=null){
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return null;
}
/**
* 得到合并后的文件的地址
* @param fileMd5 文件id即md5值
* @param fileExt 文件扩展名
* @return
*/
private String getFilePathByMd5(String fileMd5,String fileExt){
return fileMd5.substring(0,1) + "/" + fileMd5.substring(1,2) + "/" + fileMd5 + "/" +fileMd5 +fileExt;
}
7.5、 注意事项:前端对文件分块的大小为5MB,SpringBoot web默认上传文件的大小限制为1MB,这里需要在media-api工程修改配置如下:
spring:
servlet:
multipart:
max-file-size: 50MB
max-request-size: 50MB
如果上传视频前端报413错误,则需求修改NGINX配置文件,在nginx.conf的http下添加:client_max_body_size 200m;
上传一个文件进行分块上传,上传一半不传了,之前上传到minio的分块文件要清理吗?怎么做的?
1、在数据库中有一张文件表记录minio中存储的文件信息。
2、文件开始上传时会写入文件表,状态为上传中,上传完成会更新状态为上传完成。
3、当一个文件传了一半不再上传了说明该文件没有上传完成,会有定时任务去查询文件表中的记录,如果文件未上传完成则删除minio中没有上传成功的文件目录。
1.1、视频上传成功需要对视频的格式进行转码处理,比如:avi转成mp4。本项目 使用FFmpeg对视频进行编码 。
1.2、由于文件量较大需要使用多线程等技术进行高效处理,这里使用XXL-JOB是一个轻量级分布式任务调度平台处理。
2.1、任务执行器根据配置的调度中心的地址,自动注册到调度中心
2.2、达到任务触发条件,调度中心下发任务
2.3、执行器基于线程池执行任务,并把执行结果放入内存队列中、把执行日志写入日志文件中
2.4、执行器消费内存队列中的执行结果,主动上报给调度中心
2.5、当用户在调度中心查看任务日志,调度中心请求任务执行器,任务执行器读取任务日志文件并返回日志详情
3.1、将料目录中的util.zip解压,将解压出的工具类拷贝至base工程。
3.2、下载XXL-JOB,或者解压xxl-job-2.3.1.zip
GitHub:GitHub - xuxueli/xxl-job: A distributed task scheduling framework.(分布式任务调度平台XXL-JOB)
码云:https://gitee.com/xuxueli0323/xxl-job
3.3、在nacos下的media-service-dev.yaml下配置xxl-job
xxl:
job:
admin:
addresses: http://192.168.101.65:8088/xxl-job-admin
executor:
appname: media-process-service
address:
ip:
port: 9999
logpath: /data/applogs/xxl-job/jobhandler
logretentiondays: 30
accessToken: default_token
3.4、媒资管理的service工程下创建配置文件
package com.xuecheng.media.config;
import com.xxl.job.core.executor.impl.XxlJobSpringExecutor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class XxlJobConfig {
private Logger logger = LoggerFactory.getLogger(XxlJobConfig.class);
@Value("${xxl.job.admin.addresses}")
private String adminAddresses;
@Value("${xxl.job.accessToken}")
private String accessToken;
@Value("${xxl.job.executor.appname}")
private String appname;
@Value("${xxl.job.executor.address}")
private String address;
@Value("${xxl.job.executor.ip}")
private String ip;
@Value("${xxl.job.executor.port}")
private int port;
@Value("${xxl.job.executor.logpath}")
private String logPath;
@Value("${xxl.job.executor.logretentiondays}")
private int logRetentionDays;
@Bean
public XxlJobSpringExecutor xxlJobExecutor() {
logger.info(">>>>>>>>>>> xxl-job config init.");
XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
xxlJobSpringExecutor.setAppname(appname);
xxlJobSpringExecutor.setAddress(address);
xxlJobSpringExecutor.setIp(ip);
xxlJobSpringExecutor.setPort(port);
xxlJobSpringExecutor.setAccessToken(accessToken);
xxlJobSpringExecutor.setLogPath(logPath);
xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);
return xxlJobSpringExecutor;
}
}
3.5、访问地址:http://192.168.101.65:8088/xxl-job-admin/
账号和密码:admin/123456
3.6、配置xxl-job的执行器
填写执行器信息,appname是前边在nacos中配置xxl信息时指定的执行器的应用名,选自动注册。
启动后观察日志,出现下边的日志表示执行器在调度中心注册成功。
下边在调度中心添加任务,进入任务管理,点击新增。
在调度中心添加任务,填写任务信息
确定了分片方案,下边梳理整个视频上传及处理的业务流程。
4.1、任务调度中心广播作业分片。
4.2、执行器收到广播作业分片,从数据库读取待处理任务,读取未处理及处理失败的任务。
4.3、执行器更新任务为处理中,根据任务内容从MinIO下载要处理的文件。
4.4、执行器启动多线程去处理任务。
4.5、任务处理完成,上传处理后的视频到MinIO。
4.6、将更新任务处理结果,如果视频处理完成除了更新任务处理结果以外还要将文件的访问地址更新至任务处理表及文件表中,最后将任务完成记录写入历史表。
上传视频成功向视频处理待处理表添加记录,暂时只添加对avi视频的处理记录。
5.1、在视频文件信息保存到数据库表时,同时把信息添加到待处理任务表。在addMediaFilesToDb方法最后添加如下代码
//添加到待处理任务表
addWaitingTask(mediaFiles);
log.debug("保存文件信息到数据库成功,{}", mediaFiles.toString());
/**
* 添加待处理任务
* @param mediaFiles 媒资文件信息
*/
private void addWaitingTask(MediaFiles mediaFiles){
//文件名称
String filename = mediaFiles.getFilename();
//文件扩展名
String extension = filename.substring(filename.lastIndexOf("."));
//获取文件的 mimeType
String mimeType = getMimeType(extension);
if(mimeType.equals("video/x-msvideo")){//如果是avi视频写入待处理任务
MediaProcess mediaProcess = new MediaProcess();
BeanUtils.copyProperties(mediaFiles,mediaProcess);
//状态是未处理
mediaProcess.setStatus("1");
mediaProcess.setCreateDate(LocalDateTime.now());
mediaProcess.setFailCount(0);//失败次数默认0
mediaProcess.setUrl(null);
mediaProcessMapper.insert(mediaProcess);
}
}
查询待处理任务只处理未提交及处理失败的任务,任务处理失败后进行重试,最多重试3次。
6.1、编写DAO方法,保证查询到的待处理视频记录不重复。
package com.xuecheng.media.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xuecheng.media.model.po.MediaProcess;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
public interface MediaProcessMapper extends BaseMapper {
@Select("select * from media_process t where t.id % #{shardTotal} = #{shardIndex} and (t.status=1 or t.status=3) and t.fail_count<3 limit #{count}")
List selectListByShardIndex(@Param("shardTotal") int shardTotal, @Param("shardIndex") int shardIndex, @Param("count") int count);
/**
* 开启一个任务
* @param id 任务id
* @return 更新记录数
*/
@Update("update media_process m set m.status='4' where (m.status='1' or m.status='3') and m.fail_count<3 and m.id=#{id}")
int startTask(@Param("id") long id);
}
6.2、定义Service接口,查询待处理
import com.xuecheng.media.model.po.MediaProcess;
import java.util.List;
/**
* @description 任务处理
*/
public interface MediaFileProcessService {
/**
* @description 获取待处理任务
* @param shardIndex 分片序号
* @param shardTotal 分片总数
* @param count 获取记录数
* @return java.util.List
*/
public List getMediaProcessList(int shardIndex, int shardTotal, int count);
}
6.3、Service接口实现
import com.xuecheng.media.mapper.MediaFilesMapper;
import com.xuecheng.media.mapper.MediaProcessHistoryMapper;
import com.xuecheng.media.mapper.MediaProcessMapper;
import com.xuecheng.media.model.po.MediaFiles;
import com.xuecheng.media.model.po.MediaProcess;
import com.xuecheng.media.model.po.MediaProcessHistory;
import com.xuecheng.media.service.MediaFileProcessService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
/**
* @description MediaFileProcess接口实现
*/
@Slf4j
@Service
public class MediaFileProcessServiceImpl implements MediaFileProcessService {
@Autowired
MediaProcessMapper mediaProcessMapper;
@Autowired
MediaProcessHistoryMapper mediaProcessHistoryMapper;
@Autowired
MediaFilesMapper mediaFilesMapper;
@Override
public List getMediaProcessList(int shardIndex, int shardTotal, int count) {
List mediaProcesses = mediaProcessMapper.selectListByShardIndex(shardTotal, shardIndex, count);
return mediaProcesses;
}
}
为避免重复执行任务,这里使用乐观锁的方式去解决。
7.1、定义Mapper,在MediaProcessMapper中添加如下代码
/**
* 开启一个任务
* @param id 任务id
* @return 更新记录数
*/
@Update("update media_process m set m.status='4' where (m.status='1' or m.status='3') and m.fail_count<3 and m.id=#{id}")
int startTask(@Param("id") long id);
7.2、在MediaFileProcessService中定义开启任务接口
/**
* 开启一个任务
* @param id 任务id
* @return true开启任务成功,false开启任务失败
*/
public boolean startTask(long id);
7.3、实现startTask接口
//实现如下
public boolean startTask(long id) {
int result = mediaProcessMapper.startTask(id);
return result<=0?false:true;
}
任务处理完成需要更新任务处理结果,任务执行成功更新视频的URL、及任务处理结果,将待处理任务记录删除,同时向历史任务表添加记录。
8.1、在MediaFileProcessService接口添加保存任务结果的方法
/**
* @description 保存任务结果
* @param taskId 任务id
* @param status 任务状态
* @param fileId 文件id
* @param url url
* @param errorMsg 错误信息
*/
void saveProcessFinishStatus(Long taskId,String status,String fileId,String url,String errorMsg);
8.2、service接口方法实现
@Override
public void saveProcessFinishStatus(Long taskId, String status, String fileId, String url, String errorMsg) {
//要更新的任务
MediaProcess mediaProcess = mediaProcessMapper.selectById(taskId);
if(mediaProcess == null){
return ;
}
//如果任务执行失败
if(status.equals("3")){
//更新MediaProcess表的状态
mediaProcess.setStatus("3");
mediaProcess.setFailCount(mediaProcess.getFailCount()+1);//失败次数加1
mediaProcess.setErrormsg(errorMsg);
mediaProcessMapper.updateById(mediaProcess);
//更高效的更新方式
// mediaProcessMapper.update()
//todo:将上边的更新方式更改为效的更新方式
return;
}
//======如果任务执行成功======
//文件表记录
MediaFiles mediaFiles = mediaFilesMapper.selectById(fileId);
//更新media_file表中的url
mediaFiles.setUrl(url);
mediaFilesMapper.updateById(mediaFiles);
//更新MediaProcess表的状态
mediaProcess.setStatus("2");
mediaProcess.setFinishDate(LocalDateTime.now());
mediaProcess.setUrl(url);
mediaProcessMapper.updateById(mediaProcess);
//将MediaProcess表记录插入到MediaProcessHistory表
MediaProcessHistory mediaProcessHistory = new MediaProcessHistory();
BeanUtils.copyProperties(mediaProcess,mediaProcessHistory);
mediaProcessHistoryMapper.insert(mediaProcessHistory);
//从MediaProcess删除当前任务
mediaProcessMapper.deleteById(taskId);
}
视频采用并发处理,每个视频使用一个线程去处理,每次处理的视频数量不要超过cpu核心数。
所有视频处理完成结束本次执行,为防止代码异常出现无限期等待则添加超时设置,到达超时时间还没有处理完成仍结束任务。
package com.xuecheng.media.service.jobhandler;
import com.xuecheng.base.utils.Mp4VideoUtil;
import com.xuecheng.media.model.po.MediaProcess;
import com.xuecheng.media.service.MediaFileProcessService;
import com.xuecheng.media.service.MediaFileService;
import com.xxl.job.core.context.XxlJobHelper;
import com.xxl.job.core.handler.annotation.XxlJob;
import lombok.extern.slf4j.Slf4j;
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.Component;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.*;
/**
* 任务处理类
*/
@Slf4j
@Component
public class VideoTask {
@Autowired
MediaFileProcessService mediaFileProcessService;
@Autowired
MediaFileService mediaFileService;
//ffmpeg的路径
@Value("${videoprocess.ffmpegpath}")
private String ffmpegpath;
/**
* 视频处理任务
*/
@XxlJob("videoJobHandler")
public void videoJobHandler() throws Exception {
// 分片参数
int shardIndex = XxlJobHelper.getShardIndex();//执行器的序号,从0开始
int shardTotal = XxlJobHelper.getShardTotal();//执行器总数
//确定cpu的核心数
int processors = Runtime.getRuntime().availableProcessors();
//查询待处理的任务
List mediaProcessList = mediaFileProcessService.getMediaProcessList(shardIndex, shardTotal, processors);
//任务数量
int size = mediaProcessList.size();
log.debug("取到视频处理任务数:"+size);
if(size<=0){
return;
}
//创建一个线程池
ExecutorService executorService = Executors.newFixedThreadPool(size);
//使用的计数器
CountDownLatch countDownLatch = new CountDownLatch(size);
mediaProcessList.forEach(mediaProcess -> {
//将任务加入线程池
executorService.execute(()->{
try {
//任务id
Long taskId = mediaProcess.getId();
//文件id就是md5
String fileId = mediaProcess.getFileId();
//开启任务
boolean b = mediaFileProcessService.startTask(taskId);
if (!b) {
log.debug("抢占任务失败,任务id:{}", taskId);
return;
}
//桶
String bucket = mediaProcess.getBucket();
//objectName
String objectName = mediaProcess.getFilePath();
//下载minio视频到本地
File file = mediaFileService.downloadFileFromMinIO(bucket, objectName);
if (file == null) {
log.debug("下载视频出错,任务id:{},bucket:{},objectName:{}", taskId, bucket, objectName);
//保存任务处理失败的结果
mediaFileProcessService.saveProcessFinishStatus(taskId, "3", fileId, null, "下载视频到本地失败");
return;
}
//源avi视频的路径
String video_path = file.getAbsolutePath();
//转换后mp4文件的名称
String mp4_name = fileId + ".mp4";
//转换后mp4文件的路径
//先创建一个临时文件,作为转换后的文件
File mp4File = null;
try {
mp4File = File.createTempFile("minio", ".mp4");
} catch (IOException e) {
log.debug("创建临时文件异常,{}", e.getMessage());
//保存任务处理失败的结果
mediaFileProcessService.saveProcessFinishStatus(taskId, "3", fileId, null, "创建临时文件异常");
return;
}
String mp4_path = mp4File.getAbsolutePath();
//创建工具类对象
Mp4VideoUtil videoUtil = new Mp4VideoUtil(ffmpegpath, video_path, mp4_name, mp4_path);
//开始视频转换,成功将返回success,失败返回失败原因
String result = videoUtil.generateMp4();
if (!result.equals("success")) {
log.debug("视频转码失败,原因:{},bucket:{},objectName:{},", result, bucket, objectName);
mediaFileProcessService.saveProcessFinishStatus(taskId, "3", fileId, null, result);
return;
}
//上传到minio
boolean b1 = mediaFileService.addMediaFilesToMinIO(mp4File.getAbsolutePath(), "video/mp4", bucket, objectName);
if (!b1) {
log.debug("上传mp4到minio失败,taskid:{}", taskId);
mediaFileProcessService.saveProcessFinishStatus(taskId, "3", fileId, null, "上传mp4到minio失败");
return;
}
//mp4文件的url
String url = getFilePath(fileId, ".mp4");
//更新任务状态为成功
mediaFileProcessService.saveProcessFinishStatus(taskId, "2", fileId, url, "创建临时文件异常");
}finally {
//计算器减去1
countDownLatch.countDown();
}
});
});
//阻塞,指定最大限制的等待时间,阻塞最多等待一定的时间后就解除阻塞
countDownLatch.await(30, TimeUnit.MINUTES);
}
private String getFilePath(String fileMd5,String fileExt){
return fileMd5.substring(0,1) + "/" + fileMd5.substring(1,2) + "/" + fileMd5 + "/" +fileMd5 +fileExt;
}
}
注意:这里的//ffmpeg的路径 一定是你本机ffmpeg的真实路径
11.1、任务补偿机制
如果有线程抢占了某个视频的处理任务,如果线程处理过程中挂掉了,该视频的状态将会一直是处理中,其它线程将无法处理,这个问题需要用补偿机制。
单独启动一个任务找到待处理任务表中超过执行期限但仍在处理中的任务,将任务的状态改为执行失败。
任务执行期限是处理一个视频的最大时间,比如定为30分钟,通过任务的启动时间去判断任务是否超过执行期限。
这个sql该如何实现?
11.2、达到最大失败次数
当任务达到最大失败次数时一般就说明程序处理此视频存在问题,这种情况就需要人工处理,在页面上会提示失败的信息,人工可手动执行该视频进行处理,或通过其它转码工具进行视频转码,转码后直接上传mp4视频。
11.3、分块文件清理问题
上传一个文件进行分块上传,上传一半不传了,之前上传到minio的分块文件要清理吗?怎么做的?
在数据库中有一张文件表记录minio中存储的文件信息。
文件开始上传时会写入文件表,状态为上传中,上传完成会更新状态为上传完成。
当一个文件传了一半不再上传了说明该文件没有上传完成,会有定时任务去查询文件表中的记录,如果文件未上传完成则删除minio中没有上传成功的文件目录。
首先进入课程计划界面,然后选择要绑定的视频进行绑定即可。
1.1、教育机构用户进入课程管理页面并编辑某一个课程,在"课程大纲"标签页的某一小节后可点击”添加视频“。
1.2、弹出添加视频对话框,可通过视频关键字搜索已审核通过的视频媒资。
在内容管理模块中的课程计划TeachplanController接口添加绑定方法。
@ApiOperation(value = "课程计划和媒资信息绑定")
@PostMapping("/teachplan/association/media")
public void associationMedia(@RequestBody BindTeachplanMediaDto bindTeachplanMediaDto){
teachplanService.associationMedia(bindTeachplanMediaDto);
}
package com.xuecheng.content.model.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
* @author Mr.M
* @version 1.0
* @description 绑定媒资和课程计划的模型类
*/
@Data
@ApiModel(value="BindTeachplanMediaDto", description="教学计划-媒资绑定提交数据")
public class BindTeachplanMediaDto {
@ApiModelProperty(value = "媒资文件id", required = true)
private String mediaId;
@ApiModelProperty(value = "媒资文件名称", required = true)
private String fileName;
@ApiModelProperty(value = "课程计划标识", required = true)
private Long teachplanId;
}
/**
* @description 教学计划绑定媒资
* @param bindTeachplanMediaDto
* @return com.xuecheng.content.model.po.TeachplanMedia
*/
public void associationMedia(BindTeachplanMediaDto bindTeachplanMediaDto);
@Transactional
@Override
public void associationMedia(BindTeachplanMediaDto bindTeachplanMediaDto) {
//课程计划id
Long teachplanId = bindTeachplanMediaDto.getTeachplanId();
Teachplan teachplan = teachplanMapper.selectById(teachplanId);
if(teachplan == null){
XueChengPlusException.cast("课程计划不存在");
}
//先删除原有记录,根据课程计划id删除它所绑定的媒资
int delete = teachplanMediaMapper.delete(new LambdaQueryWrapper().eq(TeachplanMedia::getTeachplanId, bindTeachplanMediaDto.getTeachplanId()));
//再添加新记录
TeachplanMedia teachplanMedia = new TeachplanMedia();
BeanUtils.copyProperties(bindTeachplanMediaDto,teachplanMedia);
teachplanMedia.setCourseId(teachplan.getCourseId());
teachplanMedia.setMediaFilename(bindTeachplanMediaDto.getFileName());
teachplanMediaMapper.insert(teachplanMedia);
}