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。如果有其他需要了解的内容请留言,看到后会及时回复。

相关推荐
独泪了无痕19 分钟前
MongoTemplate 基础使用帮助手册
spring boot·mongodb
繁依Fanyi27 分钟前
我的 PDF 工具箱:CodeBuddy 打造 PDFMagician 的全过程记录
java·pdf·uni-app·生活·harmonyos·codebuddy首席试玩官
遗憾皆是温柔35 分钟前
MyBatis—动态 SQL
java·数据库·ide·sql·mybatis
LallanaLee1 小时前
常见面试题
java·开发语言
爱尚你19931 小时前
Java 泛型与类型擦除:为什么解析对象时能保留泛型信息?
java
全栈派森2 小时前
云存储最佳实践
后端·python·程序人生·flask
电商数据girl2 小时前
酒店旅游类数据采集API接口之携程数据获取地方美食品列表 获取地方美餐馆列表 景点评论
java·大数据·开发语言·python·json·旅游
CircleMouse2 小时前
基于 RedisTemplate 的分页缓存设计
java·开发语言·后端·spring·缓存
ktkiko112 小时前
顶层架构 - 消息集群推送方案
java·开发语言·架构
zybsjn2 小时前
后端系统做国际化改造,生成多语言包
java·python·c#