分布式文件系统MinIO对象存储服务
MinIO 是一个非常轻量的服务,可以很简单的和其他应用的结合使用,它兼容亚马逊 S3 云存储服务接口,非常适合于存储大容量非结构化的数据,例如图片、视频、日志文件、备份数据和容器/虚拟机镜像等。它一大特点就是轻量,使用简单,功能强大,支持各种平台,单个文件最大5TB,兼容 Amazon S3接口,提供了 Java、Python、GO等多版本SDK支持。官网:https://min.io。
常见的文件系统:FAT16/FAT32、NTFS、HFS、UFS、APFS、XFS、Ext4等 。
通过概念可以简单理解为:一个计算机无法存储海量的文件,通过网络将若干计算机组织起来共同去存储海量的文件,去接收海量用户的请求,这些组织起来的计算机通过网络进行通信,如下图:
好处:
1、一台计算机的文件系统处理能力扩充到多台计算机同时处理。
2、一台计算机挂了还有另外副本计算机提供数据。
3、每台计算机可以放在不同的地域,这样用户就可以就近访问,提高访问速度。
MinIO
介绍
MinIO 是一个非常轻量的服务,可以很简单的和其他应用的结合使用,它兼容亚马逊 S3 云存储服务接口,非常适合于存储大容量非结构化的数据,例如图片、视频、日志文件、备份数据和容器/虚拟机镜像等。
它一大特点就是轻量,使用简单,功能强大,支持各种平台,单个文件最大5TB,兼容 Amazon S3接口,提供了 Java、Python、GO等多版本SDK支持。
官网:https://min.io
中文:https://www.minio.org.cn/,http://docs.minio.org.cn/docs/
MinIO集群采用去中心化共享架构,每个结点是对等关系,通过Nginx可对MinIO进行负载均衡访问。
去中心化有什么好处?
在大数据领域,通常的设计理念都是无中心和分布式。Minio分布式模式可以帮助你搭建一个高可用的对象存储服务,你可以使用这些存储设备,而不用考虑其真实物理位置。
它将分布在不同服务器上的多块硬盘组成一个对象存储服务。由于硬盘分布在不同的节点上,分布式Minio避免了单点故障。如下图:
Minio使用纠删码技术来保护数据,它是一种恢复丢失和损坏数据的数学算法,它将数据分块冗余的分散存储在各各节点的磁盘上,所有的可用磁盘组成一个集合,上图由8块硬盘组成一个集合,当上传一个文件时会通过纠删码算法计算对文件进行分块存储,除了将文件本身分成4个数据块,还会生成4个校验块,数据块和校验块会分散的存储在这8块硬盘上。
使用纠删码的好处是即便丢失一半数量(N/2)的硬盘,仍然可以恢复数据。 比如上边集合中有4个以内的硬盘损害仍可保证数据恢复,不影响上传和下载,如果多于一半的硬盘坏了则无法恢复。
数据恢复演示
下边在本机演示MinIO恢复数据的过程,在本地创建4个目录表示4个硬盘。
首先下载MinIO,下载地址:https://dl.min.io/server/minio/release/
CMD进入有minio.exe的目录,运行下边的命令:
minio.exe server D:\develop\minio_data\data1 D:\develop\minio_data\data2 D:\develop\minio_data\data3 D:\develop\minio_data\data4
启动结果如下:
说明如下:
WARNING: MINIO_ACCESS_KEY and MINIO_SECRET_KEY are deprecated.
Please use MINIO_ROOT_USER and MINIO_ROOT_PASSWORD
Formatting 1st pool, 1 set(s), 4 drives per set.
WARNING: Host local has more than 2 drives of set. A host failure will result in data becoming unavailable.
WARNING: Detected default credentials 'minioadmin:minioadmin', we recommend that you change these values with 'MINIO_ROOT_USER' and 'MINIO_ROOT_PASSWORD' environment variables
1)老版本使用的MINIO_ACCESS_KEY 和 MINIO_SECRET_KEY不推荐使用,推荐使用MINIO_ROOT_USER 和MINIO_ROOT_PASSWORD设置账号和密码。
2)pool即minio节点组成的池子,当前有一个pool和4个硬盘组成的set集合
3)因为集合是4个硬盘,大于2的硬盘损坏数据将无法恢复。
4)账号和密码默认为minioadmin、minioadmin,可以在环境变量中设置通过'MINIO_ROOT_USER' and 'MINIO_ROOT_PASSWORD' 进行设置。
下边输入http://localhost:9000进行登录。
1)老版本使用的MINIO_ACCESS_KEY 和 MINIO_SECRET_KEY不推荐使用,推荐使用MINIO_ROOT_USER 和MINIO_ROOT_PASSWORD设置账号和密码。
2)pool即minio节点组成的池子,当前有一个pool和4个硬盘组成的set集合
3)因为集合是4个硬盘,大于2的硬盘损坏数据将无法恢复。
4)账号和密码默认为minioadmin、minioadmin,可以在环境变量中设置通过'MINIO_ROOT_USER' and 'MINIO_ROOT_PASSWORD' 进行设置。
下边输入http://localhost:9000进行登录。
登录成功:
下一步创建bucket,桶,它相当于存储文件的目录,可以创建若干的桶。
输入bucket的名称,点击“CreateBucket”,创建成功
点击“upload”上传文件。
下边上传几个文件
下边去四个目录观察文件的存储情况
我们发现上传的1.mp4文件存储在了四个目录,即四个硬盘上。
下边测试minio的数据恢复过程:
1、首先删除一个目录。
删除目录后仍然可以在web控制台上传文件和下载文件。
稍等片刻删除的目录自动恢复。
2、删除两个目录。
删除两个目录也会自动恢复。
3、删除三个目录 。
由于 集合中共有4块硬盘,有大于一半的硬盘损坏数据无法恢复。
此时报错:We encountered an internal error, please try again. (Read failed. Insufficient number of drives online)在线驱动器数量不足。
分布式集群测试
条件允许的情况下可以测试MinIO分布式存储的特性,首先准备环境。
分布式MinIO要求至少四个磁盘,建议至少4个节点,每个节点2个磁盘。
准备四台虚拟机:192.168.101.65、192.168.101.66、192.168.101.67、192.168.101.68
将课程资料下的minio的执行文件拷贝到四台虚拟机的/home/minio/目录下。
在四台虚拟机分别创建下边的脚本run.sh,内容如下:
#!/bin/bash
# 创建日志目录
mkdir -p /boot/mediafiles/logs
# 创建存储目录
mkdir -p /boot/mediafiles/data/d{1,2,3,4}
# 创建配置目录
mkdir -p /etc/minio
export MINIO_ROOT_USER=minioadmin
export MINIO_ROOT_PASSWORD=minioadmin
# 在四台机器上都执行该文件,以分布式的方式启动minio
# --address 为api端口(如Java客户端)访问的端口
# --console-address web控制台端口
/home/minio/minio server \
http://192.168.101.65:9000/home/mediafiles/data/export1 \
http://192.168.101.65:9000/home/mediafiles/data/export2 \
http://192.168.101.66:9000/home/mediafiles/data/export1 \
http://192.168.101.66:9000/home/mediafiles/data/export2 \
http://192.168.101.67:9000/home/mediafiles/data/export1 \
http://192.168.101.67:9000/home/mediafiles/data/export2 \
http://192.168.101.68:9000/home/mediafiles/data/export1 \
http://192.168.101.68:9000/home/mediafiles/data/export2
在四台虚拟机执行脚本run.sh,注意观察日志。
启动成功后访问: http://192.168.101.66:9001/、http://192.168.101.67:9001/、http://192.168.101.68:9001/、http://192.168.101.69:9001/。
访问任意一个都可以操作 minio集群。
下边进行测试:
1、向集群上传一个文件,观察每个节点的两个磁盘目录都存储了数据。
2、停止 一个节点,不影响上传和下载。
假如停止了65节点,通过其它节点上传文件,稍后启动65后自动从其它结点同步文件。
3、停止 两个节点,无法上传,可以下载。
此时上传文件客户端报错如下:
上传文件需要至少一半加1个可用的磁盘。
将停止的两个节点的minio启动,稍等片刻 minio恢复可用。
测试Docker环境
开发阶段和生产阶段统一使用Docker下的MINIO。
在下发的虚拟机中已安装了MinIO的镜像和容器,执行sh /data/soft /restart.sh启动Docker下的MinIO
启动完成登录MinIO查看是否正常。
访问http://192.168.101.65:9000
本项目创建两个buckets:
mediafiles: 普通文件
video:视频文件
SDK
上传文件
MinIO提供多个语言版本SDK的支持,下边找到java版本的文档:
地址:https://docs.min.io/docs/java-client-quickstart-guide.html
最低需求Java 1.8或更高版本:
maven依赖如下:
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.4.3</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.8.1</version>
</dependency>
参数说明:
需要三个参数才能连接到minio服务
参数 |
说明 |
Endpoint |
对象存储服务的URL |
Access Key |
Access key就像用户ID,可以唯一标识你的账户。 |
Secret Key |
Secret key是你账户的密码。 |
示例代码如下:
import io.minio.BucketExistsArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.UploadObjectArgs;
import io.minio.errors.MinioException;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
public class FileUploader {
public static void main(String[] args)throws IOException, NoSuchAlgorithmException, InvalidKeyException {
try {
// Create a minioClient with the MinIO server playground, its access key and secret key.
MinioClient minioClient =
MinioClient.builder()
.endpoint("https://play.min.io")
.credentials("Q3AM3UQ867SPQQA43P2F", "zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG")
.build();
// Make 'asiatrip' bucket if not exist.
boolean found =
minioClient.bucketExists(BucketExistsArgs.builder().bucket("asiatrip").build());
if (!found) {
// Make a new bucket called 'asiatrip'.
minioClient.makeBucket(MakeBucketArgs.builder().bucket("asiatrip").build());
} else {
System.out.println("Bucket 'asiatrip' already exists.");
}
// Upload '/home/user/Photos/asiaphotos.zip' as object name 'asiaphotos-2015.zip' to bucket
// 'asiatrip'.
minioClient.uploadObject(
UploadObjectArgs.builder()
.bucket("asiatrip")
.object("asiaphotos-2015.zip")
.filename("/home/user/Photos/asiaphotos.zip")
.build());
System.out.println(
"'/home/user/Photos/asiaphotos.zip' is successfully uploaded as "
+ "object 'asiaphotos-2015.zip' to bucket 'asiatrip'.");
} catch (MinioException e) {
System.out.println("Error occurred: " + e);
System.out.println("HTTP trace: " + e.httpTrace());
}
}
}
测试上传文件功能,
首先创建一个用于测试的bucket
点击“Manage”修改bucket的访问权限
选择public权限
测试代码如下:
package com.xuecheng.media;
import io.minio.BucketExistsArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.UploadObjectArgs;
import io.minio.errors.MinioException;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
/**
* @description 测试MinIO
* @author Mr.M
* @date 2022/9/11 21:24
* @version 1.0
*/
public class MinIOTest {
static MinioClient minioClient =
MinioClient.builder()
.endpoint("http://192.168.101.65:9000")
.credentials("minioadmin", "minioadmin")
.build();
//上传文件
public static void upload()throws IOException, NoSuchAlgorithmException, InvalidKeyException {
try {
boolean found =
minioClient.bucketExists(BucketExistsArgs.builder().bucket("testbucket").build());
//检查testbucket桶是否创建,没有创建自动创建
if (!found) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket("testbucket").build());
} else {
System.out.println("Bucket 'testbucket' already exists.");
}
//上传1.mp4
minioClient.uploadObject(
UploadObjectArgs.builder()
.bucket("testbucket")
.object("1.mp4")
.filename("D:\\develop\\upload\\1.mp4")
.build());
//上传1.avi,上传到avi子目录
minioClient.uploadObject(
UploadObjectArgs.builder()
.bucket("testbucket")
.object("avi/1.avi")
.filename("D:\\develop\\upload\\1.avi")
.build());
System.out.println("上传成功");
} catch (MinioException e) {
System.out.println("Error occurred: " + e);
System.out.println("HTTP trace: " + e.httpTrace());
}
}
public static void main(String[] args)throws IOException, NoSuchAlgorithmException, InvalidKeyException {
upload();
}
}
执行main方法,共上传两个文件,1.mp4上传到桶根目录下,1.avi上传到 桶中的avi目录下,avi目录会自动创建。
上传成功,通过web控制台查看文件,并预览文件。
删除文件
下边测试删除文件
参考:https://docs.min.io/docs/java-client-api-reference#removeObject
//删除文件
public static void delete(String bucket,String filepath)throws IOException, NoSuchAlgorithmException, InvalidKeyException {
try {
minioClient.removeObject(
RemoveObjectArgs.builder().bucket(bucket).object(filepath).build());
System.out.println("删除成功");
} catch (MinioException e) {
System.out.println("Error occurred: " + e);
System.out.println("HTTP trace: " + e.httpTrace());
}
}
public static void main(String[] args)throws IOException, NoSuchAlgorithmException, InvalidKeyException {
// upload();
delete("testbucket","1.mp4");
delete("testbucket","avi/1.avi");
}
查询文件
通过查询文件查看文件是否存在minio中。
参考:https://docs.min.io/docs/java-client-api-reference#getObject
//下载文件
public static void getFile(String bucket,String filepath,String outFile)throws IOException, NoSuchAlgorithmException, InvalidKeyException {
try {
try (InputStream stream = minioClient.getObject(
GetObjectArgs.builder()
.bucket(bucket)
.object(filepath)
.build());
FileOutputStream fileOutputStream = new FileOutputStream(new File(outFile));
) {
// Read data from stream
IOUtils.copy(stream,fileOutputStream);
System.out.println("下载成功");
}
} catch (MinioException e) {
System.out.println("Error occurred: " + e);
System.out.println("HTTP trace: " + e.httpTrace());
}
}
public static void main(String[] args)throws IOException, NoSuchAlgorithmException, InvalidKeyException {
upload();
// delete("testbucket","1.mp4");
// delete("testbucket","avi/1.avi");
getFile("testbucket","avi/1.avi","D:\\develop\\minio_data\\1.avi");
}
上传图片
业务流程
图片上传至分布式文件系统,在课程信息中保存课程图片路径,如下流程:
1、前端进入上传图片界面
2、上传图片,请求媒资管理服务。
3、媒资管理服务将图片文件存储在MinIO。
4、媒资管理记录文件信息到数据库。
5、保存课程信息,在内容管理数据库保存图片地址。
媒资管理服务由接口层和业务层共同完成,具体分工如下:
用户上传图片请求至媒资管理的接口层,接口层解析文件信息通过业务层将文件保存至minio及数据库。如下图:
数据模型
涉及到的数据表有:课程信息表中的图片字段、媒资数据库的文件表,下边主要看媒资数据库的文件表。
各字段描述如下:
准备环境
首先在minio配置bucket,bucket名称为:mediafiles,并设置bucket的权限为公开。
在nacos配置中minio的相关信息,进入media-service-dev.yaml:
配置信息如下:
minio:
endpoint: http://192.168.101.65:9000
accessKey: minioadmin
secretKey: minioadmin
bucket:
files: mediafiles
videofiles: video
编写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;
/**
* @description minio配置
* @author Mr.M
* @date 2022/9/12 19:32
* @version 1.0
*/
@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;
}
}
接口定义
根据需求分析,下边进行接口定义,此接口定义为一个通用的上传文件接口,可以上传图片或其它文件。
首先分析接口:
请求地址:/media/upload/coursefile
请求参数:
Content-Type: multipart/form-data;boundary=.....
FormData: filedata=??
响应参数:文件信息,如下
{
"id": "a16da7a132559daf9e1193166b3e7f52",
"companyId": 1232141425,
"companyName": null,
"filename": "1.jpg",
"fileType": "001001",
"tags": "",
"bucket": "/testbucket/2022/09/12/a16da7a132559daf9e1193166b3e7f52.jpg",
"fileId": "a16da7a132559daf9e1193166b3e7f52",
"url": "/testbucket/2022/09/12/a16da7a132559daf9e1193166b3e7f52.jpg",
"timelength": null,
"username": null,
"createDate": "2022-09-12T21:57:18",
"changeDate": null,
"status": "1",
"remark": "",
"auditStatus": null,
"auditMind": null,
"fileSize": 248329
}
接口开发
定义上传响应模型类
package com.xuecheng.media.model.dto;
import com.xuecheng.media.model.po.MediaFiles;
import lombok.Data;
import lombok.ToString;
/**
* @description 上传普通文件成功响应结果
* @author Mr.M
* @date 2022/9/12 18:49
* @version 1.0
*/
@Data
public class UploadFileResultDto extends MediaFiles {
}
DAO开发
根据需求分析DAO层实现向media_files表插入一条记录,使用media_files表生成的mapper即可。
domain
package com.xuecheng.media.model.dto;
import com.xuecheng.media.model.po.MediaFiles;
import lombok.Data;
import lombok.ToString;
/**
* @description 上传普通文件请求参数
* @author Mr.M
* @date 2022/9/12 18:49
* @version 1.0
*/
@Data
public class UploadFileParamsDto {
/**
* 文件名称
*/
private String filename;
/**
* 文件content-type
*/
private String contentType;
/**
* 文件类型(文档,音频,视频)
*/
private String fileType;
/**
* 文件大小
*/
private Long fileSize;
/**
* 标签
*/
private String tags;
/**
* 上传人
*/
private String username;
/**
* 备注
*/
private String remark;
}
Service开发
Service方法需要提供一个更加通用的保存文件的方法。
定义service方法
mediaFileService
/**
* @description 上传文件
* @param uploadFileParamsDto 上传文件信息
* @param folder 文件目录,如果不传则默认年、月、日
* @return com.xuecheng.media.model.dto.UploadFileResultDto 上传文件结果
* @author Mr.M
* @date 2022/9/12 19:31
*/
public UploadFileResultDto uploadFile(Long companyId,UploadFileParamsDto uploadFileParamsDto,byte[] bytes,String folder,String objectName);
实现方法如下:
Java
@Autowired
MinioClient minioClient;
@Autowired
MediaFilesMapper mediaFilesMapper;
//普通文件桶
@Value("${minio.bucket.files}")
private String bucket_Files;
@Transactional
@Override
public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, byte[] bytes, String folder, String objectName) {
//生成文件id,文件的md5值
String fileId = DigestUtils.md5Hex(bytes);
//文件名称
String filename = uploadFileParamsDto.getFilename();
//构造objectname
if (StringUtils.isEmpty(objectName)) {
objectName = fileId + filename.substring(filename.lastIndexOf("."));
}
if (StringUtils.isEmpty(folder)) {
//通过日期构造文件存储路径
folder = getFileFolder(new Date(), true, true, true);
} else if (folder.indexOf("/") < 0) {
folder = folder + "/";
}
//对象名称
objectName = folder + objectName;
MediaFiles mediaFiles = null;
try {
//转为流
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
PutObjectArgs putObjectArgs = PutObjectArgs.builder().bucket(bucket_Files).object(objectName)
//-1表示文件分片按5M(不小于5M,不大于5T),分片数量最大10000,
.stream(byteArrayInputStream, byteArrayInputStream.available(), -1)
.contentType(uploadFileParamsDto.getContentType())
.build();
minioClient.putObject(putObjectArgs);
//从数据库查询文件
mediaFiles = mediaFilesMapper.selectById(fileId);
if (mediaFiles == null) {
mediaFiles = new MediaFiles();
//拷贝基本信息
BeanUtils.copyProperties(uploadFileParamsDto, mediaFiles);
mediaFiles.setFileId(fileId);
mediaFiles.setCompanyId(companyId);
mediaFiles.setUrl("/" + bucket_Files + "/" + objectName);
mediaFiles.setBucket(bucket_Files);
mediaFiles.setCreateDate(LocalDateTime.now());
mediaFiles.setStatus("1");
//保存文件信息到文件表
int insert = mediaFilesMapper.insert(mediaFiles);
if (insert < 0) {
XueChengPlusException.cast("保存文件信息失败");
}
UploadFileResultDto uploadFileResultDto = new UploadFileResultDto();
BeanUtils.copyProperties(mediaFiles, uploadFileResultDto);
return uploadFileResultDto;
}
} catch (Exception e) {
e.printStackTrace();
XueChengPlusException.cast("上传过程中出错");
}
return null;
}
//根据日期拼接目录
private String getFileFolder(Date date, boolean year, boolean month, boolean day){
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
//获取当前日期字符串
String dateString = sdf.format(new Date());
//取出年、月、日
String[] dateStringArray = dateString.split("-");
StringBuffer folderString = new StringBuffer();
if(year){
folderString.append(dateStringArray[0]);
folderString.append("/");
}
if(month){
folderString.append(dateStringArray[1]);
folderString.append("/");
}
if(day){
folderString.append(dateStringArray[2]);
folderString.append("/");
}
return folderString.toString();
}
controller
@ApiOperation("上传文件")
@RequestMapping(value = "/upload/coursefile",consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ResponseBody
public UploadFileResultDto upload(@RequestPart("filedata") MultipartFile upload,@RequestParam(value = "folder",required=false) String folder,@RequestParam(value = "objectName",required=false) String objectName) throws IOException {
String contentType = upload.getContentType();
Long companyId = 1232141425L;
UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();
uploadFileParamsDto.setFileSize(upload.getSize());
if(contentType.indexOf("image")>=0){
//图片
uploadFileParamsDto.setFileType("001001");
}else{
//其它
uploadFileParamsDto.setFileType("001003");
}
uploadFileParamsDto.setRemark("");
uploadFileParamsDto.setFilename(upload.getOriginalFilename());
uploadFileParamsDto.setContentType(contentType);
return mediaFileService.uploadFile(companyId,uploadFileParamsDto,upload.getBytes(),folder,objectName);
}
使用httpclient测试
### 上传文件
POST {{media_host}}/media/upload/coursefile
Content-Type: multipart/form-data; boundary=WebAppBoundary
--WebAppBoundary
Content-Disposition: form-data; name="filedata"; filename="1.jpg"
Content-Type: application/octet-stream
< d:/develop/upload/1.jpg
Service代码优化
在上传文件的方法中包括两部分:向MinIO存储文件,向数据库存储文件信息,下边将这两部分抽取出来,后期可供其它Service方法调用。
@Transactional
@Override
public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, byte[] bytes, String folder, String objectName) {
//生成文件id,文件的md5值
String fileId = DigestUtils.md5Hex(bytes);
//文件名称
String filename = uploadFileParamsDto.getFilename();
//构造objectname
if (StringUtils.isEmpty(objectName)) {
objectName = fileId + filename.substring(filename.lastIndexOf("."));
}
if (StringUtils.isEmpty(folder)) {
//通过日期构造文件存储路径
folder = getFileFolder(new Date(), true, true, true);
} else if (folder.indexOf("/") < 0) {
folder = folder + "/";
}
//对象名称
objectName = folder + objectName;
MediaFiles mediaFiles = null;
try {
//上传至文件系统
addMediaFilesToMinIO(bytes,bucket_Files,objectName,uploadFileParamsDto.getContentType());
//写入文件表
mediaFiles = addMediaFilesToDb(companyId,fileId,uploadFileParamsDto,bucket_Files,objectName);
UploadFileResultDto uploadFileResultDto = new UploadFileResultDto();
BeanUtils.copyProperties(mediaFiles, uploadFileResultDto);
return uploadFileResultDto;
} catch (Exception e) {
e.printStackTrace();
XueChengPlusException.cast("上传过程中出错");
}
return null;
}
/**
* @description 将文件写入minIO
* @param bytes 文件字节数组
* @param bucket 桶
* @param objectName 对象名称
* @param contentType 内容类型
* @return void
* @author Mr.M
* @date 2022/10/12 21:22
*/
public void addMediaFilesToMinIO(byte[] bytes, String bucket, String objectName, String contentType) {
//转为流
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
try {
PutObjectArgs putObjectArgs = PutObjectArgs.builder().bucket(bucket).object(objectName)
//-1表示文件分片按5M(不小于5M,不大于5T),分片数量最大10000,
.stream(byteArrayInputStream, byteArrayInputStream.available(), -1)
.contentType(contentType)
.build();
minioClient.putObject(putObjectArgs);
} catch (Exception e) {
e.printStackTrace();
XueChengPlusException.cast("上传文件到文件系统出错");
}
}
/**
* @description 将文件信息添加到文件表
* @param companyId 机构id
* @param fileMd5 文件md5值
* @param uploadFileParamsDto 上传文件的信息
* @param bucket 桶
* @param objectName 对象名称
* @return com.xuecheng.media.model.po.MediaFiles
* @author Mr.M
* @date 2022/10/12 21:22
*/
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);
mediaFiles.setId(fileMd5);
mediaFiles.setFileId(fileMd5);
mediaFiles.setCompanyId(companyId);
mediaFiles.setUrl("/" + bucket + "/" + objectName);
mediaFiles.setBucket(bucket);
mediaFiles.setCreateDate(LocalDateTime.now());
mediaFiles.setAuditStatus("002003");
mediaFiles.setStatus("1");
//保存文件信息到文件表
int insert = mediaFilesMapper.insert(mediaFiles);
if (insert < 0) {
XueChengPlusException.cast("保存文件信息失败");
}
}
return mediaFiles;
}
优化后进行测试。
Service事务优化
上边的service方法优化后并测试通过,现在思考关于uploadFile方法的是否应该开启事务。
目前是在uploadFile方法上添加@Transactional,当调用uploadFile方法前会开启数据库事务,如果上传文件过程时间较长那么数据库的事务持续时间就会变长,这样数据库链接释放就慢,最终导致数据库链接不够用。
我们只将addMediaFilesToDb方法添加事务控制即可,uploadFile方法上的@Transactional注解去掉。
优化后如下:
@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);
mediaFiles.setId(fileMd5);
mediaFiles.setFileId(fileMd5);
mediaFiles.setCompanyId(companyId);
mediaFiles.setUrl("/" + bucket + "/" + objectName);
mediaFiles.setBucket(bucket);
mediaFiles.setCreateDate(LocalDateTime.now());
mediaFiles.setAuditStatus("002003");
mediaFiles.setStatus("1");
//保存文件信息到文件表
int insert = mediaFilesMapper.insert(mediaFiles);
if (insert < 0) {
XueChengPlusException.cast("保存文件信息失败");
}
}
return mediaFiles;
}
我们人为在int insert = mediaFilesMapper.insert(mediaFiles);下边添加一个异常代码int a=1/0;
测试是否事务控制。
很遗憾,事务控制失败。方法上已经添加了@Transactional注解为什么该方法不能被事务控制呢?
如果是在uploadFile方法上添加@Transactional注解就可以控制事务,去掉则不行。
现在的问题其实是一个非事务方法调同类一个事务方法,事务无法控制,这是为什么?
下边分析原因:
如果在uploadFile方法上添加@Transactional注解,代理对象执行此方法前会开启事务,如下图:
如果在uploadFile方法上没有@Transactional注解,代理对象执行此方法前不进行事务控制,如下图:
现在在addMediaFilesToDb方法上添加@Transactional注解,也不会进行事务是因为并不是通过代理对象执行的addMediaFilesToDb方法。为了判断在uploadFile方法中去调用addMediaFilesToDb方法是否是通过代理对象去调用,我们可以打断点跟踪。
我们发现在uploadFile方法中去调用addMediaFilesToDb方法不是通过代理对象去调用。
如何解决呢?通过代理对象去调用addMediaFilesToDb方法即可解决。
在MediaFileService的实现类中注入MediaFileService的代理对象,如下:
@Autowired
MediaFileService currentProxy;
将addMediaFilesToDb方法提成接口。
/**
* @description 将文件信息添加到文件表
* @param companyId 机构id
* @param fileMd5 文件md5值
* @param uploadFileParamsDto 上传文件的信息
* @param bucket 桶
* @param objectName 对象名称
* @return com.xuecheng.media.model.po.MediaFiles
* @author Mr.M
* @date 2022/10/12 21:22
*/
@Transactional
public MediaFiles addMediaFilesToDb(Long companyId,String fileMd5,UploadFileParamsDto uploadFileParamsDto,String bucket,String objectName);
调用addMediaFilesToDb方法的代码处改为如下:
try {
.....
//写入文件表
mediaFiles = currentProxy.addMediaFilesToDb(companyId,fileId,uploadFileParamsDto,bucket_Files,objectName);
....
service断点续传+addMediaFilesToDb方法提成接口。
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 org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.RequestBody;
import java.util.List;
/**
* @description 媒资文件管理业务类
* @author Mr.M
* @date 2022/9/10 8:55
* @version 1.0
*/
public interface MediaFileService {
/**
* @description 媒资文件查询方法
* @param pageParams 分页参数
* @param queryMediaParamsDto 查询条件
* @return com.xuecheng.base.model.PageResult<com.xuecheng.media.model.po.MediaFiles>
* @author Mr.M
* @date 2022/9/10 8:57
*/
public PageResult<MediaFiles> queryMediaFiels(Long companyId,PageParams pageParams, QueryMediaParamsDto queryMediaParamsDto);
/**
* @description 上传文件的通用接口
* @param companyId 机构id
* @param uploadFileParamsDto 文件信息
* @param bytes 文件字节数组
* @param folder 桶下边的子目录
* @param objectName 对象名称
* @return com.xuecheng.media.model.dto.UploadFileResultDto
* @author Mr.M
* @date 2022/10/13 15:51
*/
public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, byte[] bytes,String folder,String objectName);
/**
* @param companyId
* @param fileId
* @param uploadFileParamsDto
* @param bucket
* @param objectName
* @return com.xuecheng.media.model.po.MediaFiles
* @description 将文件信息入库
* @author Mr.M
* @date 2022/10/14 9:14
*/
@Transactional
public MediaFiles addMediaFilesToDb(Long companyId, String fileId, UploadFileParamsDto uploadFileParamsDto, String bucket, String objectName);
/**
* @description 检查文件是否存在
* @param fileMd5 文件的md5
* @return com.xuecheng.base.model.RestResponse<java.lang.Boolean> false不存在,true存在
* @author Mr.M
* @date 2022/9/13 15:38
*/
public RestResponse<Boolean> checkFile(String fileMd5);
/**
* @description 检查分块是否存在
* @param fileMd5 文件的md5
* @param chunkIndex 分块序号
* @return com.xuecheng.base.model.RestResponse<java.lang.Boolean> false不存在,true存在
* @author Mr.M
* @date 2022/9/13 15:39
*/
public RestResponse<Boolean> checkChunk(String fileMd5, int chunkIndex);
/**
* @description 上传分块
* @param fileMd5 文件md5
* @param chunk 分块序号
* @param bytes 文件字节
* @return com.xuecheng.base.model.RestResponse
* @author Mr.M
* @date 2022/9/13 15:50
*/
public RestResponse uploadChunk(String fileMd5,int chunk,byte[] bytes);
/**
* @description 合并分块
* @param companyId 机构id
* @param fileMd5 文件md5
* @param chunkTotal 分块总和
* @param uploadFileParamsDto 文件信息
* @return com.xuecheng.base.model.RestResponse
* @author Mr.M
* @date 2022/9/13 15:56
*/
public RestResponse mergechunks(Long companyId,String fileMd5,int chunkTotal,UploadFileParamsDto uploadFileParamsDto);
}
serviceimpl
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.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.minio.*;
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.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
/**
* @author Mr.M
* @version 1.0
* @description TODO
* @date 2022/9/10 8:58
*/
@Slf4j
@Service
public class MediaFileServiceImpl implements MediaFileService {
@Autowired
MediaFilesMapper mediaFilesMapper;
@Autowired
MinioClient minioClient;
//普通文件存储的桶
@Value("${minio.bucket.files}")
private String bucket_files;
//视频文件存储的桶
@Value("${minio.bucket.videofiles}")
private String bucket_videofiles;
@Autowired
MediaFileService currentProxy;
@Override
public PageResult<MediaFiles> queryMediaFiels(Long companyId, PageParams pageParams, QueryMediaParamsDto queryMediaParamsDto) {
//构建查询条件对象
LambdaQueryWrapper<MediaFiles> queryWrapper = new LambdaQueryWrapper<>();
//分页对象
Page<MediaFiles> page = new Page<>(pageParams.getPageNo(), pageParams.getPageSize());
// 查询数据内容获得结果
Page<MediaFiles> pageResult = mediaFilesMapper.selectPage(page, queryWrapper);
// 获取数据列表
List<MediaFiles> list = pageResult.getRecords();
// 获取数据总数
long total = pageResult.getTotal();
// 构建结果集
PageResult<MediaFiles> mediaListResult = new PageResult<>(list, total, pageParams.getPageNo(), pageParams.getPageSize());
return mediaListResult;
}
@Override
public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, byte[] bytes, String folder, String objectName) {
//得到文件的md5值
String fileMd5 = DigestUtils.md5Hex(bytes);
if (StringUtils.isEmpty(folder)) {
//自动生成目录的路径 按年月日生成,
folder = getFileFolder(new Date(), true, true, true);
} else if (folder.indexOf("/") < 0) {
folder = folder + "/";
}
//文件名称
String filename = uploadFileParamsDto.getFilename();
if (StringUtils.isEmpty(objectName)) {
//如果objectName为空,使用文件的md5值为objectName
objectName = fileMd5 + filename.substring(filename.lastIndexOf("."));
}
objectName = folder + objectName;
try {
addMediaFilesToMinIO(bytes, bucket_files, objectName);
MediaFiles mediaFiles = currentProxy.addMediaFilesToDb(companyId, fileMd5, uploadFileParamsDto, bucket_files, objectName);
//准备返回数据
UploadFileResultDto uploadFileResultDto = new UploadFileResultDto();
BeanUtils.copyProperties(mediaFiles, uploadFileResultDto);
return uploadFileResultDto;
} catch (Exception e) {
log.debug("上传文件失败:{}", e.getMessage());
throw new RuntimeException(e.getMessage());
}
// return null;
}
// @Override
// public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, byte[] bytes, String folder, String objectName) {
//
//
// //得到文件的md5值
// String fileMd5 = DigestUtils.md5Hex(bytes);
//
// if(StringUtils.isEmpty(folder)){
// //自动生成目录的路径 按年月日生成,
// folder = getFileFolder(new Date(), true, true, true);
// }else if(folder.indexOf("/")<0){
// folder = folder+"/";
// }
// //文件名称
// String filename = uploadFileParamsDto.getFilename();
//
// if(StringUtils.isEmpty(objectName)){
// //如果objectName为空,使用文件的md5值为objectName
// objectName = fileMd5 + filename.substring(filename.lastIndexOf("."));
// }
//
// objectName = folder + objectName;
//
// try {
// ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
// String contentType = uploadFileParamsDto.getContentType();
//
// PutObjectArgs putObjectArgs = PutObjectArgs.builder()
// .bucket(bucket_files)
// .object(objectName)
// //InputStream stream, long objectSize 对象大小, long partSize 分片大小(-1表示5M,最大不要超过5T,最多10000)
// .stream(byteArrayInputStream, byteArrayInputStream.available(), -1)
// .contentType(contentType)
// .build();
// //上传到minio
// minioClient.putObject(putObjectArgs);
//
// //保存到数据库
// MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
// if(mediaFiles == null){
// mediaFiles = new MediaFiles();
//
// //封装数据
// BeanUtils.copyProperties(uploadFileParamsDto,mediaFiles);
// mediaFiles.setId(fileMd5);
// mediaFiles.setFileId(fileMd5);
// mediaFiles.setCompanyId(companyId);
// mediaFiles.setFilename(filename);
// mediaFiles.setBucket(bucket_files);
// mediaFiles.setFilePath(objectName);
// mediaFiles.setUrl("/"+bucket_files+"/"+objectName);
// mediaFiles.setCreateDate(LocalDateTime.now());
// mediaFiles.setStatus("1");
// mediaFiles.setAuditStatus("002003");
//
// //插入文件表
// mediaFilesMapper.insert(mediaFiles);
//
// }
//
// //准备返回数据
// UploadFileResultDto uploadFileResultDto = new UploadFileResultDto();
// BeanUtils.copyProperties(mediaFiles,uploadFileResultDto);
// return uploadFileResultDto;
//
//
// } catch (Exception e) {
// log.debug("上传文件失败:{}",e.getMessage());
// }
//
// return null;
// }
/**
* @param companyId
* @param fileId
* @param uploadFileParamsDto
* @param bucket
* @param objectName
* @return com.xuecheng.media.model.po.MediaFiles
* @description 将文件信息入库
* @author Mr.M
* @date 2022/10/14 9:14
*/
@Transactional
public MediaFiles addMediaFilesToDb(Long companyId, String fileId, UploadFileParamsDto uploadFileParamsDto, String bucket, String objectName) {
//保存到数据库
MediaFiles mediaFiles = mediaFilesMapper.selectById(fileId);
if (mediaFiles == null) {
mediaFiles = new MediaFiles();
//封装数据
BeanUtils.copyProperties(uploadFileParamsDto, mediaFiles);
mediaFiles.setId(fileId);
mediaFiles.setFileId(fileId);
mediaFiles.setCompanyId(companyId);
mediaFiles.setBucket(bucket);
mediaFiles.setFilePath(objectName);
mediaFiles.setUrl("/" + bucket + "/" + objectName);
mediaFiles.setCreateDate(LocalDateTime.now());
mediaFiles.setStatus("1");
mediaFiles.setAuditStatus("002003");
//插入文件表
mediaFilesMapper.insert(mediaFiles);
//抛出异常,制造异常
// int i=1/0;
}
return mediaFiles;
}
@Override
public RestResponse<Boolean> checkFile(String fileMd5) {
//在文件表存在,并且在文件系统存在,此文件才存在
MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
if(mediaFiles==null){
return RestResponse.success(false);
}
//查看是否在文件系统存在
GetObjectArgs getObjectArgs = GetObjectArgs.builder().bucket(mediaFiles.getBucket()).object(mediaFiles.getFilePath()).build();
try {
InputStream inputStream = minioClient.getObject(getObjectArgs);
if(inputStream==null){
//文件不存在
return RestResponse.success(false);
}
}catch (Exception e){
//文件不存在
return RestResponse.success(false);
}
//文件已存在
return RestResponse.success(true);
}
@Override
public RestResponse<Boolean> checkChunk(String fileMd5, int chunkIndex) {
//得到分块文件所在目录
String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
//分块文件的路径
String chunkFilePath = chunkFileFolderPath + chunkIndex;
//查询文件系统分块文件是否存在
//查看是否在文件系统存在
GetObjectArgs getObjectArgs = GetObjectArgs.builder().bucket(bucket_videofiles).object(chunkFilePath).build();
try {
InputStream inputStream = minioClient.getObject(getObjectArgs);
if(inputStream==null){
//文件不存在
return RestResponse.success(false);
}
}catch (Exception e){
//文件不存在
return RestResponse.success(false);
}
return RestResponse.success(true);
}
@Override
public RestResponse uploadChunk(String fileMd5, int chunk, byte[] bytes) {
//得到分块文件所在目录
String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
//分块文件的路径
String chunkFilePath = chunkFileFolderPath + chunk;
try {
//将分块上传到文件系统
addMediaFilesToMinIO(bytes, bucket_videofiles, chunkFilePath);
//上传成功
return RestResponse.success(true);
} catch (Exception e) {
log.debug("上传分块文件失败:{}", e.getMessage());
return RestResponse.validfail(false,"上传分块失败");
}
}
//合并分块
@Override
public RestResponse mergechunks(Long companyId, String fileMd5, int chunkTotal, UploadFileParamsDto uploadFileParamsDto) {
//下载分块
File[] chunkFiles = checkChunkStatus(fileMd5, chunkTotal);
//得到合并后文件的扩展名
String filename = uploadFileParamsDto.getFilename();
//扩展名
String extension = filename.substring(filename.lastIndexOf("."));
File tempMergeFile = null;
try {
try {
//创建一个临时文件作为合并文件
tempMergeFile = File.createTempFile("'merge'", extension);
} catch (IOException e) {
XueChengPlusException.cast("创建临时合并文件出错");
}
//创建合并文件的流对象
try( RandomAccessFile raf_write =new RandomAccessFile(tempMergeFile, "rw")) {
byte[] b = new byte[1024];
for (File file : chunkFiles) {
//读取分块文件的流对象
try(RandomAccessFile raf_read = new RandomAccessFile(file, "r");) {
int len = -1;
while ((len = raf_read.read(b)) != -1) {
//向合并文件写数据
raf_write.write(b, 0, len);
}
}
}
} catch (IOException e) {
XueChengPlusException.cast("合并文件过程出错");
}
//校验合并后的文件是否正确
try {
FileInputStream mergeFileStream = new FileInputStream(tempMergeFile);
String mergeMd5Hex = DigestUtils.md5Hex(mergeFileStream);
if (!fileMd5.equals(mergeMd5Hex)) {
log.debug("合并文件校验不通过,文件路径:{},原始文件md5:{}", tempMergeFile.getAbsolutePath(), fileMd5);
XueChengPlusException.cast("合并文件校验不通过");
}
} catch (IOException e) {
log.debug("合并文件校验出错,文件路径:{},原始文件md5:{}", tempMergeFile.getAbsolutePath(), fileMd5);
XueChengPlusException.cast("合并文件校验出错");
}
//拿到合并文件在minio的存储路径
String mergeFilePath = getFilePathByMd5(fileMd5, extension);
//将合并后的文件上传到文件系统
addMediaFilesToMinIO(tempMergeFile.getAbsolutePath(), bucket_videofiles, mergeFilePath);
//将文件信息入库保存
uploadFileParamsDto.setFileSize(tempMergeFile.length());//合并文件的大小
addMediaFilesToDb(companyId, fileMd5, uploadFileParamsDto, bucket_videofiles, mergeFilePath);
return RestResponse.success(true);
}finally {
//删除临时分块文件
if(chunkFiles!=null){
for (File chunkFile : chunkFiles) {
if(chunkFile.exists()){
chunkFile.delete();
}
}
}
//删除合并的临时文件
if(tempMergeFile!=null){
tempMergeFile.delete();
}
}
}
private String getFilePathByMd5(String fileMd5,String fileExt){
return fileMd5.substring(0,1) + "/" + fileMd5.substring(1,2) + "/" + fileMd5 + "/" +fileMd5 +fileExt;
}
/**
* @description 下载分块
* @param fileMd5
* @param chunkTotal 分块数量
* @return java.io.File[] 分块文件数组
* @author Mr.M
* @date 2022/10/14 15:07
*/
private File[] checkChunkStatus(String fileMd5,int chunkTotal ){
//得到分块文件所在目录
String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
//分块文件数组
File[] chunkFiles = new File[chunkTotal];
//开始下载
for (int i = 0; i < chunkTotal; i++) {
//分块文件的路径
String chunkFilePath = chunkFileFolderPath + i;
//分块文件
File chunkFile = null;
try {
chunkFile = File.createTempFile("chunk", null);
} catch (IOException e) {
e.printStackTrace();
XueChengPlusException.cast("创建分块临时文件出错"+e.getMessage());
}
//下载分块文件
downloadFileFromMinIO(chunkFile, bucket_videofiles, chunkFilePath);
chunkFiles[i] = chunkFile;
}
return chunkFiles;
}
//根据桶和文件路径从minio下载文件
public File downloadFileFromMinIO(File file,String bucket,String objectName){
GetObjectArgs getObjectArgs = GetObjectArgs.builder().bucket(bucket).object(objectName).build();
try(
InputStream inputStream = minioClient.getObject(getObjectArgs);
FileOutputStream outputStream =new FileOutputStream(file);
) {
IOUtils.copy(inputStream,outputStream);
return file;
}catch (Exception e){
e.printStackTrace();
XueChengPlusException.cast("查询分块文件出错");
}
return null;
}
//得到分块文件的目录
private String getChunkFileFolderPath(String fileMd5) {
return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + "chunk" + "/";
}
//将文件上传到文件系统
private void addMediaFilesToMinIO(String filePath, String bucket, String objectName){
try {
UploadObjectArgs uploadObjectArgs = UploadObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.filename(filePath)
.build();
//上传
minioClient.uploadObject(uploadObjectArgs);
log.debug("文件上传成功:{}",filePath);
} catch (Exception e) {
XueChengPlusException.cast("文件上传到文件系统失败");
}
}
//将文件上传到分布式文件系统
private void addMediaFilesToMinIO(byte[] bytes, String bucket, String objectName) {
//资源的媒体类型
String contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;//默认未知二进制流
if (objectName.indexOf(".") >= 0) {
//取objectName中的扩展名
String extension = objectName.substring(objectName.lastIndexOf("."));
ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(extension);
if (extensionMatch != null) {
contentType = extensionMatch.getMimeType();
}
}
try {
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
PutObjectArgs putObjectArgs = PutObjectArgs.builder()
.bucket(bucket)
.object(objectName)
//InputStream stream, long objectSize 对象大小, long partSize 分片大小(-1表示5M,最大不要超过5T,最多10000)
.stream(byteArrayInputStream, byteArrayInputStream.available(), -1)
.contentType(contentType)
.build();
//上传到minio
minioClient.putObject(putObjectArgs);
} catch (Exception e) {
e.printStackTrace();
log.debug("上传文件到文件系统出错:{}", e.getMessage());
XueChengPlusException.cast("上传文件到文件系统出错");
}
}
//根据日期拼接目录
private String getFileFolder(Date date, boolean year, boolean month, boolean day) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
//获取当前日期字符串
String dateString = sdf.format(new Date());
//取出年、月、日
String[] dateStringArray = dateString.split("-");
StringBuffer folderString = new StringBuffer();
if (year) {
folderString.append(dateStringArray[0]);
folderString.append("/");
}
if (month) {
folderString.append(dateStringArray[1]);
folderString.append("/");
}
if (day) {
folderString.append(dateStringArray[2]);
folderString.append("/");
}
return folderString.toString();
}
public static void main(String[] args) {
String extension = ".jpg";
ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(extension);
if (extensionMatch != null) {
System.out.println(extensionMatch.getMimeType());
}
}
}
上传视频
理解断点续传
什么是断点续传
通常视频文件都比较大,所以对于媒资系统上传文件的需求要满足大文件的上传要求。http协议本身对上传文件大小没有限制,但是客户的网络环境质量、电脑硬件环境等参差不齐,如果一个大文件快上传完了网断了没有上传完成,需要客户重新上传,用户体验非常差,所以对于大文件上传的要求最基本的是断点续传。
什么是断点续传:
引用百度百科:断点续传指的是在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为的划分为几个部分,每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传下载未完成的部分,而没有必要从头开始上传下载,断点续传可以提高节省操作时间,提高用户体验性。
断点续传流程如下图:
流程如下:
1、前端上传前先把文件分成块
2、一块一块的上传,上传中断后重新上传,已上传的分块则不用再上传
3、各分块上传完成最后在服务端合并文件
分块与合并测试
为了更好的理解文件分块上传的原理,下边用java代码测试文件的分块与合并。
文件分块的流程如下:
1、获取源文件长度
2、根据设定的分块文件的大小计算出块数
3、从源文件读数据依次向每一个块文件写数据。
测试代码如下:
package com.xuecheng.media;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.io.IOUtils;
import org.junit.jupiter.api.Test;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.util.*;
/**
* @author Mr.M
* @version 1.0
* @description 大文件处理测试
* @date 2022/9/13 9:21
*/
public class BigFileTest {
//测试文件分块方法
@Test
public void testChunk() throws IOException {
//源文件
File sourceFile = new File("d:/develop/bigfile_test/nacos.avi");
//分块文件存储路径
String chunkPath = "d:/develop/bigfile_test/chunk/";
File chunkFolder = new File(chunkPath);
if (!chunkFolder.exists()) {
chunkFolder.mkdirs();
}
//分块大小
long chunkSize = 1024 * 1024 * 1;
//分块数量
long chunkNum = (long) Math.ceil(sourceFile.length() * 1.0 / chunkSize);
System.out.println("分块总数:"+chunkNum);
//缓冲区大小
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);
if(file.exists()){
file.delete();
}
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();
System.out.println("完成分块"+i);
}
}
raf_read.close();
}
}
文件合并流程:
1、找到要合并的文件并按文件合并的先后进行排序。
2、创建合并文件
3、依次从合并的文件中读取数据向合并文件写入数
文件合并的测试代码 :
//测试文件合并方法
@Test
public void testMerge() throws IOException {
//块文件目录
File chunkFolder = new File("d:/develop/bigfile_test/chunk/");
//原始文件
File originalFile = new File("d:/develop/bigfile_test/nacos.avi");
//合并文件
File mergeFile = new File("d:/develop/bigfile_test/nacos01.avi");
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) {
return Integer.parseInt(o1.getName()) - Integer.parseInt(o2.getName());
}
});
//合并文件
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();
//校验文件
try (
FileInputStream fileInputStream = new FileInputStream(originalFile);
FileInputStream mergeFileStream = new FileInputStream(mergeFile);
) {
//取出原始文件的md5
String originalMd5 = DigestUtils.md5Hex(fileInputStream);
//取出合并文件的md5进行比较
String mergeFileMd5 = DigestUtils.md5Hex(mergeFileStream);
if (originalMd5.equals(mergeFileMd5)) {
System.out.println("合并文件成功");
} else {
System.out.println("合并文件失败");
}
}
}
上传视频流程
下图是上传视频的整体流程:
1、前端上传文件前请求媒资接口层检查文件是否存在,如果已经存在则不再上传。
2、如果文件在系统不存在前端开始上传,首先对视频文件进行分块(前端分块)
3、前端分块进行上传,上传前首先检查分块是否上传,如已上传则不再上传,如果未上传则开始上传分块。
4、前端请求媒资管理接口层请求上传分块。
5、接口层请求服务层上传分块。
6、服务端将分块信息上传到MinIO。
7、前端将分块上传完毕请求接口层合并分块。
8、接口层请求服务层合并分块。
9、服务层根据文件信息找到MinIO中的分块文件,下载到本地临时目录,将所有分块下载完毕后开始合并 。
10、合并完成将合并后的文件上传到MinIO。
接口定义
根据上传视频流程,定义如下接口。
package com.xuecheng.media.api;
import com.j256.simplemagic.ContentInfo;
import com.j256.simplemagic.ContentInfoUtil;
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.*;
import org.springframework.web.multipart.MultipartFile;
/**
* @author Mr.M
* @version 1.0
* @description 大文件上传接口
* @date 2022/9/6 11:29
*/
@Api(value = "大文件上传接口", tags = "大文件上传接口")
@RestController
public class BigFilesController {
@Autowired
MediaFileService mediaFileService;
@ApiOperation(value = "文件上传前检查文件")
@PostMapping("/upload/checkfile")
public RestResponse<Boolean> checkfile(
@RequestParam("fileMd5") String fileMd5
) throws Exception {
}
@ApiOperation(value = "分块文件上传前的检测")
@PostMapping("/upload/checkchunk")
public RestResponse<Boolean> checkchunk(@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunk") int chunk) throws Exception {
}
@ApiOperation(value = "上传分块文件")
@PostMapping("/upload/uploadchunk")
public RestResponse uploadchunk(@RequestParam("file") MultipartFile file,
@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunk") int chunk) throws Exception {
}
@ApiOperation(value = "合并文件")
@PostMapping("/upload/mergechunks")
public RestResponse mergechunks(@RequestParam("fileMd5") String fileMd5,
@RequestParam("fileName") String fileName,
@RequestParam("chunkTotal") int chunkTotal) throws Exception {
}
}
RestResponse 是一个通用的模型类,在base工程定义如下,从课程资料中拷贝RestResponse.java类到base工程下的model包下。
package com.xuecheng.base.model;
import lombok.Data;
import lombok.ToString;
/**
* @description 通用结果类型
* @author Mr.M
* @date 2022/9/13 14:44
* @version 1.0
*/
@Data
@ToString
public class RestResponse<T> {
/**
* 响应编码,0为正常,-1错误
*/
private int code;
/**
* 响应提示信息
*/
private String msg;
/**
* 响应内容
*/
private T result;
public RestResponse() {
this(0, "success");
}
public RestResponse(int code, String msg) {
this.code = code;
this.msg = msg;
}
/**
* 错误信息的封装
*
* @param msg
* @param <T>
* @return
*/
public static <T> RestResponse<T> validfail(String msg) {
RestResponse<T> response = new RestResponse<T>();
response.setCode(-1);
response.setMsg(msg);
return response;
}
/**
* 添加正常响应数据(包含响应内容)
*
* @return RestResponse Rest服务封装相应数据
*/
public static <T> RestResponse<T> success(T result) {
RestResponse<T> response = new RestResponse<T>();
response.setResult(result);
return response;
}
/**
* 添加正常响应数据(不包含响应内容)
*
* @return RestResponse Rest服务封装相应数据
*/
public static <T> RestResponse<T> success() {
return new RestResponse<T>();
}
public Boolean isSuccessful() {
return this.code == 0;
}
}
接口开发
DAO开发
向媒资数据库的文件表插入记录,使用自动生成的Mapper接口即可满足要求。
Service开发
接口定义
首先定义接口,从课程资料中拷贝RestResponse.java类到base工程下的model包下。
1、检查文件方法
检查文件是否在系统存在
/**
* @description 检查文件是否存在
* @param fileMd5 文件的md5
* @return com.xuecheng.base.model.RestResponse<java.lang.Boolean> false不存在,true存在
* @author Mr.M
* @date 2022/9/13 15:38
*/
public RestResponse<Boolean> checkFile(String fileMd5);
2、检查分块是否存在
/**
* @description 检查分块是否存在
* @param fileMd5 文件的md5
* @param chunkIndex 分块序号
* @return com.xuecheng.base.model.RestResponse<java.lang.Boolean> false不存在,true存在
* @author Mr.M
* @date 2022/9/13 15:39
*/
public RestResponse<Boolean> checkChunk(String fileMd5, int chunkIndex);
3、上传分块
/**
* @description 上传分块
* @param fileMd5 文件md5
* @param chunk 分块序号
* @param bytes 文件字节
* @return com.xuecheng.base.model.RestResponse
* @author Mr.M
* @date 2022/9/13 15:50
*/
public RestResponse uploadChunk(String fileMd5,int chunk,byte[] bytes);
4、合并分块
/**
* @description 合并分块
* @param companyId 机构id
* @param fileMd5 文件md5
* @param chunkTotal 分块总和
* @param uploadFileParamsDto 文件信息
* @return com.xuecheng.base.model.RestResponse
* @author Mr.M
* @date 2022/9/13 15:56
*/
public RestResponse mergechunks(Long companyId,String fileMd5,int chunkTotal,UploadFileParamsDto uploadFileParamsDto);
检查文件和分块
接口完成进行接口实现,首先实现检查文件方法和检查分块方法。
service接口定义
/**
* @description 检查文件是否存在
* @param fileMd5 文件的md5
* @return com.xuecheng.base.model.RestResponse<java.lang.Boolean> false不存在,true存在
* @author Mr.M
* @date 2022/9/13 15:38
*/
public RestResponse<Boolean> checkFile(String fileMd5);
/**
* @description 检查分块是否存在
* @param fileMd5 文件的md5
* @param chunkIndex 分块序号
* @return com.xuecheng.base.model.RestResponse<java.lang.Boolean> false不存在,true存在
* @author Mr.M
* @date 2022/9/13 15:39
*/
public RestResponse<Boolean> checkChunk(String fileMd5, int chunkIndex);
service接口实现方法:
@Override
public RestResponse<Boolean> checkFile(String fileMd5) {
//查询文件信息
MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
if (mediaFiles != null) {
//桶
String bucket = mediaFiles.getBucket();
//存储目录
String filePath = mediaFiles.getFilePath();
//文件流
InputStream stream = null;
try {
stream = minioClient.getObject(
GetObjectArgs.builder()
.bucket(bucket)
.object(filePath)
.build());
if (stream != null) {
//文件已存在
return RestResponse.success(true);
}
} catch (Exception e) {
}
}
//文件不存在
return RestResponse.success(false);
}
@Override
public RestResponse<Boolean> checkChunk(String fileMd5, int chunkIndex) {
//得到分块文件目录
String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
//得到分块文件的路径
String chunkFilePath = chunkFileFolderPath + chunkIndex;
//文件流
InputStream fileInputStream = null;
try {
fileInputStream = minioClient.getObject(
GetObjectArgs.builder()
.bucket(bucket_videoFiles)
.object(chunkFilePath)
.build());
if (fileInputStream != null) {
//分块已存在
return RestResponse.success(true);
}
} catch (Exception e) {
}
//分块未存在
return RestResponse.success(false);
}
//得到分块文件的目录
private String getChunkFileFolderPath(String fileMd5) {
return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + "chunk" + "/";
}
上传分块
定义service接口
/**
* @description 上传分块
* @param fileMd5 文件md5
* @param chunk 分块序号
* @param bytes 文件字节
* @return com.xuecheng.base.model.RestResponse
* @author Mr.M
* @date 2022/9/13 15:50
*/
public RestResponse uploadChunk(String fileMd5,int chunk,byte[] bytes);
接口实现:
@Override
public RestResponse uploadChunk(String fileMd5, int chunk, byte[] bytes) {
//得到分块文件的目录路径
String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
//得到分块文件的路径
String chunkFilePath = chunkFileFolderPath + chunk;
try {
//将文件存储至minIO
addMediaFilesToMinIO(bytes, bucket_videoFiles,chunkFilePath,"application/octet-stream");
} catch (Exception ex) {
ex.printStackTrace();
XueChengPlusException.cast("上传过程出错请重试");
}
return RestResponse.success();
}
下载分块
合并分块前要检查分块文件是否全部上传完成,如果完成则将已经上传的分块文件下载下来,然后再进行合并,下边先实现检查及下载所有分块的方法。
//检查所有分块是否上传完毕
private File[] checkChunkStatus(String fileMd5, int chunkTotal) {
//得到分块文件的目录路径
String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
File[] files = new File[chunkTotal];
//检查分块文件是否上传完毕
for (int i = 0; i < chunkTotal; i++) {
String chunkFilePath = chunkFileFolderPath + i;
//下载文件
File chunkFile =null;
try {
chunkFile = File.createTempFile("chunk" + i, null);
} catch (IOException e) {
e.printStackTrace();
XueChengPlusException.cast("下载分块时创建临时文件出错");
}
downloadFileFromMinIO(chunkFile,bucket_videoFiles,chunkFilePath);
files[i]=chunkFile;
}
return files;
}
//得到分块文件的目录
private String getChunkFileFolderPath(String fileMd5) {
return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + "chunk" + "/";
}
//根据桶和文件路径从minio下载文件
public File downloadFileFromMinIO(File file,String bucket,String objectName){
InputStream fileInputStream = null;
OutputStream fileOutputStream = null;
try {
fileInputStream = minioClient.getObject(
GetObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.build());
try {
fileOutputStream = new FileOutputStream(file);
IOUtils.copy(fileInputStream, fileOutputStream);
} catch (IOException e) {
XueChengPlusException.cast("下载文件"+objectName+"出错");
}
} catch (Exception e) {
e.printStackTrace();
XueChengPlusException.cast("文件不存在"+objectName);
} finally {
if (fileInputStream != null) {
try {
fileInputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (fileOutputStream != null) {
try {
fileOutputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return file;
}
合并分块
所有分块文件下载成功后开始合并这些分块文件。
@Override
public RestResponse mergechunks(Long companyId, String fileMd5, int chunkTotal, UploadFileParamsDto uploadFileParamsDto) {
String fileName = uploadFileParamsDto.getFilename();
//下载所有分块文件
File[] chunkFiles = checkChunkStatus(fileMd5, chunkTotal);
//扩展名
String extName = fileName.substring(fileName.lastIndexOf("."));
//创建临时文件作为合并文件
File mergeFile = null;
try {
mergeFile = File.createTempFile(fileMd5, extName);
} catch (IOException e) {
XueChengPlusException.cast("合并文件过程中创建临时文件出错");
}
try {
//开始合并
byte[] b = new byte[1024];
try(RandomAccessFile raf_write = new RandomAccessFile(mergeFile, "rw");) {
for (File chunkFile : chunkFiles) {
try (FileInputStream chunkFileStream = new FileInputStream(chunkFile);) {
int len = -1;
while ((len = chunkFileStream.read(b)) != -1) {
//向合并后的文件写
raf_write.write(b, 0, len);
}
}
}
} catch (IOException e) {
e.printStackTrace();
XueChengPlusException.cast("合并文件过程中出错");
}
log.debug("合并文件完成{}",mergeFile.getAbsolutePath());
uploadFileParamsDto.setFileSize(mergeFile.length());
try (InputStream mergeFileInputStream = new FileInputStream(mergeFile);) {
//对文件进行校验,通过比较md5值
String newFileMd5 = DigestUtils.md5Hex(mergeFileInputStream);
if (!fileMd5.equalsIgnoreCase(newFileMd5)) {
//校验失败
XueChengPlusException.cast("合并文件校验失败");
}
log.debug("合并文件校验通过{}",mergeFile.getAbsolutePath());
} catch (Exception e) {
e.printStackTrace();
//校验失败
XueChengPlusException.cast("合并文件校验异常");
}
//将临时文件上传至minio
String mergeFilePath = getFilePathByMd5(fileMd5, extName);
try {
//上传文件到minIO
addMediaFilesToMinIO(mergeFile.getAbsolutePath(), bucket_videoFiles, mergeFilePath);
log.debug("合并文件上传MinIO完成{}",mergeFile.getAbsolutePath());
} catch (Exception e) {
e.printStackTrace();
XueChengPlusException.cast("合并文件时上传文件出错");
}
//入数据库
MediaFiles mediaFiles = addMediaFilesToDb(companyId, fileMd5, uploadFileParamsDto, bucket_videoFiles, mergeFilePath);
if (mediaFiles == null) {
XueChengPlusException.cast("媒资文件入库出错");
}
return RestResponse.success();
} finally {
//删除临时文件
for (File file : chunkFiles) {
try {
file.delete();
} catch (Exception e) {
}
}
try {
mergeFile.delete();
} catch (Exception e) {
}
}
}
private String getFilePathByMd5(String fileMd5,String fileExt){
return fileMd5.substring(0,1) + "/" + fileMd5.substring(1,2) + "/" + fileMd5 + "/" +fileMd5 +fileExt;
}
//将文件上传到minIO,传入文件绝对路径
public void addMediaFilesToMinIO(String filePath, String bucket, String objectName) {
try {
minioClient.uploadObject(
UploadObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.filename(filePath)
.build());
} catch (Exception e) {
e.printStackTrace();
XueChengPlusException.cast("上传文件到文件系统出错");
}
}
接口层完善
下边完善接口层
@ApiOperation(value = "文件上传前检查文件")
@PostMapping("/upload/checkfile")
public RestResponse<Boolean> checkfile(
@RequestParam("fileMd5") String fileMd5
) throws Exception {
return mediaFileService.checkFile(fileMd5);
}
@ApiOperation(value = "分块文件上传前的检测")
@PostMapping("/upload/checkchunk")
public RestResponse<Boolean> checkchunk(@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunk") int chunk) throws Exception {
return mediaFileService.checkChunk(fileMd5,chunk);
}
@ApiOperation(value = "上传分块文件")
@PostMapping("/upload/uploadchunk")
public RestResponse uploadchunk(@RequestParam("file") MultipartFile file,
@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunk") int chunk) throws Exception {
return mediaFileService.uploadChunk(fileMd5,chunk,file.getBytes());
}
@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.setFileType("001002");
uploadFileParamsDto.setTags("课程视频");
uploadFileParamsDto.setRemark("");
uploadFileParamsDto.setFilename(fileName);
ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(fileName);
String mimeType = extensionMatch.getMimeType();
uploadFileParamsDto.setContentType(mimeType);
return mediaFileService.mergechunks(companyId,fileMd5,chunkTotal,uploadFileParamsDto);
}
接口测试
如果是单个接口测试使用httpclient
### 检查文件
POST{{media_host}}/media/upload/register
Content-Type: application/x-www-form-urlencoded;
fileMd5=c5c75d70f382e6016d2f506d134eee11
### 上传分块前检查
POST {{media_host}}/media/upload/checkchunk
Content-Type: application/x-www-form-urlencoded;
fileMd5=c5c75d70f382e6016d2f506d134eee11&chunk=0
### 上传分块文件
POST {{media_host}}/media/upload/uploadchunk?fileMd5=c5c75d70f382e6016d2f506d134eee11&chunk=1
Content-Type: multipart/form-data; boundary=WebAppBoundary
--WebAppBoundary
Content-Disposition: form-data; name="file"; filename="1"
Content-Type: application/octet-stream
< E:/ffmpeg_test/chunks/1
### 合并文件
POST {{media_host}}/media/upload/mergechunks
Content-Type: application/x-www-form-urlencoded;
fileMd5=dcb37b85c9c03fc5243e20ab4dfbc1c8&fileName=8.avi&chunkTotal=1
下边介绍采用前后联调:
1、首先在每个接口层方法上打开断点
在前端上传视频,观察接口层是否收到参数。
2、进入service方法逐行跟踪。
3、断点续传测试
上传一部分后,停止刷新浏览器再重新上传,通过浏览器日志发现已经上传过的分块不再重新上传
文件预览
需求分析
图片上传成功、视频上传成功可以通过预览功能查看文件的内容。
预览的方式是通过浏览器直接打文件,对于图片和浏览器支持的视频格式的视频文件可以直接预览。
业务流程如下:
说明如下:
1、前端请求接口层预览文件
2、接口层将文件id传递给服务层
3、服务层使用文件id查询媒资数据库文件表,获取文件的url
4、接口层将文件url返回给前端,通过浏览器打开URL。
接口定义
根据需求分析定义接口如下:
@ApiOperation("预览文件")
@GetMapping("/preview/{mediaId}")
public RestResponse<String> getPlayUrlByMediaId(@PathVariable String mediaId){
}
接口开发
6.3.1 DAO开发
使用自动生成的MediaFiels表的Mapper接口。
6.3.2 Service开发
定义根据id查询媒资文件接口
/**
* @description 根据id查询文件信息
* @param id 文件id
* @return com.xuecheng.media.model.po.MediaFiles 文件信息
* @author Mr.M
* @date 2022/9/13 17:47
*/
public MediaFiles getFileById(String id);
方法实现:
public MediaFiles getFileById(String id) {
return mediaFilesMapper.selectById(id);
}
接口层完善
对接口层完善:
@ApiOperation("预览文件")
@GetMapping("/preview/{mediaId}")
public RestResponse<String> getPlayUrlByMediaId(@PathVariable String mediaId){
MediaFiles mediaFiles = mediaFileService.getFileById(mediaId);
if(mediaFiles == null || StringUtils.isEmpty(mediaFiles.getUrl())){
XueChengPlusException.cast("视频还没有转码处理");
}
return RestResponse.success(mediaFiles.getUrl());
}
接口测试
使用前后端联调。
上传mp4视频文件,预览文件。
上传图片文件,预览文件。
对于无法预览的视频文件,稍后通过视频处理对视频转码。
视频处理
视频编码技术
什么是视频编码
视频上传成功后需要对视频进行转码处理。
什么是视频编码?查阅百度百科如下:
详情参考 :百度百科-验证
首先我们要分清文件格式和编码格式:
文件格式:是指.mp4、.avi、.rmvb等 这些不同扩展名的视频文件的文件格式 ,视频文件的内容主要包括视频和音频,其文件格式是按照一 定的编码格式去编码,并且按照该文件所规定的封装格式将视频、音频、字幕等信息封装在一起,播放器会根据它们的封装格式去提取出编码,然后由播放器解码,最终播放音视频。
音视频编码格式:通过音视频的压缩技术,将视频格式转换成另一种视频格式,通过视频编码实现流媒体的传输。比如:一个.avi的视频文件原来的编码是a,通过编码后编码格式变为b,音频原来为c,通过编码后变为d。
音视频编码格式各类繁多,主要有几下几类:
MPEG系列
(由ISO[国际标准组织机构]下属的MPEG[运动图象专家组]开发 )视频编码方面主要是Mpeg1(vcd用的就是它)、Mpeg2(DVD使用)、Mpeg4(的DVDRIP使用的都是它的变种,如:divx,xvid等)、Mpeg4 AVC(正热门);音频编码方面主要是MPEG Audio Layer 1/2、MPEG Audio Layer 3(大名鼎鼎的mp3)、MPEG-2 AAC 、MPEG-4 AAC等等。注意:DVD音频没有采用Mpeg的。
H.26X系列
(由ITU[国际电传视讯联盟]主导,侧重网络传输,注意:只是视频编码)
包括H.261、H.262、H.263、H.263+、H.263++、H.264(就是MPEG4 AVC-合作的结晶)
目前最常用的编码标准是视频H.264,音频AAC。
提问:
H.264是编码格式还是文件格式?
mp4是编码格式还是文件格式?
FFmpeg 的基本使用
我们将视频录制完成后,使用视频编码软件对视频进行编码,本项目 使用FFmpeg对视频进行编码
。
FFmpeg被许多开源项目采用,QQ影音、暴风影音、VLC等。
下载:FFmpeg Download FFmpeg
请从课程资料目录解压ffmpeg.zip,并将解压得到的exe文件加入环境变量。
测试是否正常:cmd运行 ffmpeg -v
安装成功,作下简单测试
将一个.avi文件转成mp4、mp3、gif等。
比如我们将nacos.avi文件转成mp4,运行如下命令:
ffmpeg -i nacos.avi nacos.mp4
转成mp3:ffmpeg -i nacos.avi nacos.mp3
转成gif:ffmpeg -i nacos.avi nacos.gif
官方文档(英文):ffmpeg Documentation
视频转码工具类
package com.xuecheng.base.utils;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
public class Mp4VideoUtil extends VideoUtil {
String ffmpeg_path = "D:\\Program Files\\ffmpeg-20180227-fa0c9d6-win64-static\\bin\\ffmpeg.exe";//ffmpeg的安装位置
String video_path = "D:\\BaiduNetdiskDownload\\test1.avi";
String mp4_name = "test1.mp4";
String mp4folder_path = "D:/BaiduNetdiskDownload/Movies/test1/";
public Mp4VideoUtil(String ffmpeg_path, String video_path, String mp4_name, String mp4folder_path){
super(ffmpeg_path);
this.ffmpeg_path = ffmpeg_path;
this.video_path = video_path;
this.mp4_name = mp4_name;
this.mp4folder_path = mp4folder_path;
}
//清除已生成的mp4
private void clear_mp4(String mp4_path){
//删除原来已经生成的m3u8及ts文件
File mp4File = new File(mp4_path);
if(mp4File.exists() && mp4File.isFile()){
mp4File.delete();
}
}
/**
* 视频编码,生成mp4文件
* @return 成功返回success,失败返回控制台日志
*/
public String generateMp4(){
//清除已生成的mp4
// clear_mp4(mp4folder_path+mp4_name);
clear_mp4(mp4folder_path);
/*
ffmpeg.exe -i lucene.avi -c:v libx264 -s 1280x720 -pix_fmt yuv420p -b:a 63k -b:v 753k -r 18 .\lucene.mp4
*/
List<String> commend = new ArrayList<String>();
//commend.add("D:\\Program Files\\ffmpeg-20180227-fa0c9d6-win64-static\\bin\\ffmpeg.exe");
commend.add(ffmpeg_path);
commend.add("-i");
// commend.add("D:\\BaiduNetdiskDownload\\test1.avi");
commend.add(video_path);
commend.add("-c:v");
commend.add("libx264");
commend.add("-y");//覆盖输出文件
commend.add("-s");
commend.add("1280x720");
commend.add("-pix_fmt");
commend.add("yuv420p");
commend.add("-b:a");
commend.add("63k");
commend.add("-b:v");
commend.add("753k");
commend.add("-r");
commend.add("18");
// commend.add(mp4folder_path + mp4_name );
commend.add(mp4folder_path );
String outstring = null;
try {
ProcessBuilder builder = new ProcessBuilder();
builder.command(commend);
//将标准输入流和错误输入流合并,通过标准输入流程读取信息
builder.redirectErrorStream(true);
Process p = builder.start();
outstring = waitFor(p);
} catch (Exception ex) {
ex.printStackTrace();
}
// Boolean check_video_time = this.check_video_time(video_path, mp4folder_path + mp4_name);
Boolean check_video_time = this.check_video_time(video_path, mp4folder_path);
if(!check_video_time){
return outstring;
}else{
return "success";
}
}
public static void main(String[] args) throws IOException {
// ProcessBuilder builder = new ProcessBuilder();
// builder.command("C:\\Program Files (x86)\\Tencent\\QQ\\Bin\\QQScLauncher.exe");
// //将标准输入流和错误输入流合并,通过标准输入流程读取信息
// builder.redirectErrorStream(true);
// Process p = builder.start();
//ffmpeg的路径
String ffmpeg_path = "D:\\soft\\ffmpeg\\ffmpeg.exe";//ffmpeg的安装位置
//源avi视频的路径
String video_path = "D:\\develop\\bigfile_test\\nacos_01.avi";
//转换后mp4文件的名称
String mp4_name = "nacos_01.mp4";
//转换后mp4文件的路径
String mp4_path = "D:\\develop\\bigfile_test\\nacos_01.mp4";
//创建工具类对象
Mp4VideoUtil videoUtil = new Mp4VideoUtil(ffmpeg_path,video_path,mp4_name,mp4_path);
//开始视频转换,成功将返回success
String s = videoUtil.generateMp4();
System.out.println(s);
}
}
更多推荐
所有评论(0)