SpringBoot整合MinIO:从基础到分片上传的完整技术方案

一、MinIO简介与优势

MinIO是一款基于Go语言开发的高性能、分布式对象存储系统,完全兼容Amazon S3 API协议。作为开源项目,它采用Apache License v2.0协议,非常适合存储大容量非结构化数据,如图片、视频、日志文件等。

核心优势:

  • 轻量级:二进制文件小,部署简单,资源要求低
  • 高性能:通过优化的存储引擎和缓存机制提供极高读写性能
  • 易扩展:支持水平扩展,可通过增加节点提升存储能力和吞吐量
  • S3兼容:完全兼容Amazon S3 API,现有S3客户端和工具可直接使用
  • 开源免费:采用Apache 2.0开源协议,可自由使用和修改

二、环境准备

1. 安装MinIO服务器

Docker方式(推荐):

docker run -p 9000:9000 -p 9001:9001 \
  --name minio \
  -v /data/minio/data:/data \
  -v /data/minio/config:/root/.minio \
  -e "MINIO_ROOT_USER=minioadmin" \
  -e "MINIO_ROOT_PASSWORD=minioadmin" \
  minio/minio server /data --console-address ":9001"

Windows二进制方式:

  1. 下载MinIO二进制文件:https://min.io/download#/windows
  2. 创建目录结构:
F:\Minio\
├── bin\       (存放minio.exe)
├── data\      (存放上传的文件)
└── log\       (日志目录,可选)
  1. 创建启动脚本(start.bat):
minio.exe server F:/Minio/data --console-address ":9005" --address ":9000"

启动成功后,访问控制台:http://localhost:9001,使用默认账号密码(minioadmin/minioadmin)登录。

2. 创建存储桶(Bucket)

登录MinIO控制台后,点击”Create Bucket”按钮,输入桶名称(如file-upload-bucket)并创建。

三、SpringBoot项目集成

1. 添加Maven依赖

pom.xml中添加MinIO Java SDK依赖:

<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.9</version>
</dependency>

同时添加其他常用依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
<dependency>
    <groupId>commons-io</groupId>
    <artifactId>commons-io</artifactId>
    <version>2.11.0</version>
</dependency>

2. 配置文件

application.yml中配置MinIO连接参数:

minio:
  endpoint: http://localhost:9000
  access-key: minioadmin
  secret-key: minioadmin
  bucket-name: file-upload-bucket
  part-size: 104857600  # 分片大小(100MB)

3. 配置类

创建MinIO配置类,将MinioClient注入Spring容器:

@Configuration
public class MinioConfig {
    
    @Value("${minio.endpoint}")
    private String endpoint;
    
    @Value("${minio.access-key}")
    private String accessKey;
    
    @Value("${minio.secret-key}")
    private String secretKey;
    
    @Bean
    public MinioClient minioClient() {
        return MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .build();
    }
}

四、基础文件操作实现

1. 文件上传

@Service
public class MinioService {
    
    @Autowired
    private MinioClient minioClient;
    
    @Value("${minio.bucket-name}")
    private String bucketName;
    
    /**
     * 上传文件
     */
    public String uploadFile(MultipartFile file, String objectName) throws Exception {
        // 检查存储桶是否存在,不存在则创建
        boolean found = minioClient.bucketExists(BucketExistsArgs.builder()
                .bucket(bucketName)
                .build());
        if (!found) {
            minioClient.makeBucket(MakeBucketArgs.builder()
                    .bucket(bucketName)
                    .build());
        }
        
        // 上传文件
        minioClient.putObject(
                PutObjectArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .stream(file.getInputStream(), file.getSize(), -1)
                        .contentType(file.getContentType())
                        .build());
        
        return getFileUrl(objectName);
    }
    
    /**
     * 获取文件访问URL
     */
    public String getFileUrl(String objectName) {
        try {
            return minioClient.getPresignedObjectUrl(
                    GetPresignedObjectUrlArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .method(Method.GET)
                            .expiry(7, TimeUnit.DAYS)  // 7天有效期
                            .build());
        } catch (Exception e) {
            throw new RuntimeException("获取文件URL失败", e);
        }
    }
}

2. 文件下载

/**
 * 下载文件
 */
public void downloadFile(String objectName, HttpServletResponse response) {
    try {
        // 获取文件信息
        StatObjectResponse stat = minioClient.statObject(
                StatObjectArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .build());
        
        // 设置响应头
        response.setContentType(stat.contentType());
        response.setHeader("Content-Disposition", 
                "attachment; filename=\"" + URLEncoder.encode(objectName, "UTF-8") + "\"");
        response.setHeader("Content-Length", String.valueOf(stat.size()));
        
        // 下载文件
        try (InputStream inputStream = minioClient.getObject(
                GetObjectArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .build())) {
            IOUtils.copy(inputStream, response.getOutputStream());
        }
    } catch (Exception e) {
        throw new RuntimeException("下载文件失败", e);
    }
}

3. 文件删除

/**
 * 删除文件
 */
public void deleteFile(String objectName) {
    try {
        minioClient.removeObject(
                RemoveObjectArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .build());
    } catch (Exception e) {
        throw new RuntimeException("删除文件失败", e);
    }
}

五、大文件分片上传实现

1. 自定义MinIO客户端(关键)

由于MinIO 8.5.9版本的分片上传核心方法在S3Base类中,且权限为protected,需要自定义客户端继承才能调用:

public class CustomMinioClient extends MinioAsyncClient {
    
    public CustomMinioClient(MinioClient minioClient) {
        super(minioClient);
    }
    
    /**
     * 创建分片上传任务
     */
    public String createMultipartUpload(String bucketName, String objectName) throws Exception {
        CreateMultipartUploadResponse response = this.createMultipartUpload(
                bucketName,
                null,
                objectName,
                null,
                null,
                null);
        return response.uploadId();
    }
    
    /**
     * 上传分片
     */
    public String uploadPart(String bucketName, String objectName, String uploadId, 
                           int partNumber, InputStream inputStream, long partSize) throws Exception {
        UploadPartResponse response = this.uploadPart(
                bucketName,
                null,
                objectName,
                uploadId,
                partNumber,
                inputStream,
                partSize,
                null,
                null);
        return response.etag();
    }
    
    /**
     * 完成分片上传
     */
    public void completeMultipartUpload(String bucketName, String objectName, 
                                      String uploadId, Map<Integer, String> partETags) throws Exception {
        this.completeMultipartUpload(
                bucketName,
                null,
                objectName,
                uploadId,
                partETags,
                null,
                null);
    }
}

2. 分片上传服务

@Service
public class ChunkUploadService {
    
    @Autowired
    private MinioClient minioClient;
    
    @Value("${minio.bucket-name}")
    private String bucketName;
    
    @Value("${minio.part-size}")
    private long partSize;
    
    // 存储分片上传任务信息
    private Map<String, ChunkUploadTask> uploadTasks = new ConcurrentHashMap<>();
    
    /**
     * 初始化分片上传
     */
    public ChunkUploadInitResponse initChunkUpload(String fileName, long fileSize) {
        String uploadId = UUID.randomUUID().toString();
        int totalParts = (int) Math.ceil((double) fileSize / partSize);
        
        ChunkUploadTask task = new ChunkUploadTask();
        task.setUploadId(uploadId);
        task.setFileName(fileName);
        task.setFileSize(fileSize);
        task.setTotalParts(totalParts);
        task.setUploadedParts(new ConcurrentHashMap<>());
        
        uploadTasks.put(uploadId, task);
        
        return new ChunkUploadInitResponse(uploadId, totalParts);
    }
    
    /**
     * 上传分片
     */
    public void uploadChunk(String uploadId, int partNumber, InputStream inputStream, long chunkSize) {
        ChunkUploadTask task = uploadTasks.get(uploadId);
        if (task == null) {
            throw new RuntimeException("上传任务不存在");
        }
        
        try {
            String etag = ((CustomMinioClient) minioClient).uploadPart(
                    bucketName,
                    task.getFileName(),
                    uploadId,
                    partNumber,
                    inputStream,
                    chunkSize);
            
            task.getUploadedParts().put(partNumber, etag);
        } catch (Exception e) {
            throw new RuntimeException("分片上传失败", e);
        }
    }
    
    /**
     * 完成分片上传
     */
    public void completeChunkUpload(String uploadId) {
        ChunkUploadTask task = uploadTasks.get(uploadId);
        if (task == null) {
            throw new RuntimeException("上传任务不存在");
        }
        
        try {
            Map<Integer, String> partETags = task.getUploadedParts();
            ((CustomMinioClient) minioClient).completeMultipartUpload(
                    bucketName,
                    task.getFileName(),
                    uploadId,
                    partETags);
            
            uploadTasks.remove(uploadId);
        } catch (Exception e) {
            throw new RuntimeException("分片合并失败", e);
        }
    }
}

3. 分片上传任务实体

@Data
public class ChunkUploadTask {
    private String uploadId;
    private String fileName;
    private long fileSize;
    private int totalParts;
    private Map<Integer, String> uploadedParts;  // partNumber -> etag
}

@Data
public class ChunkUploadInitResponse {
    private String uploadId;
    private int totalParts;
    
    public ChunkUploadInitResponse(String uploadId, int totalParts) {
        this.uploadId = uploadId;
        this.totalParts = totalParts;
    }
}

六、Controller层实现

1. 基础文件操作接口

@RestController
@RequestMapping("/api/file")
public class FileController {
    
    @Autowired
    private MinioService minioService;
    
    @Autowired
    private ChunkUploadService chunkUploadService;
    
    /**
     * 文件上传
     */
    @PostMapping("/upload")
    public String uploadFile(@RequestParam("file") MultipartFile file) {
        try {
            String objectName = UUID.randomUUID().toString() + "_" + file.getOriginalFilename();
            return minioService.uploadFile(file, objectName);
        } catch (Exception e) {
            throw new RuntimeException("文件上传失败", e);
        }
    }
    
    /**
     * 文件下载
     */
    @GetMapping("/download/{fileName}")
    public void downloadFile(@PathVariable String fileName, HttpServletResponse response) {
        minioService.downloadFile(fileName, response);
    }
    
    /**
     * 删除文件
     */
    @DeleteMapping("/{fileName}")
    public void deleteFile(@PathVariable String fileName) {
        minioService.deleteFile(fileName);
    }
}

2. 分片上传接口

@RestController
@RequestMapping("/api/chunk")
public class ChunkUploadController {
    
    @Autowired
    private ChunkUploadService chunkUploadService;
    
    /**
     * 初始化分片上传
     */
    @PostMapping("/init")
    public ChunkUploadInitResponse initChunkUpload(@RequestParam String fileName, 
                                                  @RequestParam long fileSize) {
        return chunkUploadService.initChunkUpload(fileName, fileSize);
    }
    
    /**
     * 上传分片
     */
    @PostMapping("/upload/{uploadId}/{partNumber}")
    public void uploadChunk(@PathVariable String uploadId, 
                           @PathVariable int partNumber,
                           @RequestParam("file") MultipartFile file) {
        try {
            chunkUploadService.uploadChunk(uploadId, partNumber, 
                    file.getInputStream(), file.getSize());
        } catch (Exception e) {
            throw new RuntimeException("分片上传失败", e);
        }
    }
    
    /**
     * 完成分片上传
     */
    @PostMapping("/complete/{uploadId}")
    public void completeChunkUpload(@PathVariable String uploadId) {
        chunkUploadService.completeChunkUpload(uploadId);
    }
}

七、高级功能扩展

1. 秒传功能实现

/**
 * 秒传检查
 */
public boolean checkFastUpload(String fileMd5, String fileName) {
    try {
        // 检查文件是否已存在
        minioClient.statObject(
                StatObjectArgs.builder()
                        .bucket(bucketName)
                        .object(fileName)
                        .build());
        return true;
    } catch (ErrorResponseException e) {
        if (e.errorResponse().code().equals("NoSuchKey")) {
            return false;
        }
        throw new RuntimeException("检查文件失败", e);
    } catch (Exception e) {
        throw new RuntimeException("检查文件失败", e);
    }
}

2. 断点续传

通过Redis存储分片上传任务状态,实现断点续传:

@Service
public class ChunkUploadService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    private static final String UPLOAD_TASK_KEY = "chunk:upload:task:";
    
    /**
     * 获取上传任务状态
     */
    public ChunkUploadTask getUploadTask(String uploadId) {
        return (ChunkUploadTask) redisTemplate.opsForValue().get(UPLOAD_TASK_KEY + uploadId);
    }
    
    /**
     * 保存上传任务状态
     */
    private void saveUploadTask(ChunkUploadTask task) {
        redisTemplate.opsForValue().set(
                UPLOAD_TASK_KEY + task.getUploadId(),
                task,
                24,  // 24小时过期
                TimeUnit.HOURS);
    }
}

八、最佳实践与优化建议

1. 连接池优化

@Bean
public MinioClient minioClient() {
    return MinioClient.builder()
            .endpoint(endpoint)
            .credentials(accessKey, secretKey)
            .httpClient(HttpClient.newBuilder()
                    .connectionTimeout(Duration.ofSeconds(30))
                    .build())
            .build();
}

2. 异常统一处理

@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<Result<?>> handleException(Exception e) {
        log.error("系统异常", e);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(Result.error("系统异常,请稍后重试"));
    }
    
    @ExceptionHandler(MinioException.class)
    public ResponseEntity<Result<?>> handleMinioException(MinioException e) {
        log.error("MinIO操作异常", e);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(Result.error("文件服务异常,请稍后重试"));
    }
}

3. 跨域配置

@Configuration
public class CorsConfig {
    
    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOrigin("*");
        config.addAllowedMethod("*");
        config.addAllowedHeader("*");
        config.setAllowCredentials(true);
        
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        
        return new CorsFilter(source);
    }
}

九、总结

本文详细介绍了SpringBoot整合MinIO的完整方案,从基础文件操作到高级的分片上传功能。通过自定义MinIO客户端实现大文件分片上传,结合Redis实现断点续传,能够有效提升大文件上传的稳定性和用户体验。在实际项目中,建议根据业务需求进一步优化,如添加文件元数据管理、访问权限控制、文件预览等功能。

核心价值:

  • 小文件(<100MB)采用完整流传输,高效低开销
  • 大文件(≥100MB)采用分片上传+断点续传,稳定抗中断
  • 支持秒传、断点续传等企业级功能
  • 完全兼容S3协议,便于迁移和扩展

通过这套方案,可以构建出高性能、高可用的文件存储系统,满足企业级文档管理、视频存储、报表导出等业务需求。


作 者:南烛
链 接:https://www.itnotes.top/archives/1236
来 源:IT笔记
文章版权归作者所有,转载请注明出处!


上一篇
下一篇