6.实战开发 - 业务代码
6.1 检查文件是否已存在
6.1.1 MediaFileController
package com.zhulang.waveedu.service.controller; import com.alibaba.fastjson.JSONObject; import com.zhulang.waveedu.common.entity.Result; import com.zhulang.waveedu.service.service.MediaFileService; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.stereotype.Controller; import javax.annotation.Resource; /** * <p> * 第三方服务-媒资文件表 前端控制器 * </p> * * @author 狐狸半面添 * @since 2023-02-08 */ @Controller @RequestMapping("/media-file") public class MediaFileController { @Resource private MediaFileService mediaFileService; /** * 文件上传前检查文件是否存在 * * @param object 需要上传的文件的md5值 * @return 是否存在, false-不存在 true-存在 */ @PostMapping("/upload/checkFile") public Result checkFile(@RequestBody JSONObject object) { return mediaFileService.checkFile(object.getString("fileMd5")); } }
6.1.2 MediaFileService
package com.zhulang.waveedu.service.service; import com.zhulang.waveedu.common.entity.Result; import com.zhulang.waveedu.service.po.MediaFile; import com.baomidou.mybatisplus.extension.service.IService; /** * <p> * 第三方服务-媒资文件表 服务类 * </p> * * @author 狐狸半面添 * @since 2023-02-08 */ public interface MediaFileService extends IService<MediaFile> { /** * 文件上传前检查文件是否存在 * * @param fileMd5 需要上传的文件的md5值 * @return 是否存在,false-不存在 true-存在 */ Result checkFile(String fileMd5); }
6.1.3 MediaFileServiceImpl
package com.zhulang.waveedu.service.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.zhulang.waveedu.common.constant.HttpStatus; import com.zhulang.waveedu.common.entity.Result; import com.zhulang.waveedu.common.util.RegexUtils; import com.zhulang.waveedu.service.po.MediaFile; import com.zhulang.waveedu.service.dao.MediaFileMapper; import com.zhulang.waveedu.service.service.MediaFileService; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import io.minio.GetObjectArgs; import io.minio.MinioClient; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.io.InputStream; /** * <p> * 第三方服务-媒资文件表 服务实现类 * </p> * * @author 狐狸半面添 * @since 2023-02-08 */ @Service public class MediaFileServiceImpl extends ServiceImpl<MediaFileMapper, MediaFile> implements MediaFileService { @Resource private MediaFileMapper mediaFileMapper; @Resource private MinioClientUtils minioClientUtils; @Override public Result checkFile(String fileMd5) { HashMap<String, Object> resultMap = new HashMap<>(); // 1.校验 fileMd5 合法性 if (RegexUtils.isMd5HexInvalid(fileMd5)) { return Result.error(HttpStatus.HTTP_BAD_REQUEST.getCode(), "文件md5格式错误"); } // 2.在文件表存在,并且在文件系统存在,此文件才存在 // 2.1 判断是否在文件表中存在 LambdaQueryWrapper<MediaFile> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(MediaFile::getFileMd5, fileMd5); MediaFile mediaFile = mediaFileMapper.selectOne(wrapper); if (mediaFile == null) { resultMap.put("exist", false); return Result.ok(resultMap); } // 2.2 判断是否在文件系统存在 try { InputStream inputStream = minioClientUtils.getObject(mediaFile.getBucket(), mediaFile.getFilePath()); if (inputStream == null) { // 文件不存在 resultMap.put("exist", false); return Result.ok(resultMap); } } catch (Exception e) { // 文件不存在 resultMap.put("exist", false); return Result.ok(resultMap); } // 3.走到这里说明文件已存在,返回true resultMap.put("exist", true); // 4.封装文件信息 resultMap.put("info", encodeFileInfo(mediaFile)); // 5.返回结果 return Result.ok(resultMap); } /** * 将 部分信息进行封装加密 * * @param mediaFile 媒资对象 * @return 加密结果 */ private String encodeFileInfo(MediaFile mediaFile) { HashMap<String, Object> fileMap = new HashMap<>(5); fileMap.put("fileType", mediaFile.getFileType()); fileMap.put("filePath", mediaFile.getBucket() + "/" + mediaFile.getFilePath()); fileMap.put("fileFormat", mediaFile.getFileFormat()); fileMap.put("fileByteSize", mediaFile.getFileByteSize()); fileMap.put("fileFormatSize", mediaFile.getFileFormatSize()); return CipherUtils.encrypt(JSON.toJSONString(fileMap)); } }
6.2 检查分块文件是否已存在
6.2.1 MediaFileController
/** * 分块文件上传前检测分块文件是否已存在 * * @param chunkFileVO 分块文件的源文件md5和该文件索引 * @return 是否存在, false-不存在 true-存在 * @throws Exception */ @PostMapping("/upload/checkChunk") public Result checkChunk(@Validated @RequestBody ChunkFileVO chunkFileVO) throws Exception { return mediaFileService.checkChunk(chunkFileVO.getFileMd5(),chunkFileVO.getChunkIndex()); }
6.2.2 MediaFileService
/** * 分块文件上传前检测分块文件是否已存在 * * @param fileMd5 分块文件的源文件md5 * @param chunkIndex 分块文件索引 * @return 是否存在, false-不存在 true-存在 */ Result checkChunk(String fileMd5, Integer chunkIndex);
6.2.3 MediaFileServiceImpl
@Resource private MinioClientUtils minioClientUtils; @Value("${minio.bucket}") private String bucket; @Override public Result checkChunk(String fileMd5, Integer chunkIndex) { // 1.得到分块文件所在目录 String chunkFileFolderPath = getChunkFileFolderPath(fileMd5); // 2.分块文件的路径 String chunkFilePath = chunkFileFolderPath + chunkIndex; // 3.查看是否在文件系统存在(注意关闭流) try ( InputStream inputStream = minioClientUtils.getObject(bucket, chunkFilePath) ) { if (inputStream == null) { //文件不存在 return Result.ok(false); } } catch (Exception e) { //文件不存在 return Result.ok(false); } // 4.走到这里说明文件已存在,返回true return Result.ok(true); } /** * 得到分块文件的目录 * * @param fileMd5 文件的md5值 * @return 分块文件所在目录 */ private String getChunkFileFolderPath(String fileMd5) { return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + "chunk" + "/"; }
6.3 上传分块文件
6.3.1 MediaFileController
/** * 上传分块文件 * * @param file 分块文件 * @param fileMd5 原文件md5值 * @param chunkIndex 分块文件索引 * @return 上传情况 */ @PostMapping("/upload/uploadChunk") public Result uploadChunk(@RequestParam("file") MultipartFile file, @RequestParam("fileMd5") @Pattern(regexp = RegexUtils.RegexPatterns.MD5_HEX_REGEX, message = "文件md5格式错误") String fileMd5, @RequestParam("chunkIndex") @Min(value = 0, message = "索引必须大于等于0") Integer chunkIndex) throws Exception { return mediaFileService.uploadChunk(fileMd5, chunkIndex, file.getBytes()); }
6.3.2 MediaFileService
/** * 上传分块文件 * * @param fileMd5 原文件md5值 * @param chunkIndex 分块文件索引 * @param bytes 分块文件的字节数组形式 * @return 上传情况 */ Result uploadChunk(String fileMd5, Integer chunkIndex, byte[] bytes);
6.3.3 MediaFileServiceImpl
@Resource private MinioClientUtils minioClientUtils; @Value("${minio.bucket}") private String bucket; @Override public Result uploadChunk(String fileMd5, Integer chunkIndex, byte[] bytes) { // 1.得到分块文件所在目录 String chunkFileFolderPath = getChunkFileFolderPath(fileMd5); // 2.分块文件的路径 String chunkFilePath = chunkFileFolderPath + chunkIndex; try { // 3.将分块上传到文件系统 minioClientUtils.uploadChunkFile(bytes, bucket, chunkFilePath); // 4.上传成功 return Result.ok(); } catch (Exception e) { // 上传失败 return Result.error(); } } /** * 得到分块文件的目录 * * @param fileMd5 文件的md5值 * @return 分块文件所在目录 */ private String getChunkFileFolderPath(String fileMd5) { return fileMd5.charAt(0) + "/" + fileMd5.charAt(1) + "/" + fileMd5 + "/" + "chunk" + "/"; }
6.4 合并前下载分块文件(多线程下载)
合并分块前要检查分块文件是否全部上传完成,如果完成则将已经上传的分块文件从minio下载下来,然后再进行合并。
/** * 创建10个线程数量的线程池 */ private final ExecutorService threadPool = Executors.newFixedThreadPool(10); /** * 下载所有的块文件 * * @param fileMd5 源文件的md5值 * @param chunkTotal 块总数 * @return 所有块文件 */ private File[] downloadChunkFilesFromMinio(String fileMd5, int chunkTotal) throws Exception { // 1.得到分块文件所在目录 String chunkFileFolderPath = getChunkFileFolderPath(fileMd5); // 2.分块文件数组 File[] chunkFiles = new File[chunkTotal]; // 3.设置计数器 CountDownLatch countDownLatch = new CountDownLatch(chunkTotal); // 4.开始逐个下载 for (int i = 0; i < chunkTotal; i++) { int index = i; threadPool.execute(() -> { // 4.1 得到分块文件的路径 String chunkFilePath = chunkFileFolderPath + index; // 4.2 下载分块文件 try { chunkFiles[index] = minioClientUtils.downloadFile("chunk", null, bucket, chunkFilePath); } catch (Exception e) { // 计数器减1 countDownLatch.countDown(); throw new RuntimeException(e); } // 计数器减1 countDownLatch.countDown(); }); } /* 阻塞到任务执行完成,当countDownLatch计数器归零,这里的阻塞解除等待, 给一个充裕的超时时间,防止无限等待,到达超时时间还没有处理完成则结束任务 */ countDownLatch.await(30, TimeUnit.MINUTES); // 5.返回所有块文件 return chunkFiles; }
6.5 合并分块文件并上传
6.5.1 MediaFileController
/** * 合并分块文件 * * @param fileMd5 文件的md5十六进制值 * @param fileName 文件名 * @param tag 文件标签 * @param chunkTotal 文件块总数 * @return 合并与上传情况 */ @PostMapping("/upload/uploadMergeChunks") public Result uploadMergeChunks(@RequestParam("fileMd5") @Pattern(regexp = RegexUtils.RegexPatterns.MD5_HEX_REGEX, message = "文件md5格式错误") String fileMd5, @RequestParam("fileName") @Pattern(regexp = RegexUtils.RegexPatterns.FILE_NAME_REGEX, message = "文件名最多255个字符") String fileName, @RequestParam("tag") @Pattern(regexp = RegexUtils.RegexPatterns.FILE_TAG_REGEX, message = "文件标签最多32个字符") String tag, @RequestParam("chunkTotal") @Min(value = 1, message = "块总数必须大于等于1") Integer chunkTotal) { return mediaFileService.uploadMergeChunks(fileMd5, fileName, tag, chunkTotal); }
6.5.2 MediaFileService
/** * 合并分块文件 * * @param fileMd5 文件的md5十六进制值 * @param fileName 文件名 * @param tag 文件标签 * @param chunkTotal 文件块总数 * @return 合并与上传情况 */ Result uploadMergeChunks(String fileMd5, String fileName, String tag, Integer chunkTotal);
6.5.3 MediaFileServiceImpl
/** * 将 部分信息进行封装加密 * * @param mediaFile 媒资对象 * @return 加密结果 */ private String encodeFileInfo(MediaFile mediaFile) { HashMap<String, Object> fileMap = new HashMap<>(5); fileMap.put("fileType", mediaFile.getFileType()); fileMap.put("filePath", mediaFile.getBucket() + "/" + mediaFile.getFilePath()); fileMap.put("fileFormat", mediaFile.getFileFormat()); fileMap.put("fileByteSize", mediaFile.getFileByteSize()); fileMap.put("fileFormatSize", mediaFile.getFileFormatSize()); return CipherUtils.encrypt(JSON.toJSONString(fileMap)); } @Override public Result uploadMergeChunks(String fileMd5, String fileName, String tag, Integer chunkTotal) { try { // 1.下载分块 File[] chunkFiles = downloadChunkFilesFromMinio(fileMd5, chunkTotal); // 2.根据文件名得到合并后文件的扩展名 int index = fileName.lastIndexOf("."); String extension = index != -1 ? fileName.substring(index) : ""; File tempMergeFile = null; try { try { // 3.创建一个临时文件作为合并文件 tempMergeFile = File.createTempFile("merge", extension); } catch (IOException e) { return Result.error(HttpStatus.HTTP_INTERNAL_ERROR.getCode(), "创建临时合并文件出错"); } // 4.创建合并文件的流对象 try (RandomAccessFile rafWrite = new RandomAccessFile(tempMergeFile, "rw")) { byte[] b = new byte[1024]; for (File file : chunkFiles) { // 5.读取分块文件的流对象 try (RandomAccessFile rafRead = new RandomAccessFile(file, "r");) { int len = -1; while ((len = rafRead.read(b)) != -1) { // 6.向合并文件写数据 rafWrite.write(b, 0, len); } } } } catch (IOException e) { return Result.error(HttpStatus.HTTP_INTERNAL_ERROR.getCode(), "合并文件过程出错"); } // 7.校验合并后的文件是否正确 try ( // 7.1 获取合并后文件的流对象 FileInputStream mergeFileStream = new FileInputStream(tempMergeFile); ) { // 7.2 获取合并文件的md5十六进制值 String mergeMd5Hex = DigestUtils.md5Hex(mergeFileStream); // 7.3 校验 if (!fileMd5.equals(mergeMd5Hex)) { return Result.error(HttpStatus.HTTP_BAD_REQUEST.getCode(), "合并文件校验不通过"); } } catch (IOException e) { return Result.error(HttpStatus.HTTP_INTERNAL_ERROR.getCode(), "合并文件校验出错"); } // 8.得到 mimetype String mimeType = FileTypeUtils.getMimeType(tempMergeFile); // 9.拿到合并文件在minio的存储路径 String mergeFilePath = getFilePathByMd5(fileMd5, extension); // 10.将合并后的文件上传到文件系统 minioClientUtils.uploadFile(tempMergeFile.getAbsolutePath(), bucket, mergeFilePath); // 11.设置需要入库的文件信息 MediaFile mediaFile = new MediaFile(); // 11.1 文件名 mediaFile.setFileName(WaveStrUtils.removeBlank(fileName)); // 11.2 文件类型 mediaFile.setFileType(FileTypeUtils.getSimpleType(mimeType)); // 11.3 文件格式 mediaFile.setFileFormat(mimeType); // 11.4 文件标签 mediaFile.setTag(WaveStrUtils.removeBlank(tag)); // 11.5 存储桶 mediaFile.setBucket(bucket); // 11.6 存储路径 mediaFile.setFilePath(mergeFilePath); // 11.7 设置md5值 mediaFile.setFileMd5(fileMd5); // 11.8 设置合并文件大小(单位:字节) mediaFile.setFileByteSize(tempMergeFile.length()); // 11.9 设置文件格式大小 mediaFile.setFileFormatSize(FileFormatUtils.formatFileSize(mediaFile.getFileByteSize())); // 11.10 设置上传者 mediaFile.setUserId(UserHolderUtils.getUserId()); // 12.保存至数据库 this.save(mediaFile); // 15.返回成功 return Result.ok(encodeFileInfo(mediaFile)); } finally { // 13.删除临时分块文件 for (File chunkFile : chunkFiles) { if (chunkFile.exists()) { chunkFile.delete(); } } // 14.删除合并的临时文件 if (tempMergeFile != null) { tempMergeFile.delete(); } } } catch (Exception e) { return Result.error(HttpStatus.HTTP_INTERNAL_ERROR.getCode(), e.getMessage()); } } /** * 得到合并文件的路径 * * @param fileMd5 文件的md5十六进制值 * @param fileExt 文件的扩展名 * @return 合并文件路径 */ private String getFilePathByMd5(String fileMd5, String fileExt) { return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + fileMd5 + fileExt; }
7.补充-面试题
7.1 MinIO是什么?
MinIO一个轻量级的分布式文件系统,由多个个MinIO节点连接组成,可根据文件规模进行扩展,适用于海量文件的存储与访问。
7.2 为什么用MinIO
- MinIO开源,使用简单,功能强大。
- MinIO使用纠删码算法,只要不超过一半的节点坏掉整个文件系统就可以使用。
- 如果将坏的节点重新启动,自动恢复没有上传成功的文件。
7.3 怎么样构建这个独立文件服务?
- 我们项目中有很多要上传文件的地方,比如上传图片、上传文档、上传视频等,所以我们要构建一 个独立的文件服务负责上传、下载等功能,负责对文件进行统一管理。
- 创建单独的文件服务,提供以下接口:
- 上传接口
- 下载接口
- 我的图库接口
- 我的文件库接口
- 删除文件接口
- 文件的存储和下载使用MinIO实现。
MinIO是一个分布式的文件系统,性能高,扩展强。
- 使用Nginx+MinIO组成一个文件服务器。通过访问Nginx,由nginx代理将请求转发到MinIO去浏览、下载文件。
7.4 断点续传是怎么做的?
我们是基于分块上传的模式实现断点续传的需求,当文件上传一部分断网后前边已经上传过的不再上传。
- 前端对文件分块。
- 前端使用多线程一块-块上传,上传前给服务端发一 个消息校验该分块是否上传,如果已上传则不再上传。
- 等所有分块上传完毕,服务端合并所有分块,校验文件的完整性。因为分块全部上传到了服务器,服务器将所在分块按顺序进行合并,就是写每个分块文件内容按顺序依次写入一个文件中。(使用字节流去读写文件)
- 前端给服务传了一个md5值,服务端合并文件后计算合并后文件的md5是否和前端传的一样,如果一样则说文件完整,如果不一样说明可能由于网络丢包导致文件不完整,这时上传失败需要重新上传。
7.5 分块文件清理问题
上传一个文件进行分块上传,上传一半不传了, 之前上传到minio的分块文件要清理吗?怎么做的?
- 在数据库中有一张文件表记录minio中存储的文件信息。
- 文件开始上传时会写入文件表,状态为,上传中,上传完成会更新状态为上传完成。
- 当一个文件传了一半不再上传了说明该文件没有上传完成,会有定时任务去查询文件表中的记录,如果文件未上传完成则删除minio中没有上传成功的文件目录。