SpringBoot 整合 MinIO:从入门到精通的全场景文件管理方案
最近项目中需要处理大量文件存储和管理的需求,对比了 Nginx、FastDFS、阿里云 OSS 等多种方案后,最终选择了 MinIO。今天就来和大家分享一下 SpringBoot 整合 MinIO 的全过程,从为什么选择 MinIO,到各种文件操作的实现,包括简单的文件上传、批量上传、文件下载、文件预览,再到大文件分片上传和秒传功能。
一、为什么选择 MinIO?
在选择文件存储方案时,我们需要考虑多个因素,如功能、性能、成本、扩展性等。对比其他常见的文件存储方案,MinIO 具有以下优势:
1. 功能丰富
MinIO 支持标准的 S3 协议,可以与其他支持 S3 协议的工具和服务无缝集成。同时,它还提供了丰富的 API,包括文件上传、下载、预览、删除、版本控制等,满足各种文件管理需求。
2. 高性能
MinIO 专为高性能设计,采用分布式架构,可以横向扩展,支持 PB 级数据存储。在读写性能方面,MinIO 表现出色,尤其适合大文件的存储和处理。
3. 开源免费
MinIO 是开源项目,采用 AGPL v3 许可证,企业可以免费使用。对于中小企业来说,这无疑是一个很大的优势。
4. 易于部署和管理
MinIO 提供了简单易用的命令行工具和 Web 界面,部署和管理都非常方便。可以在几分钟内完成部署,并开始使用。
5. 数据安全
MinIO 支持数据加密、访问控制、多因素认证等安全功能,保障数据的安全性和隐私性。
对比其他方案
-
Nginx:主要用于静态文件服务,不支持分布式存储和大规模文件管理。
-
FastDFS:功能相对简单,缺乏统一的管理界面,扩展性有限。
-
阿里云 OSS:云服务成本较高,依赖于网络环境,不适合对数据隐私要求较高的场景。
综上所述,MinIO 是一个功能强大、性能出色、易于部署和管理的文件存储方案,非常适合作为企业级文件存储系统。
二、环境准备
1. 安装 MinIO
可以通过 Docker 快速安装 MinIO:
bash
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"
安装完成后,可以通过访问http://localhost:9001
进入 MinIO 管理界面,使用用户名minioadmin
和密码minioadmin
登录。
2. 创建 SpringBoot 项目
使用 Spring Initializr 创建一个 SpringBoot 项目,添加以下依赖:
- Spring Web
- Lombok
- MinIO Client
三、整合 MinIO
1. 添加依赖
在pom.xml
中添加 MinIO 客户端依赖:
xml
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.5</version>
</dependency>
2. 配置 MinIO 连接信息
在application.yml
中添加 MinIO 配置信息:
yaml
minio:
endpoint: http://localhost:9000
access-key: minioadmin
secret-key: minioadmin
bucket-name: test-bucket
3. 创建 MinIO 配置类
创建一个配置类,用于创建 MinIO 客户端:
kotlin
import io.minio.MinioClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@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();
}
}
四、创建 MinIO 工具类
为了方便使用 MinIO 的各种功能,我们创建一个工具类,封装 MinIO 的常用操作:
scss
import io.minio.*;
import io.minio.http.Method;
import io.minio.messages.Bucket;
import io.minio.messages.DeleteError;
import io.minio.messages.DeleteObject;
import io.minio.messages.Item;
import lombok.SneakyThrows;
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.ByteArrayInputStream;
import java.io.InputStream;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Component
public class MinioUtil {
@Autowired
private MinioClient minioClient;
@Value("${minio.bucket-name}")
private String defaultBucketName;
/**
* 检查存储桶是否存在
* @param bucketName 存储桶名称
* @return 是否存在
*/
@SneakyThrows
public boolean bucketExists(String bucketName) {
return minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
}
/**
* 创建存储桶
* @param bucketName 存储桶名称
*/
@SneakyThrows
public void makeBucket(String bucketName) {
if (!bucketExists(bucketName)) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
}
}
/**
* 获取所有存储桶
* @return 存储桶列表
*/
@SneakyThrows
public List<Bucket> listBuckets() {
return minioClient.listBuckets();
}
/**
* 删除存储桶
* @param bucketName 存储桶名称
*/
@SneakyThrows
public void removeBucket(String bucketName) {
minioClient.removeBucket(RemoveBucketArgs.builder().bucket(bucketName).build());
}
/**
* 简单文件上传
* @param file 文件
* @param bucketName 存储桶名称
* @return 文件信息
*/
@SneakyThrows
public Map<String, String> uploadFile(MultipartFile file, String bucketName) {
if (file == null || file.isEmpty()) {
return null;
}
if (!bucketExists(bucketName)) {
makeBucket(bucketName);
}
String originalFilename = file.getOriginalFilename();
String fileName = UUID.randomUUID().toString() + originalFilename.substring(originalFilename.lastIndexOf("."));
minioClient.putObject(PutObjectArgs.builder()
.bucket(bucketName)
.object(fileName)
.contentType(file.getContentType())
.stream(file.getInputStream(), file.getSize(), -1)
.build());
Map<String, String> resultMap = new HashMap<>();
resultMap.put("fileName", fileName);
resultMap.put("originalFilename", originalFilename);
resultMap.put("url", getObjectUrl(bucketName, fileName, 7));
return resultMap;
}
/**
* 简单文件上传(使用默认存储桶)
* @param file 文件
* @return 文件信息
*/
public Map<String, String> uploadFile(MultipartFile file) {
return uploadFile(file, defaultBucketName);
}
/**
* 批量文件上传
* @param files 文件列表
* @param bucketName 存储桶名称
* @return 文件信息列表
*/
public List<Map<String, String>> uploadFiles(List<MultipartFile> files, String bucketName) {
return files.stream()
.map(file -> uploadFile(file, bucketName))
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
/**
* 批量文件上传(使用默认存储桶)
* @param files 文件列表
* @return 文件信息列表
*/
public List<Map<String, String>> uploadFiles(List<MultipartFile> files) {
return uploadFiles(files, defaultBucketName);
}
/**
* 下载文件
* @param bucketName 存储桶名称
* @param objectName 对象名称
* @return 输入流
*/
@SneakyThrows
public InputStream downloadFile(String bucketName, String objectName) {
return minioClient.getObject(GetObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.build());
}
/**
* 下载文件(使用默认存储桶)
* @param objectName 对象名称
* @return 输入流
*/
public InputStream downloadFile(String objectName) {
return downloadFile(defaultBucketName, objectName);
}
/**
* 删除文件
* @param bucketName 存储桶名称
* @param objectName 对象名称
*/
@SneakyThrows
public void deleteFile(String bucketName, String objectName) {
minioClient.removeObject(RemoveObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.build());
}
/**
* 删除文件(使用默认存储桶)
* @param objectName 对象名称
*/
public void deleteFile(String objectName) {
deleteFile(defaultBucketName, objectName);
}
/**
* 批量删除文件
* @param bucketName 存储桶名称
* @param objectNames 对象名称列表
* @return 删除错误列表
*/
@SneakyThrows
public List<DeleteError> deleteFiles(String bucketName, List<String> objectNames) {
List<DeleteObject> objects = objectNames.stream()
.map(DeleteObject::new)
.collect(Collectors.toList());
Iterable<Result<DeleteError>> results = minioClient.removeObjects(RemoveObjectsArgs.builder()
.bucket(bucketName)
.objects(objects)
.build());
List<DeleteError> errors = new ArrayList<>();
for (Result<DeleteError> result : results) {
errors.add(result.get());
}
return errors;
}
/**
* 批量删除文件(使用默认存储桶)
* @param objectNames 对象名称列表
* @return 删除错误列表
*/
public List<DeleteError> deleteFiles(List<String> objectNames) {
return deleteFiles(defaultBucketName, objectNames);
}
/**
* 获取文件URL
* @param bucketName 存储桶名称
* @param objectName 对象名称
* @param expires 过期时间(天)
* @return 文件URL
*/
@SneakyThrows
public String getObjectUrl(String bucketName, String objectName, int expires) {
return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(bucketName)
.object(objectName)
.expiry(expires, TimeUnit.DAYS)
.build());
}
/**
* 获取文件URL(使用默认存储桶)
* @param objectName 对象名称
* @param expires 过期时间(天)
* @return 文件URL
*/
public String getObjectUrl(String objectName, int expires) {
return getObjectUrl(defaultBucketName, objectName, expires);
}
/**
* 检查文件是否存在
* @param bucketName 存储桶名称
* @param objectName 对象名称
* @return 是否存在
*/
@SneakyThrows
public boolean objectExists(String bucketName, String objectName) {
try {
minioClient.statObject(StatObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.build());
return true;
} catch (Exception e) {
return false;
}
}
/**
* 检查文件是否存在(使用默认存储桶)
* @param objectName 对象名称
* @return 是否存在
*/
public boolean objectExists(String objectName) {
return objectExists(defaultBucketName, objectName);
}
/**
* 列出存储桶中的所有对象
* @param bucketName 存储桶名称
* @return 对象列表
*/
@SneakyThrows
public List<Item> listObjects(String bucketName) {
Iterable<Result<Item>> results = minioClient.listObjects(ListObjectsArgs.builder()
.bucket(bucketName)
.build());
List<Item> items = new ArrayList<>();
for (Result<Item> result : results) {
items.add(result.get());
}
return items;
}
/**
* 列出存储桶中的所有对象(使用默认存储桶)
* @return 对象列表
*/
public List<Item> listObjects() {
return listObjects(defaultBucketName);
}
/**
* 创建分片上传
* @param bucketName 存储桶名称
* @param objectName 对象名称
* @return 上传ID
*/
@SneakyThrows
public String createMultipartUpload(String bucketName, String objectName) {
CreateMultipartUploadResponse response = minioClient.createMultipartUpload(CreateMultipartUploadArgs.builder()
.bucket(bucketName)
.object(objectName)
.build());
return response.result().uploadId();
}
/**
* 上传分片
* @param bucketName 存储桶名称
* @param objectName 对象名称
* @param uploadId 上传ID
* @param partNumber 分片编号
* @param stream 输入流
* @param size 大小
* @return 分片ETag
*/
@SneakyThrows
public String uploadPart(String bucketName, String objectName, String uploadId, int partNumber, InputStream stream, long size) {
UploadPartResponse response = minioClient.uploadPart(UploadPartArgs.builder()
.bucket(bucketName)
.object(objectName)
.uploadId(uploadId)
.partNumber(partNumber)
.stream(stream, size, -1)
.build());
return response.etag();
}
/**
* 完成分片上传
* @param bucketName 存储桶名称
* @param objectName 对象名称
* @param uploadId 上传ID
* @param etags 分片ETag列表
*/
@SneakyThrows
public void completeMultipartUpload(String bucketName, String objectName, String uploadId, List<String> etags) {
List<CompletePart> completeParts = new ArrayList<>();
for (int i = 0; i < etags.size(); i++) {
completeParts.add(new CompletePart(i + 1, etags.get(i)));
}
minioClient.completeMultipartUpload(CompleteMultipartUploadArgs.builder()
.bucket(bucketName)
.object(objectName)
.uploadId(uploadId)
.parts(completeParts)
.build());
}
/**
* 生成文件哈希值(用于秒传判断)
* @param file 文件
* @return 哈希值
*/
@SneakyThrows
public String generateFileHash(MultipartFile file) {
// 这里使用简单的文件大小和修改时间作为哈希值,实际应用中应使用MD5或SHA-1等算法
return file.getSize() + "-" + file.getOriginalFilename();
}
}
五、创建 Controller
接下来,我们创建一个 Controller,提供各种文件操作的接口:
less
import io.minio.messages.Item;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
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/minio")
public class MinioController {
@Autowired
private MinioUtil minioUtil;
/**
* 简单文件上传
*/
@PostMapping("/upload")
public ResponseEntity<Map<String, Object>> uploadFile(@RequestParam("file") MultipartFile file) {
Map<String, Object> result = new HashMap<>();
try {
Map<String, String> fileInfo = minioUtil.uploadFile(file);
if (fileInfo != null) {
result.put("code", 200);
result.put("message", "上传成功");
result.put("data", fileInfo);
return ResponseEntity.ok(result);
} else {
result.put("code", 500);
result.put("message", "上传失败");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result);
}
} catch (Exception e) {
result.put("code", 500);
result.put("message", "上传异常:" + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result);
}
}
/**
* 批量文件上传
*/
@PostMapping("/upload/batch")
public ResponseEntity<Map<String, Object>> uploadFiles(@RequestParam("files") List<MultipartFile> files) {
Map<String, Object> result = new HashMap<>();
try {
List<Map<String, String>> fileInfos = minioUtil.uploadFiles(files);
result.put("code", 200);
result.put("message", "上传成功");
result.put("data", fileInfos);
return ResponseEntity.ok(result);
} catch (Exception e) {
result.put("code", 500);
result.put("message", "上传异常:" + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result);
}
}
/**
* 文件下载
*/
@GetMapping("/download/{fileName}")
public ResponseEntity<byte[]> downloadFile(@PathVariable("fileName") String fileName) {
try {
InputStream inputStream = minioUtil.downloadFile(fileName);
byte[] bytes = inputStream.readAllBytes();
inputStream.close();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
headers.setContentDispositionFormData("attachment", fileName);
return new ResponseEntity<>(bytes, headers, HttpStatus.OK);
} catch (Exception e) {
return new ResponseEntity<>(null, null, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
/**
* 文件预览
*/
@GetMapping("/preview/{fileName}")
public ResponseEntity<Map<String, Object>> previewFile(@PathVariable("fileName") String fileName) {
Map<String, Object> result = new HashMap<>();
try {
String url = minioUtil.getObjectUrl(fileName, 1);
result.put("code", 200);
result.put("message", "获取成功");
result.put("url", url);
return ResponseEntity.ok(result);
} catch (Exception e) {
result.put("code", 500);
result.put("message", "获取异常:" + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result);
}
}
/**
* 删除文件
*/
@DeleteMapping("/delete/{fileName}")
public ResponseEntity<Map<String, Object>> deleteFile(@PathVariable("fileName") String fileName) {
Map<String, Object> result = new HashMap<>();
try {
minioUtil.deleteFile(fileName);
result.put("code", 200);
result.put("message", "删除成功");
return ResponseEntity.ok(result);
} catch (Exception e) {
result.put("code", 500);
result.put("message", "删除异常:" + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result);
}
}
/**
* 列出所有文件
*/
@GetMapping("/list")
public ResponseEntity<Map<String, Object>> listFiles() {
Map<String, Object> result = new HashMap<>();
try {
List<Item> items = minioUtil.listObjects();
result.put("code", 200);
result.put("message", "获取成功");
result.put("data", items);
return ResponseEntity.ok(result);
} catch (Exception e) {
result.put("code", 500);
result.put("message", "获取异常:" + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result);
}
}
/**
* 初始化分片上传
*/
@PostMapping("/multipart/init")
public ResponseEntity<Map<String, Object>> initMultipartUpload(@RequestParam("fileName") String fileName) {
Map<String, Object> result = new HashMap<>();
try {
String uploadId = minioUtil.createMultipartUpload("test-bucket", fileName);
result.put("code", 200);
result.put("message", "初始化成功");
result.put("uploadId", uploadId);
return ResponseEntity.ok(result);
} catch (Exception e) {
result.put("code", 500);
result.put("message", "初始化异常:" + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result);
}
}
/**
* 上传分片
*/
@PostMapping("/multipart/upload")
public ResponseEntity<Map<String, Object>> uploadPart(
@RequestParam("fileName") String fileName,
@RequestParam("uploadId") String uploadId,
@RequestParam("partNumber") int partNumber,
@RequestParam("file") MultipartFile file) {
Map<String, Object> result = new HashMap<>();
try {
String etag = minioUtil.uploadPart("test-bucket", fileName, uploadId, partNumber, file.getInputStream(), file.getSize());
result.put("code", 200);
result.put("message", "分片上传成功");
result.put("etag", etag);
return ResponseEntity.ok(result);
} catch (Exception e) {
result.put("code", 500);
result.put("message", "分片上传异常:" + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result);
}
}
/**
* 完成分片上传
*/
@PostMapping("/multipart/complete")
public ResponseEntity<Map<String, Object>> completeMultipartUpload(
@RequestParam("fileName") String fileName,
@RequestParam("uploadId") String uploadId,
@RequestParam("etags") List<String> etags) {
Map<String, Object> result = new HashMap<>();
try {
minioUtil.completeMultipartUpload("test-bucket", fileName, uploadId, etags);
result.put("code", 200);
result.put("message", "分片合并成功");
return ResponseEntity.ok(result);
} catch (Exception e) {
result.put("code", 500);
result.put("message", "分片合并异常:" + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result);
}
}
/**
* 文件秒传检查
*/
@PostMapping("/check")
public ResponseEntity<Map<String, Object>> checkFile(@RequestParam("file") MultipartFile file) {
Map<String, Object> result = new HashMap<>();
try {
String fileHash = minioUtil.generateFileHash(file);
// 这里应该查询数据库或缓存,检查是否存在相同哈希值的文件
// 为简化示例,直接返回不存在
boolean exists = false;
result.put("code", 200);
result.put("message", "检查成功");
result.put("exists", exists);
result.put("fileHash", fileHash);
return ResponseEntity.ok(result);
} catch (Exception e) {
result.put("code", 500);
result.put("message", "检查异常:" + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result);
}
}
}
六、大文件分片上传和秒传实现原理
1. 大文件分片上传
大文件分片上传是将一个大文件分成多个小片段,分别上传这些片段,最后在服务器端将这些片段合并成一个完整的文件。实现步骤如下:
-
前端:将文件切成固定大小的片段(如 1MB / 片),为每个片段生成唯一标识(如序号),按顺序上传这些片段。
-
后端:
- 接收前端上传的片段,保存到临时目录。
- 记录已上传的片段信息(如文件名、片段序号、ETag 等)。
- 当所有片段上传完成后,按顺序合并这些片段。
2. 秒传功能
秒传功能是指当用户上传一个文件时,系统首先检查该文件是否已经存在,如果存在则直接返回文件链接,无需重新上传。实现步骤如下:
-
前端:计算文件的哈希值(如 MD5、SHA-1),并将哈希值发送给后端。
-
后端:
- 根据哈希值查询数据库或缓存,检查是否存在相同哈希值的文件。
- 如果存在,返回文件链接;如果不存在,通知前端正常上传。
七、测试与验证
1. 简单文件上传测试
使用 Postman 或其他工具,向/api/minio/upload
接口发送 POST 请求,上传一个文件,验证是否能成功上传并返回文件信息。
2. 批量文件上传测试
向/api/minio/upload/batch
接口发送 POST 请求,上传多个文件,验证是否能成功批量上传。
3. 文件下载测试
访问/api/minio/download/{fileName}
接口,验证是否能成功下载文件。
4. 文件预览测试
访问/api/minio/preview/{fileName}
接口,验证是否能获取文件预览链接。
5. 大文件分片上传测试
使用前端工具(如 webuploader、plupload 等)实现大文件分片上传功能,调用后端提供的分片上传接口,验证大文件是否能成功上传。
6. 秒传功能测试
上传一个文件,记录文件哈希值,再次上传相同文件,验证是否能秒传成功。