3.上传视频到Minio实现断点续传

分块与合并测试

断点续传

通常视频文件都比较大,所以对于媒资系统上传文件的需求要满足大文件的上传要求,虽然Http协议本身对上传文件大小没有限制,但其他因素可能导致上传中断问题

  • 断点续传: 将下载/上传的文件人为划分为几部分,每一部分采用一个线程进行上传或下载,遇到网络故障时从已上传或下载的部分开始继续上传下载未完成的部分

在这里插入图片描述

文件分块

根据设定的分块文件的大小将源文件分成指定的块数,按照顺序从源文件读取数据并依次向每一个块文件中写入数据

public class BigFileTest {
    @Test
    public void testChunk() throws IOException {
        // 源文件路径
        File sourceFile = new File("d:/develop/bigfile_test/nacos.mp4");
        // 分块文件存储路径
        String chunkPath = "d:/develop/bigfile_test/chunk/";
        File chunkFolder = new File(chunkPath);
        if (!chunkFolder.exists()) {
            chunkFolder.mkdirs();
        }
        // 分块大小1M
        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对象,路径d:/develop/bigfile_test/chunk/0
            File file = new File(chunkPath + i);
            if(file.exists()){
                file.delete();
            }
            // 在硬盘中创建分块文件
            boolean successful = file.createNewFile();
            // 判断是否创建成功
            if (successful) {
                // 使用RandomAccessFile向分块文件中写数据,如果硬盘中没有对应的写入文件会自动创建,要求写入文件目录必须存在
                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();

    }
}

文件合并

找到所有要合并的分块文件,按照之前文件的分块顺序将每个分块文件中的数据写入到最终要合并的文件中,最后校验文件合并后的文件和源文件是否相等

@Test
public void testMerge() throws IOException {
    // 分块文件所在目录
    File chunkFolder = new File("d:/develop/bigfile_test/chunk/");
    // 原始文件
    File originalFile = new File("d:/develop/bigfile_test/nacos.mp4");
    // 最终合并后的文件
    File mergeFile = new File("d:/develop/bigfile_test/nacos01.mp4");
    if (mergeFile.exists()) {
        mergeFile.delete();
    }
    // 在硬盘中创建新的合并文件
    mergeFile.createNewFile();
    // 使用RandomAccessFile向新创建的文件中写入数据,要求按分块文件顺序写入
    RandomAccessFile raf_write = new RandomAccessFile(mergeFile, "rw");
    // 指针指向文件顶端,默认就是指向顶端
    raf_write.seek(0);
    // 缓冲区
    byte[] b = new byte[1024];
    // 获取分块目录下的所有分块文件对应的File对象
    File[] fileArray = chunkFolder.listFiles();
    // 将File数组转成集合便于排序.List集合有序可重复
    List<File> fileList = Arrays.asList(fileArray);
    // 按照分块文件的文件名升序排序
    //Collections.sort(fileList, Comparator.comparingInt(o -> Integer.parseInt(o.getName())));
    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("合并文件失败");
        }
    }
}

Minio上传分块文件并合并

方法名 参数 参数
public void uploadObject(uploadObjectArgs) ComposeObjectArgs保存所有合并分块文件的信息 ComposeSource保存单个合并分块文件的信息
public void removeObjects(removeObjectsArgs) RemoveObjectsArgs保存所有删除分块文件的信息 DeleteObject保存单个删除分块文件的信息

合并文件

将本地文件进行分块并逐个上传至Minio,要求分块文件最小5M

@Test
public void uploadChunk(){
    // 分块文件所在目录
    String chunkFolderPath = "D:\develop\upload\chunk\";
    File chunkFolder = new File(chunkFolderPath);
    // 获取分块目录下所有文件对应的File对象
    File[] files = chunkFolder.listFiles();
    // 按照分块文件的名称顺序将分块文件上传至minio
    for (int i = 0; i < files.length; i++) {
        try {
           UploadObjectArgs uploadObjectArgs = UploadObjectArgs.builder()
               		.bucket("testbucket")
               		.object("chunk/" + i)
               		.filename(files[i].getAbsolutePath())
               		.build();
            minioClient.uploadObject(uploadObjectArgs);
            System.out.println("上传分块成功"+i);
        } catch (Exception e) {
          e.printStackTrace();
        }
    }

}

Minio中合并文件时默认要求分块最小为5M,否则报java.lang.IllegalArgumentException类型的异常

@Test
public void test_merge() throws Exception {
    // 使用for循环保存每个要合并的分块文件的信息
    List<ComposeSource> sources = new ArrayList<>(); 
    for(int i = 0,i < 6,i++){
        ComposeSource.builder()
            .bucket("testbucket")
            .object("chunk/".concat(Integer.toString(i)))
            .build())

    } 
	// 使用Stream保存每个要合并的分块文件的信息
    List<ComposeSource> sources = Stream.iterate(0, i -> ++i).limit(6)
        .map(i -> ComposeSource.builder()
             .bucket("testbucket")
             .object("chunk/".concat(Integer.toString(i)))
             .build())
        .collect(Collectors.toList());
	// 根据分块文件的信息合并对应的分块文件
    ComposeObjectArgs composeObjectArgs = ComposeObjectArgs.builder()
        .bucket("testbucket")
        .object("merge01.mp4")// 指定合并后的文件,这个文件会自动创建
        .sources(sources)// 所有分块文件的信息
        .build();
    // 合并文件
    minioClient.composeObject(composeObjectArgs);

}

删除合并文件

合并成功后需要清除对应的分块文件节省内存

@Test
public void test_removeObjects(){
    // 保存所有需要删除的分块文件信息
    List<DeleteObject> deleteObjects = Stream.iterate(0, i -> ++i).limit(6)
            .map(i -> new DeleteObject("chunk/".concat(Integer.toString(i))))
            .collect(Collectors.toList());
	// 根据分块文件的信息删除对应的分块文件
    RemoveObjectsArgs removeObjectsArgs = RemoveObjectsArgs.builder().bucket("testbucket").objects(deleteObjects).build();
    Iterable<Result<DeleteError>> results = minioClient.removeObjects(removeObjectsArgs);
    results.forEach(r->{
        DeleteError deleteError = null;
        try {
            deleteError = r.get();
        } catch (Exception e) {
            e.printStackTrace();
        }
    });
}

上传视频

界面原型

教学机构人员进入媒资管理列表查询自己上传的媒资文件,也可以点击上传视频按钮上传视频

在这里插入图片描述

上传视频后媒资管理系统的后台会自动处理需要转码的视频并生成视频对应的URL

在这里插入图片描述

环境准备

这里由前端先对上传的文件进行分块并且提供文件的MD5值,每次上传分块文件前都会请求媒资服务检查要上传的分块文件在Minio中是否存在

  • 上传分块: 如果要上传的分块文件在Minio中已经存在则不再上传,如果不存在则请求媒资服务上传分块并将分块保存到MinIO
  • 合并分块: 将所有的分块上传完毕后请求媒资服务合并MinIO中保存的分块文件,合并完成后还要校验合并后的文件是否完整,完整才会保存文件信息到数据库,否则直接删除合并后的文件

在这里插入图片描述

前端对文件分块的大小为5MB,SpringBoot web默认上传文件的大小限制为1MB,需要在media-api工程修改配置如下

spring:
  servlet:
    multipart:
      # 单个文件的大小限制
      max-file-size: 50MB
      # 单次请求的大小限制
      max-request-size: 50MB

通用结果类

base工程的model包下定义通用结果类型,与前端约定操作成功返回{code:0}失败返回{code:-1}

@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;
    }
  	// 错误信息的封装
    public static <T> RestResponse<T> validfail(String msg) {
        RestResponse<T> response = new RestResponse<T>();
        response.setCode(-1);
        response.setMsg(msg);
        return response;
    }
    public static <T> RestResponse<T> validfail(T result,String msg) {
        RestResponse<T> response = new RestResponse<T>();
        response.setCode(-1);
        response.setResult(result);
        response.setMsg(msg);
        return response;
    }
    // 添加正常响应数据且包含响应内容
    public static <T> RestResponse<T> success(T result) {
        RestResponse<T> response = new RestResponse<T>();
        response.setResult(result);
        return response;
    }
    public static <T> RestResponse<T> success(T result,String msg) {
        RestResponse<T> response = new RestResponse<T>();
        response.setResult(result);
        response.setMsg(msg);
        return response;
    }
    // 添加正常响应数据但不包含响应内容
    public static <T> RestResponse<T> success() {
        return new RestResponse<T>();
    }
    public Boolean isSuccessful() {
        return this.code == 0;
    }

}

接口工程定义

上传视频的整体流程,前端先对上传的文件进行分块并且携带文件的MD5值请求后端接口

  • 检查文件是否存在: 前端对视频文件进行分块,上传分块文件前请求媒资接口层根据文件的MD5值检查完整文件在数据库中是否有上传记录以及Minio中是否存在
  • 检查分块是否存在: 如果完整的文件还没有上传过则开始上传分块,同理检查要上传的分块在Minio中是否存在,如果存在该分块不再上传
  • 上传分块: 如果上传的分块文件在Minio中不存在则请求媒资服务将该分块文件上传到Minio中
  • 合并分块: 前端将所有分块文件上传完毕请求媒资服务合并分块文件,媒资服务判断分块上传完成则请求MinIO合并文件
  • 下载分块: 在合并分块前媒资服务层需要根据文件信息找到MinIO中所有要合并的分块文件并下载到本地的临时目录
  • 校验完整性: 将所有分块下载完毕后开始在本地合并,合并完成还需校验合并后的文件是否完整,如果完整则上传到MinIO,否则删除本地合并的文件
@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 {
        RestResponse<Boolean> booleanRestResponse = mediaFileService.checkFile(fileMd5);
        return booleanRestResponse;
    }

    @ApiOperation(value = "分块文件上传前的检测")
    @PostMapping("/upload/checkchunk")
    public RestResponse<Boolean> checkchunk(@RequestParam("fileMd5") String fileMd5,
                                            @RequestParam("chunk") int chunk) throws Exception {
        RestResponse<Boolean> booleanRestResponse = mediaFileService.checkChunk(fileMd5,chunk);
        return booleanRestResponse;
    }
	// 使用字节数组
    @ApiOperation(value = "上传分块文件")
    @PostMapping("/upload/uploadchunk")
    public RestResponse uploadChunk(@RequestParam("file") MultipartFile file, @RequestParam("fileMd5") String fileMd5, 		                                             @RequestParam("chunk") int chunk) throws Exception {
        return mediaFileService.uploadChunk(fileMd5, chunk, file.getBytes());
    }
	
    // 使用临时文件的方式
    @ApiOperation(value = "上传分块文件")
    @PostMapping("/upload/uploadchunk")
    public RestResponse uploadchunk(@RequestParam("file") MultipartFile file,
                                    @RequestParam("fileMd5") String fileMd5,
                                    @RequestParam("chunk") int chunk) throws Exception {

        // 创建一个临时文件
        File tempFile = File.createTempFile("minio", ".temp");
        // 上传的文件拷贝到临时文件
        file.transferTo(tempFile);
        // 文件路径
        String localFilePath = tempFile.getAbsolutePath();
        RestResponse restResponse = mediaFileService.uploadChunk(fileMd5, chunk, localFilePath);
        return restResponse;
    }

    @ApiOperation(value = "合并文件")
    @PostMapping("/upload/mergechunks")
    public RestResponse mergechunks(@RequestParam("fileMd5") String fileMd5,
                                    @RequestParam("fileName") String fileName,
                                    @RequestParam("chunkTotal") int chunkTotal) throws Exception {
        Long companyId = 1232141425L;
        //文件信息对象
        UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();
        uploadFileParamsDto.setFilename(fileName);
        uploadFileParamsDto.setTags("视频文件");
        uploadFileParamsDto.setFileType("001002");
        RestResponse restResponse = mediaFileService.mergechunks(1232141425L, fileMd5, chunkTotal, uploadFileParamsDto);
        return restResponse;

    }
}

检查完整文件

根据文件的MD5值检查完整文件是否存在,先检查media_files表中是否有对应的上传记录,然后检查Minio的bucket中是否有该文件

/**
 * @description 检查文件是否存在
 * @param fileMd5 文件的md5
 * @return com.xuecheng.base.model.RestResponse<java.lang.Boolean> false不存在,true存在
*/
public RestResponse<Boolean> checkFile(String fileMd5);
@Override
public RestResponse<Boolean> checkFile(String fileMd5) {
    // 查询数据库中对应的文件上传记录
    MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
    if (mediaFiles != null) {
        // 桶
        String bucket = mediaFiles.getBucket();
        // 存储目录即Mino中的objectname
        String filePath = mediaFiles.getFilePath();
        // 文件流
        InputStream stream = null;
        // 如果数据库中有对应的文件上传记录还要再查询Minio中的bucket中是否存在该文件
        try {
            stream = minioClient.getObject(
                    GetObjectArgs.builder()
                            .bucket(bucket)
                            .object(filePath)
                            .build());

            if (stream != null) {
                // true表示Minio的bucket中有对应的文件
                return RestResponse.success(true);
            }
        } catch (Exception e) {
             // false表示Minio的bucket中没有对应的文件
             return RestResponse.success(false);
        }
    }
    // false表示数据库中没有文件上传记录
    return RestResponse.success(false);
}

@Override
public RestResponse<Boolean> checkFile(String fileMd5) {
    MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
    // 数据库中没有文件的上传记录则直接返回false表示不存在
    if (mediaFiles == null) {
        return RestResponse.success(false);
    }
    // 若数据库中存在文件的上传记录,还需要根据数据库中的文件信息继续判断Minio的bucket中是否存在该文件
    try {
        InputStream inputStream = minioClient.getObject(GetObjectArgs
                .builder()
                .bucket(mediaFiles.getBucket())
                .object(mediaFiles.getFilePath())
                .build());
        if (inputStream == null) {
            // false表示Minio中没有对应的文件
            return RestResponse.success(false);
        }
    } catch (Exception e) {
        return RestResponse.success(false);
    }
    // 程序走到这里说明数据库中有上传记录,Minio中有对应的文件
    return RestResponse.success(true);
}

检查分块文件

检查要上传的分块文件在minio对应的目录下是否存在

/**
 * @description 检查分块是否存在
 * @param fileMd5  文件的md5
 * @param chunkIndex  分块序号
 * @return com.xuecheng.base.model.RestResponse<java.lang.Boolean> false不存在,true存在
*/
public RestResponse<Boolean> checkChunk(String fileMd5, int chunkIndex);
@Override
public RestResponse<Boolean> checkChunk(String fileMd5, int chunkIndex) {
    // 根据文件的MD5值得到分块文件在Minio中的存储目录
    String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
    // 获取分块文件的完整路径,即分块文件在Minio中的objectname
    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) {
          // 出异常也返回false
        return RestResponse.success(false);
    }
    // 分块未存在
    return RestResponse.success(false);
}
// 根据文件的MD5值得到分块文件在Minio中的存储目录
private String getChunkFileFolderPath(String fileMd5) {
    // MD5值的首字母/MD5值的第二个字母/文件md5值/chunk/
    return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + "chunk" + "/";
}

上传分块(临时文件)

如果上传的分块文件在Minio中不存在则请求媒资服务将该分块文件上传到Minio中,上传分块文件并不需要保存文件信息到数据库

/**
 * @description 上传分块
 * @param fileMd5  文件md5
 * @param chunk  分块序号
 * @param localChunkFilePath  分块文件本地路径
 * @return com.xuecheng.base.model.RestResponse
 * @author Mr.M
 * @date 2022/9/13 15:50
 */
public RestResponse uploadChunk(String fileMd5,int chunk,String localChunkFilePath);
@Override
public RestResponse uploadChunk(String fileMd5, int chunk, String localChunkFilePath) {
    // 根据MD5值得到分块文件的存储目录
    String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
    // 指定分块文件的完整存储路径,即分块文件在Minio中的objectname
    String chunkFilePath = chunkFileFolderPath + chunk;
    // 根据文件扩展名获取文件的mimeType
    String mimeType = getMimeType(null);
    // 将分块文件上传至minIO
    boolean b = addMediaFilesToMinIO(localChunkFilePath, mimeType, bucket_videoFiles, chunkFilePath);
    if (!b) {
        log.debug("上传分块文件失败:{}", chunkFilePath);
        return RestResponse.validfail(false, "上传分块失败");
    }
    log.debug("上传分块文件成功:{}",chunkFilePath);
    return RestResponse.success(true);

}

上传分块(字节数组)

/**
 * 上传分块
 * @param fileMd5   文件MD5
 * @param chunk     分块序号
 * @param bytes     文件字节
 * @return
 */
RestResponse uploadChunk(String fileMd5,int chunk,byte[] bytes);
@Override
public RestResponse uploadChunk(String fileMd5, int chunk, byte[] bytes) {
    // 指定分块文件的完整存储路径,即分块文件在Minio中的objectname
    String chunkFilePath = getChunkFileFolderPath(fileMd5) + chunk;
    try {
        addMediaFilesToMinIO(bytes, video_files, chunkFilePath);
        return RestResponse.success(true);
    } catch (Exception e) {
        log.debug("上传分块文件:{}失败:{}", chunkFilePath, e.getMessage());
    }
    return RestResponse.validfail("上传文件失败", false);
}

合并分块(临时文件)

/**
 * @description 合并分块
 * @param companyId  机构id
 * @param fileMd5  文件md5
 * @param chunkTotal 分块文件的个数总和
 * @param uploadFileParamsDto 文件信息
 * @return com.xuecheng.base.model.RestResponse
 */
public RestResponse mergechunks(Long companyId,String fileMd5,int chunkTotal,UploadFileParamsDto uploadFileParamsDto);
@Override
public RestResponse mergechunks(Long companyId, String fileMd5, int chunkTotal, UploadFileParamsDto uploadFileParamsDto) {
    // 获取分块文件的存储目录
    String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
    // List<ComposeSource>存储Minio中所有分块文件的信息 
    List<ComposeSource> sourceObjectList = Stream.iterate(0, i -> ++i)
        .limit(chunkTotal)
        .map(i -> ComposeSource.builder()
             .bucket(bucket_videoFiles)
             .object(chunkFileFolderPath.concat(Integer.toString(i)))
             .build())
        .collect(Collectors.toList());
    // 源文件名称
    String fileName = uploadFileParamsDto.getFilename();
    // 源文件扩展名
    String extName = fileName.substring(fileName.lastIndexOf("."));
    // 合并后文件的在Minio中的完整路径,即MD5首字母/MD5第二个字母/文件MD5值/文件MD5值.后缀
    String mergeFilePath = getFilePathByMd5(fileMd5, extName);
    try {
        // 合并文件
        ObjectWriteResponse response = minioClient.composeObject(
            ComposeObjectArgs.builder()
            .bucket(bucket_videoFiles)
            .object(mergeFilePath)// 合并后文件的objectname,这个文件会自动创建
            .sources(sourceObjectList)// 合并文件需要的所有分块文件
            .build());
        log.debug("合并文件成功:{}",mergeFilePath);
    } catch (Exception e) {
        log.debug("合并文件失败,fileMd5:{},异常:{}",fileMd5,e.getMessage(),e);
        return RestResponse.validfail(false, "合并文件失败。");
    }

    // 下载(查询)Minio中合并后的文件
    File minioFile = downloadFileFromMinIO(bucket_videoFiles,mergeFilePath);
    if(minioFile == null){
        log.debug("下载合并后文件失败,mergeFilePath:{}",mergeFilePath);
        return RestResponse.validfail(false, "下载合并后文件失败");
    }
    // 计算Minio上合并后文件的md5值并与前端携带的文件MD5值进行比对,如果不一致则说明文件不完整
    try (InputStream newFileInputStream = new FileInputStream(minioFile)) {
        String md5Hex = DigestUtils.md5Hex(newFileInputStream);
        if(!fileMd5.equals(md5Hex)){
            return RestResponse.validfail(false, "文件合并校验失败,最终上传失败");
        }
        // 文件大小
        uploadFileParamsDto.setFileSize(minioFile.length());
    }catch (Exception e){
        log.debug("校验文件失败,fileMd5:{},异常:{}",fileMd5,e.getMessage(),e);
        return RestResponse.validfail(false, "文件合并校验失败,最终上传失败。");
    }finally {
        if(minioFile!=null){
            minioFile.delete();
        }
    }
    // 将合并后文件的信息入库,使用代理对象对应调用addMediaFilesToDb方法保证事务生效
    currentProxy.addMediaFilesToDb(companyId,fileMd5,uploadFileParamsDto,bucket_videoFiles,mergeFilePath);
    if(mediaFiles == null){
        return RestResponse.validfail(false,"文件入库失败");
    }
    // 清除分块文件
    clearChunkFiles(chunkFileFolderPath,chunkTotal);
    return RestResponse.success(true);
}

指定合并后的文件在Minio中的objectname为MD5首字母/MD5第二个字母/文件MD5值/文件MD5值.后缀

/**
 * 得到合并后的文件在Minio中的地址,/2/f/2f6451sdg/2f6451sdg.mp4
 * @param fileMd5 文件id即md5值
 * @param fileExt 文件扩展名
 * @return
 */
private String getFilePathByMd5(String fileMd5,String fileExt){
    // MD5首字母/MD5第二个字母/文件MD5值/文件MD5值.后缀
    return   fileMd5.substring(0,1) + "/" + fileMd5.substring(1,2) + "/" + fileMd5 + "/" +fileMd5 +fileExt;
}

下载合并文件校验完整性

从MInio中将合并后的文件到下载本地服务器,然后与本地上传的文件的MD5值进行校验比对

/**
 * 从minio下载文件
 * @param bucket 桶
 * @param objectName 对象名称
 * @return 下载后的文件
 */
public File downloadFileFromMinIO(String bucket,String objectName){
    // 创建临时文件
    File minioFile = null;
    FileOutputStream outputStream = null;
    try{
        InputStream stream = minioClient.getObject(GetObjectArgs.builder()
                .bucket(bucket)
                .object(objectName)
                .build());
        minioFile=File.createTempFile("minio", ".merge");
        // 将合并后的文件信息写入到临时文件
        outputStream = new FileOutputStream(minioFile);
        IOUtils.copy(stream,outputStream);
        return minioFile;
    } catch (Exception e) {
       e.printStackTrace();
    }finally {
        if(outputStream!=null){
            try {
                outputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    return null;
}

合并分块(字节)

@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 mergeFile = null;
    try {
        mergeFile = File.createTempFile(fileName, extension);
    } catch (IOException e) {
        XueChengPlusException.cast("创建合并临时文件出错");
    }
    try {
        // 缓冲区
        byte[] buffer = new byte[1024];
        // 写入流,向临时文件写入
        try (RandomAccessFile raf_write = new RandomAccessFile(mergeFile, "rw")) {
            // 遍历分块文件数组
            for (File chunkFile : chunkFiles) {
                // 读取流,读分块文件
                try (RandomAccessFile raf_read = new RandomAccessFile(chunkFile, "r")) {
                    int len;
                    while ((len = raf_read.read(buffer)) != -1) {
                        raf_write.write(buffer, 0, len);
                    }
                }
            }
        } catch (Exception e) {
            XueChengPlusException.cast("合并文件过程中出错");
        }
        uploadFileParamsDto.setFileSize(mergeFile.length());
        // 对合并后的文件进行校验,通过与前端传递的MD5值比较
        try (FileInputStream mergeInputStream = new FileInputStream(mergeFile)) {
            String mergeMd5 = org.apache.commons.codec.digest.DigestUtils.md5Hex(mergeInputStream);
            if (!fileMd5.equals(mergeMd5)) {
                XueChengPlusException.cast("合并文件校验失败");
            }
            log.debug("合并文件校验通过:{}", mergeFile.getAbsolutePath());
        } catch (Exception e) {
            XueChengPlusException.cast("合并文件校验异常");
        }
        // 拼接合并文件在Minio中的完整存储路径
        String mergeFilePath = getFilePathByMd5(fileMd5, extension);
        // 将本地合并好的文件上传到minio中,这里重载了一个方法
        addMediaFilesToMinIO(mergeFile.getAbsolutePath(), video_files, mergeFilePath);
        log.debug("合并文件上传至MinIO完成{}", mergeFile.getAbsolutePath());
        // 将文件信息写入数据库
        MediaFiles mediaFiles = addMediaFilesToDB(companyId, uploadFileParamsDto, mergeFilePath, fileMd5, video_files);
        if (mediaFiles == null) {
            XueChengPlusException.cast("媒资文件入库出错");
        }
        log.debug("媒资文件入库完成");
        return RestResponse.success();
    } finally {
        for (File chunkFile : chunkFiles) {
            try {
                chunkFile.delete();
            } catch (Exception e) {
                log.debug("临时分块文件删除错误:{}", e.getMessage());
            }
        }
        try {
            mergeFile.delete();
        } catch (Exception e) {
            log.debug("临时合并文件删除错误:{}", e.getMessage());
        }
    }
}

查询(下载)Minio中的分块文件写入合并文件中

/**
 * 下载分块文件
 * @param fileMd5       文件的MD5
 * @param chunkTotal    总块数
 * @return 分块文件数组
 */
private File[] checkChunkStatus(String fileMd5, int chunkTotal) {
    // 作为结果返回
    File[] files = new File[chunkTotal];
    // 获取分块文件目录
    String chunkFileFolder = getChunkFileFolderPath(fileMd5);
    for (int i = 0; i < chunkTotal; i++) {
        // 获取分块文件路径
        String chunkFilePath = chunkFileFolder + i;
        File chunkFile = null;
        try {
            // 创建临时的分块文件
            chunkFile = File.createTempFile("chunk" + i, null);
        } catch (Exception e) {
            XueChengPlusException.cast("创建临时分块文件出错:" + e.getMessage());
        }
        // 下载分块文件
        chunkFile = downloadFileFromMinio(chunkFile, video_files, chunkFilePath);
        // 组成结果
        files[i] = chunkFile;
    }
    return files;
}

将本地文件上传到minio

/**
 * 将本地文件上传到minio
 * @param filePath      本地文件路径
 * @param bucket        桶
 * @param objectName    对象名称
 */
private void addMediaFilesToMinIO(String filePath, String bucket, String objectName) {
    String contentType = getContentType(objectName);
    try {
        minioClient.uploadObject(UploadObjectArgs
                .builder()
                .bucket(bucket)
                .object(objectName)
                .filename(filePath)
                .contentType(contentType)
                .build());
    } catch (Exception e) {
        XueChengPlusException.cast("上传到文件系统出错");
    }
}

从Minio中查询(下载)文件

/**
 * 从Minio中下载文件
 * @param file          目标文件
 * @param bucket        桶
 * @param objectName    桶内文件路径
 * @return
 */
private File downloadFileFromMinio(File file, String bucket, String objectName) {
    try (FileOutputStream fileOutputStream = new FileOutputStream(file);
         InputStream inputStream = minioClient.getObject(GetObjectArgs
                 .builder()
                 .bucket(bucket)
                 .object(objectName)
                 .build())) {
        IOUtils.copy(inputStream, fileOutputStream);
        return file;
    } catch (Exception e) {
        XueChengPlusException.cast("查询文件分块出错");
    }
    return null;
}

清除分块

如果分块文件合并后并且完整则可以清除对应的分块文件节省空间

/**
 * 清除分块文件
 * @param chunkFileFolderPath 分块文件路径
 * @param chunkTotal 分块文件总数
 */
private void clearChunkFiles(String chunkFileFolderPath,int chunkTotal){
    try {
        List<DeleteObject> deleteObjects = Stream.iterate(0, i -> ++i)
                .limit(chunkTotal)
                .map(i -> new DeleteObject(chunkFileFolderPath.concat(Integer.toString(i))))
                .collect(Collectors.toList());

        RemoveObjectsArgs removeObjectsArgs = RemoveObjectsArgs.builder().bucket("video").objects(deleteObjects).build();
        Iterable<Result<DeleteError>> results = minioClient.removeObjects(removeObjectsArgs);
        results.forEach(r->{
            DeleteError deleteError = null;
            try {
                deleteError = r.get();
            } catch (Exception e) {
                e.printStackTrace();
                log.error("清楚分块文件失败,objectname:{}",deleteError.objectName(),e);
            }
        });
    } catch (Exception e) {
        e.printStackTrace();
        log.error("清楚分块文件失败,chunkFileFolderPath:{}",chunkFileFolderPath,e);
    }
}

前后端联调测试

上传一个视频测试合并分块的执行逻辑,当上传一部分后停止上传,然后刷新浏览器重新上传,查看浏览器日志发现已经上传过的分块不再重新上传

在这里插入图片描述