📌 引言
MinIO 是一个高性能的分布式对象存储系统,它与 Amazon S3 API 兼容,被广泛用于存储非结构化数据(如图片、视频、日志文件、备份文件等)。相比传统文件系统,MinIO 提供了更好的扩展性、高可用性和安全性。
MinIO 的核心特点
- 高性能:基于 Golang 开发,性能优异,支持大规模并发
- S3 兼容:完全兼容 AWS S3 API,易于迁移和集成
- 轻量级:单节点部署简单,资源占用低
- 分布式:支持分布式部署,数据自动分片和冗余
- 安全性:支持服务端加密、数据完整性校验
- 开源免费:采用 AGPLv3 许可证,可自由使用和修改
典型应用场景
- 电商平台:商品图片、商品视频存储
- 内容管理:CMS 系统的媒体文件管理
- 日志归档:应用日志、审计日志的集中存储
- 备份存储:数据库备份、文件备份
- 移动应用:用户头像、用户上传文件存储
- 远程文件采集:从外部URL抓取并存储资源
🔧 环境准备
1. 开发环境要求
- JDK:JDK 8 或更高版本
- Spring Boot:2.x 或 3.x
- Maven:3.6+
- IDE:IntelliJ IDEA 或 Eclipse
2. MinIO 服务搭建
方式一:Docker 快速部署(推荐)
bash
# 拉取 MinIO 镜像
docker pull minio/minio
# 启动 MinIO 服务
docker run -d \
-p 9000:9000 \
-p 9001:9001 \
--name minio \
-e "MINIO_ROOT_USER=minioadmin" \
-e "MINIO_ROOT_PASSWORD=minioadmin" \
-v /data/minio:/data \
minio/minio server /data --console-address ":9001"
参数说明:
-p 9000:9000:API 服务端口-p 9001:9001:Web 控制台端口MINIO_ROOT_USER:管理员账号MINIO_ROOT_PASSWORD:管理员密码-v /data/minio:/data:数据持久化目录
方式二:二进制文件部署
bash
# 下载 MinIO 二进制文件
wget https://dl.min.io/server/minio/release/linux-amd64/minio
# 赋予执行权限
chmod +x minio
# 启动服务
./minio server /data --console-address ":9001"
访问 MinIO 控制台
启动成功后,访问 http://localhost:9001,使用设置的账号密码登录。
3. Spring Boot 项目配置
3.1 添加 Maven 依赖
在 pom.xml 中添加 MinIO Java SDK 依赖:
xml
<dependencies>
<!-- MinIO Java SDK -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.7</version>
</dependency>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Lombok(可选,简化代码) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
3.2 配置文件
在 application.yml 或 application.properties 中配置 MinIO 连接信息:
yaml
# application.yml
minio:
endpoint: http://localhost:9000 # MinIO 服务地址
accessKey: minioadmin # 访问密钥
secretKey: minioadmin # 密钥
bucketName: my-bucket # 默认存储桶名称
或使用 properties 格式:
properties
# application.properties
minio.endpoint=http://localhost:9000
minio.accessKey=minioadmin
minio.secretKey=minioadmin
minio.bucketName=my-bucket
💡 核心实现
架构设计
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Controller │ ───> │ FileUploadUtil │ ───> │ MinIO │
│ │ │ │ │ Server │
└─────────────┘ └──────────────────┘ └─────────────┘
│
├── 初始化客户端
├── 文件上传(MultipartFile)
├── 流式上传
├── 远程URL上传
├── 文件下载
├── URL 生成
├── 文件删除
└── 文件管理
工具类核心功能模块
1. 客户端初始化与配置
工具类通过 @PostConstruct 注解在 Bean 实例化后自动执行初始化逻辑,包括:
- 创建 MinIO 客户端实例
- 检查并创建默认存储桶
- 记录初始化日志
2. 文件上传功能
支持三种上传方式:
- MultipartFile 上传:适用于 Web 文件上传场景
- 流式上传:适用于大文件或网络流上传
- 远程URL上传:从 HTTP/HTTPS URL 直接拉取文件并上传
3. 文件访问功能
提供两种 URL 生成方式:
- 永久 URL:基于 Endpoint 直接拼接
- 临时 URL:带过期时间的预签名 URL
4. 文件管理功能
- 文件删除(单个/批量)
- 文件存在性检查
- 文件复制
- 文件信息获取
5. 远程文件采集
- 支持从任意 HTTP/HTTPS URL 上传文件
- 自动识别文件类型
- 支持批量远程文件上传
- 完善的超时控制和异常处理
6. 异常处理与日志
- 统一异常捕获与转换
- 详细的操作日志记录
- 友好的错误信息提示
完整工具类代码
以下是完整的 MinIO 文件上传工具类代码,包含所有核心功能:
java
package com.example.minio.util;
import io.minio.*;
import io.minio.errors.*;
import io.minio.http.Method;
import io.minio.messages.BucketExistsArgs;
import io.minio.messages.DeleteError;
import io.minio.messages.DeleteObject;
import io.minio.messages.RemoveBucketArgs;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.PostConstruct;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* MinIO文件上传工具类
* 提供完整的MinIO对象存储服务操作功能,包括:
* - 文件上传(支持MultipartFile、流式上传、远程URL上传)
* - 文件下载/访问URL生成
* - 文件管理(删除、检查存在、复制)
* - 异常处理与日志记录
*
* @author System
* @since 2026-01-28
*/
@Component
public class MinIOFileUploadUtil {
private static final Logger logger = LoggerFactory.getLogger(MinIOFileUploadUtil.class);
/**
* 默认连接超时时间(毫秒)
*/
private static final int DEFAULT_CONNECT_TIMEOUT = 10000;
/**
* 默认读取超时时间(毫秒)
*/
private static final int DEFAULT_READ_TIMEOUT = 30000;
@Value("${minio.endpoint}")
private String endpoint;
@Value("${minio.accessKey}")
private String accessKey;
@Value("${minio.secretKey}")
private String secretKey;
@Value("${minio.bucketName}")
private String defaultBucketName;
private MinioClient minioClient;
/**
* 初始化MinIO客户端
* 在Bean实例化后自动执行
*/
@PostConstruct
public void init() {
try {
this.minioClient = MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
// 确保默认存储桶存在
ensureBucketExists(defaultBucketName);
logger.info("MinIO客户端初始化成功,endpoint: {}", endpoint);
} catch (Exception e) {
logger.error("MinIO客户端初始化失败", e);
throw new RuntimeException("MinIO客户端初始化失败", e);
}
}
/**
* 检查并创建存储桶(如果不存在)
*
* @param bucketName 存储桶名称
* @throws Exception 异常
*/
private void ensureBucketExists(String bucketName) throws Exception {
boolean exists = bucketExists(bucketName);
if (!exists) {
makeBucket(bucketName);
logger.info("存储桶 [{}] 创建成功", bucketName);
} else {
logger.info("存储桶 [{}] 已存在", bucketName);
}
}
/**
* 检查存储桶是否存在
*
* @param bucketName 存储桶名称
* @return true-存在,false-不存在
*/
public boolean bucketExists(String bucketName) {
try {
BucketExistsArgs args = BucketExistsArgs.builder()
.bucket(bucketName)
.build();
return minioClient.bucketExists(args);
} catch (Exception e) {
logger.error("检查存储桶存在性失败,bucketName: {}", bucketName, e);
return false;
}
}
/**
* 创建存储桶
*
* @param bucketName 存储桶名称
* @throws Exception 异常
*/
public void makeBucket(String bucketName) throws Exception {
minioClient.makeBucket(
MakeBucketArgs.builder()
.bucket(bucketName)
.build()
);
}
/**
* 上传文件(MultipartFile方式)
*
* @param file 上传的文件
* @param objectName 对象名称(文件在MinIO中的路径)
* @return 文件访问URL
*/
public String uploadFile(MultipartFile file, String objectName) {
return uploadFile(file, objectName, defaultBucketName);
}
/**
* 上传文件到指定存储桶
*
* @param file 上传的文件
* @param objectName 对象名称
* @param bucketName 存储桶名称
* @return 文件访问URL
*/
public String uploadFile(MultipartFile file, String objectName, String bucketName) {
try {
InputStream inputStream = file.getInputStream();
// 设置文件元数据
Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", file.getContentType());
headers.put("Content-Disposition", "inline; filename=" + file.getOriginalFilename());
// 上传文件
ObjectWriteResponse response = minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.stream(inputStream, file.getSize(), -1)
.contentType(file.getContentType())
.headers(headers)
.build()
);
String fileUrl = getFileUrl(objectName, bucketName);
logger.info("文件上传成功,bucket: {}, object: {}, etag: {}", bucketName, objectName, response.etag());
return fileUrl;
} catch (Exception e) {
logger.error("文件上传失败,objectName: {}, bucketName: {}", objectName, bucketName, e);
throw new RuntimeException("文件上传失败: " + e.getMessage(), e);
}
}
/**
* 流式上传文件
*
* @param inputStream 文件输入流
* @param objectName 对象名称
* @param contentType 内容类型
* @param size 文件大小
* @return 文件访问URL
*/
public String uploadStream(InputStream inputStream, String objectName, String contentType, long size) {
return uploadStream(inputStream, objectName, contentType, size, defaultBucketName);
}
/**
* 流式上传到指定存储桶
*
* @param inputStream 文件输入流
* @param objectName 对象名称
* @param contentType 内容类型
* @param size 文件大小
* @param bucketName 存储桶名称
* @return 文件访问URL
*/
public String uploadStream(InputStream inputStream, String objectName, String contentType, long size, String bucketName) {
try {
ObjectWriteResponse response = minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.stream(inputStream, size, -1)
.contentType(contentType)
.build()
);
String fileUrl = getFileUrl(objectName, bucketName);
logger.info("流式文件上传成功,bucket: {}, object: {}", bucketName, objectName);
return fileUrl;
} catch (Exception e) {
logger.error("流式文件上传失败,objectName: {}", objectName, e);
throw new RuntimeException("流式文件上传失败: " + e.getMessage(), e);
}
}
/**
* 从远程URL上传文件到MinIO
* 支持HTTP/HTTPS协议,自动识别文件类型
*
* @param fileUrl 远程文件URL(如:https://example.com/image.jpg)
* @param objectName 对象名称(文件在MinIO中的路径)
* @return 文件访问URL
*/
public String uploadFromRemoteUrl(String fileUrl, String objectName) {
return uploadFromRemoteUrl(fileUrl, objectName, defaultBucketName, null);
}
/**
* 从远程URL上传文件到MinIO(指定内容类型)
*
* @param fileUrl 远程文件URL
* @param objectName 对象名称
* @param bucketName 存储桶名称
* @param contentType 内容类型(可选,不指定则自动识别)
* @return 文件访问URL
*/
public String uploadFromRemoteUrl(String fileUrl, String objectName, String bucketName, String contentType) {
return uploadFromRemoteUrl(fileUrl, objectName, bucketName, contentType,
DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT);
}
/**
* 从远程URL上传文件到MinIO(完整参数版本)
*
* @param fileUrl 远程文件URL
* @param objectName 对象名称
* @param bucketName 存储桶名称
* @param contentType 内容类型(可选)
* @param connectTimeout 连接超时时间(毫秒)
* @param readTimeout 读取超时时间(毫秒)
* @return 文件访问URL
*/
public String uploadFromRemoteUrl(String fileUrl, String objectName, String bucketName,
String contentType, int connectTimeout, int readTimeout) {
HttpURLConnection connection = null;
InputStream inputStream = null;
try {
logger.info("开始从远程URL上传文件,url: {}, objectName: {}", fileUrl, objectName);
// 创建HTTP连接
URL url = new URL(fileUrl);
connection = (HttpURLConnection) url.openConnection();
// 设置连接参数
connection.setRequestMethod("GET");
connection.setConnectTimeout(connectTimeout);
connection.setReadTimeout(readTimeout);
connection.setInstanceFollowRedirects(true); // 自动跟随重定向
// 设置User-Agent,避免某些服务器拒绝请求
connection.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
// 连接并获取响应码
int responseCode = connection.getResponseCode();
if (responseCode != HttpURLConnection.HTTP_OK) {
throw new IOException("远程文件访问失败,HTTP响应码: " + responseCode);
}
// 获取输入流
inputStream = connection.getInputStream();
// 获取文件大小
long fileSize = connection.getContentLengthLong();
if (fileSize <= 0) {
fileSize = -1; // 未知大小
logger.warn("无法获取远程文件大小,将使用流式上传");
}
// 自动识别内容类型
if (contentType == null || contentType.isEmpty()) {
contentType = connection.getContentType();
if (contentType == null || contentType.isEmpty()) {
contentType = "application/octet-stream"; // 默认二进制流
logger.warn("无法识别内容类型,使用默认类型: {}", contentType);
}
}
// 上传到MinIO
ObjectWriteResponse response = minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.stream(inputStream, fileSize, -1)
.contentType(contentType)
.build()
);
String minioFileUrl = getFileUrl(objectName, bucketName);
logger.info("远程文件上传成功,url: {}, bucket: {}, object: {}, etag: {}",
fileUrl, bucketName, objectName, response.etag());
return minioFileUrl;
} catch (Exception e) {
logger.error("从远程URL上传文件失败,url: {}, objectName: {}", fileUrl, objectName, e);
throw new RuntimeException("从远程URL上传文件失败: " + e.getMessage(), e);
} finally {
// 关闭资源
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {
logger.error("关闭输入流失败", e);
}
}
if (connection != null) {
connection.disconnect();
}
}
}
/**
* 批量从远程URL上传文件
*
* @param fileUrlMap URL映射表(URL -> 对象名称)
* @return 上传结果统计
*/
public Map<String, Object> batchUploadFromRemoteUrls(Map<String, String> fileUrlMap) {
return batchUploadFromRemoteUrls(fileUrlMap, defaultBucketName);
}
/**
* 批量从远程URL上传文件到指定存储桶
*
* @param fileUrlMap URL映射表(URL -> 对象名称)
* @param bucketName 存储桶名称
* @return 上传结果统计
*/
public Map<String, Object> batchUploadFromRemoteUrls(Map<String, String> fileUrlMap, String bucketName) {
Map<String, Object> result = new HashMap<>();
int successCount = 0;
int failCount = 0;
List<String> failedUrls = new ArrayList<>();
Map<String, String> successUrls = new HashMap<>(); // URL -> MinIO URL
logger.info("开始批量从远程URL上传文件,总数: {}", fileUrlMap.size());
for (Map.Entry<String, String> entry : fileUrlMap.entrySet()) {
String fileUrl = entry.getKey();
String objectName = entry.getValue();
try {
String minioUrl = uploadFromRemoteUrl(fileUrl, objectName, bucketName, null);
successCount++;
successUrls.put(fileUrl, minioUrl);
} catch (Exception e) {
failCount++;
failedUrls.add(fileUrl);
logger.error("批量上传失败,url: {}, error: {}", fileUrl, e.getMessage());
}
}
logger.info("批量上传完成,成功: {}, 失败: {}", successCount, failCount);
result.put("totalCount", fileUrlMap.size());
result.put("successCount", successCount);
result.put("failCount", failCount);
result.put("successUrls", successUrls);
result.put("failedUrls", failedUrls);
return result;
}
/**
* 生成文件访问URL(永久URL)
*
* @param objectName 对象名称
* @return 文件访问URL
*/
public String getFileUrl(String objectName) {
return getFileUrl(objectName, defaultBucketName);
}
/**
* 生成指定存储桶的文件访问URL
*
* @param objectName 对象名称
* @param bucketName 存储桶名称
* @return 文件访问URL
*/
public String getFileUrl(String objectName, String bucketName) {
return String.format("%s/%s/%s", endpoint, bucketName, objectName);
}
/**
* 生成临时访问URL(带过期时间)
*
* @param objectName 对象名称
* @param expires 过期时间(秒)
* @return 临时访问URL
*/
public String getPresignedUrl(String objectName, int expires) {
return getPresignedUrl(objectName, defaultBucketName, expires);
}
/**
* 生成指定存储桶的临时访问URL
*
* @param objectName 对象名称
* @param bucketName 存储桶名称
* @param expires 过期时间(秒)
* @return 临时访问URL
*/
public String getPresignedUrl(String objectName, String bucketName, int expires) {
try {
return minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(bucketName)
.object(objectName)
.expiry(expires, TimeUnit.SECONDS)
.build()
);
} catch (Exception e) {
logger.error("生成临时URL失败,objectName: {}", objectName, e);
throw new RuntimeException("生成临时URL失败: " + e.getMessage(), e);
}
}
/**
* 下载文件
*
* @param objectName 对象名称
* @return 文件输入流
*/
public InputStream downloadFile(String objectName) {
return downloadFile(objectName, defaultBucketName);
}
/**
* 从指定存储桶下载文件
*
* @param objectName 对象名称
* @param bucketName 存储桶名称
* @return 文件输入流
*/
public InputStream downloadFile(String objectName, String bucketName) {
try {
return minioClient.getObject(
GetObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.build()
);
} catch (Exception e) {
logger.error("文件下载失败,objectName: {}, bucketName: {}", objectName, bucketName, e);
throw new RuntimeException("文件下载失败: " + e.getMessage(), e);
}
}
/**
* 删除文件
*
* @param objectName 对象名称
* @return true-删除成功,false-删除失败
*/
public boolean deleteFile(String objectName) {
return deleteFile(objectName, defaultBucketName);
}
/**
* 从指定存储桶删除文件
*
* @param objectName 对象名称
* @param bucketName 存储桶名称
* @return true-删除成功,false-删除失败
*/
public boolean deleteFile(String objectName, String bucketName) {
try {
minioClient.removeObject(
RemoveObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.build()
);
logger.info("文件删除成功,bucket: {}, object: {}", bucketName, objectName);
return true;
} catch (Exception e) {
logger.error("文件删除失败,objectName: {}, bucketName: {}", objectName, bucketName, e);
return false;
}
}
/**
* 批量删除文件
*
* @param objectNames 对象名称列表
* @return 删除结果统计
*/
public Map<String, Object> deleteFiles(List<String> objectNames) {
return deleteFiles(objectNames, defaultBucketName);
}
/**
* 从指定存储桶批量删除文件
*
* @param objectNames 对象名称列表
* @param bucketName 存储桶名称
* @return 删除结果统计
*/
public Map<String, Object> deleteFiles(List<String> objectNames, String bucketName) {
Map<String, Object> result = new HashMap<>();
int successCount = 0;
int failCount = 0;
try {
List<DeleteObject> deleteObjects = new ArrayList<>();
for (String objectName : objectNames) {
deleteObjects.add(new DeleteObject(objectName));
}
Iterable<Result<DeleteError>> results = minioClient.removeObjects(
RemoveObjectsArgs.builder()
.bucket(bucketName)
.objects(deleteObjects)
.build()
);
for (Result<DeleteError> resultItem : results) {
DeleteError error = resultItem.get();
logger.error("删除文件失败: objectName={}, message={}", error.objectName(), error.message());
failCount++;
}
successCount = objectNames.size() - failCount;
logger.info("批量删除完成,成功: {}, 失败: {}", successCount, failCount);
} catch (Exception e) {
logger.error("批量删除失败", e);
failCount = objectNames.size();
}
result.put("successCount", successCount);
result.put("failCount", failCount);
result.put("totalCount", objectNames.size());
return result;
}
/**
* 检查文件是否存在
*
* @param objectName 对象名称
* @return true-存在,false-不存在
*/
public boolean fileExists(String objectName) {
return fileExists(objectName, defaultBucketName);
}
/**
* 检查指定存储桶中的文件是否存在
*
* @param objectName 对象名称
* @param bucketName 存储桶名称
* @return true-存在,false-不存在
*/
public boolean fileExists(String objectName, String bucketName) {
try {
minioClient.statObject(
StatObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.build()
);
return true;
} catch (ErrorResponseException e) {
if (e.errorResponse().code().equals("NoSuchKey")) {
return false;
}
logger.error("检查文件存在性失败,objectName: {}, bucketName: {}", objectName, bucketName, e);
return false;
} catch (Exception e) {
logger.error("检查文件存在性异常,objectName: {}, bucketName: {}", objectName, bucketName, e);
return false;
}
}
/**
* 复制文件
*
* @param sourceObject 源对象名称
* @param targetObject 目标对象名称
* @return true-复制成功,false-复制失败
*/
public boolean copyFile(String sourceObject, String targetObject) {
return copyFile(sourceObject, targetObject, defaultBucketName, defaultBucketName);
}
/**
* 跨存储桶复制文件
*
* @param sourceObject 源对象名称
* @param targetObject 目标对象名称
* @param sourceBucketName 源存储桶名称
* @param targetBucketName 目标存储桶名称
* @return true-复制成功,false-复制失败
*/
public boolean copyFile(String sourceObject, String targetObject, String sourceBucketName, String targetBucketName) {
try {
minioClient.copyObject(
CopyObjectArgs.builder()
.bucket(targetBucketName)
.object(targetObject)
.source(
CopySource.builder()
.bucket(sourceBucketName)
.object(sourceObject)
.build()
)
.build()
);
logger.info("文件复制成功,从 {}:{} 到 {}:{}", sourceBucketName, sourceObject, targetBucketName, targetObject);
return true;
} catch (Exception e) {
logger.error("文件复制失败", e);
return false;
}
}
/**
* 获取文件信息
*
* @param objectName 对象名称
* @return 文件信息
*/
public Map<String, Object> getFileInfo(String objectName) {
return getFileInfo(objectName, defaultBucketName);
}
/**
* 获取指定存储桶的文件信息
*
* @param objectName 对象名称
* @param bucketName 存储桶名称
* @return 文件信息
*/
public Map<String, Object> getFileInfo(String objectName, String bucketName) {
try {
StatObjectResponse stat = minioClient.statObject(
StatObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.build()
);
Map<String, Object> fileInfo = new HashMap<>();
fileInfo.put("objectName", objectName);
fileInfo.put("bucketName", bucketName);
fileInfo.put("size", stat.size());
fileInfo.put("contentType", stat.contentType());
fileInfo.put("lastModified", stat.lastModified());
fileInfo.put("etag", stat.etag());
return fileInfo;
} catch (Exception e) {
logger.error("获取文件信息失败,objectName: {}", objectName, e);
return null;
}
}
}
🚀 使用示例
1. 创建 Controller 层
java
package com.example.minio.controller;
import com.example.minio.util.MinIOFileUploadUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 文件上传控制器
*/
@RestController
@RequestMapping("/api/file")
public class FileUploadController {
@Autowired
private MinIOFileUploadUtil fileUploadUtil;
/**
* 单文件上传
*/
@PostMapping("/upload")
public ResponseEntity<Map<String, Object>> uploadFile(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "objectName", required = false) String objectName) {
Map<String, Object> result = new HashMap<>();
try {
// 如果未指定 objectName,使用原文件名
if (objectName == null || objectName.isEmpty()) {
objectName = file.getOriginalFilename();
}
// 添加时间戳避免文件名冲突
objectName = System.currentTimeMillis() + "_" + objectName;
// 上传文件
String fileUrl = fileUploadUtil.uploadFile(file, objectName);
result.put("success", true);
result.put("message", "文件上传成功");
result.put("data", Map.of(
"objectName", objectName,
"fileUrl", fileUrl
));
return ResponseEntity.ok(result);
} catch (Exception e) {
result.put("success", false);
result.put("message", "文件上传失败: " + e.getMessage());
return ResponseEntity.status(500).body(result);
}
}
/**
* 从远程URL上传文件
*/
@PostMapping("/uploadFromRemote")
public ResponseEntity<Map<String, Object>> uploadFromRemote(
@RequestParam("fileUrl") String fileUrl,
@RequestParam(value = "objectName", required = false) String objectName) {
Map<String, Object> result = new HashMap<>();
try {
// 如果未指定 objectName,从URL提取文件名
if (objectName == null || objectName.isEmpty()) {
String[] parts = fileUrl.split("/");
objectName = parts[parts.length - 1];
// 添加时间戳避免文件名冲突
objectName = System.currentTimeMillis() + "_" + objectName;
}
// 从远程URL上传文件
String minioUrl = fileUploadUtil.uploadFromRemoteUrl(fileUrl, objectName);
result.put("success", true);
result.put("message", "远程文件上传成功");
result.put("data", Map.of(
"objectName", objectName,
"sourceUrl", fileUrl,
"minioUrl", minioUrl
));
return ResponseEntity.ok(result);
} catch (Exception e) {
result.put("success", false);
result.put("message", "远程文件上传失败: " + e.getMessage());
return ResponseEntity.status(500).body(result);
}
}
/**
* 批量从远程URL上传文件
*/
@PostMapping("/batchUploadFromRemote")
public ResponseEntity<Map<String, Object>> batchUploadFromRemote(
@RequestBody Map<String, String> urlMap) {
Map<String, Object> result = new HashMap<>();
try {
// 批量上传
Map<String, Object> uploadResult = fileUploadUtil.batchUploadFromRemoteUrls(urlMap);
result.put("success", true);
result.put("message", "批量上传完成");
result.put("data", uploadResult);
return ResponseEntity.ok(result);
} catch (Exception e) {
result.put("success", false);
result.put("message", "批量上传失败: " + e.getMessage());
return ResponseEntity.status(500).body(result);
}
}
/**
* 获取文件访问URL
*/
@GetMapping("/url/{objectName}")
public ResponseEntity<Map<String, Object>> getFileUrl(
@PathVariable String objectName,
@RequestParam(value = "expires", required = false, defaultValue = "3600") int expires) {
Map<String, Object> result = new HashMap<>();
try {
// 生成临时访问URL(默认1小时过期)
String url = fileUploadUtil.getPresignedUrl(objectName, expires);
result.put("success", true);
result.put("data", Map.of(
"url", url,
"expires", expires
));
return ResponseEntity.ok(result);
} catch (Exception e) {
result.put("success", false);
result.put("message", "生成URL失败: " + e.getMessage());
return ResponseEntity.status(500).body(result);
}
}
/**
* 下载文件
*/
@GetMapping("/download/{objectName}")
public ResponseEntity<InputStream> downloadFile(@PathVariable String objectName) {
try {
InputStream inputStream = fileUploadUtil.downloadFile(objectName);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=" + objectName)
.body(inputStream);
} catch (Exception e) {
return ResponseEntity.status(500).build();
}
}
/**
* 删除文件
*/
@DeleteMapping("/{objectName}")
public ResponseEntity<Map<String, Object>> deleteFile(@PathVariable String objectName) {
Map<String, Object> result = new HashMap<>();
try {
boolean deleted = fileUploadUtil.deleteFile(objectName);
result.put("success", deleted);
result.put("message", deleted ? "删除成功" : "删除失败");
return ResponseEntity.ok(result);
} catch (Exception e) {
result.put("success", false);
result.put("message", "删除失败: " + e.getMessage());
return ResponseEntity.status(500).body(result);
}
}
/**
* 检查文件是否存在
*/
@GetMapping("/exists/{objectName}")
public ResponseEntity<Map<String, Object>> checkFileExists(@PathVariable String objectName) {
Map<String, Object> result = new HashMap<>();
boolean exists = fileUploadUtil.fileExists(objectName);
result.put("exists", exists);
result.put("objectName", objectName);
return ResponseEntity.ok(result);
}
}
2. 远程文件上传示例
单个远程文件上传
java
@Service
public class RemoteFileService {
@Autowired
private MinIOFileUploadUtil fileUploadUtil;
/**
* 从外部URL采集图片并存储
*/
public String crawlAndStoreImage(String imageUrl, String productId) {
try {
// 生成唯一的文件名
String objectName = String.format("products/%s/%s.jpg",
productId,
System.currentTimeMillis()
);
// 从远程URL上传到MinIO
String minioUrl = fileUploadUtil.uploadFromRemoteUrl(imageUrl, objectName);
logger.info("图片采集成功,原始URL: {}, MinIO URL: {}", imageUrl, minioUrl);
return minioUrl;
} catch (Exception e) {
logger.error("图片采集失败,URL: {}", imageUrl, e);
throw new RuntimeException("图片采集失败", e);
}
}
}
批量远程文件上传
java
@Service
public class BatchFileService {
@Autowired
private MinIOFileUploadUtil fileUploadUtil;
/**
* 批量采集商品图片
*/
public Map<String, Object> batchCrawlProductImages(List<String> imageUrls, String productId) {
// 构建URL映射表
Map<String, String> urlMap = new HashMap<>();
for (int i = 0; i < imageUrls.size(); i++) {
String imageUrl = imageUrls.get(i);
String objectName = String.format("products/%s/image_%d.jpg", productId, i + 1);
urlMap.put(imageUrl, objectName);
}
// 批量上传
Map<String, Object> result = fileUploadUtil.batchUploadFromRemoteUrls(urlMap);
logger.info("批量采集完成,总数: {}, 成功: {}, 失败: {}",
result.get("totalCount"),
result.get("successCount"),
result.get("failCount")
);
return result;
}
}
3. 集成到业务服务
java
@Service
public class UserService {
@Autowired
private MinIOFileUploadUtil fileUploadUtil;
/**
* 用户头像上传
*/
public String uploadUserAvatar(Long userId, MultipartFile avatarFile) {
// 生成唯一的文件名:user/avatar/{userId}/avatar_{timestamp}.jpg
String objectName = String.format("user/avatar/%d/avatar_%d.%s",
userId,
System.currentTimeMillis(),
getFileExtension(avatarFile.getOriginalFilename())
);
// 上传文件
return fileUploadUtil.uploadFile(avatarFile, objectName);
}
/**
* 从外部URL采集用户头像
*/
public String crawlUserAvatar(Long userId, String avatarUrl) {
String objectName = String.format("user/avatar/%d/avatar_%d.jpg",
userId,
System.currentTimeMillis()
);
// 从远程URL上传
return fileUploadUtil.uploadFromRemoteUrl(avatarUrl, objectName);
}
/**
* 获取用户头像URL
*/
public String getUserAvatarUrl(String objectName) {
// 生成7天有效期的临时URL
return fileUploadUtil.getPresignedUrl(objectName, 7 * 24 * 3600);
}
/**
* 获取文件扩展名
*/
private String getFileExtension(String filename) {
if (filename == null) return "jpg";
int lastDot = filename.lastIndexOf('.');
return lastDot > 0 ? filename.substring(lastDot + 1) : "jpg";
}
}
❓ 常见问题与解决方案
1. 连接失败:Connection refused
问题现象:
io.minio.errors.MinioException: Connection refused
可能原因:
- MinIO 服务未启动
- 端口配置错误
- 防火墙阻止连接
解决方案:
bash
# 检查 MinIO 服务状态
docker ps | grep minio
# 检查端口是否监听
netstat -tlnp | grep 9000
# 检查防火墙设置
firewall-cmd --list-ports
2. 认证失败:Access Denied
问题现象:
io.minio.errors.ErrorResponseException: Access Denied
可能原因:
- AccessKey 或 SecretKey 配置错误
- 用户权限不足
解决方案:
- 检查配置文件中的密钥是否正确
- 在 MinIO 控制台中创建新用户并分配适当权限
3. 存储桶不存在:NoSuchBucket
问题现象:
io.minio.errors.ErrorResponseException: NoSuchBucket
解决方案:
- 工具类已实现自动创建存储桶功能
- 手动在控制台创建存储桶
- 检查存储桶名称是否正确
4. 文件名中文乱码
问题现象:上传的中文文件名在 MinIO 中显示为乱码
解决方案:
java
// 对文件名进行 URL 编码
String encodedFileName = URLEncoder.encode(file.getOriginalFilename(), "UTF-8");
String objectName = System.currentTimeMillis() + "_" + encodedFileName;
5. 大文件上传失败
问题现象:大文件上传时出现内存溢出或超时
解决方案:
yaml
# application.yml
spring:
servlet:
multipart:
max-file-size: 100MB # 单个文件最大大小
max-request-size: 500MB # 请求最大大小
mvc:
async:
request-timeout: 300000 # 异步请求超时时间(毫秒)
server:
tomcat:
max-swallow-size: 500MB # Tomcat 最大请求大小
6. 跨域问题(CORS)
问题现象:前端无法访问 MinIO 文件
解决方案:
在 MinIO 控制台配置 CORS 规则:
Allowed Origins: *
Allowed Methods: GET, PUT, POST, DELETE, HEAD
Allowed Headers: *
Expose Headers: ETag, X-Amz-Request-Id
Max Age: 86400
或通过 MC 命令行工具配置:
bash
mc alias set myminio http://localhost:9000 minioadmin minioadmin
mc admin config set myminio cors:
- http://localhost:3000
- http://localhost:8080
7. 远程文件上传失败
问题1:连接超时
问题现象:
java.net.SocketTimeoutException: connect timed out
解决方案:
java
// 增加超时时间
String minioUrl = fileUploadUtil.uploadFromRemoteUrl(
fileUrl,
objectName,
bucketName,
null,
30000, // 连接超时 30 秒
60000 // 读取超时 60 秒
);
问题2:403 Forbidden
问题现象:
java.io.IOException: 远程文件访问失败,HTTP响应码: 403
可能原因:
- 目标服务器防盗链
- 需要 Referer 或特定 Header
- IP 地址被限制
解决方案:
java
// 在 uploadFromRemoteUrl 方法中添加额外请求头
connection.setRequestProperty("Referer", "https://example.com");
connection.setRequestProperty("Accept", "image/webp,*/*");
connection.setRequestProperty("Accept-Language", "zh-CN,zh;q=0.9");
问题3:重定向问题
问题现象:某些 URL 返回 302/301 重定向,但文件未正确上传
解决方案 :
工具类已设置 connection.setInstanceFollowRedirects(true) 自动跟随重定向。如果仍有问题,可以手动处理:
java
// 手动处理重定向
while (true) {
int responseCode = connection.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_MOVED_PERM
|| responseCode == HttpURLConnection.HTTP_MOVED_TEMP
|| responseCode == HttpURLConnection.HTTP_SEE_OTHER) {
String newUrl = connection.getHeaderField("Location");
connection.disconnect();
connection = (HttpURLConnection) new URL(newUrl).openConnection();
// 重新设置参数...
} else if (responseCode == HttpURLConnection.HTTP_OK) {
break;
} else {
throw new IOException("远程文件访问失败,HTTP响应码: " + responseCode);
}
}
8. 临时 URL 无效
问题现象:生成的临时 URL 无法访问
解决方案:
- 检查系统时间是否同步(时间不同步会导致签名验证失败)
- 确认 URL 未过期
- 检查文件是否存在于指定存储桶
9. 批量上传部分失败
问题现象:批量上传时部分文件失败
解决方案:
java
// 获取批量上传结果
Map<String, Object> result = fileUploadUtil.batchUploadFromRemoteUrls(urlMap);
// 检查失败的URL列表
List<String> failedUrls = (List<String>) result.get("failedUrls");
// 重试失败的文件
if (!failedUrls.isEmpty()) {
logger.info("重试失败的文件,数量: {}", failedUrls.size());
// 实现重试逻辑...
}
10. 性能优化建议
上传优化
- 使用分片上传处理大文件
- 启用多线程并发上传
- 使用压缩减少传输数据量
远程上传优化
- 对于大批量任务,考虑使用线程池
- 实现失败重试机制
- 添加进度监控
java
// 使用线程池批量上传
ExecutorService executor = Executors.newFixedThreadPool(10);
List<Future<String>> futures = new ArrayList<>();
for (Map.Entry<String, String> entry : urlMap.entrySet()) {
final String url = entry.getKey();
final String objectName = entry.getValue();
Future<String> future = executor.submit(() -> {
return fileUploadUtil.uploadFromRemoteUrl(url, objectName);
});
futures.add(future);
}
// 等待所有任务完成
for (Future<String> future : futures) {
try {
String result = future.get();
// 处理结果...
} catch (Exception e) {
// 处理异常...
}
}
executor.shutdown();
下载优化
- 使用 CDN 加速
- 启用缓存策略
- 使用 Range 请求支持断点续传
java
// 启用分片上传示例
minioClient.uploadObject(
UploadObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.filename(localFilePath)
.contentType("application/octet-stream")
.partSize(10 * 1024 * 1024) // 10MB 分片
.build()
);
📚 总结与扩展建议
核心要点总结
- MinIO 优势:高性能、S3 兼容、轻量级、分布式、安全可靠
- 集成要点:依赖引入、配置文件、客户端初始化、工具类封装
- 功能完整 :
- 上传:MultipartFile、流式、远程URL
- 下载/URL:永久URL、临时URL
- 管理:删除、检查存在、复制、信息获取
- 生产就绪:完善的日志、异常处理、配置灵活
- 远程采集:支持HTTP/HTTPS、自动识别类型、批量处理、超时控制
扩展建议
1. 文件类型与大小限制
java
public class FileUploadValidator {
// 允许的文件类型
private static final Set<String> ALLOWED_TYPES = Set.of(
"image/jpeg", "image/png", "image/gif",
"application/pdf", "text/plain"
);
// 最大文件大小(10MB)
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024;
public static void validate(MultipartFile file) {
// 检查文件类型
if (!ALLOWED_TYPES.contains(file.getContentType())) {
throw new IllegalArgumentException("不支持的文件类型");
}
// 检查文件大小
if (file.getSize() > MAX_FILE_SIZE) {
throw new IllegalArgumentException("文件大小超过限制");
}
}
}
2. 文件预览功能
java
/**
* 文件预览服务
*/
@Service
public class FilePreviewService {
@Autowired
private MinIOFileUploadUtil fileUploadUtil;
/**
* 生成图片缩略图预览URL
*/
public String getThumbnailUrl(String objectName, int width, int height) {
// 使用 MinIO 的图片处理功能
// 格式:http://endpoint/bucket/object?process=resize/width,height
return String.format("%s/%s?process=resize/%d,%d",
fileUploadUtil.getFileUrl(objectName),
width, height);
}
/**
* 生成PDF预览URL(第一页)
*/
public String getPdfPreviewUrl(String objectName) {
// 使用 PDF.js 等工具生成预览
// 这里返回临时URL供前端处理
return fileUploadUtil.getPresignedUrl(objectName, 3600);
}
}