上传图片
界面原型
第一步: 用户在课程信息编辑界面
可以上传课程图片或者修改上传的课程图片
第二步: 请求媒资管理服务
将课程图片上传至分布式文件系统同时在媒资管理数据库保存文件信息,上传成功后返回图片在MinIO中的地址
第三步: 请求内容管理服务
保存课程信息含课程封面对应图片所在的地址
数据模型
MediaFiles
表保存上传的文件信息,文件的Id和file_id
都是文件的md5值,file_path(文件Minio中存储的路径)和url(文件访问地址)
类似,对于视频需要转码
CourseBase
表保存课程信息含课程图片地址
请求响应模型类
请求模型类
上传文件一般需要文件名称、文件内容类型、文件类型(对应数据字典表中的类型)、文件大小、标签、上传人、备注
@Data@ToString
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;
}
# 上传文件
POST {{media_host}}/media/upload/coursefile
Content-Type: multipart/form-data; boundary=WebAppBoundary # 指定上传文件的内容类型--WebAppBoundary
Content-Disposition: form-data; name="filedata"; filename="1.jpg"# filedata表示上传文件请求参数中的name,1.jpg表示原始文件的名称
Content-Type: application/octet-stream< d:/develop/upload/1.jpg # 本地文件
响应模型类
: 由于PO类的属性和数据表的字段一一映射不方便修改,所以单独定义一个DTO类直接继承MediaFiles
,如果后期要修改响应结果可以修改响应模型类
/*** @description 上传普通文件成功响应结果* @author Mr.M* @date 2022/9/12 18:49* @version 1.0*/
@Data
public class UploadFileResultDto extends MediaFiles {
}
{
"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
}
配置环境
第一步: 在minio
中创建一个名为mediafiles
的bucket,并将其权限设置为public
,这样我们就可以在浏览器中通过URL直接访问桶内的文件
第二步: 在nacos
的开发环境dev
中的media-service-dev.yam
文件中新增配置
spring:datasource:driver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://127.0.0.1:3306/xc_media?serverTimezone=UTC&userUnicode=true&useSSL=falseusername: rootpassword: 123456cloud:config:override-none: trueminio:endpoint: http://127.0.0.1:9000accessKey: minioadminsecretKey: minioadminbucket:files: mediafilesvideofiles: video
第三步: 在本地的media-service
工程中添加bootstrap.yml
文件
spring:application:name: media-servicecloud:nacos:server-addr: 127.0.0.1:8848discovery:namespace: ${spring.profiles.active}group: xuecheng-plus-projectconfig:namespace: ${spring.profiles.active}group: xuecheng-plus-projectfile-extension: yamlrefresh-enabled: trueshared-configs:- data-id: logging-${spring.profiles.active}.yamlgroup: xuecheng-plus-commonrefresh: true#profiles默认为devprofiles:active: dev
第四步: 在media-service工程中编写config/MinioConfigminio
配置类,根据yaml文件中的minio配置信息创建一个MinioClient对象
并交给容器管理
@Configuration
public class MinioConfig {@Value("${minio.endpoint}")private String endpoint;@Value("${minio.accessKey}")private String accessKey;@Value("${minio.secretKey}")private String secretKey;@Beanpublic MinioClient minioClient() {return MinioClient.builder().endpoint(endpoint).credentials(accessKey, secretKey).build();}
}
api工程定义接口
在api接口工程
中定义一个通用的上传文件的接口,可以上传图片或其他文件
/*** @description 上传文件接口* @param objectName 对象名称,如果传入objectname则需要按照其指定的目录存储,如果不传默认按年月日目录结构去存储* @return com.xuecheng.media.model.dto.UploadFileResultDto*/
@ApiOperation ("上传文件")
@RequestMapping(value = "/upload/coursefile",consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ResponseBody
public UploadFileResultDto upload(@RequestPart("filedata") MultipartFile filedata,@RequestParam(value = "objectName",required=false) String objectName) throws IOException {Long companyId = 1232141425L;// 准备上传文件的信息UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();// 文件名称uploadFileParamsDto.setFilename(filedata.getOriginalFilename());// 文件大小uploadFileParamsDto.setFileSize(filedata.getSize());// 获取请求参数中上传文件的内容类型String contentType = upload.getContentType();if (contentType.contains("image")) {// 图片uploadFileParamsDto.setFileType("001001");} else {// 其他uploadFileParamsDto.setFileType("001003");}// 创建临时文件File tempFile = File.createTempFile("minio", "temp");// 将上传的文件拷贝到临时文件filedata.transferTo(tempFile);// 获取File对象在硬盘中对应临时文件的绝对路径String absolutePath = tempFile.getAbsolutePath();// 上传文件UploadFileResultDto uploadFileResultDto = mediaFileService.uploadFile(companyId, uploadFileParamsDto, absolutePath,objectName);return uploadFileResultDto;
}
service工程定义业务类
上传文件时需要指定上传的机构ID,上传的文件信息,上传源文件的磁盘路径(这里的源文件是服务器接收的临时文件)
getMimeType
: 根据上传文件的扩展名获取文件的媒体类型MimeTypegetDefaultFolderPath
: 获取文件的默认存储目录,遵循年/月/日/文件名
规范addMediaFilesToMinIO
: 将文件上传到MinIOaddMediaFilesToDb
: 将上传的文件信息存储到数据库
/*** 上传文件* @param companyId 上传文件的机构ID* @param uploadFileParamsDto 上传的文件信息* @param localFilePath 上传文件对应的临时文件在服务器的绝对路径* @param objectname 对象名称,包含存储目录* @return 文件信息*/
public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, String localFilePath,String objectname);
@Autowired
MediaFilesMapper mediaFilesMapper;
@Autowired
MinioClient minioClient;
// 存储普通文件的桶
@Value("${minio.bucket.files}")
private String bucket_files;// 这里把事务添加到uploadFile上,细粒度比较大
@Transactional
@Override
public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, String localFilePath,String objectname) {// 获取临时文件对象的File对象File file = new File(localFilePath);if (!file.exists()) {XueChengPlusException.cast("文件不存在");}// 文件名称String filename = uploadFileParamsDto.getFilename();// 文件扩展名String extension = filename.substring(filename.lastIndexOf("."));// 根据文件扩展名获取文件对应的mimeTypeString mimeType = getMimeType(extension);// 获取文件在Minio中默认的存储目录String defaultFolderPath = getDefaultFolderPath();// 根据File对象获取文件对应的md5值String fileMd5 = getFileMd5(file);if(StringUtils.isEmpty(objectName)){// 默认存储方式,存储到minio中的完整对象名,默认目录/文件MD5值.扩展名objectName = defaultFolderPath + fileMd5 + extension;}// 将文件上传到minioboolean b = addMediaFilesToMinIO(localFilePath, mimeType, bucket_files, objectName);if(!result){XueChengPlusException.cast("上传文件失败");}// 将文件信息存储到数据库MediaFiles mediaFiles = addMediaFilesToDb(companyId, fileMd5, uploadFileParamsDto, bucket_files, objectName);if(mediaFiles==null){XueChengPlusException.cast("文件上传后保存信息失败");}// 准备返回的UploadFileResultDto对象UploadFileResultDto uploadFileResultDto = new UploadFileResultDto();BeanUtils.copyProperties(mediaFiles, uploadFileResultDto);return uploadFileResultDto;
}
在base工程中创建一个根据上传文件的扩展名取出mimeType
媒体类型的工具类,后续可以供其他微服务使用
- 在base工程中需要添加
simplemagic
依赖,它提供的方法可以根据文件扩展名得到资源的媒体类型
<dependency><groupId>com.j256.simplemagic</groupId><artifactId>simplemagic</artifactId><version>1.17</version>
</dependency>
private String getMimeType(String extension){if(extension==null)extension = "";// 根据文件扩展名取出mimeType,extension不能为null,否则会报空指针异常ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(extension);// 对于未知扩展名的文件采用通用的mimeTypeString mimeType = MediaType.APPLICATION_OCTET_STREAM_VALUE;if(extensionMatch!=null){mimeType = extensionMatch.getMimeType();}return mimeType;
}
获取文件默认存储目录路径,遵守年/月/日
规范
private String getDefaultFolderPath() {SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");String folder = sdf.format(new Date()).replace("-", "/")+"/";return folder;
}
根据文件的字节流获取文件的MD5
值
private String getFileMd5(File file) {try (FileInputStream fileInputStream = new FileInputStream(file)) {String fileMd5 = DigestUtils.md5Hex(fileInputStream);return fileMd5;} catch (Exception e) {e.printStackTrace();return null;}
}
将服务器中接收的临时文件上传到minIO
,如果前端没有指定存储文件的对象名称objectName,默认由默认目录/文件MD5值.扩展名
组成
/*** @param localFilePath 上传文件对应的临时文件在服务器的绝对路径* @param objectName 待存储文件的对象名称,默认目录/文件名.扩展名* @return void*/
public boolean addMediaFilesToMinIO(String localFilePath,String mimeType,String bucket, String objectName) {try {UploadObjectArgs testbucket = UploadObjectArgs.builder().bucket(bucket).object(objectName).filename(localFilePath).contentType(mimeType).build();minioClient.uploadObject(testbucket);log.debug("上传文件到minio成功,bucket:{},objectName:{}",bucket,objectName);System.out.println("上传成功");return true;} catch (Exception e) {e.printStackTrace();log.error("上传文件到minio出错,bucket:{},objectName:{},错误原因:{}",bucket,objectName,e.getMessage(),e);XueChengPlusException.cast("上传文件到文件系统失败");}return false;
}
将上传到Minio中的文件信息添加到media_files
表
/*** @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);mediaFiles.setId(fileMd5);mediaFiles.setFileId(fileMd5);mediaFiles.setCompanyId(companyId);mediaFiles.setUrl("/" + bucket + "/" + objectName);mediaFiles.setBucket(bucket);mediaFiles.setFilePath(objectName);mediaFiles.setCreateDate(LocalDateTime.now());// 对于上传的图片默认审核通过mediaFiles.setAuditStatus("002003");mediaFiles.setStatus("1");// 保存文件信息到文件表int insert = mediaFilesMapper.insert(mediaFiles);if (insert < 0) {log.error("保存文件信息到数据库失败,{}",mediaFiles.toString());XueChengPlusException.cast("保存文件信息失败");}log.debug("保存文件信息到数据库成功,{}",mediaFiles.toString());}return mediaFiles;}
事务失效优化
在updateFile
方法上添加@Transactional
注解表示在调用updateFile
方法前开启数据库事务
- 缺点: 如果上传文件过程时间较长,数据库的事务持续时间也会变长,这样会导致数据库连接释放就慢,最终导致数据库连接不够用
在addMediaFilesToDb
添加事务控制表示在调用addMediaFilesToDb
方法前开启数据库事务,但需要避免事务失效的问题
在一个非事务控制的方法里直接使用this调用
一个被事务控制的方法,此时的事务不会生效
- 方法可以被事务控制的条件: 在此方法上添加了
@Transactional
注解并且需要通过代理对象(Spring注入的对象)
执行该方法事务才会生效
第一步: 在addMediaFilesToDb
方法上加上@Transactional
注解并将其提取成MediaFileService
的接口方法,这样我们就可以通过代理对象调用该方法
/*** @description 将文件信息添加到文件表* @param companyId 机构id* @param fileMd5 文件md5值* @param uploadFileParamsDto 上传文件的信息* @param bucket 桶* @param objectName 对象名称*/
public MediaFiles addMediaFilesToDb(Long companyId,String fileMd5,UploadFileParamsDto uploadFileParamsDto,String bucket,String objectName);
第二步: 在MediaFileService
的实现类MediaFileServiceImpl
中注入MediaFileService
的代理对象
// 注入代理对象
@Autowired
MediaFileService currentProxy;@Override
public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, String localFilePath,String objectname) {// 使用代理对象调用方法,将文件信息存储到数据库MediaFiles mediaFiles = currentProxy.addMediaFilesToDb(companyId, fileMd5, uploadFileParamsDto, bucket_files, objectName);// ........
}
查看上传的媒资信息
上传图片完成后我们可以在媒资管理界面查看刚刚上传的图片信息
在MediaFileServiceImpl中
编写queryMediaFiles
方法查询上传的文件信息
@Override
public PageResult<MediaFiles> queryMediaFiles(Long companyId, PageParams pageParams, QueryMediaParamsDto queryMediaParamsDto) {//构建查询条件对象LambdaQueryWrapper<MediaFiles> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.like(!StringUtils.isEmpty(queryMediaParamsDto.getFilename()), MediaFiles::getFilename, queryMediaParamsDto.getFilename());queryWrapper.eq(!StringUtils.isEmpty(queryMediaParamsDto.getFileType()), MediaFiles::getFileType, queryMediaParamsDto.getFileType());// 分页对象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;}
使用字节数组
api工程定义接口
MultipartFile
是SpringMVC提供简化上传操作的工具类,为了使接口更通用可以使用字节数组代替MultpartFile
类型
HttpServletRequest
: 用来接收上传的数据,如果上传的是文件将以二进制流的形式传递到后端
@ApiOperation("上传文件")
@RequestMapping(value = "/upload/coursefile",consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public UploadFileResultDto upload(@RequestPart("filedata") MultipartFile upload,@RequestParam(value = "folder", required = false) String folder,@RequestParam(value = "objectName", required = false) String objectName) {// 封装上传文件的信息UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();// 文件名称uploadFileParamsDto.setFilename(upload.getOriginalFilename());// 文件大小uploadFileParamsDto.setFileSize(upload.getSize());// 获取请求参数中上传文件的内容类型String contentType = upload.getContentType();if (contentType.contains("image")) {// 图片uploadFileParamsDto.setFileType("001001");} else {// 其他uploadFileParamsDto.setFileType("001003");}uploadFileParamsDto.setContentType(contentType);Long companyId = 1232141425L;try {// 获取上传文件对应的字节UploadFileResultDto uploadFileResultDto = mediaFileService.uploadFile(companyId, uploadFileParamsDto, upload.getBytes(), folder, objectName);return uploadFileResultDto;} catch (IOException e) {XueChengPlusException.cast("上传文件过程出错");}return null;
}
service工程定义业务类
/*** @description 上传文件的通用接口* @param companyId 机构id* @param uploadFileParamsDto 文件信息* @param bytes 文件字节数组* @param folder 桶下边的子目录* @param objectName 对象名称不包含存储目录* @return com.xuecheng.media.model.dto.UploadFileResultDto*/
UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, byte[] bytes, String folder, String objectName);
@Override
public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, byte[] bytes, String folder, String objectName) {// 根据上传文件对应的字节获取对应的MD5值,md5Hex方法是根据上传文件的字节流获取对应的MD5值String fileMD5 = DigestUtils.md5DigestAsHex(bytes);// 获取存储的目录if (StringUtils.isEmpty(folder)) {// 如果桶下的子目录不存在,则按照年/月/日的规范自动生成一个目录folder = getFileFolder(true, true, true);} else if (!folder.endsWith("/")) {// 如果目录末尾没有/后缀则加一个folder = folder + "/";}// 这里接收的objectName不包含存储目录if (StringUtils.isEmpty(objectName)) {// 如果文件名为空,则设置其默认文件名为文件的md5码+文件后缀名String filename = uploadFileParamsDto.getFilename();objectName = fileMD5 + filename.substring(filename.lastIndexOf("."));}objectName = folder + objectName;try {// 将服务中接收的临时文件上传到minioaddMediaFilesToMinIO(bytes, bucket_files, objectName);// 将上传文件的信息保存到数据库MediaFiles mediaFiles = addMediaFilesToDB(companyId, uploadFileParamsDto, objectName, fileMD5, bucket_files);// 准备返回的UploadFileResultDto对象UploadFileResultDto uploadFileResultDto = new UploadFileResultDto();BeanUtils.copyProperties(mediaFiles, uploadFileResultDto);return uploadFileResultDto;} catch (Exception e) {XueChengPlusException.cast("上传过程中出错");}return null;
}
根据上传文件的对象名称中包含的扩展名获取文件对应的mimeType
媒体类型
// 根
private static String getContentType(String objectName) {// 对于未知扩展名的文件默认content-type为通用的二进制流String contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;if (objectName.indexOf(".") >= 0) {// 对象名称包含`.`则划分出扩展名String extension = objectName.substring(objectName.lastIndexOf("."));if(extension==null)extension = "";// 根据扩展名得到content-type,extension为null会报空指针异常,对于未知扩展名的则会返回nullContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(extension);// 如果得到了正常的content-type则重新赋值覆盖默认类型if (extensionMatch != null) {contentType = extensionMatch.getMimeType();}}return contentType;
}
按照条件生成文件的存储目录,按照年/月/日
规范
/*** 自动生成目录* @param year 是否包含年* @param month 是否包含月* @param day 是否包含日* @return*/
private String getFileFolder(boolean year, boolean month, boolean day) {StringBuffer stringBuffer = new StringBuffer();SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");String dateString = dateFormat.format(new Date());String[] split = dateString.split("-");if (year) {stringBuffer.append(split[0]).append("/");}if (month) {stringBuffer.append(split[1]).append("/");}if (day) {stringBuffer.append(split[2]).append("/");}return stringBuffer.toString();
}
将服务器中接收的临时文件上传到minIO
,如果前端没有指定存储文件的目录folder
和对象名称objectName
,默认由默认目录/文件MD5值.扩展名
组成
/*** @param bytes 文件字节数组* @param bucket 桶* @param objectName 对象名称 23/02/15/porn.mp4*/
private void addMediaFilesToMinIO(byte[] bytes, String bucket, String objectName) {ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);// 获取上传文件的媒体类型String contentType = getContentType(objectName);try {minioClient.putObject(PutObjectArgs.builder().bucket(bucket).object(objectName).stream(byteArrayInputStream, byteArrayInputStream.available(), -1).contentType(contentType).build());} catch (Exception e) {log.debug("上传到文件系统出错:{}", e.getMessage());throw new XueChengPlusException("上传到文件系统出错");}
}
将上传到Minio中的文件信息添加到media_files
表
/*** 将文件信息添加到文件表* @param companyId 机构id* @param uploadFileParamsDto 上传文件的信息* @param objectName 对象名称* @param fileMD5 文件的md5码* @param bucket 桶* @return*/
private MediaFiles addMediaFilesToDB(Long companyId, UploadFileParamsDto uploadFileParamsDto, String objectName, String fileMD5, String bucket) {// 保存到数据库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.setBucket(bucket);mediaFiles.setCreateDate(LocalDateTime.now());mediaFiles.setStatus("1");mediaFiles.setFilePath(objectName);mediaFiles.setUrl("/" + bucket + "/" + objectName);// 查阅数据字典,002003表示审核通过mediaFiles.setAuditStatus("002003");}int insert = mediaFilesMapper.insert(mediaFiles);if (insert <= 0) {XueChengPlusException.cast("保存文件信息失败");}return mediaFiles;
测试
前后端联调测试
第一步: 使用HTTP Client
测试,然后在Minio分布式文件系统对应的bucket中查看上传的图片
// 上传文件
POST {{media_host}}/media/upload/coursefile
Content-Type: multipart/form-data; boundary=WebAppBoundary--WebAppBoundary
Content-Disposition: form-data; name="filedata"; filename="test01.jpg"
Content-Type: application/octet-stream< C:\Users\kyle\Desktop\Picture\photo\bg01.jpg// 响应结果如下
POST http://localhost:53050/media/upload/coursefileHTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Thu, 16 Feb 2023 09:57:48 GMT
Keep-Alive: timeout=60
Connection: keep-alive{"id": "632fb34166d91865da576032b9330ced","companyId": 1232141425,"companyName": null,"filename": "test01.jpg","fileType": "001003","tags": null,"bucket": "mediafiles","filePath": "2023/57/16/632fb34166d91865da576032b9330ced.jpg","fileId": "632fb34166d91865da576032b9330ced","url": "/mediafiles/2023/57/16/632fb34166d91865da576032b9330ced.jpg","username": null,"createDate": "2023-02-16 17:57:48","changeDate": null,"status": "1","remark": "","auditStatus": "002003","auditMind": null,"fileSize": 22543
}
第二步: 将前端保存图片的服务器地址
改为自己的minio服务的地址,这样我们就可以在浏览器中查看上传的图片信息
## 图片服务器地址
VUE_APP_SERVER_PICSERVER_URL=http://127.0.0.1:9000