在互联网业务的全链路开发中,非结构化数据(图片、视频、文档、安装包等)的存储与访问始终是核心需求之一。传统的本地文件存储、FTP服务器在扩展性、高可用、访问性能上难以匹配云原生时代的业务诉求,而公有云对象存储又存在数据主权、成本、内网访问性能等问题。MinIO作为一款高性能、云原生、100%兼容S3协议的对象存储系统,凭借轻量部署、极简运维、无限扩展的特性,成为企业级自建对象存储的首选方案。
一、MinIO核心底层逻辑与架构解析
1.1 对象存储的核心定义与三大存储类型的本质区别
对象存储的核心存储单元是对象,每个对象由三部分组成:
- 对象数据:文件本身的二进制内容
- 元数据:描述对象的键值对信息,比如Content-Type、文件大小、MD5、自定义标签等
- 唯一标识符:全局唯一的对象名,通过该标识符可精准定位对象,无需依赖树形目录结构
为了清晰区分易混淆的存储类型,这里对三大主流存储方案做核心对比:
| 存储类型 | 核心结构 | 访问方式 | 核心优势 | 典型适用场景 |
| 块存储 | 固定大小的块(通常4KB) | 裸设备挂载 | 极低延迟、随机读写性能强 | 数据库、虚拟机磁盘 |
| 文件存储 | 树形目录结构 | POSIX协议、SMB/NFS | 多节点共享、兼容传统文件操作 | 办公文件共享、日志存储 |
| 对象存储 | 扁平化键值对结构 | HTTP/HTTPS的RESTful API | 无限横向扩展、海量数据低成本存储、元数据能力强 | 图片/视频/文档等非结构化数据、静态资源托管、数据备份 |
1.2 MinIO的核心架构设计
MinIO的设计核心理念是极简主义,一切皆对象,完全兼容S3协议,原生适配云原生环境。其核心架构如下:
MinIO分为单机部署和分布式部署两种模式,生产环境优先选择分布式部署,核心组件说明如下:
- 节点(Server Node):运行MinIO服务的服务器实例,集群内所有节点完全对等,无中心节点、无单点故障。
- 驱动器(Drive):节点上挂载的磁盘,是MinIO数据存储的最小物理单元,生产环境建议每个驱动器对应独立的物理磁盘。
- 桶(Bucket):对象的顶层容器,相当于文件系统的根目录,全局唯一,用于隔离不同业务的对象数据。
- 纠删码(Erasure Code):MinIO高可用的核心,基于Reed-Solomon算法实现,将对象切分为N个数据块和M个奇偶校验块,只要剩余可用块总数≥N,就能完整恢复数据。相比三副本机制,存储空间利用率提升一倍以上。
- 分布式一致性:基于严格的读后写、写后读一致性模型,所有写操作必须写入超过半数的节点后才会返回成功,确保数据的强一致性。
1.3 MinIO的核心特性
- 100% S3协议兼容:完全兼容亚马逊S3 API,现有基于S3开发的应用可无缝迁移到MinIO,无需修改业务代码。
- 极致性能:基于Go语言开发,原生支持高并发,在标准硬件上可实现每秒数GB的吞吐量,延迟低至毫秒级。
- 极简部署:单个二进制文件即可启动服务,支持Docker、Kubernetes、裸机等多种部署方式,运维成本极低。
- 无限扩展:通过集群扩容可实现EB级别的存储容量扩展,性能随节点数量线性增长。
- 丰富的企业级特性:支持版本控制、生命周期管理、WORM(一次写入多次读取)、数据加密、配额管理、事件通知等。
二、MinIO环境快速搭建
2.1 单机模式部署(开发测试环境)
单机模式适合开发测试场景,单节点单驱动器,无纠删码数据保护,执行以下Docker命令即可一键启动:
docker run -d \
--name minio \
-p 9000:9000 \
-p 9001:9001 \
-v /data/minio/data:/data \
-v /data/minio/config:/root/.minio \
-e "MINIO_ROOT_USER=minioadmin" \
-e "MINIO_ROOT_PASSWORD=minioadmin@2026" \
minio/minio server /data --console-address ":9001"
参数说明:
- 9000端口:MinIO API服务端口,应用程序通过该端口与MinIO交互
- 9001端口:MinIO Web控制台端口,用于可视化管理桶、对象、权限等配置
- MINIO_ROOT_USER/MINIO_ROOT_PASSWORD:管理员账号密码,生产环境需设置高强度密码
- 目录挂载:将容器内的数据目录挂载到宿主机,实现数据持久化
部署完成后,访问http://宿主机IP:9001,输入管理员账号密码即可进入控制台,创建业务桶(如demo-bucket)并配置访问策略。
2.2 分布式模式部署
分布式模式实现多节点多驱动器部署,通过纠删码提供数据高可用,无单点故障。以下是4节点8驱动器的Docker Compose部署方案,每个节点挂载2个独立驱动器:
version: '3.8'
services:
minio1:
image: minio/minio:latest
hostname: minio1
ports:
- "9000:9000"
- "9001:9001"
volumes:
- /data/minio1/drive1:/data1
- /data/minio1/drive2:/data2
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin@2026
command: server http://minio{1...4}/data{1...2} --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
minio2:
image: minio/minio:latest
hostname: minio2
ports:
- "9002:9000"
- "9003:9001"
volumes:
- /data/minio2/drive1:/data1
- /data/minio2/drive2:/data2
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin@2026
command: server http://minio{1...4}/data{1...2} --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
minio3:
image: minio/minio:latest
hostname: minio3
ports:
- "9004:9000"
- "9005:9001"
volumes:
- /data/minio3/drive1:/data1
- /data/minio3/drive2:/data2
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin@2026
command: server http://minio{1...4}/data{1...2} --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
minio4:
image: minio/minio:latest
hostname: minio4
ports:
- "9006:9000"
- "9007:9001"
volumes:
- /data/minio4/drive1:/data1
- /data/minio4/drive2:/data2
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin@2026
command: server http://minio{1...4}/data{1...2} --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
部署说明:
- 4个节点共8个驱动器,MinIO会自动划分为4个数据块+4个校验块,最多可容忍4个驱动器同时故障,数据不丢失
- 所有节点的管理员账号密码必须完全一致,否则集群无法正常启动
- 生产环境建议每个驱动器挂载独立的物理磁盘,避免单磁盘故障导致多个驱动器失效
- 集群前端需配置负载均衡,将请求均匀分发到各个节点,提升并发性能
三、Spring Boot项目整合MinIO
3.1 项目依赖配置
基于Spring Boot 3.2.5、JDK 17构建项目,pom.xml配置如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.jam</groupId>
<artifactId>minio-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>minio-demo</name>
<description>MinIO file upload and download demo project</description>
<properties>
<java.version>17</java.version>
<minio.version>8.5.12</minio.version>
<mybatis-plus.version>3.5.6</mybatis-plus.version>
<fastjson2.version>2.0.52</fastjson2.version>
<guava.version>33.1.0-jre</guava.version>
<lombok.version>1.18.30</lombok.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>${minio.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson2.version}</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
3.2 项目配置文件
application.yml配置文件,包含MinIO、数据源、MyBatis-Plus、Swagger3等核心配置:
server:
port: 8080
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
spring:
application:
name: minio-demo
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/minio_demo?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: root@2026
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
minio:
endpoint: http://127.0.0.1:9000
access-key: minioadmin
secret-key: minioadmin@2026
bucket-name: demo-bucket
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: assign_id
logic-delete-field: isDeleted
logic-delete-value: 1
logic-not-delete-value: 0
springdoc:
api-docs:
enabled: true
path: /v3/api-docs
swagger-ui:
enabled: true
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: alpha
3.3 数据库表结构设计
创建文件元数据表,存储上传文件的核心信息,用于业务查询与管理,MySQL 8.0执行以下SQL:
CREATE TABLE `file_info` (
`id` bigint NOT NULL COMMENT '主键ID',
`file_name` varchar(255) NOT NULL COMMENT '存储文件名(唯一)',
`original_file_name` varchar(255) NOT NULL COMMENT '原始文件名',
`bucket_name` varchar(100) NOT NULL COMMENT '存储桶名称',
`file_path` varchar(500) NOT NULL COMMENT '文件存储路径',
`file_size` bigint NOT NULL COMMENT '文件大小(字节)',
`content_type` varchar(100) DEFAULT NULL COMMENT '文件MIME类型',
`md5` varchar(32) NOT NULL COMMENT '文件MD5值',
`is_deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除 0-未删除 1-已删除',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_file_name` (`file_name`),
KEY `idx_md5` (`md5`),
KEY `idx_bucket_name` (`bucket_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='文件信息表';
3.4 核心常量定义
提取魔法值为常量,提升代码可维护性与可读性:
package com.jam.demo.constant;
/**
* MinIO相关常量
*
* @author ken
*/
public final class MinioConstants {
private MinioConstants() {
}
/**
* 默认分片大小 5MB
*/
public static final long DEFAULT_PART_SIZE = 5 * 1024 * 1024L;
/**
* 最大文件大小 1GB
*/
public static final long MAX_FILE_SIZE = 1024 * 1024 * 1024L;
/**
* 预签名URL默认过期时间 1小时(秒)
*/
public static final int DEFAULT_PRESIGNED_EXPIRY = 3600;
/**
* 最小分片数量
*/
public static final int MIN_PART_COUNT = 1;
/**
* 最大分片数量
*/
public static final int MAX_PART_COUNT = 10000;
/**
* 公共读桶策略模板
*/
public static final String PUBLIC_READ_POLICY = """
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::%s/*"]
}
]
}
""";
}
3.5 统一异常与响应处理
自定义业务异常类
package com.jam.demo.exception;
import lombok.Getter;
/**
* 业务异常
*
* @author ken
*/
@Getter
public class BusinessException extends RuntimeException {
private final int code;
public BusinessException(int code, String message) {
super(message);
this.code = code;
}
public BusinessException(String message) {
super(message);
this.code = 500;
}
}
统一响应结果类
package com.jam.demo.common;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 统一响应结果
*
* @author ken
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "统一响应结果")
public class Result<T> {
@Schema(description = "响应码", example = "200")
private int code;
@Schema(description = "响应信息", example = "操作成功")
private String message;
@Schema(description = "响应数据")
private T data;
public static <T> Result<T> success(T data) {
return new Result<>(200, "操作成功", data);
}
public static <T> Result<T> success(String message, T data) {
return new Result<>(200, message, data);
}
public static <T> Result<T> fail(String message) {
return new Result<>(500, message, null);
}
public static <T> Result<T> fail(int code, String message) {
return new Result<>(code, message, null);
}
}
全局异常处理器
package com.jam.demo.exception;
import com.jam.demo.common.Result;
import io.minio.errors.MinioException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.util.StringUtils;
import java.util.Objects;
/**
* 全局异常处理器
*
* @author ken
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
log.error("业务异常:", e);
return Result.fail(e.getCode(), e.getMessage());
}
@ExceptionHandler(MinioException.class)
public Result<Void> handleMinioException(MinioException e) {
log.error("MinIO操作异常:", e);
return Result.fail("文件存储操作失败,请稍后重试");
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleValidException(MethodArgumentNotValidException e) {
log.error("参数校验异常:", e);
String message = Objects.requireNonNull(e.getBindingResult().getFieldError()).getDefaultMessage();
return Result.fail(400, StringUtils.hasText(message) ? message : "参数校验失败");
}
@ExceptionHandler(BindException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleBindException(BindException e) {
log.error("参数绑定异常:", e);
String message = Objects.requireNonNull(e.getBindingResult().getFieldError()).getDefaultMessage();
return Result.fail(400, StringUtils.hasText(message) ? message : "参数绑定失败");
}
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常:", e);
return Result.fail("系统内部错误,请稍后重试");
}
}
3.6 实体类与Mapper层
文件信息实体类
package com.jam.demo.entity;
import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 文件信息实体
*
* @author ken
*/
@Data
@TableName("file_info")
@Schema(description = "文件信息实体")
public class FileInfo {
@Schema(description = "主键ID")
@TableId(type = IdType.ASSIGN_ID)
private Long id;
@Schema(description = "存储文件名(唯一)")
private String fileName;
@Schema(description = "原始文件名")
private String originalFileName;
@Schema(description = "存储桶名称")
private String bucketName;
@Schema(description = "文件存储路径")
private String filePath;
@Schema(description = "文件大小(字节)")
private Long fileSize;
@Schema(description = "文件MIME类型")
private String contentType;
@Schema(description = "文件MD5值")
private String md5;
@Schema(description = "是否删除 0-未删除 1-已删除")
@TableLogic
private Integer isDeleted;
@Schema(description = "创建时间")
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@Schema(description = "更新时间")
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
Mapper接口
package com.jam.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.demo.entity.FileInfo;
import org.apache.ibatis.annotations.Mapper;
/**
* 文件信息Mapper
*
* @author ken
*/
@Mapper
public interface FileInfoMapper extends BaseMapper<FileInfo> {
}
3.7 MinIO核心配置与工具类
MinIO配置类
package com.jam.demo.config;
import io.minio.MinioClient;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import com.jam.demo.exception.BusinessException;
/**
* MinIO配置类
*
* @author ken
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "minio")
public class MinioConfig {
private String endpoint;
private String accessKey;
private String secretKey;
private String bucketName;
/**
* 构建MinioClient客户端并注入Spring容器
*/
@Bean
public MinioClient minioClient() {
if (!StringUtils.hasText(endpoint)) {
throw new BusinessException("MinIO endpoint不能为空");
}
if (!StringUtils.hasText(accessKey)) {
throw new BusinessException("MinIO accessKey不能为空");
}
if (!StringUtils.hasText(secretKey)) {
throw new BusinessException("MinIO secretKey不能为空");
}
return MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
}
}
MinIO操作工具类
package com.jam.demo.util;
import com.jam.demo.constant.MinioConstants;
import com.jam.demo.exception.BusinessException;
import io.minio.*;
import io.minio.http.Method;
import io.minio.messages.Part;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import java.io.InputStream;
import java.util.concurrent.TimeUnit;
/**
* MinIO操作工具类
*
* @author ken
*/
@Slf4j
public final class MinioUtil {
private MinioUtil() {
}
/**
* 检查桶是否存在
*
* @param minioClient MinIO客户端
* @param bucketName 桶名称
* @return 桶是否存在
*/
public static boolean bucketExists(MinioClient minioClient, String bucketName) {
if (!StringUtils.hasText(bucketName)) {
throw new BusinessException("桶名称不能为空");
}
try {
return minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
} catch (Exception e) {
log.error("检查桶是否存在失败,bucketName:{}", bucketName, e);
throw new BusinessException("检查桶是否存在失败");
}
}
/**
* 创建桶
*
* @param minioClient MinIO客户端
* @param bucketName 桶名称
*/
public static void createBucket(MinioClient minioClient, String bucketName) {
if (bucketExists(minioClient, bucketName)) {
return;
}
try {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
} catch (Exception e) {
log.error("创建桶失败,bucketName:{}", bucketName, e);
throw new BusinessException("创建桶失败");
}
}
/**
* 设置桶公共读策略
*
* @param minioClient MinIO客户端
* @param bucketName 桶名称
*/
public static void setBucketPublicReadPolicy(MinioClient minioClient, String bucketName) {
if (!StringUtils.hasText(bucketName)) {
throw new BusinessException("桶名称不能为空");
}
try {
String policy = String.format(MinioConstants.PUBLIC_READ_POLICY, bucketName);
minioClient.setBucketPolicy(SetBucketPolicyArgs.builder().bucket(bucketName).config(policy).build());
} catch (Exception e) {
log.error("设置桶策略失败,bucketName:{}", bucketName, e);
throw new BusinessException("设置桶策略失败");
}
}
/**
* 上传文件
*
* @param minioClient MinIO客户端
* @param bucketName 桶名称
* @param objectName 对象名称(存储文件名)
* @param inputStream 文件输入流
* @param contentType 文件MIME类型
*/
public static void uploadFile(MinioClient minioClient, String bucketName, String objectName,
InputStream inputStream, String contentType) {
if (!bucketExists(minioClient, bucketName)) {
createBucket(minioClient, bucketName);
}
try {
minioClient.putObject(PutObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.stream(inputStream, inputStream.available(), -1)
.contentType(contentType)
.build());
} catch (Exception e) {
log.error("上传文件失败,bucketName:{},objectName:{}", bucketName, objectName, e);
throw new BusinessException("上传文件失败");
}
}
/**
* 下载文件
*
* @param minioClient MinIO客户端
* @param bucketName 桶名称
* @param objectName 对象名称
* @return 文件输入流
*/
public static InputStream downloadFile(MinioClient minioClient, String bucketName, String objectName) {
if (!bucketExists(minioClient, bucketName)) {
throw new BusinessException("桶不存在");
}
try {
return minioClient.getObject(GetObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.build());
} catch (Exception e) {
log.error("下载文件失败,bucketName:{},objectName:{}", bucketName, objectName, e);
throw new BusinessException("下载文件失败");
}
}
/**
* 删除文件
*
* @param minioClient MinIO客户端
* @param bucketName 桶名称
* @param objectName 对象名称
*/
public static void deleteFile(MinioClient minioClient, String bucketName, String objectName) {
if (!bucketExists(minioClient, bucketName)) {
throw new BusinessException("桶不存在");
}
try {
minioClient.removeObject(RemoveObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.build());
} catch (Exception e) {
log.error("删除文件失败,bucketName:{},objectName:{}", bucketName, objectName, e);
throw new BusinessException("删除文件失败");
}
}
/**
* 获取预签名访问URL
*
* @param minioClient MinIO客户端
* @param bucketName 桶名称
* @param objectName 对象名称
* @param expiry 过期时间(秒)
* @param method HTTP请求方法
* @return 预签名URL
*/
public static String getPresignedObjectUrl(MinioClient minioClient, String bucketName, String objectName,
int expiry, Method method) {
if (!bucketExists(minioClient, bucketName)) {
throw new BusinessException("桶不存在");
}
if (expiry <= 0) {
expiry = MinioConstants.DEFAULT_PRESIGNED_EXPIRY;
}
try {
return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.bucket(bucketName)
.object(objectName)
.expiry(expiry, TimeUnit.SECONDS)
.method(method)
.build());
} catch (Exception e) {
log.error("获取预签名URL失败,bucketName:{},objectName:{}", bucketName, objectName, e);
throw new BusinessException("获取预签名URL失败");
}
}
/**
* 初始化分片上传
*
* @param minioClient MinIO客户端
* @param bucketName 桶名称
* @param objectName 对象名称
* @param contentType 文件MIME类型
* @return uploadId 分片上传ID
*/
public static String initMultipartUpload(MinioClient minioClient, String bucketName, String objectName,
String contentType) {
if (!bucketExists(minioClient, bucketName)) {
createBucket(minioClient, bucketName);
}
try {
CreateMultipartUploadResponse response = minioClient.createMultipartUpload(
CreateMultipartUploadArgs.builder()
.bucket(bucketName)
.object(objectName)
.contentType(contentType)
.build());
return response.result().uploadId();
} catch (Exception e) {
log.error("初始化分片上传失败,bucketName:{},objectName:{}", bucketName, objectName, e);
throw new BusinessException("初始化分片上传失败");
}
}
/**
* 上传分片
*
* @param minioClient MinIO客户端
* @param bucketName 桶名称
* @param objectName 对象名称
* @param uploadId 分片上传ID
* @param partNumber 分片序号(从1开始)
* @param inputStream 分片输入流
* @param partSize 分片大小
* @return 分片的etag值
*/
public static String uploadPart(MinioClient minioClient, String bucketName, String objectName,
String uploadId, int partNumber, InputStream inputStream, long partSize) {
try {
UploadPartResponse response = minioClient.uploadPart(
UploadPartArgs.builder()
.bucket(bucketName)
.object(objectName)
.uploadId(uploadId)
.partNumber(partNumber)
.stream(inputStream, partSize, -1)
.build());
return response.etag();
} catch (Exception e) {
log.error("上传分片失败,bucketName:{},objectName:{},partNumber:{}", bucketName, objectName, partNumber, e);
throw new BusinessException("上传分片失败");
}
}
/**
* 合并分片
*
* @param minioClient MinIO客户端
* @param bucketName 桶名称
* @param objectName 对象名称
* @param uploadId 分片上传ID
* @param parts 分片数组(包含partNumber和etag)
*/
public static void completeMultipartUpload(MinioClient minioClient, String bucketName, String objectName,
String uploadId, Part[] parts) {
if (ObjectUtils.isEmpty(parts)) {
throw new BusinessException("分片列表不能为空");
}
try {
minioClient.completeMultipartUpload(
CompleteMultipartUploadArgs.builder()
.bucket(bucketName)
.object(objectName)
.uploadId(uploadId)
.parts(parts)
.build());
} catch (Exception e) {
log.error("合并分片失败,bucketName:{},objectName:{},uploadId:{}", bucketName, objectName, uploadId, e);
throw new BusinessException("合并分片失败");
}
}
/**
* 取消分片上传
*
* @param minioClient MinIO客户端
* @param bucketName 桶名称
* @param objectName 对象名称
* @param uploadId 分片上传ID
*/
public static void abortMultipartUpload(MinioClient minioClient, String bucketName, String objectName,
String uploadId) {
try {
minioClient.abortMultipartUpload(
AbortMultipartUploadArgs.builder()
.bucket(bucketName)
.object(objectName)
.uploadId(uploadId)
.build());
} catch (Exception e) {
log.error("取消分片上传失败,bucketName:{},objectName:{},uploadId:{}", bucketName, objectName, uploadId, e);
throw new BusinessException("取消分片上传失败");
}
}
/**
* 查询已上传的分片列表
*
* @param minioClient MinIO客户端
* @param bucketName 桶名称
* @param objectName 对象名称
* @param uploadId 分片上传ID
* @return 已上传的分片数组
*/
public static Part[] listParts(MinioClient minioClient, String bucketName, String objectName, String uploadId) {
try {
ListPartsResponse response = minioClient.listParts(
ListPartsArgs.builder()
.bucket(bucketName)
.object(objectName)
.uploadId(uploadId)
.maxParts(MinioConstants.MAX_PART_COUNT)
.build());
return response.result().partList().toArray(new Part[0]);
} catch (Exception e) {
log.error("查询分片列表失败,bucketName:{},objectName:{},uploadId:{}", bucketName, objectName, uploadId, e);
throw new BusinessException("查询分片列表失败");
}
}
}
3.8 业务层实现
业务接口与VO定义
首先定义核心请求与响应VO,用于接口参数传递:
package com.jam.demo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 文件上传响应VO
*
* @author ken
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "文件上传响应VO")
public class FileUploadVO {
@Schema(description = "文件ID")
private Long id;
@Schema(description = "存储文件名")
private String fileName;
@Schema(description = "原始文件名")
private String originalFileName;
@Schema(description = "文件大小(字节)")
private Long fileSize;
@Schema(description = "文件访问URL")
private String url;
@Schema(description = "上传时间")
private LocalDateTime uploadTime;
}
package com.jam.demo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 分片上传初始化请求VO
*
* @author ken
*/
@Data
@Schema(description = "分片上传初始化请求VO")
public class MultipartUploadInitRequest {
@NotBlank(message = "原始文件名不能为空")
@Schema(description = "原始文件名", requiredMode = Schema.RequiredMode.REQUIRED)
private String originalFileName;
@NotBlank(message = "文件MD5值不能为空")
@Schema(description = "文件MD5值", requiredMode = Schema.RequiredMode.REQUIRED)
private String md5;
@NotNull(message = "文件大小不能为空")
@Schema(description = "文件大小(字节)", requiredMode = Schema.RequiredMode.REQUIRED)
private Long fileSize;
@Schema(description = "文件MIME类型")
private String contentType;
}
package com.jam.demo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 分片上传初始化响应VO
*
* @author ken
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "分片上传初始化响应VO")
public class MultipartUploadInitVO {
@Schema(description = "存储文件名")
private String fileName;
@Schema(description = "分片上传ID")
private String uploadId;
@Schema(description = "分片大小(字节)")
private Long partSize;
@Schema(description = "总分片数")
private Integer totalPartCount;
}
package com.jam.demo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.web.multipart.MultipartFile;
/**
* 分片上传请求VO
*
* @author ken
*/
@Data
@Schema(description = "分片上传请求VO")
public class MultipartUploadPartRequest {
@NotBlank(message = "存储文件名不能为空")
@Schema(description = "存储文件名", requiredMode = Schema.RequiredMode.REQUIRED)
private String fileName;
@NotBlank(message = "分片上传ID不能为空")
@Schema(description = "分片上传ID", requiredMode = Schema.RequiredMode.REQUIRED)
private String uploadId;
@NotNull(message = "分片序号不能为空")
@Schema(description = "分片序号(从1开始)", requiredMode = Schema.RequiredMode.REQUIRED)
private Integer partNumber;
@NotNull(message = "分片文件不能为空")
@Schema(description = "分片文件", requiredMode = Schema.RequiredMode.REQUIRED)
private MultipartFile file;
@Schema(description = "文件MD5值")
private String md5;
}
package com.jam.demo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 分片上传响应VO
*
* @author ken
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "分片上传响应VO")
public class MultipartUploadPartVO {
@Schema(description = "分片序号")
private Integer partNumber;
@Schema(description = "分片ETag值")
private String etag;
}
package com.jam.demo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
* 合并分片请求VO
*
* @author ken
*/
@Data
@Schema(description = "合并分片请求VO")
public class MultipartUploadCompleteRequest {
@NotBlank(message = "存储文件名不能为空")
@Schema(description = "存储文件名", requiredMode = Schema.RequiredMode.REQUIRED)
private String fileName;
@NotBlank(message = "分片上传ID不能为空")
@Schema(description = "分片上传ID", requiredMode = Schema.RequiredMode.REQUIRED)
private String uploadId;
@NotBlank(message = "文件MD5值不能为空")
@Schema(description = "文件MD5值", requiredMode = Schema.RequiredMode.REQUIRED)
private String md5;
@NotNull(message = "文件大小不能为空")
@Schema(description = "文件大小(字节)", requiredMode = Schema.RequiredMode.REQUIRED)
private Long fileSize;
@NotBlank(message = "原始文件名不能为空")
@Schema(description = "原始文件名", requiredMode = Schema.RequiredMode.REQUIRED)
private String originalFileName;
@NotEmpty(message = "分片列表不能为空")
@Schema(description = "分片列表", requiredMode = Schema.RequiredMode.REQUIRED)
private List<MultipartUploadPartVO> parts;
}
定义业务接口:
package com.jam.demo.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.jam.demo.entity.FileInfo;
import com.jam.demo.vo.*;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletResponse;
/**
* 文件服务接口
*
* @author ken
*/
public interface FileService extends IService<FileInfo> {
/**
* 普通文件上传
*
* @param file 上传的文件
* @return 文件上传结果
*/
FileUploadVO upload(MultipartFile file);
/**
* 文件下载
*
* @param fileName 存储文件名
* @param response 响应对象
*/
void download(String fileName, HttpServletResponse response);
/**
* 删除文件
*
* @param fileName 存储文件名
* @return 删除结果
*/
Boolean delete(String fileName);
/**
* 获取文件预签名访问URL
*
* @param fileName 存储文件名
* @return 预签名URL
*/
String getPresignedUrl(String fileName);
/**
* 初始化分片上传
*
* @param request 初始化请求参数
* @return 分片上传初始化结果
*/
MultipartUploadInitVO initMultipartUpload(MultipartUploadInitRequest request);
/**
* 上传分片
*
* @param request 分片上传请求参数
* @return 分片上传结果
*/
MultipartUploadPartVO uploadPart(MultipartUploadPartRequest request);
/**
* 合并分片
*
* @param request 合并分片请求参数
* @return 合并结果
*/
FileUploadVO completeMultipartUpload(MultipartUploadCompleteRequest request);
/**
* 校验文件是否已上传(秒传)
*
* @param md5 文件MD5值
* @return 文件信息,若未上传返回null
*/
FileUploadVO checkFileExist(String md5);
}
业务接口实现类
采用编程式事务保证文件上传与元数据存储的一致性,异常时自动回滚:
package com.jam.demo.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.jam.demo.config.MinioConfig;
import com.jam.demo.constant.MinioConstants;
import com.jam.demo.entity.FileInfo;
import com.jam.demo.exception.BusinessException;
import com.jam.demo.mapper.FileInfoMapper;
import com.jam.demo.service.FileService;
import com.jam.demo.util.MinioUtil;
import com.jam.demo.vo.*;
import io.minio.MinioClient;
import io.minio.http.Method;
import io.minio.messages.Part;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.TransactionTemplate;
import org.springframework.util.DigestUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletResponse;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.UUID;
/**
* 文件服务实现类
*
* @author ken
*/
@Slf4j
@Service
public class FileServiceImpl extends ServiceImpl<FileInfoMapper, FileInfo> implements FileService {
private final MinioClient minioClient;
private final MinioConfig minioConfig;
private final TransactionTemplate transactionTemplate;
public FileServiceImpl(MinioClient minioClient, MinioConfig minioConfig, TransactionTemplate transactionTemplate) {
this.minioClient = minioClient;
this.minioConfig = minioConfig;
this.transactionTemplate = transactionTemplate;
}
@Override
public FileUploadVO upload(MultipartFile file) {
if (file.isEmpty()) {
throw new BusinessException("上传文件不能为空");
}
if (file.getSize() > MinioConstants.MAX_FILE_SIZE) {
throw new BusinessException("文件大小超过最大限制");
}
String originalFileName = file.getOriginalFilename();
if (!StringUtils.hasText(originalFileName)) {
throw new BusinessException("文件名不能为空");
}
try {
String md5 = DigestUtils.md5DigestAsHex(file.getInputStream());
FileUploadVO existFile = checkFileExist(md5);
if (!ObjectUtils.isEmpty(existFile)) {
return existFile;
}
String suffix = originalFileName.substring(originalFileName.lastIndexOf("."));
String fileName = UUID.randomUUID().toString().replace("-", "") + suffix;
String contentType = file.getContentType();
MinioUtil.uploadFile(minioClient, minioConfig.getBucketName(), fileName, file.getInputStream(), contentType);
String url = MinioUtil.getPresignedObjectUrl(minioClient, minioConfig.getBucketName(), fileName, MinioConstants.DEFAULT_PRESIGNED_EXPIRY, Method.GET);
FileInfo fileInfo = new FileInfo();
fileInfo.setFileName(fileName);
fileInfo.setOriginalFileName(originalFileName);
fileInfo.setBucketName(minioConfig.getBucketName());
fileInfo.setFilePath(minioConfig.getBucketName() + "/" + fileName);
fileInfo.setFileSize(file.getSize());
fileInfo.setContentType(contentType);
fileInfo.setMd5(md5);
Boolean saveResult = transactionTemplate.execute(status -> {
try {
return save(fileInfo);
} catch (Exception e) {
status.setRollbackOnly();
MinioUtil.deleteFile(minioClient, minioConfig.getBucketName(), fileName);
log.error("保存文件信息失败,回滚文件上传", e);
throw new BusinessException("保存文件信息失败");
}
});
if (ObjectUtils.isEmpty(saveResult) || !saveResult) {
MinioUtil.deleteFile(minioClient, minioConfig.getBucketName(), fileName);
throw new BusinessException("保存文件信息失败");
}
FileUploadVO vo = new FileUploadVO();
vo.setId(fileInfo.getId());
vo.setFileName(fileName);
vo.setOriginalFileName(originalFileName);
vo.setFileSize(file.getSize());
vo.setUrl(url);
vo.setUploadTime(fileInfo.getCreateTime());
return vo;
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("文件上传失败", e);
throw new BusinessException("文件上传失败");
}
}
@Override
public void download(String fileName, HttpServletResponse response) {
if (!StringUtils.hasText(fileName)) {
throw new BusinessException("文件名不能为空");
}
FileInfo fileInfo = getOne(new LambdaQueryWrapper<FileInfo>().eq(FileInfo::getFileName, fileName));
if (ObjectUtils.isEmpty(fileInfo)) {
throw new BusinessException("文件不存在");
}
try (InputStream inputStream = MinioUtil.downloadFile(minioClient, fileInfo.getBucketName(), fileName);
OutputStream outputStream = response.getOutputStream()) {
response.setContentType(fileInfo.getContentType());
response.setContentLengthLong(fileInfo.getFileSize());
response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(fileInfo.getOriginalFileName(), StandardCharsets.UTF_8.name()));
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
response.setHeader("Pragma", "no-cache");
response.setHeader("Expires", "0");
byte[] buffer = new byte[8192];
int len;
while ((len = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, len);
}
outputStream.flush();
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("文件下载失败,fileName:{}", fileName, e);
throw new BusinessException("文件下载失败");
}
}
@Override
public Boolean delete(String fileName) {
if (!StringUtils.hasText(fileName)) {
throw new BusinessException("文件名不能为空");
}
FileInfo fileInfo = getOne(new LambdaQueryWrapper<FileInfo>().eq(FileInfo::getFileName, fileName));
if (ObjectUtils.isEmpty(fileInfo)) {
throw new BusinessException("文件不存在");
}
return transactionTemplate.execute(status -> {
try {
boolean removeResult = removeById(fileInfo.getId());
if (!removeResult) {
status.setRollbackOnly();
return false;
}
MinioUtil.deleteFile(minioClient, fileInfo.getBucketName(), fileName);
return true;
} catch (Exception e) {
status.setRollbackOnly();
log.error("删除文件失败,fileName:{}", fileName, e);
throw new BusinessException("删除文件失败");
}
});
}
@Override
public String getPresignedUrl(String fileName) {
if (!StringUtils.hasText(fileName)) {
throw new BusinessException("文件名不能为空");
}
FileInfo fileInfo = getOne(new LambdaQueryWrapper<FileInfo>().eq(FileInfo::getFileName, fileName));
if (ObjectUtils.isEmpty(fileInfo)) {
throw new BusinessException("文件不存在");
}
return MinioUtil.getPresignedObjectUrl(minioClient, fileInfo.getBucketName(), fileName, MinioConstants.DEFAULT_PRESIGNED_EXPIRY, Method.GET);
}
@Override
public MultipartUploadInitVO initMultipartUpload(MultipartUploadInitRequest request) {
if (request.getFileSize() > MinioConstants.MAX_FILE_SIZE) {
throw new BusinessException("文件大小超过最大限制");
}
FileUploadVO existFile = checkFileExist(request.getMd5());
if (!ObjectUtils.isEmpty(existFile)) {
throw new BusinessException("文件已存在,无需重复上传", 200);
}
String originalFileName = request.getOriginalFileName();
String suffix = originalFileName.substring(originalFileName.lastIndexOf("."));
String fileName = UUID.randomUUID().toString().replace("-", "") + suffix;
long partSize = MinioConstants.DEFAULT_PART_SIZE;
long fileSize = request.getFileSize();
int totalPartCount = (int) Math.ceil((double) fileSize / partSize);
if (totalPartCount < MinioConstants.MIN_PART_COUNT) {
totalPartCount = MinioConstants.MIN_PART_COUNT;
}
if (totalPartCount > MinioConstants.MAX_PART_COUNT) {
partSize = (long) Math.ceil((double) fileSize / MinioConstants.MAX_PART_COUNT);
totalPartCount = MinioConstants.MAX_PART_COUNT;
}
String uploadId = MinioUtil.initMultipartUpload(minioClient, minioConfig.getBucketName(), fileName, request.getContentType());
MultipartUploadInitVO vo = new MultipartUploadInitVO();
vo.setFileName(fileName);
vo.setUploadId(uploadId);
vo.setPartSize(partSize);
vo.setTotalPartCount(totalPartCount);
return vo;
}
@Override
public MultipartUploadPartVO uploadPart(MultipartUploadPartRequest request) {
if (request.getFile().isEmpty()) {
throw new BusinessException("分片文件不能为空");
}
try {
String etag = MinioUtil.uploadPart(
minioClient,
minioConfig.getBucketName(),
request.getFileName(),
request.getUploadId(),
request.getPartNumber(),
request.getFile().getInputStream(),
request.getFile().getSize()
);
MultipartUploadPartVO vo = new MultipartUploadPartVO();
vo.setPartNumber(request.getPartNumber());
vo.setEtag(etag);
return vo;
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("分片上传失败,fileName:{},partNumber:{}", request.getFileName(), request.getPartNumber(), e);
throw new BusinessException("分片上传失败");
}
}
@Override
public FileUploadVO completeMultipartUpload(MultipartUploadCompleteRequest request) {
try {
Part[] parts = request.getParts().stream()
.sorted((p1, p2) -> p1.getPartNumber() - p2.getPartNumber())
.map(vo -> new Part(vo.getPartNumber(), vo.getEtag()))
.toArray(Part[]::new);
MinioUtil.completeMultipartUpload(
minioClient,
minioConfig.getBucketName(),
request.getFileName(),
request.getUploadId(),
parts
);
String url = MinioUtil.getPresignedObjectUrl(minioClient, minioConfig.getBucketName(), request.getFileName(), MinioConstants.DEFAULT_PRESIGNED_EXPIRY, Method.GET);
FileInfo fileInfo = new FileInfo();
fileInfo.setFileName(request.getFileName());
fileInfo.setOriginalFileName(request.getOriginalFileName());
fileInfo.setBucketName(minioConfig.getBucketName());
fileInfo.setFilePath(minioConfig.getBucketName() + "/" + request.getFileName());
fileInfo.setFileSize(request.getFileSize());
fileInfo.setContentType("application/octet-stream");
fileInfo.setMd5(request.getMd5());
Boolean saveResult = transactionTemplate.execute(status -> {
try {
return save(fileInfo);
} catch (Exception e) {
status.setRollbackOnly();
MinioUtil.deleteFile(minioClient, minioConfig.getBucketName(), request.getFileName());
log.error("保存文件信息失败,回滚合并操作", e);
throw new BusinessException("保存文件信息失败");
}
});
if (ObjectUtils.isEmpty(saveResult) || !saveResult) {
MinioUtil.deleteFile(minioClient, minioConfig.getBucketName(), request.getFileName());
throw new BusinessException("保存文件信息失败");
}
FileUploadVO vo = new FileUploadVO();
vo.setId(fileInfo.getId());
vo.setFileName(request.getFileName());
vo.setOriginalFileName(request.getOriginalFileName());
vo.setFileSize(request.getFileSize());
vo.setUrl(url);
vo.setUploadTime(fileInfo.getCreateTime());
return vo;
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("合并分片失败,fileName:{}", request.getFileName(), e);
throw new BusinessException("合并分片失败");
}
}
@Override
public FileUploadVO checkFileExist(String md5) {
if (!StringUtils.hasText(md5)) {
throw new BusinessException("文件MD5值不能为空");
}
FileInfo fileInfo = getOne(new LambdaQueryWrapper<FileInfo>().eq(FileInfo::getMd5, md5));
if (ObjectUtils.isEmpty(fileInfo)) {
return null;
}
String url = MinioUtil.getPresignedObjectUrl(minioClient, fileInfo.getBucketName(), fileInfo.getFileName(), MinioConstants.DEFAULT_PRESIGNED_EXPIRY, Method.GET);
FileUploadVO vo = new FileUploadVO();
vo.setId(fileInfo.getId());
vo.setFileName(fileInfo.getFileName());
vo.setOriginalFileName(fileInfo.getOriginalFileName());
vo.setFileSize(fileInfo.getFileSize());
vo.setUrl(url);
vo.setUploadTime(fileInfo.getCreateTime());
return vo;
}
}
3.9 控制层实现
基于RESTful规范设计接口,添加Swagger3注解与参数校验,覆盖全场景文件操作能力:
package com.jam.demo.controller;
import com.jam.demo.common.Result;
import com.jam.demo.service.FileService;
import com.jam.demo.vo.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletResponse;
/**
* 文件操作控制器
*
* @author ken
*/
@Slf4j
@RestController
@RequestMapping("/file")
@Tag(name = "文件操作接口", description = "包含普通文件上传、下载、删除、预签名URL获取、秒传校验等功能")
public class FileController {
private final FileService fileService;
public FileController(FileService fileService) {
this.fileService = fileService;
}
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "普通文件上传", description = "支持单文件上传,内置秒传能力,文件大小限制1GB")
public Result<FileUploadVO> upload(
@Parameter(description = "上传的文件", required = true)
@RequestPart("file") MultipartFile file) {
return Result.success(fileService.upload(file));
}
@GetMapping("/download/{fileName}")
@Operation(summary = "文件下载", description = "根据存储文件名下载文件,自动设置响应头与文件名编码")
public void download(
@Parameter(description = "存储文件名", required = true)
@PathVariable String fileName,
HttpServletResponse response) {
fileService.download(fileName, response);
}
@DeleteMapping("/{fileName}")
@Operation(summary = "删除文件", description = "根据存储文件名删除文件,同时删除MinIO中的对象与数据库元数据")
public Result<Boolean> delete(
@Parameter(description = "存储文件名", required = true)
@PathVariable String fileName) {
return Result.success(fileService.delete(fileName));
}
@GetMapping("/presigned-url/{fileName}")
@Operation(summary = "获取文件预签名访问URL", description = "生成带签名的临时访问URL,默认有效期1小时")
public Result<String> getPresignedUrl(
@Parameter(description = "存储文件名", required = true)
@PathVariable String fileName) {
return Result.success(fileService.getPresignedUrl(fileName));
}
@GetMapping("/check-exist/{md5}")
@Operation(summary = "校验文件是否已存在(秒传)", description = "根据文件MD5值校验是否已上传,已上传直接返回文件信息")
public Result<FileUploadVO> checkFileExist(
@Parameter(description = "文件MD5值", required = true)
@PathVariable String md5) {
FileUploadVO vo = fileService.checkFileExist(md5);
if (vo == null) {
return Result.fail(404, "文件不存在");
}
return Result.success(vo);
}
}
package com.jam.demo.controller;
import com.jam.demo.common.Result;
import com.jam.demo.service.FileService;
import com.jam.demo.vo.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
/**
* 分片上传控制器
*
* @author ken
*/
@Slf4j
@RestController
@RequestMapping("/multipart")
@Tag(name = "分片上传接口", description = "大文件分片上传、断点续传相关接口,支持GB级大文件上传")
public class MultipartUploadController {
private final FileService fileService;
public MultipartUploadController(FileService fileService) {
this.fileService = fileService;
}
@PostMapping("/init")
@Operation(summary = "初始化分片上传", description = "初始化分片上传任务,生成uploadId、分片大小、总分片数等信息")
public Result<MultipartUploadInitVO> initMultipartUpload(@Valid @RequestBody MultipartUploadInitRequest request) {
return Result.success(fileService.initMultipartUpload(request));
}
@PostMapping(value = "/upload-part", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "上传单个分片", description = "上传文件的单个分片,返回分片的ETag值,用于最终合并")
public Result<MultipartUploadPartVO> uploadPart(@Valid MultipartUploadPartRequest request) {
return Result.success(fileService.uploadPart(request));
}
@PostMapping("/complete")
@Operation(summary = "合并分片", description = "所有分片上传完成后,调用此接口合并分片,生成完整文件并保存元数据")
public Result<FileUploadVO> completeMultipartUpload(@Valid @RequestBody MultipartUploadCompleteRequest request) {
return Result.success(fileService.completeMultipartUpload(request));
}
}
3.10 项目启动类与辅助配置
项目启动类
package com.jam.demo;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 项目启动类
*
* @author ken
*/
@SpringBootApplication
@MapperScan("com.jam.demo.mapper")
public class MinioDemoApplication {
public static void main(String[] args) {
SpringApplication.run(MinioDemoApplication.class, args);
}
}
MyBatis-Plus自动填充配置
package com.jam.demo.config;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* MyBatis-Plus字段自动填充配置
*
* @author ken
*/
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
}
四、核心业务流程深度解析
4.1 普通文件上传全流程
普通文件上传适用于100MB以内的小文件场景,流程简单、开发成本低,核心流程如下:
核心逻辑说明:
- 秒传能力:通过文件MD5值实现秒传,避免重复上传相同文件,节省存储空间与带宽资源
- 事务一致性:采用编程式事务保证MinIO文件上传与数据库元数据保存的一致性,异常时自动回滚,避免脏数据
- 文件名唯一性:通过UUID生成全局唯一的存储文件名,避免同名文件覆盖问题
4.2 大文件分片上传与断点续传全流程
分片上传适用于100MB以上的大文件场景,解决了大文件上传超时、网络中断后需重新上传的痛点,核心流程如下:
核心逻辑说明:
- 断点续传实现:客户端记录已上传成功的分片,网络中断或页面刷新后,只需上传未完成的分片,无需从头开始
- 分片规则:默认分片大小5MB,最小分片数1,最大分片数10000,适配MinIO的分片上传规范,支持最大50GB的文件上传
- ETag校验:每个分片上传后返回唯一的ETag值,合并时需携带所有分片的ETag与序号,MinIO会校验分片的完整性,避免分片错乱或损坏
- 资源清理:未完成的分片上传任务,MinIO会保留未合并的分片,可通过生命周期规则自动清理过期的分片数据,避免存储空间浪费
五、前端对接实战示例
5.1 普通文件上传前端示例
基于原生JavaScript实现,适配主流浏览器,可直接集成到Vue、React等前端框架中:
// 普通文件上传
async function uploadFile(event) {
const file = event.target.files[0];
if (!file) {
alert("请选择要上传的文件");
return;
}
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch("http://localhost:8080/file/upload", {
method: "POST",
body: formData
});
const result = await response.json();
if (result.code === 200) {
console.log("上传成功", result.data);
alert("上传成功,文件访问地址:" + result.data.url);
} else {
alert("上传失败:" + result.message);
}
} catch (error) {
console.error("上传异常", error);
alert("上传异常,请检查网络");
}
}
// 页面绑定
document.getElementById("fileInput").addEventListener("change", uploadFile);
5.2 大文件分片上传前端示例
实现文件切片、MD5计算、断点续传、进度展示等核心能力:
// 分片大小 5MB,与后端保持一致
const PART_SIZE = 5 * 1024 * 1024;
// 已上传的分片信息
let uploadedParts = [];
// 分片上传任务信息
let uploadTask = null;
// 文件MD5值
let fileMd5 = "";
// 计算文件MD5
async function calculateFileMD5(file) {
return new Promise((resolve) => {
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
let loaded = 0;
const chunkSize = 2 * 1024 * 1024;
const totalChunks = Math.ceil(file.size / chunkSize);
let currentChunk = 0;
fileReader.onload = function (e) {
spark.append(e.target.result);
currentChunk++;
loaded += e.target.result.byteLength;
const progress = Math.floor((loaded / file.size) * 100);
console.log("MD5计算进度:" + progress + "%");
if (currentChunk < totalChunks) {
loadNextChunk();
} else {
resolve(spark.end());
}
};
function loadNextChunk() {
const start = currentChunk * chunkSize;
const end = Math.min(start + chunkSize, file.size);
fileReader.readAsArrayBuffer(file.slice(start, end));
}
loadNextChunk();
});
}
// 秒传校验
async function checkFileExist(md5) {
const response = await fetch(`http://localhost:8080/file/check-exist/${md5}`);
const result = await response.json();
return result.code === 200 ? result.data : null;
}
// 初始化分片上传
async function initMultipartUpload(file, md5) {
const response = await fetch("http://localhost:8080/multipart/init", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
originalFileName: file.name,
md5: md5,
fileSize: file.size,
contentType: file.type
})
});
const result = await response.json();
if (result.code !== 200) {
throw new Error(result.message);
}
return result.data;
}
// 上传单个分片
async function uploadPart(chunk, partNumber) {
const formData = new FormData();
formData.append("fileName", uploadTask.fileName);
formData.append("uploadId", uploadTask.uploadId);
formData.append("partNumber", partNumber);
formData.append("file", chunk);
const response = await fetch("http://localhost:8080/multipart/upload-part", {
method: "POST",
body: formData
});
const result = await response.json();
if (result.code !== 200) {
throw new Error(result.message);
}
return result.data;
}
// 合并分片
async function completeMultipartUpload(file) {
const response = await fetch("http://localhost:8080/multipart/complete", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
fileName: uploadTask.fileName,
uploadId: uploadTask.uploadId,
md5: fileMd5,
fileSize: file.size,
originalFileName: file.name,
parts: uploadedParts
})
});
const result = await response.json();
if (result.code !== 200) {
throw new Error(result.message);
}
return result.data;
}
// 主上传方法
async function uploadLargeFile(event) {
const file = event.target.files[0];
if (!file) {
alert("请选择要上传的文件");
return;
}
try {
// 1. 计算文件MD5
console.log("开始计算文件MD5...");
fileMd5 = await calculateFileMD5(file);
console.log("文件MD5:", fileMd5);
// 2. 秒传校验
const existFile = await checkFileExist(fileMd5);
if (existFile) {
console.log("文件已存在,秒传成功", existFile);
alert("秒传成功,文件访问地址:" + existFile.url);
return;
}
// 3. 初始化分片上传
console.log("初始化分片上传...");
uploadTask = await initMultipartUpload(file, fileMd5);
console.log("分片上传初始化完成", uploadTask);
// 4. 切割文件
const totalChunks = uploadTask.totalPartCount;
const chunks = [];
for (let i = 0; i < totalChunks; i++) {
const start = i * uploadTask.partSize;
const end = Math.min(start + uploadTask.partSize, file.size);
chunks.push(file.slice(start, end));
}
// 5. 循环上传分片
console.log("开始上传分片,总分片数:", totalChunks);
uploadedParts = [];
for (let i = 0; i < chunks.length; i++) {
const partNumber = i + 1;
try {
const partResult = await uploadPart(chunks[i], partNumber);
uploadedParts.push(partResult);
const progress = Math.floor(((i + 1) / totalChunks) * 100);
console.log(`分片${partNumber}/${totalChunks}上传成功,进度:${progress}%`);
} catch (error) {
console.error(`分片${partNumber}上传失败`, error);
alert(`分片${partNumber}上传失败:${error.message}`);
return;
}
}
// 6. 合并分片
console.log("所有分片上传完成,开始合并...");
const finalResult = await completeMultipartUpload(file);
console.log("文件上传完成", finalResult);
alert("上传成功,文件访问地址:" + finalResult.url);
} catch (error) {
console.error("大文件上传异常", error);
alert("上传失败:" + error.message);
}
}
// 页面绑定
document.getElementById("largeFileInput").addEventListener("change", uploadLargeFile);
六、生产环境最佳实践
6.1 权限管控最佳实践
- 最小权限原则:禁止在业务代码中使用管理员账号,应通过MinIO控制台创建专属业务用户,仅分配对应桶的读写权限,避免权限泄露导致的数据安全问题
- 预签名URL优先:业务访问优先使用预签名URL,避免将桶设置为公共读,防止数据被恶意爬取或篡改
- 临时凭证管控:前端直传场景使用STS临时凭证,设置较短的有效期,避免永久凭证泄露
- 访问策略精细化:通过桶策略限制IP访问、请求来源、文件大小、文件类型,禁止上传可执行文件、脚本文件等危险类型
6.2 性能优化最佳实践
- 分片大小优化:小文件使用普通上传,大文件使用分片上传,100MB-1GB文件分片大小设置为5MB,1GB-10GB文件分片大小设置为10MB,10GB以上文件分片大小设置为50MB,平衡分片数量与上传性能
- 并发上传优化:客户端分片上传采用并发上传,控制并发数在3-5个,避免并发过高导致服务端压力过大
- 部署架构优化:MinIO节点与业务服务部署在同一内网,减少网络延迟;生产环境使用SSD磁盘,提升IO性能;前端配置CDN加速静态资源访问
- 连接池优化:MinIO客户端配置合理的连接池参数,设置最大连接数、连接超时时间、读写超时时间,避免连接泄露
6.3 数据高可用与安全最佳实践
- 纠删码配置:分布式部署时,数据块与校验块的比例建议设置为1:1,平衡存储空间利用率与数据安全性;至少部署4个节点,确保集群高可用
- 数据备份:定期对核心数据进行跨集群备份,开启MinIO的版本控制功能,防止误删除导致的数据丢失
- 数据加密:敏感数据开启服务端加密(SSE-S3),传输过程使用HTTPS协议,确保数据传输与存储的安全性
- 生命周期管理:配置生命周期规则,自动清理过期文件、临时分片、历史版本文件,降低存储成本
6.4 监控与运维最佳实践
- 监控指标采集:通过Prometheus+Grafana采集MinIO的核心监控指标,包括节点健康状态、磁盘使用率、请求吞吐量、延迟、错误率等,设置告警阈值
- 日志审计:开启MinIO的访问日志,记录所有操作请求,用于安全审计与问题排查
- 定期巡检:定期检查集群节点健康状态、磁盘健康状态、数据一致性,及时更换故障磁盘,避免集群可用性下降
- 版本升级:定期升级MinIO到最新稳定版本,修复已知漏洞与问题,提升性能与稳定性
七、常见问题排查与解决方案
7.1 跨域问题
问题现象:前端调用接口时出现CORS跨域错误解决方案:
- 配置Spring Boot全局跨域过滤器:
package com.jam.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.Arrays;
/**
* 全局跨域配置
*
* @author ken
*/
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(Arrays.asList("*"));
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(Arrays.asList("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
- 在MinIO控制台配置桶的跨域规则,允许前端域名的跨域请求
7.2 大文件上传超时问题
问题现象:大文件上传时出现请求超时、连接重置错误解决方案:
- 调整Spring Boot的文件上传大小限制与超时时间
- 调整Nginx反向代理的超时配置,设置
proxy_connect_timeout、proxy_send_timeout、proxy_read_timeout为300s以上 - 开启分片上传,降低单个请求的文件大小,避免单次请求超时
- 调整MinIO客户端的超时参数,设置合理的读写超时时间
7.3 预签名URL失效问题
问题现象:生成的预签名URL访问时出现签名不匹配、过期错误解决方案:
- 确保MinIO服务端与业务服务端的系统时间一致,时间差超过15分钟会导致签名校验失败
- 预签名URL的有效期设置合理,避免过期时间过短
- 确保生成预签名URL的endpoint与客户端访问的endpoint一致,内网与外网endpoint混用会导致签名不匹配
- 避免在预签名URL中包含特殊字符,文件名使用URL编码
7.4 分片合并失败问题
问题现象:所有分片上传完成后,合并分片时出现分片不存在、ETag不匹配错误解决方案:
- 确保分片序号从1开始,连续且不重复,合并时按序号升序排列
- 确保每个分片的ETag值正确,与上传时返回的ETag完全一致
- 确保合并时使用的uploadId、bucketName、fileName与初始化时完全一致
- 检查未合并的分片是否被生命周期规则清理,延长临时分片的保留时间
八、总结
MinIO作为一款轻量、高性能、兼容S3协议的对象存储系统,完美适配了云原生时代企业级非结构化数据存储的需求。本文从MinIO的底层架构出发,拆解了其核心设计理念与特性,通过Spring Boot项目的全流程整合,实现了从普通文件上传下载、大文件分片上传、断点续传、秒传等全场景能力,同时梳理了生产环境的最佳实践与常见问题解决方案。