企业级微服务大项目实战《学成在线》【四】(媒资管理模块)
封面为啥要用苍穹外卖,想纪念下下以前的项目,不知道现在还跑得起来不哈哈哈哈~
上传图片
大部分都是源文档的东西,懒得写了~
流程:
课程图片上传至分布式文件系统,在课程信息中保存课程图片路径,如下流程:
1、前端进入上传图片界面
2、上传图片,请求媒资管理服务。
3、媒资管理服务将图片文件存储在MinIO。
4、媒资管理记录文件信息到数据库。
5、保存课程信息,在内容管理数据库保存图片地址。
环境准备
首先在minio配置bucket,bucket名称为:mediafiles,并设置bucket的权限为公开。
在nacos配置中minio的相关信息,进入media-service-dev.yaml:
minio:
endpoint: http://localhost:9000
accessKey: minioadmin
secretKey: minioadmin
bucket:
files: mediafiles
videofiles: video
在media-service工程编写minio的配置类:
MinIO配置属性类
package com.xuecheng.media.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* @author woldier
* @version 1.0
* @description TODO
* @date 2023/3/9 21:31
**/
@Configuration
@ConfigurationProperties(prefix = "minio")
public class MinIOProperties {
}
Minio配置类
package com.xuecheng.media.config;
import io.minio.MinioClient;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author woldier
* @version 1.0
* @description TODO
* @date 2023/3/9 21:33
**/
@Configuration
@EnableConfigurationProperties({MinIOProperties.class})
@RequiredArgsConstructor
public class MinIOConfig {
private final MinIOProperties minIOProperties;
/**
* @description 初始化MinIO客户端,并且交给spring管理
*
* @return io.minio.MinioClient
* @author: woldier
* @date: 2023/3/9 21:36
*/
@Bean
public MinioClient minioClient(){
return MinioClient.builder()
.endpoint("http://localhost:9000")
.credentials("minioadmin", "minioadmin")
.build();
}
}
接口定义
根据需求分析,下边进行接口定义,此接口定义为一个通用的上传文件接口,可以上传图片或其它文件。
首先分析接口:
请求地址:/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
}
在media-model定义上传响应模型类:
package com.xuecheng.media.model.dto;
import com.xuecheng.media.model.po.MediaFiles;
/**
* @author woldier
* @version 1.0
* @description 上传文件响应结果类
* @date 2023/3/9 21:55
**/
public class UploadFileResultDto extends MediaFiles {
}
定义接口如下
/**
* @param upload 表单数据
* @param folder 文件夹名-非必须
* @param objectName 文件名-非必须
* @return com.xuecheng.media.model.dto.UploadFileResultDto
* @description 文件上传
* @author: woldier
* @date: 2023/3/9 22:09
*/
@ApiOperation("文件上传")
@RequestMapping(value = "/upload/coursefile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
// 定义请求url与可消费的文件操作content-type类型
public UploadFileResultDto upload(
@RequestPart("filedata") MultipartFile upload,
@RequestParam(value = "folder", required = false) String folder,
@RequestParam(value = "objectName", required = false) String objectName) {
return null;
}
环境准备
首先在minio配置bucket,bucket名称为:mediafiles,并设置bucket的权限为公开。
在nacos配置中minio的相关信息,进入media-service-dev.yaml:
minio:
endpoint: http://localhost:9000
accessKey: minioadmin
secretKey: minioadmin
bucket:
files: mediafiles
videofiles: video
在media-service工程编写minio的配置类:
MinIO配置属性类
package com.xuecheng.media.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* @author woldier
* @version 1.0
* @description TODO
* @date 2023/3/9 21:31
**/
@Configuration
@ConfigurationProperties(prefix = "minio")
public class MinIOProperties {
}
Minio配置类
package com.xuecheng.media.config;
import io.minio.MinioClient;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author woldier
* @version 1.0
* @description TODO
* @date 2023/3/9 21:33
**/
@Configuration
@EnableConfigurationProperties({MinIOProperties.class})
@RequiredArgsConstructor
public class MinIOConfig {
private final MinIOProperties minIOProperties;
/**
* @description 初始化MinIO客户端,并且交给spring管理
*
* @return io.minio.MinioClient
* @author: woldier
* @date: 2023/3/9 21:36
*/
@Bean
public MinioClient minioClient(){
return MinioClient.builder()
.endpoint("http://localhost:9000")
.credentials("minioadmin", "minioadmin")
.build();
}
}
接口定义
根据需求分析,下边进行接口定义,此接口定义为一个通用的上传文件接口,可以上传图片或其它文件。
首先分析接口:
请求地址:/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
}
在media-model定义上传响应模型类:
package com.xuecheng.media.model.dto;
import com.xuecheng.media.model.po.MediaFiles;
/**
* @author woldier
* @version 1.0
* @description 上传文件响应结果类
* @date 2023/3/9 21:55
**/
public class UploadFileResultDto extends MediaFiles {
}
定义接口如下
/**
* @param upload 表单数据
* @param folder 文件夹名-非必须
* @param objectName 文件名-非必须
* @return com.xuecheng.media.model.dto.UploadFileResultDto
* @description 文件上传
* @author: woldier
* @date: 2023/3/9 22:09
*/
@ApiOperation("文件上传")
@RequestMapping(value = "/upload/coursefile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
// 定义请求url与可消费的文件操作content-type类型
public UploadFileResultDto upload(
@RequestPart("filedata") MultipartFile upload,
@RequestParam(value = "folder", required = false) String folder,
@RequestParam(value = "objectName", required = false) String objectName) {
return null;
}
controller层中我们需要将文件暂存在本地,让后将临时文件的地址放进服务层方法参数中
接口开发
根据需求分析DAO层实现向media_files表插入一条记录,使用media_files表生成的mapper即可。
为了使代码更具有可读性,我们创建了两个枚举工具类,用于区分数据库字段值
可以看到我们操作mediaFile表时需要用到这两种字段,因此我们使用枚举简化
在meida-model的dto包下创建如下两个枚举类
package com.xuecheng.media.model.dto;
/**
* @author woldier
* @version 1.0
* @description 媒体资源类型枚举
* [{"code":"001001","desc":"图片"},{"code":"001002","desc":"视频"},{"code":"001003","desc":"其它"}]
* @date 2023/3/10 15:45
**/
public enum MediaResourceType {
IMAGE("001001","图片"),
VIDEO("001002","视频"),
OTHER("001003","其它")
;
private String code;
private String description;
MediaResourceType(String code, String description) {
this.code = code;
this.description = description;
}
public String getCode() {
return code;
}
}
package com.xuecheng.media.model.dto;
/**
* @author woldier
* @version 1.0
* @description 审核状态
* [{"code":"002001","desc":"审核未通过"},
* {"code":"002002","desc":"未审核"},
* {"code":"002003","desc":"审核通过"}]
* @date 2023/3/10 14:55
**/
public enum MediaAuditStatus {
NOT_Approved("002001","审核未通过"),
Not_Audited("002002","未审核"),
Approved("002003","审核通过");
private String code;
private String description;
MediaAuditStatus(String code, String description){
this.code = code;
this.description = description;
}
public String getCode() {
return code;
}
}
除此之外由于我们操作的数据表有公共字段,updateTime,createTime。因此我们可以加入一个mp的自动填充功能。
在media-service的config包下创建如下类
package com.xuecheng.media.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* <P>
* Mybatis-Plus 配置
* </p>
*/
@Configuration
@MapperScan("com.xuecheng.media.mapper")
public class MybatisPlusConfig {
/**
* 新的分页插件
* 需要设置 MybatisConfiguration#useDeprecatedExecutor = false
* 避免缓存出现问题(该属性会在旧插件移除后一同移除)
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
Service方法需要提供一个更加通用的保存文件的方法。
定义请求参数类:
package com.xuecheng.media.model.dto;
import lombok.Data;
/**
* @author woldier
* @version 1.0
* @description 文件上传通用参数dto,这里的大部分都来自与 MediaFiles
* @date 2023/3/9 22:14
**/
@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方法:
com.xuecheng.media.service.MediaFileService
/**
* @param companyId 公司ID
* @param uploadFileParamsDto 上传文件参数类
* @param LocalFilePath 要上传的文件其本地路径
* @return com.xuecheng.media.model.dto.UploadFileResultDto
* @description 上传文件
* @author: woldier
* @date: 2023/3/10 13:36
*/
UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, String LocalFilePath) throws XueChengPlusException;
/**
* @description 将上传的文件插入数据库
* @param companyId
* @param uploadFileParamsDto
* @param md5
* @param bucket
* @param objectName
* @return com.xuecheng.media.model.po.MediaFiles
* @author: woldier
* @date: 2023/3/10 16:42
*/
MediaFiles insertMediaFile2DB(Long companyId, UploadFileParamsDto uploadFileParamsDto, String md5, String bucket,String objectName);
com.xuecheng.media.service.impl.MediaFileServiceImpl
/**
* @param companyId 公司ID
* @param uploadFileParamsDto 上传文件参数类
* @param localFilePath 要上传的文件其本地路径
* @return com.xuecheng.media.model.dto.UploadFileResultDto
* @description 上传文件
* @author: woldier
* @date: 2023/3/10 13:36
*/
@Override
public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, String localFilePath) throws XueChengPlusException {
/*
* 1.上传文件到minio ,文件路径为 /{桶名}/{年}/{月}/{日}/
* 2.插入数据库
* */
/*通过扩展名获取媒体资源类型*/
String mimeType = getMimeType(uploadFileParamsDto.getFilename());
/*组装文件基路径*/
String basePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd/"));
/*由于Minio中同一天的数据保存位置都在同一个文件夹下,为了防止重名现象,不能用原文件名,应该用md5值加后缀*/
int index = uploadFileParamsDto.getFilename().lastIndexOf(".");
//文件后缀
String fileSuffix = uploadFileParamsDto.getFilename().substring(index);
//文件md5值
String md5 = getMd5(localFilePath);
//拼接得到Minio存储路径
String objectName = basePath + md5 + fileSuffix;
//上传到minio
boolean minIOUpload = minIOUpload(localFilePath, mimeType, fileBucket, objectName);
if (!minIOUpload) XueChengPlusException.cast("MinIO上传出错");
//上传到数据库
MediaFiles files = insertMediaFile2DB(companyId, uploadFileParamsDto, md5, fileBucket, objectName);
// MediaFileService proxy = (MediaFileService)AopContext.currentProxy();
// MediaFiles files = proxy.insertMediaFile2DB(companyId, uploadFileParamsDto, md5, fileBucket,objectName);
//结果为空表示上传失败
if (files == null) XueChengPlusException.cast("文件上传后保存信息到数据库失败");
UploadFileResultDto uploadFileResultDto = new UploadFileResultDto();
BeanUtils.copyProperties(files, uploadFileResultDto);
return uploadFileResultDto;
}
/**
* @param companyId 公司id
* @param uploadFileParamsDto 上传参数信息
* @param md5 md5
* @param bucket 桶
* @param objectName 对象名
* @return com.xuecheng.media.model.po.MediaFiles
* @description 插入数据库
* @author: woldier
* @date: 2023/3/10 15:21
*/
@Transactional
public MediaFiles insertMediaFile2DB(Long companyId, UploadFileParamsDto uploadFileParamsDto, String md5, String bucket, String objectName) {
/*添加数据库之前,根据md5查询该文件是否已经存在*/
MediaFiles files = mediaFilesMapper.selectById(md5);
if (files == null) {
/*生成数据库entity*/
MediaFiles mediaFiles = new MediaFiles();
BeanUtils.copyProperties(uploadFileParamsDto, mediaFiles);
//设置uploadFileParamsDto中不存在的部分
//设置id
mediaFiles.setId(md5);
//机构id
mediaFiles.setCompanyId(companyId);
//bucket
mediaFiles.setBucket(bucket);
//存储路径
mediaFiles.setFilePath(objectName);
//file_id
mediaFiles.setFileId(md5);
//url
mediaFiles.setUrl("/" + bucket + "/" + objectName);
//上传时间,更新时间自动设置
//文件状态
mediaFiles.setStatus("1");
//审核状态
mediaFiles.setAuditStatus(MediaAuditStatus.Approved.getCode());
int insert = mediaFilesMapper.insert(mediaFiles);
if (insert <= 0) {
log.debug("向数据库保存文件失败,bucket:{},objectName{}", fileBucket, objectName);
return null;
}
return mediaFiles;
}
return files;
}
@NotNull
private static String getMd5(String localFilePath) throws XueChengPlusException {
String md5 = null;
try {
md5 = DigestUtils.md5Hex(Files.newInputStream(new File(localFilePath).toPath()));
} catch (IOException e) {
XueChengPlusException.cast("md5计算时出错");
}
return md5;
}
/**
* @param fileName 带后缀文件名
* @return java.lang.String
* @description 根据文件后缀名获取MimeType
* @author: woldier
* @date: 2023/3/10 13:55
*/
private String getMimeType(String fileName) {
if (fileName == null) fileName = "";
ContentInfo contentInfo = ContentInfoUtil.findExtensionMatch(fileName);
String mimeType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
if (contentInfo != null) mimeType = contentInfo.getMimeType();
return mimeType;
}
/**
* @param localFilePath 本地文件路径
* @param fileType 文件类型
* @param bucket 桶名称
* @return boolean
* @description 上传文件到MinIO的方法
* @author: woldier
* @date: 2023/3/10 13:33
*/
private boolean minIOUpload(String localFilePath, String fileType, String bucket, String objectName) {
/*上传*/
try {
minioClient.uploadObject(
UploadObjectArgs.builder()
.bucket(bucket) //桶
.object(objectName) // 对象名,在桶下存储的文件
.filename(localFilePath) //指定本地文件路径
.contentType(fileType) //设置媒体文件类型
.build()
);
} catch (Exception e) {
log.error("文件上传到MinIO出错,buckcet:{},path:{},error:{}", bucket, objectName, e.getMessage());
e.printStackTrace();
return false;
}
return true;
}
接口代码完善
/**
* @param upload 表单数据
* @param folder 文件夹名-非必须
* @param objectName 文件名-非必须
* @return com.xuecheng.media.model.dto.UploadFileResultDto
* @description 文件上传
* @author: woldier
* @date: 2023/3/9 22:09
*/
@ApiOperation("文件上传")
@RequestMapping(value = "/upload/coursefile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
// 定义请求url与可消费的文件操作content-type类型
public UploadFileResultDto upload(
@RequestPart("filedata") MultipartFile upload,
@RequestParam(value = "folder", required = false) String folder,
@RequestParam(value = "objectName", required = false) String objectName) throws IOException, XueChengPlusException {
/*
*对接受到的文件进行处理
*/
/*产生一个临时文件*/
File tempFile = File.createTempFile("minio", "temp");
/*将请求中的表单数据拷贝到临时文件中*/
upload.transferTo(tempFile);
/*获取绝对路径*/
String absolutePath = tempFile.getAbsolutePath();
/*
*对上传参数进行处理
*/
//上传文件参数类
UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();
//原始文件名称
uploadFileParamsDto.setFilename(upload.getOriginalFilename());
//文件大小
uploadFileParamsDto.setFileSize(upload.getSize());
//文件类型
uploadFileParamsDto.setFileType(MediaResourceType.IMAGE.getCode());
/*
* 公司id获取
* */
//TODO 硬编码公司id
Long companyId = 123456789L;
UploadFileResultDto uploadFileResultDto = mediaFileService.uploadFile(companyId, uploadFileParamsDto, absolutePath);
/*删除临时文件*/
tempFile.deleteOnExit();
return uploadFileResultDto;
service事务代码优化
上边的service方法优化后并测试通过,现在思考关于uploadFile方法的是否应该开启事务。
目前是在uploadFile方法上添加@Transactional,当调用uploadFile方法前会开启数据库事务,如果上传文件过程时间较长那么数据库的事务持续时间就会变长,这样数据库链接释放就慢,最终导致数据库链接不够用。
我们只将addMediaFilesToDb方法添加事务控制即可,uploadFile方法上的@Transactional注解去掉。(上小节代码已经时这样做的)
但是现在的问题是,controller调用的service方法upload没用加入事务注解,相当于在service中一个没有事务的方法调用了另一个事务方法,事务不生效
下边分析原因:
如果在uploadFile方法上添加@Transactional注解,代理对象执行此方法前会开启事务,如下图:
如果在uploadFile方法上没有@Transactional注解,代理对象执行此方法前
不进行事务控制,如下图:
现在在addMediaFilesToDb方法上添加@Transactional注解,也不会进行事务是因为并不是通过代理对象执行的addMediaFilesToDb方法。为了判断在uploadFile方法中去调用addMediaFilesToDb方法是否是通过代理对象去调用,我们可以打断点跟踪。
我们发现在uploadFile方法中去调用addMediaFilesToDb方法不是通过代理对象去调用。
如何解决呢?通过代理对象去调用addMediaFilesToDb方法即可解决。
我们先获取代理对象,然后调用代理对象的insertMediaFile2DB方法
MediaFileService proxy = (MediaFileService)AopContext.currentProxy();
MediaFiles files = proxy.insertMediaFile2DB(companyId, uploadFileParamsDto, md5, fileBucket,objectName);
但是只修改这个代码启动会报错
Cannot find current proxy: Set 'exposeProxy' property on Advised to 'true' to make it available, and ensure that AopContext.currentProxy() is invoked in the same thread as the AOP invocation context.
我们需要在启动类下加入注解并且设置exposeProxy属性,除此之外可能的报错原因是没有加入包
@EnableAspectJAutoProxy(exposeProxy = true)
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
我们可以在代码中插入数据库后除0模拟错误,看一下是否进行了数据库回归,经过测试可以发现进行了回滚
接口测试
### 上传文件
POST {{gateway_host}}/media//upload/coursefile
Content-Type: multipart/form-data; boundary=WebAppBoundary
--WebAppBoundary
Content-Disposition: form-data;name="filedata"; filename="123.jpg"
Content-Type: application/octet-stream
< d:/123.jpg
POST http://localhost:63010/media//upload/coursefile
HTTP/1.1 200 OK
transfer-encoding: chunked
Content-Type: application/json
Date: Fri, 10 Mar 2023 08:33:46 GMT
{
"id": "8a58662af30ace3e83f629a10ddd8662",
"companyId": 123456789,
"companyName": null,
"filename": "1.jpg",
"fileType": "001001",
"tags": null,
"bucket": "mediafiles",
"filePath": "2023/03/10/8a58662af30ace3e83f629a10ddd8662.jpg",
"fileId": "8a58662af30ace3e83f629a10ddd8662",
"url": "/mediafiles/2023/03/10/8a58662af30ace3e83f629a10ddd8662.jpg",
"username": null,
"createDate": null,
"changeDate": null,
"status": "1",
"remark": null,
"auditStatus": "002003",
"auditMind": null,
"fileSize": 9778
}
上传视频