使用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);
}
相关推荐
青鱼入云1 天前
介绍Spring Cloud Stream
spring cloud·微服务
青鱼入云1 天前
介绍一下Ribbon的工作原理
spring cloud·微服务·ribbon
一晌小贪欢1 天前
Python爬虫第10课:分布式爬虫架构与Scrapy-Redis
分布式·爬虫·python·网络爬虫·python爬虫·python3
沐浴露z2 天前
一篇文章详解Kafka Broker
java·分布式·kafka
青鱼入云2 天前
Ribbon是如何与服务注册中心nacos交互的
spring cloud·微服务·ribbon
pythonpioneer2 天前
Ray Tune 强大的分布式超参数调优框架
分布式·其他
笨蛋少年派2 天前
Hadoop High Availability 简介
大数据·hadoop·分布式
青鱼入云2 天前
OpenFeign介绍
spring cloud·微服务
勇往直前plus2 天前
学习和掌握RabbitMQ及其与springboot的整合实践(篇一)
spring boot·学习·spring cloud·rabbitmq·java-rabbitmq
一只小透明啊啊啊啊2 天前
Java电商项目中的概念: 高并发、分布式、高可用、微服务、海量数据处理
java·分布式·微服务