Backend

MinioUtils

MinioUtils - 通用 MinIO 文件上传工具类

JavaMinio

MinioUtils - 通用 MinIO 文件上传工具类

目录

概述

基于 MinIO 的通用文件上传工具类,提供完整的文件管理功能。优化后具有更好的通用性、性能和可维护性。

特性

  • ✅ 多种上传方式(随机文件名、自定义文件名、动态目录)
  • ✅ 完整文件管理(上传、下载、删除、存在检查、列表查询)
  • ✅ 灵活的文件类型和大小验证
  • ✅ 批量文件操作支持
  • ✅ 预签名 URL 生成
  • ✅ 异常处理优化
  • ✅ 配置外部化
  • ✅ 高性能文件存在检查

依赖配置

Maven 依赖

<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.7</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

配置文件 (application.yml)

# MinIO 配置
minio:
  endpoint: https://your-minio-endpoint.com
  accessKey: your-access-key
  secretKey: your-secret-key
  bucketName: your-bucket-name

# 文件上传配置
file:
  upload:
    # 默认配置
    default-max-size: 10MB
    allowed-formats: 
      - jpg
      - jpeg
      - png
      - gif
      - webp
      - pdf
      - doc
      - docx
    # 特定类型配置
    rules:
      avatar:
        directory: "user/avatar/"
        max-size: 5MB
        allowed-formats: [jpg, jpeg, png, webp]
      article-cover:
        directory: "article/cover/"
        max-size: 8MB
        allowed-formats: [jpg, jpeg, png, webp]
      document:
        directory: "documents/"
        max-size: 50MB
        allowed-formats: [pdf, doc, docx, txt]

spring:
  servlet:
    multipart:
      max-file-size: 100MB
      max-request-size: 100MB

Spring Boot 配置类

@Configuration
@EnableConfigurationProperties(FileUploadConfig.class)
public class MinioConfiguration {

    @Value("${minio.endpoint}")
    private String endpoint;

    @Value("${minio.accessKey}")
    private String accessKey;

    @Value("${minio.secretKey}")
    private String secretKey;

    @Bean
    public MinioClient minioClient() {
        return MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .build();
    }
}

核心类定义

1. 文件上传配置类

package com.example.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Data
@Component
@ConfigurationProperties(prefix = "file.upload")
public class FileUploadConfig {
    
    private String defaultMaxSize = "10MB";
    private List<String> allowedFormats = List.of("jpg", "jpeg", "png", "gif");
    private Map<String, UploadRule> rules = new HashMap<>();
    
    @Data
    public static class UploadRule {
        private String directory;
        private String maxSize;
        private List<String> allowedFormats;
    }
    
    public UploadRule getRule(String type) {
        return rules.getOrDefault(type, createDefaultRule());
    }
    
    private UploadRule createDefaultRule() {
        UploadRule rule = new UploadRule();
        rule.setMaxSize(defaultMaxSize);
        rule.setAllowedFormats(allowedFormats);
        rule.setDirectory("default/");
        return rule;
    }
}

2. 文件上传异常类

package com.example.exception;

/**
 * 文件上传相关异常
 * @author haibara
 */
public class FileUploadException extends RuntimeException {
    
    private final String errorCode;
    
    public FileUploadException(String message) {
        super(message);
        this.errorCode = "FILE_UPLOAD_ERROR";
    }
    
    public FileUploadException(String message, String errorCode) {
        super(message);
        this.errorCode = errorCode;
    }
    
    public FileUploadException(String message, Throwable cause) {
        super(message, cause);
        this.errorCode = "FILE_UPLOAD_ERROR";
    }
    
    public FileUploadException(String message, String errorCode, Throwable cause) {
        super(message, cause);
        this.errorCode = errorCode;
    }
    
    public String getErrorCode() {
        return errorCode;
    }
    
    // 预定义异常类型
    public static FileUploadException fileEmpty() {
        return new FileUploadException("上传文件为空", "FILE_EMPTY");
    }
    
    public static FileUploadException fileTooLarge(String maxSize) {
        return new FileUploadException("文件大小超过限制: " + maxSize, "FILE_TOO_LARGE");
    }
    
    public static FileUploadException unsupportedFormat(List<String> supportedFormats) {
        return new FileUploadException("不支持的文件格式,支持的格式: " + supportedFormats, "UNSUPPORTED_FORMAT");
    }
    
    public static FileUploadException uploadFailed(String reason) {
        return new FileUploadException("文件上传失败: " + reason, "UPLOAD_FAILED");
    }
    
    public static FileUploadException fileNotFound(String fileName) {
        return new FileUploadException("文件不存在: " + fileName, "FILE_NOT_FOUND");
    }
}

3. 文件信息实体类

package com.example.entity;

import lombok.Data;
import lombok.Builder;
import java.time.LocalDateTime;

/**
 * 文件信息实体类
 * @author haibara
 */
@Data
@Builder
public class FileInfo {
    private String fileName;
    private String originalName;
    private String contentType;
    private long size;
    private String url;
    private String objectName;
    private LocalDateTime uploadTime;
    private String directory;
    
    public String getSizeInMB() {
        return String.format("%.2f MB", size / (1024.0 * 1024.0));
    }
    
    public String getSizeInKB() {
        return String.format("%.2f KB", size / 1024.0);
    }
}

MinioUtils 工具类

package com.example.utils;

import com.example.config.FileUploadConfig;
import com.example.entity.FileInfo;
import com.example.exception.FileUploadException;
import io.minio.*;
import io.minio.errors.*;
import io.minio.http.Method;
import io.minio.messages.DeleteError;
import io.minio.messages.DeleteObject;
import io.minio.messages.Item;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import java.io.InputStream;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;

/**
 * MinIO 文件上传工具类
 * 提供完整的文件管理功能,包括上传、下载、删除、查询等操作
 * 
 * @author haibara
 * @version 2.0
 */
@Slf4j
@Component
public class MinioUtils {

    @Autowired
    private MinioClient minioClient;

    @Autowired
    private FileUploadConfig fileUploadConfig;

    @Value("${minio.bucketName}")
    private String bucketName;

    @Value("${minio.endpoint}")
    private String endpoint;

    // 文件大小单位正则
    private static final Pattern SIZE_PATTERN = Pattern.compile("^(\\d+(?:\\.\\d+)?)\\s*(B|KB|MB|GB)$", Pattern.CASE_INSENSITIVE);

    /**
     * 上传文件 - 随机文件名
     */
    public FileInfo upload(String fileType, MultipartFile file) {
        return upload(fileType, file, null, null);
    }

    /**
     * 上传文件 - 指定文件名
     */
    public FileInfo upload(String fileType, MultipartFile file, String fileName) {
        return upload(fileType, file, fileName, null);
    }

    /**
     * 上传文件 - 完整配置
     * @param fileType  文件类型
     * @param file      上传的文件
     * @param fileName  指定文件名(不含扩展名),null时使用UUID
     * @param customDir 自定义目录,null时使用配置目录
     */
    public FileInfo upload(String fileType, MultipartFile file, String fileName, String customDir) {
        try {
            // 获取上传规则
            FileUploadConfig.UploadRule rule = fileUploadConfig.getRule(fileType);
            
            // 文件验证
            validateFile(file, rule);
            
            // 构建文件名和路径
            String fileExtension = getFileExtension(file.getOriginalFilename());
            String finalFileName = (fileName != null ? fileName : UUID.randomUUID().toString()) + "." + fileExtension;
            String directory = customDir != null ? ensureDirectoryFormat(customDir) : rule.getDirectory();
            String objectName = directory + finalFileName;
            
            // 执行上传
            uploadToMinio(file, objectName);
            
            // 构建返回信息
            return FileInfo.builder()
                    .fileName(finalFileName)
                    .originalName(file.getOriginalFilename())
                    .contentType(file.getContentType())
                    .size(file.getSize())
                    .url(buildFileUrl(objectName))
                    .objectName(objectName)
                    .uploadTime(LocalDateTime.now())
                    .directory(directory)
                    .build();
                    
        } catch (FileUploadException e) {
            throw e;
        } catch (Exception e) {
            log.error("文件上传失败: {}", e.getMessage(), e);
            throw FileUploadException.uploadFailed(e.getMessage());
        }
    }

    /**
     * 下载文件
     */
    public InputStream downloadFile(String objectName) {
        try {
            return minioClient.getObject(
                    GetObjectArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .build()
            );
        } catch (Exception e) {
            log.error("文件下载失败: {}", e.getMessage(), e);
            throw FileUploadException.fileNotFound(objectName);
        }
    }

    /**
     * 生成预签名下载URL
     */
    public String getPresignedUrl(String objectName, int expiredHours) {
        try {
            return minioClient.getPresignedObjectUrl(
                    GetPresignedObjectUrlArgs.builder()
                            .method(Method.GET)
                            .bucket(bucketName)
                            .object(objectName)
                            .expiry(expiredHours, TimeUnit.HOURS)
                            .build()
            );
        } catch (Exception e) {
            log.error("生成预签名URL失败: {}", e.getMessage(), e);
            throw new FileUploadException("生成预签名URL失败: " + e.getMessage());
        }
    }

    /**
     * 删除单个文件
     */
    public boolean deleteFile(String objectName) {
        try {
            minioClient.removeObject(
                    RemoveObjectArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .build()
            );
            log.info("文件删除成功: {}", objectName);
            return true;
        } catch (Exception e) {
            log.error("文件删除失败: {}", e.getMessage(), e);
            return false;
        }
    }

    /**
     * 批量删除文件
     */
    public Map<String, Boolean> deleteFiles(List<String> objectNames) {
        Map<String, Boolean> results = new HashMap<>();
        
        if (objectNames == null || objectNames.isEmpty()) {
            return results;
        }

        try {
            List<DeleteObject> deleteObjects = objectNames.stream()
                    .map(DeleteObject::new)
                    .toList();

            RemoveObjectsArgs args = RemoveObjectsArgs.builder()
                    .bucket(bucketName)
                    .objects(deleteObjects)
                    .build();

            Iterable<Result<DeleteError>> deleteResults = minioClient.removeObjects(args);
            
            // 初始化所有文件为成功
            objectNames.forEach(name -> results.put(name, true));
            
            // 处理删除错误
            for (Result<DeleteError> result : deleteResults) {
                try {
                    DeleteError error = result.get();
                    results.put(error.objectName(), false);
                    log.error("文件删除失败: {} - {}", error.objectName(), error.message());
                } catch (Exception e) {
                    log.error("处理删除结果时出错: {}", e.getMessage(), e);
                }
            }
            
        } catch (Exception e) {
            log.error("批量删除文件失败: {}", e.getMessage(), e);
            objectNames.forEach(name -> results.put(name, false));
        }

        return results;
    }

    /**
     * 检查文件是否存在(高性能版本)
     */
    public boolean isFileExist(String objectName) {
        try {
            minioClient.statObject(
                    StatObjectArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .build()
            );
            return true;
        } catch (ErrorResponseException e) {
            if ("NoSuchKey".equals(e.errorResponse().code())) {
                return false;
            }
            log.error("检查文件存在性失败: {}", e.getMessage(), e);
            throw new FileUploadException("检查文件存在性失败: " + e.getMessage());
        } catch (Exception e) {
            log.error("检查文件存在性失败: {}", e.getMessage(), e);
            throw new FileUploadException("检查文件存在性失败: " + e.getMessage());
        }
    }

    /**
     * 列出指定目录下的所有文件
     */
    public List<FileInfo> listFiles(String directory) {
        List<FileInfo> fileInfos = new ArrayList<>();
        String formattedDir = ensureDirectoryFormat(directory);

        try {
            ListObjectsArgs args = ListObjectsArgs.builder()
                    .bucket(bucketName)
                    .prefix(formattedDir)
                    .build();

            Iterable<Result<Item>> results = minioClient.listObjects(args);

            for (Result<Item> result : results) {
                try {
                    Item item = result.get();
                    if (!item.objectName().endsWith("/")) { // 排除目录
                        FileInfo fileInfo = FileInfo.builder()
                                .fileName(extractFileName(item.objectName()))
                                .objectName(item.objectName())
                                .size(item.size())
                                .url(buildFileUrl(item.objectName()))
                                .directory(extractDirectory(item.objectName()))
                                .build();
                        fileInfos.add(fileInfo);
                    }
                } catch (Exception e) {
                    log.error("处理文件列表项时出错: {}", e.getMessage(), e);
                }
            }
        } catch (Exception e) {
            log.error("列出文件失败: {}", e.getMessage(), e);
            throw new FileUploadException("列出文件失败: " + e.getMessage());
        }

        return fileInfos;
    }

    /**
     * 获取文件信息
     */
    public FileInfo getFileInfo(String objectName) {
        try {
            StatObjectResponse stat = minioClient.statObject(
                    StatObjectArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .build()
            );

            return FileInfo.builder()
                    .fileName(extractFileName(objectName))
                    .objectName(objectName)
                    .contentType(stat.contentType())
                    .size(stat.size())
                    .url(buildFileUrl(objectName))
                    .directory(extractDirectory(objectName))
                    .build();
                    
        } catch (Exception e) {
            log.error("获取文件信息失败: {}", e.getMessage(), e);
            throw FileUploadException.fileNotFound(objectName);
        }
    }

    // ================================ 私有方法 ================================

    // 文件验证
    private void validateFile(MultipartFile file, FileUploadConfig.UploadRule rule) {
        if (file == null || file.isEmpty()) {
            throw FileUploadException.fileEmpty();
        }

        // 验证文件大小
        long maxSizeBytes = parseSize(rule.getMaxSize());
        if (file.getSize() > maxSizeBytes) {
            throw FileUploadException.fileTooLarge(rule.getMaxSize());
        }

        // 验证文件格式
        String extension = getFileExtension(file.getOriginalFilename());
        if (!rule.getAllowedFormats().contains(extension.toLowerCase())) {
            throw FileUploadException.unsupportedFormat(rule.getAllowedFormats());
        }
    }

    // 执行MinIO上传
    private void uploadToMinio(MultipartFile file, String objectName) throws Exception {
        try (InputStream inputStream = file.getInputStream()) {
            PutObjectArgs args = PutObjectArgs.builder()
                    .bucket(bucketName)
                    .object(objectName)
                    .stream(inputStream, file.getSize(), -1)
                    .contentType(file.getContentType())
                    .build();

            minioClient.putObject(args);
            log.info("文件上传成功: {}", objectName);
        }
    }

    // 获取文件扩展名
    private String getFileExtension(String fileName) {
        if (fileName == null || !fileName.contains(".")) {
            throw new FileUploadException("文件名无效或缺少扩展名");
        }
        return fileName.substring(fileName.lastIndexOf(".") + 1);
    }

    // 确保目录格式正确(以/结尾)
    private String ensureDirectoryFormat(String directory) {
        if (directory == null || directory.trim().isEmpty()) {
            return "";
        }
        return directory.endsWith("/") ? directory : directory + "/";
    }

    // 构建文件访问URL
    private String buildFileUrl(String objectName) {
        return String.format("%s/%s/%s", endpoint, bucketName, objectName);
    }

    // 从完整路径中提取文件名
    private String extractFileName(String objectName) {
        return objectName.substring(objectName.lastIndexOf("/") + 1);
    }

    // 从完整路径中提取目录
    private String extractDirectory(String objectName) {
        int lastSlashIndex = objectName.lastIndexOf("/");
        return lastSlashIndex > 0 ? objectName.substring(0, lastSlashIndex + 1) : "";
    }

    // 解析文件大小字符串为字节数
    private long parseSize(String sizeStr) {
        if (sizeStr == null || sizeStr.trim().isEmpty()) {
            return Long.MAX_VALUE;
        }

        var matcher = SIZE_PATTERN.matcher(sizeStr.trim());
        if (!matcher.matches()) {
            throw new IllegalArgumentException("无效的文件大小格式: " + sizeStr);
        }

        double size = Double.parseDouble(matcher.group(1));
        String unit = matcher.group(2).toUpperCase();

        return switch (unit) {
            case "B" -> (long) size;
            case "KB" -> (long) (size * 1024);
            case "MB" -> (long) (size * 1024 * 1024);
            case "GB" -> (long) (size * 1024 * 1024 * 1024);
            default -> throw new IllegalArgumentException("不支持的文件大小单位: " + unit);
        };
    }
}

使用示例

1. 基本上传

@RestController
@RequestMapping("/api/file")
public class FileController {

    @Autowired
    private MinioUtils minioUtils;

    // 上传用户头像
    @PostMapping("/upload/avatar")
    public ResponseEntity<FileInfo> uploadAvatar(@RequestParam("file") MultipartFile file) {
        FileInfo fileInfo = minioUtils.upload("avatar", file);
        return ResponseEntity.ok(fileInfo);
    }

    // 上传文章封面(指定文件名)
    @PostMapping("/upload/article-cover")
    public ResponseEntity<FileInfo> uploadArticleCover(
            @RequestParam("file") MultipartFile file,
            @RequestParam("articleId") String articleId) {
        FileInfo fileInfo = minioUtils.upload("article-cover", file, "cover_" + articleId);
        return ResponseEntity.ok(fileInfo);
    }

    // 上传到自定义目录
    @PostMapping("/upload/custom")
    public ResponseEntity<FileInfo> uploadToCustomDir(
            @RequestParam("file") MultipartFile file,
            @RequestParam("directory") String directory) {
        FileInfo fileInfo = minioUtils.upload("document", file, null, directory);
        return ResponseEntity.ok(fileInfo);
    }
}

2. 文件管理

@Service
public class FileService {

    @Autowired
    private MinioUtils minioUtils;

    // 获取用户文件列表
    public List<FileInfo> getUserFiles(String userId) {
        return minioUtils.listFiles("user/" + userId + "/");
    }

    // 删除用户所有文件
    public void deleteUserFiles(String userId) {
        List<FileInfo> userFiles = getUserFiles(userId);
        List<String> objectNames = userFiles.stream()
                .map(FileInfo::getObjectName)
                .toList();
        
        Map<String, Boolean> results = minioUtils.deleteFiles(objectNames);
        results.forEach((name, success) -> {
            if (!success) {
                log.warn("删除文件失败: {}", name);
            }
        });
    }

    // 生成临时下载链接
    public String generateDownloadLink(String objectName, int hours) {
        if (!minioUtils.isFileExist(objectName)) {
            throw new FileUploadException("文件不存在");
        }
        return minioUtils.getPresignedUrl(objectName, hours);
    }
}

3. 异常处理

@ControllerAdvice
public class FileUploadExceptionHandler {

    @ExceptionHandler(FileUploadException.class)
    public ResponseEntity<ErrorResponse> handleFileUploadException(FileUploadException e) {
        ErrorResponse error = ErrorResponse.builder()
                .code(e.getErrorCode())
                .message(e.getMessage())
                .timestamp(LocalDateTime.now())
                .build();
        
        return ResponseEntity.badRequest().body(error);
    }
}

@Data
@Builder
class ErrorResponse {
    private String code;
    private String message;
    private LocalDateTime timestamp;
}

高级特性

1. 文件分片上传(大文件)

// 分片上传大文件
public FileInfo uploadLargeFile(String fileType, MultipartFile file, String fileName) {
    // 实现分片上传逻辑
    // 这里可以扩展支持大文件的分片上传
    return upload(fileType, file, fileName);
}

2. 图片处理

// 上传并生成缩略图
public FileInfo uploadWithThumbnail(String fileType, MultipartFile file) {
    // 上传原图
    FileInfo originalFile = upload(fileType, file);
    
    // 生成缩略图(需要图片处理库)
    // generateThumbnail(originalFile);
    
    return originalFile;
}

3. 文件同步

// 同步文件到其他存储
public void syncToOtherStorage(String objectName) {
    // 实现文件同步逻辑
    try (InputStream inputStream = minioUtils.downloadFile(objectName)) {
        // 同步到其他存储系统
    } catch (Exception e) {
        log.error("文件同步失败: {}", e.getMessage(), e);
    }
}

post.comments