Spring Boot 封装 MinIO 工具

MinIO 简介

MinIO 是一款高性能、开源、兼容Amazon S3 API的分布式对象存储系统,专为云原生架构和大规模非结构化数据场景设计。其核心定位是成为私有云/混合云环境中的标准存储方案,适用于从数据湖到AI/ML、容器化部署等多样化需求。

背景

为解决业务开时对接 MinIO繁琐接口对工作

目录结构

  • cdkj-minio MinIO工具

    • annotation 注解
      • EnableAutoMinio 启用自动MinIO
    • config 配置
      • MinioAutoConfiguration MinIO 自动配置
      • MinioMarkerConfiguration MinIO 标记配置
      • MinioProperties MinIO 配置读取
    • connectivity 连接库
      • MinioConfiguration MinIO 配置
    • enums 枚举库
      • ContentTypeEnums 内容类型枚举
    • MinioUtils MinIO工具库

项目介绍

POM引入包

XML 复制代码
    <dependencies>
        <!-- MinIO -->
        <dependency>
            <groupId>io.minio</groupId>
            <artifactId>minio</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>com.squareup.okhttp3</groupId>
                    <artifactId>okhttp</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>com.squareup.okhttp3</groupId>
            <artifactId>okhttp</artifactId>
        </dependency>
    </dependencies>

EnableAutoMinio 启用自动MinIO

spring boot 启动加载工具包

java 复制代码
package com.cdkjframework.minio.annotation;

import com.cdkjframework.minio.config.MinioMarkerConfiguration;
import org.springframework.context.annotation.Import;

import java.lang.annotation.*;

/**
 * @ProjectName: cdkjframework
 * @Package: com.cdkjframework.minio.annotation
 * @ClassName: EnableAutoMinio
 * @Description: java类作用描述
 * @Author: xiaLin
 * @Date: 2024/9/2 11:27
 * @Version: 1.0
 */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import({MinioMarkerConfiguration.class})
public @interface EnableAutoMinio {
}

Spring Boot 项目引入

java 复制代码
package cn.qjwxlcps.ims.annotation;

import com.cdkjframework.cloud.job.core.handler.annotation.EnableAutoCdkjJob;
import com.cdkjframework.datasource.mongodb.annotation.EnableAutoMongo;
import com.cdkjframework.minio.annotation.EnableAutoMinio;
import com.cdkjframework.swagger.annotation.EnableAutoSwagger;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Configuration;
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * @ProjectName: com.lesmarthome.interface
 * @Package: com.lesmarthome.interfaces.annotation
 * @ClassName: EnableAutoApi
 * @Description: java类作用描述
 * @Author: xiaLin
 * @Date: 2024/3/29 16:02
 * @Version: 1.0
 */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@EnableRetry
@EnableAsync
@Configuration
@EnableAutoMinio
@EnableAutoMongo
@EnableAutoCdkjJob
//@EnableAutoSwagger
@EnableDiscoveryClient
@EnableTransactionManagement
@EnableFeignClients(basePackages = {"com.*.*.client"})
public @interface EnableAutoApi {
}

MinIO 配置读取

该类主要读取用户配置信息

java 复制代码
package com.cdkjframework.minio.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Configuration;

/**
 * @ProjectName: cdkjframework
 * @Package: com.cdkjframework.minio.config
 * @ClassName: MinioProperties
 * @Description: mini配置
 * @Author: xiaLin
 * @Date: 2024/9/2 11:28
 * @Version: 1.0
 */
@Data
@RefreshScope
@Configuration
@ConfigurationProperties(prefix = "spring.minio")
public class MinioProperties {

	/**
	 * 访问域名
	 */
	private String domain;

	/**
	 * 存储端点
	 */
	private String endpoint;

	/**
	 * 端口
	 */
	private Integer port;

	/**
	 * 访问密钥
	 */
	private String accessKey;

	/**
	 * 密钥
	 */
	private String secretKey;

	/**
	 * 存储桶名称
	 */
	private String bucketName;

	/**
	 * 分片对象过期时间 单位(天)
	 */
	private Integer expiry;

	/**
	 * 断点续传有效时间,在redis存储任务的时间 单位(天)
	 */
	private Integer breakpointTime;
}

内容类型枚举

java 复制代码
package com.cdkjframework.minio.enums;

import com.cdkjframework.constant.IntegerConsts;
import com.cdkjframework.util.tool.StringUtils;

/**
 * @ProjectName: cdkjframework
 * @Package: com.cdkjframework.minio.enums
 * @ClassName: ContentTypeEnums
 * @Description: 内容类型枚举
 * @Author: xiaLin
 * @Date: 2024/9/2 13:30
 * @Version: 1.0
 */
public enum ContentTypeEnums {
	/**
	 * 默认类型
	 */
	DEFAULT("default", "application/octet-stream"),
	JPG("jpg", "image/jpeg"),
	TIFF("tiff", "image/tiff"),
	GIF("gif", "image/gif"),
	JFIF("jfif", "image/jpeg"),
	PNG("png", "image/png"),
	TIF("tif", "image/tiff"),
	ICO("ico", "image/x-icon"),
	JPEG("jpeg", "image/jpeg"),
	WBMP("wbmp", "image/vnd.wap.wbmp"),
	FAX("fax", "image/fax"),
	NET("net", "image/pnetvue"),
	JPE("jpe", "image/jpeg"),
	RP("rp", "image/vnd.rn-realpix"),
	MP4("mp4", "video/mp4");

	/**
	 * 文件名后缀
	 */
	private final String suffix;

	/**
	 * 返回前端请求头中,Content-Type具体的值
	 */
	private final String value;

	ContentTypeEnums(String suffix, String value) {
		this.suffix = suffix;
		this.value = value;
	}

	/**
	 * 根据文件后缀,获取Content-Type
	 *
	 * @param suffix 文件后缀
	 * @return 返回结果
	 */
	public static String formContentType(String suffix) {
		if (StringUtils.isNullAndSpaceOrEmpty(suffix)) {
			return DEFAULT.getValue();
		}
		int beginIndex = suffix.lastIndexOf(StringUtils.POINT) + IntegerConsts.ONE;
		suffix = suffix.substring(beginIndex);
		for (ContentTypeEnums value : ContentTypeEnums.values()) {
			if (suffix.equalsIgnoreCase(value.getSuffix())) {
				return value.getValue();
			}
		}
		return DEFAULT.getValue();
	}

	public String getSuffix() {
		return suffix;
	}

	public String getValue() {
		return value;
	}
}

MinIO 标记配置

java 复制代码
package com.cdkjframework.minio.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @ProjectName: cdkjframework
 * @Package: com.cdkjframework.minio.config
 * @ClassName: MinioMarkerConfiguration
 * @Description: Minio标记配置
 * @Author: xiaLin
 * @Date: 2024/9/2 11:28
 * @Version: 1.0
 */
@Configuration(proxyBeanMethods = false)
public class MinioMarkerConfiguration {
	@Bean
	public Marker mybatisMarker() {
		return new Marker();
	}

	public static class Marker {

	}
}

MinIO 自动配置

注入 Bean 之前需要先加载配置 MinioProperties,且基于Bean的条件 MinioMarkerConfiguration.Marker 完成。开始调用 MinioConfiguration 中的start Bean方法。

java 复制代码
package com.cdkjframework.minio.config;

import com.cdkjframework.minio.connectivity.MinioConfiguration;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;

/**
 * @ProjectName: cdkjframework
 * @Package: com.cdkjframework.minio.config
 * @ClassName: MinioAutoConfiguration
 * @Description: Minio自动配置
 * @Author: xiaLin
 * @Date: 2024/9/2 11:29
 * @Version: 1.0
 */
@Lazy(false)
@RequiredArgsConstructor
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties({
		MinioProperties.class
})
@AutoConfigureAfter({WebClientAutoConfiguration.class})
@ConditionalOnBean(MinioMarkerConfiguration.Marker.class)
public class MinioAutoConfiguration {

	/**
	 * 配置信息
	 */
	private final MinioProperties minioProperties;

	/**
	 * minio配置
	 *
	 * @return 返回配置信息
	 */
	@Bean(initMethod = "start")
	public MinioConfiguration minioConfiguration() {
		return new MinioConfiguration(minioProperties);
	}
}

MinIO 配置

java 复制代码
package com.cdkjframework.minio.connectivity;

import com.cdkjframework.minio.MinioUtils;
import com.cdkjframework.minio.config.MinioProperties;
import io.minio.MinioClient;
import org.springframework.context.annotation.Bean;

/**
 * @ProjectName: cdkjframework
 * @Package: com.cdkjframework.minio.connectivity
 * @ClassName: MinioConfiguration
 * @Description: minio配置
 * @Author: xiaLin
 * @Date: 2024/9/2 11:30
 * @Version: 1.0
 */
public class MinioConfiguration {

	/**
	 * 配置信息
	 */
	private final MinioProperties minioProperties;

	/**
	 * 构建函数
	 */
	public MinioConfiguration(MinioProperties minioProperties) {
		this.minioProperties = minioProperties;
	}

	/**
	 * 启动
	 */
	@Bean(name = "start")
	public void start() {
		MinioClient.Builder builder = MinioClient.builder();
		if (minioProperties.getPort() == null) {
			builder.endpoint(minioProperties.getEndpoint());
		} else {
			builder.endpoint(minioProperties.getEndpoint(), minioProperties.getPort(), Boolean.FALSE);
		}
		MinioClient client = builder
				// 服务端用户名 、 服务端密码
				.credentials(minioProperties.getAccessKey(), minioProperties.getSecretKey())
				.build();

		// 实例化工具类
		new MinioUtils(client);
	}
}

MinIO工具库

该工具库主要提供静态接口方法

java 复制代码
package com.cdkjframework.minio;

import com.cdkjframework.builder.ResponseBuilder;
import com.cdkjframework.constant.IntegerConsts;
import com.cdkjframework.exceptions.GlobalException;
import com.cdkjframework.exceptions.GlobalRuntimeException;
import com.cdkjframework.minio.enums.ContentTypeEnums;
import com.cdkjframework.util.tool.StringUtils;
import com.google.common.collect.Sets;
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 org.springframework.web.multipart.MultipartFile;

import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * @ProjectName: cdkjframework
 * @Package: com.cdkjframework.minio
 * @ClassName: MinioUtils
 * @Description: minio工具类
 * @Author: xiaLin
 * @Date: 2024/9/2 13:32
 * @Version: 1.0
 */
public class MinioUtils {

	/**
	 * 默认的临时文件存储桶
	 */
	private static final String DEFAULT_TEMP_BUCKET_NAME = "temp-bucket";

	/**
	 * minio客户端
	 */
	private static MinioClient client = null;

	/**
	 * 构造函数
	 */
	public MinioUtils(MinioClient client) {
		MinioUtils.client = client;
	}

	/**
	 * 将URLDecoder编码转成UTF8
	 *
	 * @param str 待转码的字符串
	 * @return 转码后的字符串
	 */
	public static String getUtf8ByURLDecoder(String str) throws UnsupportedEncodingException {
		String url = str.replaceAll("%(?![0-9a-fA-F]{2})", "%25");
		return URLDecoder.decode(url, StandardCharsets.UTF_8);
	}

	/**
	 * 把路径开头的"/"去掉,并在末尾添加"/",这个是minio对象名的样子。
	 *
	 * @param projectPath 以"/"开头、以字母结尾的路径
	 * @return 去掉开头"/"
	 */
	private static String trimHead(String projectPath) {
		return projectPath.substring(IntegerConsts.ONE);
	}

	/**
	 * 把路径开头的"/"去掉,并在末尾添加"/",这个是minio对象名的样子。
	 *
	 * @param projectPath 以"/"开头、以字母结尾的路径
	 * @return 添加结尾"/"
	 */
	private static String addTail(String projectPath) {
		return projectPath + StringUtils.BACKSLASH;
	}

	/**
	 * 获取数值的位数(用于构造临时文件名)
	 *
	 * @param number
	 * @return
	 */
	private static int countDigits(int number) {
		if (number == IntegerConsts.ZERO) {
			// 0 本身有一位
			return IntegerConsts.ONE;
		}
		int count = IntegerConsts.ZERO;
		while (number != IntegerConsts.ZERO) {
			number /= IntegerConsts.TEN;
			count++;
		}
		return count;
	}

	/**
	 * 检查是否空
	 *
	 * @param objects 待检查的对象
	 */
	private static void checkNull(Object... objects) {
		for (Object o : objects) {
			if (o == null) {
				throw new GlobalRuntimeException("Null param");
			}
			if (o instanceof String && StringUtils.isNullAndSpaceOrEmpty(o)) {
				throw new GlobalRuntimeException("Empty string");
			}
		}
	}

	/**
	 * 给定一个字符串,返回其"/"符号后面的字符串
	 *
	 * @param input 输入字符串
	 * @return 截取后的字符串
	 */
	private static String getContentAfterSlash(String input) {
		if (StringUtils.isNullAndSpaceOrEmpty(input)) {
			return StringUtils.Empty;
		}
		int slashIndex = input.indexOf(StringUtils.BACKSLASH);
		if (slashIndex != IntegerConsts.MINUS_ONE && slashIndex < input.length() - IntegerConsts.ONE) {
			return input.substring(slashIndex + IntegerConsts.ONE);
		}
		return StringUtils.Empty;
	}

	/**
	 * 判断Bucket是否存在
	 *
	 * @return true:存在,false:不存在
	 */
	private static boolean bucketExists(String bucketName) throws Exception {
		return client.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
	}

	/**
	 * 如果一个桶不存在,则创建该桶
	 */
	public static void createBucket(String bucketName) throws Exception {
		if (!bucketExists(bucketName)) {
			client.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
		}
	}

	/**
	 * 获取 Bucket 的相关信息
	 */
	public static Optional<Bucket> getBucketInfo(String bucketName) throws Exception {
		return client.listBuckets().stream().filter(b -> b.name().equals(bucketName)).findFirst();
	}

	/**
	 * 使用MultipartFile进行文件上传
	 *
	 * @param bucketName  存储桶
	 * @param file        文件
	 * @param fileName    对象名
	 * @param contentType 类型
	 * @return
	 * @throws Exception
	 */
	public static ObjectWriteResponse uploadFile(String bucketName, MultipartFile file,
																							 String fileName, ContentTypeEnums contentType) throws Exception {
		InputStream inputStream = file.getInputStream();
		return client.putObject(
				PutObjectArgs.builder()
						.bucket(bucketName)
						.object(fileName)
						.contentType(contentType.getValue())
						.stream(inputStream, inputStream.available(), IntegerConsts.MINUS_ONE)
						.build());
	}

	/**
	 * 将文件进行分片上传
	 * <p>有一个未处理的bug(虽然概率很低很低):</p>
	 * 当两个线程同时上传md5相同的文件时,由于两者会定位到同一个桶的同一个临时目录,两个线程会相互产生影响!
	 *
	 * @param file        分片文件
	 * @param currIndex   当前文件的分片索引
	 * @param totalPieces 切片总数(对于同一个文件,请确保切片总数始终不变)
	 * @param md5         整体文件MD5
	 * @return 剩余未上传的文件索引集合
	 */
	public static ResponseBuilder uploadFileFragment(MultipartFile file,
																									 Integer currIndex, Integer totalPieces, String md5) throws Exception {
		checkNull(currIndex, totalPieces, md5);
		// 把当前分片上传至临时桶
		if (!bucketExists(DEFAULT_TEMP_BUCKET_NAME)) {
			createBucket(DEFAULT_TEMP_BUCKET_NAME);
		}
		uploadFileStream(DEFAULT_TEMP_BUCKET_NAME, getFileTempPath(md5, currIndex, totalPieces), file.getInputStream());
		// 得到已上传的文件索引
		Iterable<Result<Item>> results = getFilesByPrefix(DEFAULT_TEMP_BUCKET_NAME, md5.concat(StringUtils.BACKSLASH), Boolean.FALSE);
		Set<Integer> savedIndex = Sets.newHashSet();
		boolean fileExists = Boolean.FALSE;
		for (Result<Item> item : results) {
			Integer idx = Integer.valueOf(getContentAfterSlash(item.get().objectName()));
			if (currIndex.equals(idx)) {
				fileExists = Boolean.TRUE;
			}
			savedIndex.add(idx);
		}
		// 得到未上传的文件索引
		Set<Integer> remainIndex = Sets.newTreeSet();
		for (int i = IntegerConsts.ZERO; i < totalPieces; i++) {
			if (!savedIndex.contains(i)) {
				remainIndex.add(i);
			}
		}
		if (fileExists) {
			return ResponseBuilder.failBuilder("index [" + currIndex + "] exists");
		}
		// 还剩一个索引未上传,当前上传索引刚好是未上传索引,上传完当前索引后就完全结束了。
		if (remainIndex.size() == IntegerConsts.ONE && remainIndex.contains(currIndex)) {
			return ResponseBuilder.successBuilder("completed");
		}
		return ResponseBuilder.failBuilder("index [" + currIndex + "] has been uploaded");
	}

	/**
	 * 合并分片文件,并放到指定目录
	 * 前提是之前已把所有分片上传完毕。
	 *
	 * @param bucketName  目标文件桶名
	 * @param targetName  目标文件名(含完整路径)
	 * @param totalPieces 切片总数(对于同一个文件,请确保切片总数始终不变)
	 * @param md5         文件md5
	 * @return minio原生对象,记录了文件上传信息
	 */
	public static boolean composeFileFragment(String bucketName, String targetName,
																						Integer totalPieces, String md5) throws Exception {
		checkNull(bucketName, targetName, totalPieces, md5);
		// 检查文件索引是否都上传完毕
		Iterable<Result<Item>> results = getFilesByPrefix(DEFAULT_TEMP_BUCKET_NAME, md5.concat(StringUtils.BACKSLASH), false);
		Set<String> savedIndex = Sets.newTreeSet();
		for (Result<Item> item : results) {
			savedIndex.add(item.get().objectName());
		}
		if (savedIndex.size() == totalPieces) {
			// 文件路径 转 文件合并对象
			List<ComposeSource> sourceObjectList = savedIndex.stream()
					.map(filePath -> ComposeSource.builder()
							.bucket(DEFAULT_TEMP_BUCKET_NAME)
							.object(filePath)
							.build())
					.collect(Collectors.toList());
			ObjectWriteResponse objectWriteResponse = client.composeObject(
					ComposeObjectArgs.builder()
							.bucket(bucketName)
							.object(targetName)
							.sources(sourceObjectList)
							.build());
			// 上传成功,则删除所有的临时分片文件
			List<String> filePaths = Stream.iterate(IntegerConsts.ZERO, i -> ++i)
					.limit(totalPieces)
					.map(i -> getFileTempPath(md5, i, totalPieces))
					.collect(Collectors.toList());
			Iterable<Result<DeleteError>> deleteResults = removeFiles(DEFAULT_TEMP_BUCKET_NAME, filePaths);
			// 遍历错误集合(无元素则成功)
			for (Result<DeleteError> result : deleteResults) {
				DeleteError error = result.get();
				System.err.printf("[Bigfile] 分片'%s'删除失败! 错误信息: %s", error.objectName(), error.message());
			}
			return true;
		}
		throw new GlobalException("The fragment index is not complete. Please check parameters [totalPieces] or [md5]");
	}

	/**
	 * 上传本地文件
	 *
	 * @param bucketName 存储桶
	 * @param fileName   文件名称
	 * @param filePath   本地文件路径
	 */
	public ObjectWriteResponse uploadFile(String bucketName, String fileName,
																				String filePath) throws Exception {
		return client.uploadObject(
				UploadObjectArgs.builder()
						.bucket(bucketName)
						.object(fileName)
						.filename(filePath)
						.build());
	}

	/**
	 * 通过流上传文件
	 *
	 * @param bucketName  存储桶
	 * @param fileName    文件名
	 * @param inputStream 文件流
	 */
	public static ObjectWriteResponse uploadFileStream(String bucketName, String fileName, InputStream inputStream) throws Exception {
		return client.putObject(
				PutObjectArgs.builder()
						.bucket(bucketName)
						.object(fileName)
						.stream(inputStream, inputStream.available(), IntegerConsts.MINUS_ONE)
						.build());
	}

	/**
	 * 判断文件是否存在
	 *
	 * @param bucketName 存储桶
	 * @param fileName   文件名
	 * @return true: 存在
	 */
	public static boolean isFileExist(String bucketName, String fileName) {
		boolean exist = true;
		try {
			client.statObject(StatObjectArgs.builder().bucket(bucketName).object(fileName).build());
		} catch (Exception e) {
			exist = false;
		}
		return exist;
	}

	/**
	 * 判断文件夹是否存在
	 *
	 * @param bucketName 存储桶
	 * @param folderName 目录名称:本项目约定路径是以"/"开头,不以"/"结尾
	 * @return true: 存在
	 */
	public boolean isFolderExist(String bucketName, String folderName) {
		// 去掉头"/",才能搜索到相关前缀
		folderName = trimHead(folderName);
		boolean exist = false;
		try {
			Iterable<Result<Item>> results = client.listObjects(
					ListObjectsArgs.builder().bucket(bucketName).prefix(folderName).recursive(false).build());
			for (Result<Item> result : results) {
				Item item = result.get();
				// 增加尾"/",才能匹配到目录名字
				String objectName = addTail(folderName);
				if (item.isDir() && objectName.equals(item.objectName())) {
					exist = true;
				}
			}
		} catch (Exception e) {
			exist = false;
		}
		return exist;
	}

	/**
	 * 获取路径下文件列表
	 *
	 * @param bucketName 存储桶
	 * @param prefix     文件名称
	 * @param recursive  是否递归查找,false:模拟文件夹结构查找
	 * @return 二进制流
	 */
	public static Iterable<Result<Item>> getFilesByPrefix(String bucketName, String prefix,
																												boolean recursive) {
		return client.listObjects(
				ListObjectsArgs.builder()
						.bucket(bucketName)
						.prefix(prefix)
						.recursive(recursive)
						.build());

	}

	/**
	 * 获取文件信息, 如果抛出异常则说明文件不存在
	 *
	 * @param bucketName 存储桶
	 * @param fileName   文件名称
	 */
	public StatObjectResponse getFileStatusInfo(String bucketName, String fileName) throws Exception {
		return client.statObject(
				StatObjectArgs.builder()
						.bucket(bucketName)
						.object(fileName)
						.build());
	}

	/**
	 * 根据文件前缀查询文件
	 *
	 * @param bucketName 存储桶
	 * @param prefix     前缀
	 * @param recursive  是否使用递归查询
	 * @return MinioItem 列表
	 */
	public List<Item> getAllFilesByPrefix(String bucketName,
																				String prefix,
																				boolean recursive) throws Exception {
		List<Item> list = new ArrayList<>();
		Iterable<Result<Item>> objectsIterator = client.listObjects(
				ListObjectsArgs.builder().bucket(bucketName).prefix(prefix).recursive(recursive).build());
		if (objectsIterator != null) {
			for (Result<Item> o : objectsIterator) {
				Item item = o.get();
				list.add(item);
			}
		}
		return list;
	}

	/**
	 * 批量删除文件
	 *
	 * @param bucketName        存储桶
	 * @param filePaths<String> 需要删除的文件列表
	 * @return Result
	 */
	public static Iterable<Result<DeleteError>> removeFiles(String bucketName, List<String> filePaths) {
		List<DeleteObject> objectPaths = filePaths.stream()
				.map(filePath -> new DeleteObject(filePath))
				.collect(Collectors.toList());
		return client.removeObjects(
				RemoveObjectsArgs.builder().bucket(bucketName).objects(objectPaths).build());
	}

	/**
	 * 获取文件的二进制流
	 *
	 * @param bucketName 存储桶
	 * @param fileName   文件名
	 * @return 二进制流
	 */
	public InputStream getFileStream(String bucketName, String fileName) throws Exception {
		return client.getObject(GetObjectArgs.builder().bucket(bucketName).object(fileName).build());
	}

	/**
	 * 断点下载
	 *
	 * @param bucketName 存储桶
	 * @param fileName   文件名称
	 * @param offset     起始字节的位置
	 * @param length     要读取的长度
	 * @return 二进制流
	 */
	public InputStream getFileStream(String bucketName, String fileName, long offset, long length) throws Exception {
		return client.getObject(
				GetObjectArgs.builder()
						.bucket(bucketName)
						.object(fileName)
						.offset(offset)
						.length(length)
						.build());
	}

	/**
	 * 拷贝文件
	 *
	 * @param bucketName    存储桶
	 * @param fileName      文件名
	 * @param srcBucketName 目标存储桶
	 * @param srcFileName   目标文件名
	 */
	public ObjectWriteResponse copyFile(String bucketName, String fileName,
																			String srcBucketName, String srcFileName) throws Exception {
		return client.copyObject(
				CopyObjectArgs.builder()
						.source(CopySource.builder().bucket(bucketName).object(fileName).build())
						.bucket(srcBucketName)
						.object(srcFileName)
						.build());
	}

	/**
	 * 删除文件夹(未完成)
	 *
	 * @param bucketName 存储桶
	 * @param fileName   路径
	 */
	@Deprecated
	public void removeFolder(String bucketName, String fileName) throws Exception {
		// 加尾
		fileName = addTail(fileName);
		client.removeObject(
				RemoveObjectArgs.builder()
						.bucket(bucketName)
						.object(fileName)
						.build());
	}

	/**
	 * 删除文件
	 *
	 * @param bucketName 存储桶
	 * @param fileName   文件名称
	 */
	public void removeFile(String bucketName, String fileName) throws Exception {
		// 掐头
		fileName = trimHead(fileName);
		client.removeObject(
				RemoveObjectArgs.builder()
						.bucket(bucketName)
						.object(fileName)
						.build());
	}

	/**
	 * 获得文件外链
	 *
	 * @param bucketName 存储桶
	 * @param fileName   文件名
	 * @return url 返回地址
	 * @throws Exception
	 */
	public static String getPresignedObjectUrl(String bucketName, String fileName) throws Exception {
		GetPresignedObjectUrlArgs args = GetPresignedObjectUrlArgs.builder()
				.bucket(bucketName)
				.object(fileName)
				.method(Method.GET).build();
		return client.getPresignedObjectUrl(args);
	}

	/**
	 * 获取文件外链
	 *
	 * @param bucketName 存储桶
	 * @param fileName   文件名
	 * @param expires    过期时间 <=7 秒 (外链有效时间(单位:秒))
	 * @return url
	 * @throws Exception
	 */
	public String getPresignedObjectUrl(String bucketName, String fileName, Integer expires) throws Exception {
		GetPresignedObjectUrlArgs args = GetPresignedObjectUrlArgs.builder()
				.method(Method.GET)
				.expiry(expires, TimeUnit.SECONDS)
				.bucket(bucketName)
				.object(fileName)
				.build();
		return client.getPresignedObjectUrl(args);
	}

	/**
	 * 通过文件的md5,以及分片文件的索引,构造分片文件的临时存储路径
	 *
	 * @param md5         文件md5
	 * @param currIndex   分片文件索引(从0开始)
	 * @param totalPieces 总分片
	 * @return 临时存储路径
	 */
	private static String getFileTempPath(String md5, Integer currIndex, Integer totalPieces) {

		int zeroCnt = countDigits(totalPieces) - countDigits(currIndex);
		StringBuilder name = new StringBuilder(md5);
		name.append(StringUtils.BACKSLASH);
		for (int i = IntegerConsts.ZERO; i < zeroCnt; i++) {
			name.append(IntegerConsts.ZERO);
		}
		name.append(currIndex);
		return name.toString();
	}

	/**
	 * 创建目录
	 *
	 * @param bucketName 存储桶
	 * @param folderName 目录路径:本项目约定路径是以"/"开头,不以"/"结尾
	 */
	public ObjectWriteResponse createFolder(String bucketName, String folderName) throws Exception {
		// 这是minio的bug,只有在路径的尾巴加上"/",才能当成文件夹。
		folderName = addTail(folderName);
		return client.putObject(
				PutObjectArgs.builder()
						.bucket(bucketName)
						.object(folderName)
						.stream(new ByteArrayInputStream(new byte[]{}), IntegerConsts.ZERO, IntegerConsts.MINUS_ONE)
						.build());
	}

}

资源目录下新建 META-INF\spring

该方式支持 Spring Boot 3.x

新建文件 org.springframework.boot.autoconfigure.AutoConfiguration.imports 内容

XML 复制代码
com.cdkjframework.minio.config.MinioAutoConfiguration

总结

Spring Boot 封装 MinIO 工具的核心意义在于将分布式存储能力转化为可复用的基础设施,通过标准化、模块化的设计,显著降低开发复杂度,提升系统健壮性和可维护性。这种封装不仅是技术层面的优化,更是工程实践中的最佳选择,尤其适用于需要快速迭代、高并发处理及多云兼容的现代应用架构。

更多使用或者有更好的方法欢迎连续博主,完整的代码可查看博主开源的框架:维基框架

Gitee:https://gitee.com/cdkjframework/wiki-framework

Github:https://github.com/cdkjframework/wiki-framework

若觉得博主的项目还不错,希望你能给博主 star及fork。如果有其他需要了解的内容请留言,看到后会及时回复。

相关推荐
葫芦和十三几秒前
图解 MongoDB 21|选举与 failover:Primary 是怎么选出来的
后端·mongodb·agent
GetcharZp27 分钟前
26k Star 开源内网穿透神器 NetBird,一分钟实现全球设备互联!
后端
考虑考虑1 小时前
Mybatis实现批量插入
java·后端·mybatis
咖啡八杯2 小时前
GoF设计模式——中介者模式
java·后端·spring·设计模式
lizhongxuan4 小时前
多Agent之间的区别
后端
青石路6 小时前
记一次多JDK版本问题的排查,一坑套一坑,差点没爬上来
java
杨充6 小时前
1.面向对象设计思想
后端
IT_陈寒6 小时前
Java的Date类又坑了我一次,改用时间戳真香
前端·人工智能·后端
systemPro7 小时前
2.6亿条设备数据,历史查询从超时到50ms,我做了什么
后端
要阿尔卑斯吗7 小时前
提示词优化启示:为什么“按顺序输出“比“关键度评分“更有效
后端