使用MinIO搭建自己的分布式文件存储

目录

引言:

[一.什么是 MinIO ?](#一.什么是 MinIO ?)

[二.MinIO 的安装与部署:](#二.MinIO 的安装与部署:)

[三.Spring Cloud 集成 MinIO:](#三.Spring Cloud 集成 MinIO:)

1.前提准备:

(1)安装依赖:

(2)配置MinIO连接:

(3)修改bucket的访问权限:

2.测试上传、删除、下载文件:

3.图片操作:

[(1)MinioConfig 配置类:](#(1)MinioConfig 配置类:)

[(2)Controller 接口定义:](#(2)Controller 接口定义:)

(3)Service开发:

【1】根据扩展名获取mimeType:

【2】将文件上传到minio:

[【3】获取文件默认存储目录路径 年/ 月/ 日:](#【3】获取文件默认存储目录路径 年/ 月/ 日:)

【4】获取文件的md5:

4.视频操作:

(1)断点上传:

(2)测试文件分块上传与合并:

【1】分块上传:

【2】合并分块文件:

(3)使用MinIO合并分块:

【1】将分块文件上传至minio:

【2】合并文件,要求分块文件最小5M:

【3】清除分块文件:

(4)三层架构------上传分块:

【1】检查文件是否存在:

【2】文件上传前检查分块文件是否存在:

【3】上传分块文件:

[(4) 三层架构------清除分块文件:](#(4) 三层架构——清除分块文件:)

(5)三层架构------从MinIO下载文件:

(6)三层架构------合并分块文件:


引言:

一个计算机无法存储海量的文件,通过网络将若干计算机组织起来共同去存储海量的文件,去接收海量用户的请求,这些组织起来的计算机通过网络进行通信,如下图:

好处:

1、一台计算机的文件系统处理能力扩充到多台计算机同时处理。

2、一台计算机挂了还有另外副本计算机提供数据。

3、每台计算机可以放在不同的地域,这样用户就可以就近访问,提高访问速度。
市面上有哪些分布式文件系统的产品呢?

  1. NFS:在客户端上映射NFS服务器的驱动器,客户端通过网络访问NFS服务器的硬盘完全透明。

  2. GFS:采用主从结构,一个GFS集群由一个master和大量的chunkserver组成,master存储了数据文件的元数据,一个文件被分成了若干块存储在多个chunkserver中。用户从master中获取数据元信息,向chunkserver存储数据。

  3. HDFS:是Hadoop抽象文件系统的一种实现,高度容错性的系统,适合部署在廉价的机器上。能提供高吞吐量的数据访问,非常适合大规模数据集上的应用,HDFS的文件分布在集群机器上,同时提供副本进行容错及可靠性保证。例如客户端写入读取文件的直接操作都是分布在集群各个机器上的,没有单点性能压力。

  4. 阿里云对象存储服务OSS:对象存储 OSS_云存储服务阿里云对象存储 OSS 是一款海量、安全、低成本、高可靠的云存储服务,提供 99.995 % 的服务可用性和多种存储类型,适用于数据湖存储,数据迁移,企业数据管理,数据处理等多种场景,可对接多种计算分析平台,直接进行数据处理与分析,打破数据孤岛,优化存储成本,提升业务价值。https://www.aliyun.com/product/oss

  5. 百度对象存储BOS:对象存储BOS_百度智能云百度智能云对象存储BOS提供稳定、安全、高效、高可扩展的云存储服务。您可以将任意数量和形式的非结构化数据存入对象存储BOS,BOS支持标准、低频、冷和归档存储等多种存储类型,适用于数据迁移、企业数据管理、数据处理、数据湖存储等多种场景。https://cloud.baidu.com/product/bos.html

一.什么是 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的主要特点包括:

  • 高性能:作为世界上最快的对象存储之一,MinIO可以支持高达每秒数百GB的吞吐量
  • 简单易用:简单的命令行和Web界面,几分钟内即可完成安装和配置
  • 云原生:从公有云到私有云再到边缘计算,MinIO都能完美运行
  • 开源:采用Apache V2开源协议,可以自由使用和修改
  • 轻量级:单个二进制文件即可运行,没有外部依赖

MinIO集群采用去中心化共享架构,每个结点是对等关系,通过Nginx可对MinIO进行负载均衡访问。

去中心化有什么好处?

在大数据领域,通常的设计理念都是无中心和分布式。Minio分布式模式可以帮助你搭建一个高可用的对象存储服务,你可以使用这些存储设备,而不用考虑其真实物理位置

它将分布在不同服务器上的多块硬盘组成一个对象存储服务。由于硬盘分布在不同的节点上,分布式Minio避免了单点故障。如下图:

Minio使用纠删码技术来保护数据,它是一种恢复丢失和损坏数据的数学算法,它将数据分块冗余的分散存储在各各节点的磁盘上,所有的可用磁盘组成一个集合,上图由8块硬盘组成一个集合,当上传一个文件时会通过纠删码算法计算对文件进行分块存储,除了将文件本身分成4个数据块,还会生成4个校验块,数据块和校验块会分散的存储在这8块硬盘上。

使用纠删码的好处是即便丢失一半数量(N / 2)的硬盘,仍然可以恢复数据。 比如上边集合中有4个以内的硬盘损害仍可保证数据恢复,不影响上传和下载,如果多于一半的硬盘坏了则无法恢复。

二.MinIO 的安装与部署:

下边在本机演示MinIO恢复数据的过程,在本地创建4个目录表示4个硬盘。

下载minio,下载地址在Minio下载地址

随后CMD进入有minio.exe的目录,运行下边的命令:( 替换自己的安装地址)

bash 复制代码
minio.exe server E:\minio_data\data1 E:\minio_data\data2 E:\minio_data\data3 E:\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://192.168.88.1:9000 进行登录(看自己的地址),账号和密码均为为:minioadmin

输入bucket的名称,点击"CreateBucket",创建成功:

随后就可以进行上传、删除等操作了。

三.Spring Cloud 集成 MinIO:

1.前提准备:

(1)安装依赖:

XML 复制代码
<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.2</version>
</dependency>
<dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>okhttp</artifactId>
    <version>4.12.0</version>
</dependency>
<!--根据扩展名取mimetype-->
<dependency>
    <groupId>com.j256.simplemagic</groupId>
    <artifactId>simplemagic</artifactId>
    <version>1.17</version>
</dependency>

(2)配置MinIO连接:

因为我们要上传普通文件与视频文件,所以创建 mediafiles(普通文件) 与 video(视频文件) 两个 buckets 。

bootstrap.yml中添加配置:

XML 复制代码
minio:
  endpoint: http://192.168.56.1:9000
  accessKey: minioadmin
  secretKey: minioadmin
  bucket:
    files: mediafiles
    videofiles: video

需要三个参数才能连接到minio服务。

|------------|------------------------------|
| 参数 | 说明 |
| Endpoint | 对象存储服务的URL |
| Access Key | Access key就像用户ID,可以唯一标识你的账户。 |
| Secret Key | Secret key是你账户的密码。 |

随后也可以添加对上传文件的限制:

java 复制代码
spring:
  servlet:
    multipart:
      max-file-size: 50MB
      max-request-size: 50MB

max-file-size:单个文件的大小限制

Max-request-size: 单次请求的大小限制

(3)修改bucket的访问权限:

点击"Manage"修改bucket的访问权限:

选择public权限:

2.测试上传、删除、下载文件:

首先初始化 minioClient:

java 复制代码
MinioClient minioClient =
        MinioClient.builder()
                .endpoint("http://192.168.56.1:9000")
                .credentials("minioadmin", "minioadmin")
                .build();

随后设置contentType可以通过com.j256.simplemagic.ContentType枚举类查看常用的mimeType(媒体类型)。通过扩展名得到mimeType,代码如下:

java 复制代码
// 根据扩展名取出mimeType
ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(".mp4");
String mimeType = MediaType.APPLICATION_OCTET_STREAM_VALUE;// 通用mimeType,字节流

校验文件的完整性,对文件计算出md5值,比较原始文件的md5和目标文件的md5,一致则说明完整:

java 复制代码
//校验文件的完整性对文件的内容进行md5
FileInputStream fileInputStream1 = new FileInputStream(new File("D:\\develop\\upload\\1.mp4"));
String source_md5 = DigestUtils.md5Hex(fileInputStream1);
FileInputStream fileInputStream = new FileInputStream(new File("D:\\develop\\upload\\1a.mp4"));
String local_md5 = DigestUtils.md5Hex(fileInputStream);
if(source_md5.equals(local_md5)){
    System.out.println("下载成功");
}

下面是完整的测试代码:

java 复制代码
package com.xuecheng.media;

import com.j256.simplemagic.ContentInfo;
import com.j256.simplemagic.ContentInfoUtil;
import io.minio.*;
import org.apache.commons.codec.digest.DigestUtils;

import org.apache.commons.compress.utils.IOUtils;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import java.io.*;

public class MinioTest {

    // 初始化minioClient
    MinioClient minioClient =
            MinioClient.builder()
                    .endpoint("http://192.168.56.1:9000")
                    .credentials("minioadmin", "minioadmin")
                    .build();

    @Test
    public void test_upload() throws Exception {

        // 通过扩展名得到媒体资源类型 mimeType
        // 根据扩展名取出mimeType
        ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(".mp4");
        String mimeType = MediaType.APPLICATION_OCTET_STREAM_VALUE;// 通用mimeType,字节流
        if(extensionMatch != null){
            mimeType = extensionMatch.getMimeType();
        }

        // 上传文件的参数信息
        UploadObjectArgs uploadObjectArgs = UploadObjectArgs.builder()
                .bucket("testbucket")// 桶
                .filename("C:\\Users\\Eleven\\Videos\\4月16日.mp4") // 指定本地文件路径
//                .object("1.mp4")// 对象名在桶下存储该文件
                .object("test/01/1.mp4")// 对象名 放在子目录下
                .contentType(mimeType)// 设置媒体文件类型
                .build();

        // 上传文件
        minioClient.uploadObject(uploadObjectArgs);
    }

    // 删除文件
    @Test
    public void test_delete() throws Exception {

        //RemoveObjectArgs
        RemoveObjectArgs removeObjectArgs = RemoveObjectArgs.builder()
                .bucket("testbucket")
                .object("test/01/1.mp4")
                .build();

        // 删除文件
        minioClient.removeObject(removeObjectArgs);
    }

    // 查询文件 从minio中下载
    @Test
    public void test_getFile() throws Exception {

        GetObjectArgs getObjectArgs = GetObjectArgs.builder().bucket("testbucket").object("test/01/1.mp4").build();
        //查询远程服务获取到一个流对象
        FilterInputStream inputStream = minioClient.getObject(getObjectArgs);
        //指定输出流
        FileOutputStream outputStream = new FileOutputStream(new File("D:\\develop\\upload\\1a.mp4"));
        IOUtils.copy(inputStream,outputStream);
        //校验文件的完整性对文件的内容进行md5
        FileInputStream fileInputStream1 = new FileInputStream(new File("D:\\develop\\upload\\1.mp4"));
        String source_md5 = DigestUtils.md5Hex(fileInputStream1);
        FileInputStream fileInputStream = new FileInputStream(new File("D:\\develop\\upload\\1a.mp4"));
        String local_md5 = DigestUtils.md5Hex(fileInputStream);
        if(source_md5.equals(local_md5)){
            System.out.println("下载成功");
        }
    }
}

3.图片操作:

上传课程图片总体上包括两部分:

1、上传课程图片前端请求媒资管理服务将文件上传至分布式文件系统,并且在媒资管理数据库保存文件信息。

2、上传图片成功保存图片地址到课程基本信息表中。

详细流程如下:

1、前端进入上传图片界面

2、上传图片,请求媒资管理服务。

3、媒资管理服务将图片文件存储在MinIO。

4、媒资管理记录文件信息到数据库。

5、前端请求内容管理服务保存课程信息,在内容管理数据库保存图片地址。

首先在minio配置bucket,bucket名称为:mediafiles,并设置bucket的权限为公开。

在nacos配置中minio的相关信息,进入media-service-dev.yaml:

配置信息如下:

bash 复制代码
minio:
  endpoint: http://192.168.56.1:9000
  accessKey: minioadmin
  secretKey: minioadmin
  bucket:
    files: mediafiles
    videofiles: video

(1)MinioConfig 配置类:

java 复制代码
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;

/**
 * @author eleven
 * @version 1.0
 * @description TODO
 * @date 2025/6/4 15:00
 */
@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;
    }
}

(2)Controller 接口定义:

根据需求分析,下边进行接口定义,此接口定义为一个通用的上传文件接口,可以上传图片或其它文件。

首先分析接口:

请求地址:/media/upload/coursefile

请求内容:**Content-Type:**multipart/form-data;

因为无法直接获取上传文件的本地路径,所以创建临时文件作为中转,临时文件名以"minio"为前缀,".temp"为后缀。

java 复制代码
package com.xuecheng.media.api;

import com.xuecheng.base.model.PageParams;
import com.xuecheng.base.model.PageResult;
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.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;

/**
 * @description 媒资文件管理接口
 * @author eleven
 * @version 1.0
 */
 @Tag(name = "媒资文件管理接口",description = "媒资文件管理接口")
 @RestController
public class MediaFilesController {

    @Autowired
    MediaFileService mediaFileService;

    @Operation(summary = "上传图片")
    @RequestMapping(value = "/upload/coursefile",consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public UploadFileResultDto upload(@RequestPart("filedata") MultipartFile filedata) throws IOException {
        // 准备上传文件的信息
        UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();
        // 原始文件名称
        uploadFileParamsDto.setFilename(filedata.getOriginalFilename());
        // 文件大小
        uploadFileParamsDto.setFileSize(filedata.getSize());
        // 文件类型
        uploadFileParamsDto.setFileType("001001");  // 自定义的字典,001001代表图片
        // 因为无法直接获得该文件的路径,所以创建一个临时文件
        File tempFile = File.createTempFile("minio", ".temp");
        filedata.transferTo(tempFile); // 拷贝文件
        Long companyId = 1232141425L;
        // 文件路径
        String localFilePath = tempFile.getAbsolutePath();

        //调用service上传图片
        UploadFileResultDto uploadFileResultDto = mediaFileService.uploadFile(companyId, uploadFileParamsDto, localFilePath);

        return uploadFileResultDto;
    }
}

(3)Service开发:

这里分几个方法进行开发:

【1】根据扩展名获取mimeType:
java 复制代码
/**
 * 根据扩展名获取mimeType
 */
private String getMimeType(String extension){
    if(extension == null){
        extension = "";
    }
    // 根据扩展名取出mimeType
    ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(extension);
    String mimeType = MediaType.APPLICATION_OCTET_STREAM_VALUE;//通用mimeType,字节流
    if(extensionMatch != null){
        mimeType = extensionMatch.getMimeType();
    }
    return mimeType;
}
【2】将文件上传到minio:
java 复制代码
/**
 * 将文件上传到minio
 * @param localFilePath 文件本地路径
 * @param mimeType 媒体类型
 * @param bucket 桶
 * @param objectName 对象名
 * @return
 */
public boolean addMediaFilesToMinIO(String localFilePath,String mimeType,String bucket, String objectName){
    try {
        UploadObjectArgs uploadObjectArgs = UploadObjectArgs.builder()
                .bucket(bucket)//桶
                .filename(localFilePath) //指定本地文件路径
                .object(objectName)//对象名 放在子目录下
                .contentType(mimeType)//设置媒体文件类型
                .build();
        //上传文件
        minioClient.uploadObject(uploadObjectArgs);
        log.debug("上传文件到minio成功,bucket:{},objectName:{}",bucket,objectName);
        return true;
    } catch (Exception e) {
       e.printStackTrace();
       log.error("上传文件出错,bucket:{},objectName:{},错误信息:{}",bucket,objectName,e.getMessage());
    }
    return false;
}
【3】获取文件默认存储目录路径 年/ 月/ 日:
java 复制代码
/**
 * 获取文件默认存储目录路径 年/月/日
 */
private String getDefaultFolderPath() {
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
    String folder = sdf.format(new Date()).replace("-", "/")+"/";
    return folder;
}
【4】获取文件的md5:
java 复制代码
/**
 * 获取文件的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;
    }
}

随后 MediaFileServiceImpl 类创建方法实现上传图片并校验是否成功上传:

如果在uploadFile方法上添加@Transactional,当调用uploadFile方法前会开启数据库事务,如果上传文件过程时间较长那么数据库的事务持续时间就会变长,这样数据库链接释放就慢,最终导致数据库链接不够用。

我们只将addMediaFilesToDb方法添加事务控制即可,将该方法提取出来在 MediaFileTransactionalServiceImpl 中创建方法。

java 复制代码
@Autowired
MediaFilesMapper mediaFilesMapper;

@Autowired
MinioClient minioClient;

@Autowired
private MediaFileTransactionalServiceImpl transactionalService; // 事务,操作数据库

//存储普通文件
@Value("${minio.bucket.files}")
private String bucket_mediafiles;

//存储视频
@Value("${minio.bucket.videofiles}")
private String bucket_video;

@Override
public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, String localFilePath) {

    // 文件名
    String filename = uploadFileParamsDto.getFilename();
    // 先得到扩展名
    String extension = filename.substring(filename.lastIndexOf("."));

    // 根据扩展名得到mimeType
    String mimeType = getMimeType(extension);

    // 子目录
    String defaultFolderPath = getDefaultFolderPath();
    // 文件的md5值
    String fileMd5 = getFileMd5(new File(localFilePath));
    String objectName = defaultFolderPath+fileMd5+extension;
    // 上传文件到minio
    boolean result = addMediaFilesToMinIO(localFilePath, mimeType, bucket_mediafiles, objectName);
    if(!result){
        XueChengPlusException.cast("上传文件失败");
    }
    try {
        // 调用事务方法
        MediaFiles mediaFiles = transactionalService.addMediaFilesToDbWithTransaction(
                companyId, fileMd5, uploadFileParamsDto, bucket_mediafiles, objectName);
        UploadFileResultDto uploadFileResultDto = new UploadFileResultDto();
        BeanUtils.copyProperties(mediaFiles, uploadFileResultDto);
        return uploadFileResultDto;
    } catch (Exception e) {
        // 如果事务失败,尝试删除已上传的MinIO文件
        try {
            minioClient.removeObject(
                    RemoveObjectArgs.builder()
                            .bucket(bucket_mediafiles)
                            .object(objectName)
                            .build());
        } catch (Exception ex) {
            log.error("回滚时删除MinIO文件失败", ex);
        }
        throw e;
    }
}

而为了回滚数据库,我们在新建的 MediaFileTransactionalServiceImpl 类中创建:

java 复制代码
package com.xuecheng.media.service.impl;

import com.xuecheng.base.exception.XueChengPlusException;
import com.xuecheng.media.mapper.MediaFilesMapper;
import com.xuecheng.media.model.dto.UploadFileParamsDto;
import com.xuecheng.media.model.po.MediaFiles;
import com.xuecheng.media.service.MediaFileTransactionalService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;


@Slf4j
@Service
public class MediaFileTransactionalServiceImpl implements MediaFileTransactionalService {

    @Autowired
    private MediaFilesMapper mediaFilesMapper;

    /**
     * @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
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public MediaFiles addMediaFilesToDbWithTransaction(Long companyId, String fileMd5,
                                                       UploadFileParamsDto uploadFileParamsDto,
                                                       String bucket, String objectName) {
        // 原addMediaFilesToDb方法内容
        MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
        if(mediaFiles == null){
            mediaFiles = new MediaFiles();
            BeanUtils.copyProperties(uploadFileParamsDto,mediaFiles);
            mediaFiles.setId(fileMd5);
            mediaFiles.setCompanyId(companyId);
            mediaFiles.setBucket(bucket);
            mediaFiles.setFilePath(objectName);
            mediaFiles.setFileId(fileMd5);
            mediaFiles.setUrl("/" + bucket + "/" + objectName);
            mediaFiles.setCreateDate(LocalDateTime.now());
            mediaFiles.setStatus("1");
            mediaFiles.setAuditStatus("002003");

            int insert = mediaFilesMapper.insert(mediaFiles);
            if(insert <= 0){
                log.error("向数据库保存文件失败,bucket:{},objectName:{}",bucket,objectName);
                throw new XueChengPlusException("保存文件信息失败"); // 触发回滚
            }
        }
        return mediaFiles;
    }
}

4.视频操作:

(1)断点上传:

通常视频文件都比较大,所以对于媒资系统上传文件的需求要满足大文件的上传要求。http协议本身对上传文件大小没有限制,但是客户的网络环境质量、电脑硬件环境等参差不齐,如果一个大文件快上传完了网断了没有上传完成,需要客户重新上传,用户体验非常差,所以对于大文件上传的要求最基本的是断点续传。

什么是断点续传?

引用百度百科:断点续传指的是在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为的划分为几个部分,每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传下载未完成的部分,而没有必要从头开始上传下载,断点续传可以提高节省操作时间,提高用户体验性。

断点续传流程如下图:

流程如下:

1、前端上传前先把文件分成块

2、一块一块的上传,上传中断后重新上传,已上传的分块则不用再上传

3、各分块上传完成最后在服务端合并文件

(2)测试文件分块上传与合并:

为了更好的理解文件分块上传的原理,下边用java代码测试文件的分块与合并。

文件分块的流程如下:

1、获取源文件长度

2、根据设定的分块文件的大小计算出块数

3、从源文件读数据依次向每一个块文件写数据。

而为了实现文件分块,需要使用 RandomAccessFile。

RandomAccessFile 是 Java 提供的 ​随机访问文件 ​ 类,允许对文件进行 ​任意位置读写,适用于大文件分块、断点续传、数据库索引等场景。

构造方法:

java 复制代码
// 模式:
// "r" : 只读
// "rw": 读写(文件不存在则自动创建)
// "rws": 读写 + 同步写入元数据(强制刷盘)
// "rwd": 读写 + 同步写入文件内容(强制刷盘)
RandomAccessFile raf = new RandomAccessFile(File file, String mode);
RandomAccessFile raf = new RandomAccessFile(String path, String mode);

操作指针:

方法 作用
long getFilePointer() 返回当前指针位置
void seek(long pos) 移动指针到指定位置
long length() 返回文件长度
void setLength(long newLength) 扩展/截断文件

读写数据:

方法 说明
int read() 读取1字节(返回 0~255,失败返回 -1
int read(byte[] b) 读取数据到字节数组
readInt(), readDouble() 读取基本类型
write(byte[] b) 写入字节数组
writeInt(), writeUTF() 写入基本类型或字符串
【1】分块上传:

流程分析:

①初始化阶段

  • 设置源文件路径分块存储目录,自动创建不存在的目录
  • 定义每个分块大小为1MB,并根据文件总大小计算所需分块数量
  • 初始化1KB的读写缓冲区:byte[] b = new byte[1024];

②文件读取准备

  • 使用 RandomAccessFile 以只读模式(r)打开源文件
  • 文件指针自动记录读取位置,确保连续性

③分块处理核心流程

  • 循环创建每个分块文件,先删除已存在的旧文件
  • 为每个分块创建新的 RandomAccessFile 写入流(rw)
  • 通过缓冲区循环读取源文件数据,写入分块文件
  • 实时检查分块文件大小,达到1MB立即切换下一个分块

④收尾工作

  • 每个分块写入完成后立即关闭文件流
  • 所有分块处理完毕后关闭源文件流
java 复制代码
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 eleven
 * @version 1.0
 * @description 大文件处理测试
 */
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();  // 不存在则创建
        }
        // 分块大小
        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"); // r:允许对文件进行读操作
        // 分块
        for (int i = 0; i < chunkNum; i++) {
            // 创建分块文件
            File file = new File(chunkPath + i);
            if(file.exists()){
                file.delete(); // 确保每个文件都是重新生成
            }
            boolean newFile = file.createNewFile(); // 在指定路径下创建一个空的物理文件
            // 如果文件已存在(尽管前面有 file.delete(),但极端情况下可能删除失败)
            // createNewFile() 会返回 false,防止后续 RandomAccessFile 仍会覆盖写入
            if (newFile) {
                // 在 RandomAccessFile 中,文件指针(File Pointer) 会自动记录当前读写位置
                // 确保每次 read() 或 write() 操作都会从上次结束的位置继续。
                // 创建分块文件写入流,向分块文件中写数据
                RandomAccessFile raf_write = new RandomAccessFile(file, "rw"); // rw:允许对文件进行读写操作
                int len = -1;
                // 从源文件(raf_read)读取数据到缓冲区 byte[] b,每次最多读取 1024 字节(缓冲区大小)
                // len 返回实际读取的字节数,如果 len = -1 表示源文件已读完
                while ((len = raf_read.read(b)) != -1) {
                    // 将缓冲区 b 中的数据读取并写入目标文件(分块文件file),写入范围是 [0, len),确保只写入有效数据
                    raf_write.write(b, 0, len);
                    // 确保每个分块文件不超过指定大小chunkSize
                    if (file.length() >= chunkSize) {
                        break;
                    }
                }
                raf_write.close();
                System.out.println("完成分块"+i);
            }
        }
        raf_read.close();
    }
}
【2】合并分块文件:

流程分析:

  1. 初始化阶段:检查并创建合并文件,初始化写入流和缓冲区(1KB),获取所有分块文件并按文件名数字排序,确保按原始顺序合并。
  2. 文件合并阶段
    • 遍历每个分块文件,使用RandomAccessFile读取数据到缓冲区
    • 通过seek(0)确保每次从分块文件头部读取
    • 将缓冲区数据写入合并文件,循环直到当前分块读取完毕
  3. 资源释放:每个分块处理完后立即关闭流,全部合并后关闭写入流。
  4. 完整性校验
    • 使用FileInputStream读取原始文件和合并文件的二进制内容
    • 通过DigestUtils.md5Hex()计算MD5哈希值比对
    • 完全一致则判定合并成功
java 复制代码
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 eleven
 * @version 1.0
 * @description 大文件处理测试
 */
public class BigFileTest {

    /**
     * 测试文件合并方法
     */
    @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 raf_write = new RandomAccessFile(mergeFile, "rw");
        // 指针指向文件顶端
        raf_write.seek(0);
        // 缓冲区
        byte[] b = new byte[1024];
        // 分块列表
        File[] fileArray = chunkFolder.listFiles();
        // 转成集合,便于排序
        List<File> fileList = 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("合并文件失败");
            }
        }
    }
}

(3)使用MinIO合并分块:

【1】将分块文件上传至minio:
java 复制代码
// 测试方法:将本地分块文件上传至MinIO对象存储
@Test
public void uploadChunk() {
    // 1. 初始化分块文件目录
    String chunkFolderPath = "D:\\develop\\upload\\chunk\\";  // 本地分块文件存储路径
    File chunkFolder = new File(chunkFolderPath);           // 创建文件对象表示该目录
    
    // 2. 获取所有分块文件
    File[] files = chunkFolder.listFiles();  // 列出目录下所有文件(分块文件)
    
    // 3. 遍历并上传每个分块文件
    for (int i = 0; i < files.length; i++) {
        try {
            // 3.1 构建上传参数对象
            UploadObjectArgs uploadObjectArgs = UploadObjectArgs.builder()
                    .bucket("testbucket")         // 设置目标存储桶名称
                    .object("chunk/" + i)         // 设置对象存储路径(格式:chunk/0, chunk/1...)
                    .filename(files[i].getAbsolutePath())  // 设置本地文件绝对路径
                    .build();                     // 构建上传参数
            
            // 3.2 执行上传操作
            minioClient.uploadObject(uploadObjectArgs);  // 调用MinIO客户端上传文件
            
            // 3.3 打印上传成功日志
            System.out.println("上传分块成功" + i);  // 标识当前上传的分块序号
        } catch (Exception e) {
            // 3.4 捕获并打印上传异常
            e.printStackTrace();  // 打印异常堆栈(如网络问题、权限不足等)
        }
    }
}
【2】合并文件,要求分块文件最小5M:
java 复制代码
//合并文件,要求分块文件最小5M
@Test
public void test_merge() throws Exception {
    List<ComposeSource> sources =
            Stream.iterate(0, i -> ++i)    // 从0开始生成无限递增序列
                    .limit(6)                      // 限制取前6个元素(0-5)
                    .map(i -> ComposeSource.builder()  // 将每个整数映射为ComposeSource对象
                            .bucket("testbucket")      // 设置存储桶名
                            .object("chunk/" + i)      // 设置分块对象路径
                            .build())                  // 构建ComposeSource
                    .collect(Collectors.toList());     // 收集为List
    // 合并操作构建对象
    ComposeObjectArgs composeObjectArgs = ComposeObjectArgs.builder()
            .bucket("testbucket")
            .object("merge01.mp4")  // 合并后的文件名
            .sources(sources).build();  // 要合并的分块文件列表
    minioClient.composeObject(composeObjectArgs);
}
【3】清除分块文件:
java 复制代码
// 测试方法:清除MinIO中的分块文件
@Test
public void test_removeObjects() {
    // 1. 准备待删除的分块文件列表
    // 使用Stream API生成0-5的序列,构建DeleteObject列表
    List<DeleteObject> deleteObjects = Stream.iterate(0, i -> ++i)  // 生成无限递增序列(0,1,2...)
            .limit(6)                        // 限制只处理前6个分块(0-5)
            .map(i -> new DeleteObject(       // 将每个数字转为DeleteObject
                "chunk/".concat(Integer.toString(i))  // 构造分块路径格式:chunk/0, chunk/1...
            ))
            .collect(Collectors.toList());    // 收集为List

    // 2. 构建删除参数对象
    RemoveObjectsArgs removeObjectsArgs = RemoveObjectsArgs.builder()
            .bucket("testbucket")       // 设置目标存储桶
            .objects(deleteObjects)     // 设置要删除的对象列表
            .build();                   // 构建参数对象

    // 3. 执行批量删除操作
    // 返回一个包含删除结果的Iterable对象(可能包含成功/失败信息)
    Iterable<Result<DeleteError>> results = minioClient.removeObjects(removeObjectsArgs);

    // 4. 处理删除结果(检查是否有删除失败的记录)
    results.forEach(r -> {
        DeleteError deleteError = null;
        try {
            // 获取单个删除操作的结果(如果删除失败会抛出异常)
            deleteError = r.get();
            // 如果deleteError不为null,表示对应文件删除失败
        } catch (Exception e) {
            // 打印删除过程中出现的异常(如网络问题、权限不足等)
            e.printStackTrace();
        }
    });
}

(4)三层架构------上传分块:

下图是上传视频的整体流程:

1、前端对文件进行分块

2、前端上传分块文件前请求媒资服务检查文件是否存在,如果已经存在则不再上传。

3、如果分块文件不存在则前端开始上传

4、前端请求媒资服务上传分块。

5、媒资服务将分块上传至MinIO

6、前端将分块上传完毕 请求媒资服务合并分块

7、媒资服务判断分块上传完成请求MinIO合并文件

8、合并完成校验 合并后的文件是否完整,如果不完整则删除文件。


其实整体实现无外乎就是将逻辑由一个文件的操作变为多文件操作。

【1】检查文件是否存在:
java 复制代码
/​**​
 * 文件上传前检查文件是否存在(基于文件MD5值)
 * @param fileMd5 文件的MD5哈希值(用于唯一标识文件)
 * @return RestResponse<Boolean> 封装检查结果(true=文件已存在,false=文件不存在)
 */
@Override
public RestResponse<Boolean> checkFile(String fileMd5) {
    // 1. 根据文件MD5查询数据库中的文件记录
    MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
    
    // 2. 如果数据库中存在该文件记录,则进一步检查MinIO存储中是否真实存在该文件
    if (mediaFiles != null) {
        // 从数据库记录中获取MinIO存储的桶名称
        String bucket = mediaFiles.getBucket();
        // 从数据库记录中获取MinIO中的文件路径(对象键)
        String filePath = mediaFiles.getFilePath();
        
        // 3. 初始化文件输入流(用于检查文件是否存在)
        InputStream stream = null;
        try {
            // 4. 通过MinIO客户端API获取文件对象
            //    - 使用GetObjectArgs构建获取对象的参数
            //    - .bucket(bucket) 指定存储桶
            //    - .object(filePath) 指定对象路径
            stream = minioClient.getObject(
                    GetObjectArgs.builder()
                            .bucket(bucket)
                            .object(filePath)
                            .build());
            
            // 5. 如果成功获取到输入流(不为null),说明文件确实存在于MinIO中
            if (stream != null) {
                // 文件已存在,返回成功响应(true)
                return RestResponse.success(true);
            }
        } catch (Exception e) {
            // 6. 捕获并处理可能发生的异常(如网络问题、MinIO服务不可用、权限问题等)
            //    - 使用自定义异常处理器抛出业务异常
            //    - 异常信息会包含具体的错误详情
            XueChengPlusException.cast(e.getMessage());
        } finally {
            // 7. 资源清理:确保输入流被正确关闭(防止资源泄漏)
            if (stream != null) {
                try {
                    stream.close();
                } catch (IOException e) {
                    // 关闭流时的异常可以记录日志,但不需要中断业务流程
                    log.error("关闭MinIO文件流失败", e);
                }
            }
        }
    }
    
    // 8. 如果数据库中没有记录 或 MinIO中不存在文件,则返回文件不存在
    return RestResponse.success(false);
}
【2】文件上传前检查分块文件是否存在:

首先我们保存分块文件的路径格式如下:

假设 fileMd5 = "d41d8cd98f00b204e9800998ecf8427e"(一个标准的32位MD5值),生成的路径会是:

java 复制代码
d/4/d41d8cd98f00b204e9800998ecf8427e/chunk/

即:

  • 第1级目录:d(MD5的第1个字符)
  • 第2级目录:4(MD5的第2个字符)
  • 第3级目录:完整的MD5值(d41d8cd98f00b204e9800998ecf8427e
  • 第4级目录:固定字符串chunk

最终路径示例:

java 复制代码
d/4/d41d8cd98f00b204e9800998ecf8427e/chunk/

所以获取路径代码为:

java 复制代码
// 得到分块文件的目录
private String getChunkFileFolderPath(String fileMd5) {
    return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + "chunk" + "/";
}

下面为检查分块文件是否存在代码:

java 复制代码
/​**​
 * 文件上传前检查指定分块文件是否存在(用于大文件分片上传的断点续传/秒传功能)
 * @param fileMd5 文件的MD5值(用于唯一标识整个文件)
 * @param chunkIndex 当前分块的序号(从0开始或从1开始,需与前端约定一致)
 * @return RestResponse<Boolean> 封装检查结果(true=分块已存在,false=分块不存在)
 */
@Override
public RestResponse<Boolean> checkChunk(String fileMd5, int chunkIndex) {
    
    // 1. 根据文件MD5生成分块存储目录路径
    // 例如:/chunks/{fileMd5}/ 这样的目录结构,用于按文件分组存储分块
    String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
    
    // 2. 拼接完整的分块文件路径
    // 例如:/chunks/{fileMd5}/1 表示文件MD5为{fileMd5}的第1个分块
    String chunkFilePath = chunkFileFolderPath + chunkIndex;

    // 3. 初始化文件输入流(用于检查分块是否存在)
    InputStream fileInputStream = null;
    try {
        // 4. 通过MinIO客户端API尝试获取分块对象
        //    - 使用GetObjectArgs构建获取对象的参数
        //    - .bucket(bucket_videofiles) 指定存储桶(视频文件专用桶)
        //    - .object(chunkFilePath) 指定分块对象路径
        fileInputStream = minioClient.getObject(
                GetObjectArgs.builder()
                        .bucket(bucket_videofiles)
                        .object(chunkFilePath)
                        .build());
        
        // 5. 如果成功获取到输入流(不为null),说明分块确实存在于MinIO中
        if (fileInputStream != null) {
            // 分块已存在,返回成功响应(true)
            return RestResponse.success(true);
        }
    } catch (Exception e) {
        // 6. 捕获并处理可能发生的异常
        //    - NoSuchKeyException:分块不存在(MinIO特定异常)
        //    - 其他异常:可能是网络问题、MinIO服务不可用、权限问题等
        // 当前实现只是打印堆栈跟踪,建议:
        //    1. 使用日志框架记录异常(如SLF4J)
        //    2. 区分不同类型的异常返回更精确的响应
        e.printStackTrace();
    } finally {
        // 7. 资源清理:确保输入流被正确关闭(防止资源泄漏)
        if (fileInputStream != null) {
            try {
                fileInputStream.close();
            } catch (IOException e) {
                // 关闭流时的异常可以记录日志,但不需要中断业务流程
                e.printStackTrace();
            }
        }
    }
    // 8. 如果MinIO中不存在分块(或发生异常),返回文件不存在
    return RestResponse.success(false);
}
【3】上传分块文件:

首先根据扩展名获取mimeType:

如果传入的extension为空,那么就使用通用的mimeType字节流:

java 复制代码
String APPLICATION_OCTET_STREAM_VALUE = "application/octet-stream";
java 复制代码
/**
 * 根据扩展名获取mimeType
 */
private String getMimeType(String extension){
    if(extension == null){
        extension = "";
    }
    // 根据扩展名取出mimeType
    ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(extension);
    String mimeType = MediaType.APPLICATION_OCTET_STREAM_VALUE;// 通用mimeType,字节流
    if(extensionMatch != null){
        mimeType = extensionMatch.getMimeType();
    }
    return mimeType;
}

随后编写 addMediaFilesToMinIO 上传文件方法:

java 复制代码
/**
 * 将文件上传到minio
 * @param localFilePath 文件本地路径
 * @param mimeType 媒体类型
 * @param bucket 桶
 * @param objectName 对象名
 * @return
 */
public boolean addMediaFilesToMinIO(String localFilePath,String mimeType,String bucket, String objectName){
    try {
        UploadObjectArgs uploadObjectArgs = UploadObjectArgs.builder()
                .bucket(bucket) // 桶
                .filename(localFilePath) // 指定本地文件路径
                .object(objectName) // 对象名 放在子目录下
                .contentType(mimeType) // 设置媒体文件类型
                .build();
        // 上传文件
        minioClient.uploadObject(uploadObjectArgs);
        log.debug("上传文件到minio成功,bucket:{},objectName:{}",bucket,objectName);
        return true;
    } catch (Exception e) {
       e.printStackTrace();
       log.error("上传文件出错,bucket:{},objectName:{},错误信息:{}",bucket,objectName,e.getMessage());
    }
    return false;
}

整体调用:

java 复制代码
@Override
public RestResponse uploadChunk(String fileMd5, int chunk, String localChuckFilePath) {

    // 得到分块文件的目录路径
    String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
    // 得到分块文件的路径
    String chunkFilePath = chunkFileFolderPath + chunk;
    String mimeType = getMimeType(null);
    boolean b = addMediaFilesToMinIO(localChuckFilePath, mimeType, bucket_mediafiles, chunkFilePath);
    if(!b){
        return RestResponse.validfail(false,"上传分块文件失败");
    }
    return RestResponse.success(true);
}

(4) 三层架构------清除分块文件:

java 复制代码
/**
 * 清除分块文件
 * @param chunkFileFolderPath 分块文件路径
 * @param chunkTotal 分块文件总数
 */
private void clearChunkFiles(String chunkFileFolderPath, int chunkTotal) {

    try {
        // 使用Stream生成从0到chunkTotal-1的整数序列
        // 每个整数代表一个分块文件的序号
        List<DeleteObject> deleteObjects = Stream.iterate(0, i -> ++i)
                // 限制流的大小为chunkTotal,即只生成chunkTotal个序号
                .limit(chunkTotal)
                // 将每个序号转换为对应的DeleteObject对象
                // 文件名格式为:chunkFileFolderPath + 序号(转换为字符串)
                .map(i -> new DeleteObject(chunkFileFolderPath.concat(Integer.toString(i))))
                // 将所有DeleteObject对象收集到一个List中
                .collect(Collectors.toList());

        // 构建删除对象的参数
        // 指定存储桶名称为"video"
        // 设置要删除的对象列表
        RemoveObjectsArgs removeObjectsArgs = RemoveObjectsArgs.builder()
                .bucket("video")
                .objects(deleteObjects)
                .build();

        // 执行批量删除操作,返回一个包含删除结果的Iterable
        Iterable<Result<DeleteError>> results = minioClient.removeObjects(removeObjectsArgs);

        // 遍历删除结果
        results.forEach(r -> {
            DeleteError deleteError = null;
            try {
                // 获取删除操作的错误信息(如果有)
                deleteError = r.get();
                if (deleteError != null) {
                    log.error("清除分块文件失败,objectname:{}", deleteError.objectName(), deleteError);
                } else {
                    log.error("清除分块文件失败,但未获取到具体的错误信息");
                }
            } catch (Exception e) {
                // 如果获取错误信息时发生异常,打印堆栈并记录错误日志
                e.printStackTrace();
                // 记录错误日志,包含出错的对象名和异常信息
                log.error("清楚分块文件失败,objectname:{}", deleteError.objectName(), e);
            }
        });
    } catch (Exception e) {
        // 如果整个删除过程中发生异常,打印堆栈并记录错误日志
        e.printStackTrace();
        // 记录错误日志,包含分块文件路径和异常信息
        log.error("清楚分块文件失败,chunkFileFolderPath:{}", chunkFileFolderPath, e);
    }
}

(5)三层架构------从MinIO下载文件:

java 复制代码
/**
 * 从MinIO下载文件
 * @param bucket 桶名称
 * @param objectName 对象在桶中的名称
 * @return 下载后的文件(临时文件),如果下载失败则返回null
 */
public File downloadFileFromMinIO(String bucket, String objectName) {
    // 创建临时文件用于存储下载的内容
    File minioFile = null;

    // 使用try-with-resources确保InputStream和FileOutputStream都能正确关闭
    // 这样可以避免资源泄漏,无需在finally块中手动关闭
    try (
            // 从MinIO获取对象(文件)的输入流
            InputStream stream = minioClient.getObject(
                    GetObjectArgs.builder()
                            .bucket(bucket)          // 指定桶名称
                            .object(objectName)      // 指定对象名称
                            .build()
            );

            // 创建文件输出流,用于将下载的内容写入临时文件
            FileOutputStream outputStream = new FileOutputStream(minioFile)
    ) {
        // 创建临时文件
        // 文件名前缀为"minio",后缀为".merge"
        minioFile = File.createTempFile("minio", ".merge");

        // 使用IOUtils工具类将输入流的内容复制到输出流
        // 这样可以高效地将文件内容从MinIO传输到本地临时文件
        IOUtils.copy(stream, outputStream);

        // 返回下载的临时文件
        return minioFile;
    } catch (Exception e) {
        // 如果下载过程中发生任何异常,打印堆栈跟踪
        e.printStackTrace();

        // 可以添加更详细的日志记录
        log.error("从MinIO下载文件失败,bucket: {}, objectName: {}", bucket, objectName, e);

        // 下载失败,返回null
        return null;
    }
    // 注意:由于使用了try-with-resources,不再需要finally块来手动关闭资源
    // 资源会在try块结束时自动关闭
}

(6)三层架构------合并分块文件:

首先得到合并后的地址:

java 复制代码
/**
 * 得到合并后的文件的地址
 * @param fileMd5 文件id即md5值
 * @param fileExt 文件扩展名
 * @return
 */
private String getFilePathByMd5(String fileMd5,String fileExt){
    return fileMd5.substring(0,1) + "/" + fileMd5.substring(1,2) + "/" + fileMd5 + "/" +fileMd5 +fileExt;
}

随后调用方法:

java 复制代码
/​**​
 * 合并文件分块为完整文件(用于大文件分片上传的最终合并阶段)
 * 
 * @param companyId 公司ID(用于业务关联)
 * @param fileMd5 文件的MD5值(用于唯一标识整个文件)
 * @param chunkTotal 分块总数(用于确定需要合并的分块数量)
 * @param uploadFileParamsDto 文件上传参数DTO(包含文件名等信息)
 * @return RestResponse<Boolean> 封装合并结果(true=合并成功,false=合并失败)
 */
@Override
public RestResponse mergechunks(Long companyId, String fileMd5, int chunkTotal, UploadFileParamsDto uploadFileParamsDto) {
    
    // =====获取分块文件路径=====
    // 根据文件MD5生成分块存储的目录路径(如:/chunks/{fileMd5}/)
    String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
    
    // 组成将分块文件路径组成 List<ComposeSource>
    // 使用Stream生成从0到chunkTotal-1的分块索引列表
    // 为每个分块构建ComposeSource对象(包含bucket和object路径信息)
    List<ComposeSource> sourceObjectList = Stream.iterate(0, i -> ++i)
            .limit(chunkTotal)
            .map(i -> ComposeSource.builder()
                    .bucket(bucket_videofiles)  // 指定存储桶(视频文件专用桶)
                    .object(chunkFileFolderPath.concat(Integer.toString(i)))  // 构建分块路径(如:/chunks/{fileMd5}/0)
                    .build())
            .collect(Collectors.toList());
    
    // =====合并=====
    // 从DTO中获取原始文件名(如:"example.mp4")
    String fileName = uploadFileParamsDto.getFilename();
    // 提取文件扩展名(如:".mp4")
    String extName = fileName.substring(fileName.lastIndexOf("."));
    // 根据文件MD5和扩展名生成合并后的文件存储路径(如:/videos/{fileMd5}.mp4)
    String mergeFilePath = getFilePathByMd5(fileMd5, extName);
    
    try {
        // 调用MinIO的composeObject方法合并分块
        // 参数说明:
        // - bucket: 存储桶名称
        // - object: 合并后的文件路径
        // - sources: 待合并的分块列表
        ObjectWriteResponse response = minioClient.composeObject(
                ComposeObjectArgs.builder()
                        .bucket(bucket_videofiles)
                        .object(mergeFilePath)  
                        .sources(sourceObjectList)
                        .build());
        
        // 记录合并成功的日志
        log.debug("合并文件成功:{}", mergeFilePath);
    } catch (Exception e) {
        // 合并失败的异常处理
        log.debug("合并文件失败,fileMd5:{},异常:{}", fileMd5, e.getMessage(), e);
        return RestResponse.validfail(false, "合并文件失败。");
    }

    // ====验证md5====
    // 从MinIO下载合并后的文件到本地临时文件
    File minioFile = downloadFileFromMinIO(bucket_videofiles, mergeFilePath);
    if (minioFile == null) {
        // 下载失败的处理
        log.debug("下载合并后文件失败,mergeFilePath:{}", mergeFilePath);
        return RestResponse.validfail(false, "下载合并后文件失败。");
    }

    try (InputStream newFileInputStream = new FileInputStream(minioFile)) {
        // 计算下载文件的MD5值(用于校验文件完整性)
        String md5Hex = DigestUtils.md5Hex(newFileInputStream);
        
        // 比较计算出的MD5与原始MD5是否一致
        // 不一致说明文件在合并过程中可能损坏或不完整
        if (!fileMd5.equals(md5Hex)) {
            return RestResponse.validfail(false, "文件合并校验失败,最终上传失败。");
        }
        
        // 设置文件大小到DTO中(用于后续入库)
        uploadFileParamsDto.setFileSize(minioFile.length());
    } catch (Exception e) {
        // 文件校验过程中的异常处理
        log.debug("校验文件失败,fileMd5:{},异常:{}", fileMd5, e.getMessage(), e);
        return RestResponse.validfail(false, "文件合并校验失败,最终上传失败。");
    } finally {
        // 确保临时文件被删除(避免磁盘空间泄漏)
        if (minioFile != null) {
            minioFile.delete();
        }
    }

    // 文件入库
    // 将文件元数据(包括公司ID、MD5、文件参数等)事务性保存到数据库
    mediaFileTransactionalService.addMediaFilesToDbWithTransaction(
            companyId, 
            fileMd5, 
            uploadFileParamsDto, 
            bucket_videofiles, 
            mergeFilePath);
    
    // =====清除分块文件=====
    // 合并完成后删除所有分块文件(释放存储空间)
    clearChunkFiles(chunkFileFolderPath, chunkTotal);
    
    // 返回成功响应
    return RestResponse.success(true);
}
相关推荐
纪元A梦7 小时前
分布式拜占庭容错算法——PBFT算法深度解析
java·分布式·算法
LI JS@你猜啊9 小时前
window安装docker
java·spring cloud·eureka
TCChzp12 小时前
Kafka入门-消费者
分布式·kafka
FakeOccupational15 小时前
【p2p、分布式,区块链笔记 MESH】Bluetooth蓝牙通信 BLE Mesh协议的拓扑结构 & 定向转发机制
笔记·分布式·p2p
·云扬·17 小时前
【PmHub面试篇】性能监控与分布式追踪利器Skywalking面试专题分析
分布式·面试·skywalking
后端码匠17 小时前
Spark 单机模式部署与启动
大数据·分布式·spark
Dnui_King18 小时前
Kafka 入门指南与一键部署
分布式·kafka
TCChzp19 小时前
Kafka入门-生产者
分布式·kafka