Spring Boot 3 整合 MinIO 实现分布式文件存储

发布于:2025-03-06 ⋅ 阅读:(16) ⋅ 点赞:(0)

引言

文件存储已成为一个做任何应用都不可回避的需求。传统的单机文件存储方案在面对大规模数据和高并发访问时往往力不从心,而分布式文件存储系统则提供了更好的解决方案。本篇文章我将基于Spring Boot 3 为大家讲解如何基于MinIO来实现分布式文件存储。

分布式存储的出现

在探讨核心内容之前,我们不妨先回顾分布式存储技术是如何伴随系统架构演变发展的。在单体架构早期,文件直接存储于应用服务器中,这种方式简单直接,存取便捷。然而,随着业务规模扩大和用户量激增,系统架构逐步向分布式或微服务方向演进。此时,若仍将文件存储在应用服务器中,在负载均衡机制下可能导致文件访问异常 —— 用户上传的文件可能因路由到其他服务节点而无法访问。
在这里插入图片描述面对这个挑战,我们可以借鉴"分层解决"的架构思想:将文件存储从应用服务中剥离,集中在独立的存储服务中统一管理。这便是分布式文件存储系统的雏形。

技术选型

在了解了分布式存储的演进背景后,让我们来梳理当前主流的分布式存储解决方案。

其他

  • FastDFS -> 架构老旧,社区活跃度低,文档资料匮乏
  • Ambry -> 过度依赖 LinkedIn 技术栈,通用性不足
  • MooseFS -> 部署配置繁琐,运维门槛高
  • MogileFS -> 性能一般,扩展性受限
  • LeoFS -> 更新维护缓慢,生态系统不完善
  • openstack -> 架构复杂重量级,不适合轻量级应用
  • TFS -> 主要服务于阿里内部,外部支持有限
  • ceph -> 学习曲线陡峭,配置调优复杂
  • GlusterFS -> 架构复杂,问题定位困难
  • OSS -> 商业收费服务,成本随数据量增长

✨MinIO

MinIO 是一款轻量级的分布式对象存储系统,完全兼容 Amazon S3 云存储服务接口。其部署维护简单,性能卓越,成为我们的首选方案。

MinIO安装

MinIO 提供了多种部署方式,包括单机部署和分布式部署。本文主要关注 Spring BootMinIO 的整合实践,因此我们选择使用Docker(Ps:没安装Docker的同学速速去安装,或者用别的方式只要本地部署的能跑就行)进行快速部署。

首先,通过命令拉取镜像。

docker pull minio/minio

接着在 本地创建一个存储文件的映射目录 D:\minio\data(Ps:我当前演示的环境是win系统,大家根据自己的操作系统建个目录就行),使用以下命令启动 MinIO:

🔑 补充一个小细节:MinIO 的安全限制要求用户名长度至少需要 3 个字符,密码长度至少需要 8 个字符。

    docker run -d --name minio -p 9000:9000 -p 9001:9001 -v D:\minio\data:/data -e "MINIO_ROOT_USER=root" -e "MINIO_ROOT_PASSWORD=12345678" minio/minio server /data --console-address ":9001" --address ":9000"

参数说明:

  • -d: 后台运行容器
  • --name: 容器名称
  • -p: 端口映射,9000用于API访问,9001用于控制台访问
  • -v: 目录映射,将本地目录映射到容器的 /data
  • -e: 环境变量,设置管理员账号和密码
  • --console-address: 指定控制台端口
  • --restart=always: 容器自动重启策略
  • --address ":9000": 显式指定 API 端口

运行成功后访问 http://localhost:9001,使用执行命令中的凭据(Ps:大家在使用时可以修改为自己的用户名和密码)登录:

  • 用户名:root
  • 密码:12345678
    在这里插入图片描述登录系统后,界面会提示创建。熟悉云服务商OSS服务的读者对此概念应该不陌生。对初次接触的读者,可以将理解为一个命名空间或文件夹,您可以创建多个,每个内还能包含多层级的文件夹和文件。

在这里插入图片描述这里我演示下控制台如何建桶和上传文件,方便大家理解文件在MinIO上的存储结构。
在这里插入图片描述只需要输入名称就可以,建好之后可以看到的使用状态。
在这里插入图片描述点击它进入的内部,这里大家需要关注一个设置- Access Policy,默认是Private。这个设置需要根据业务的实际情况来,如果你的业务是需要提供一些不需要鉴权的公共访问的文件,就设为public;反之,就保持private。我这里把它修改为public
在这里插入图片描述然后点击右上角的上传按钮进入上传页可以向桶内上传文件。
在这里插入图片描述上传成功后可以在桶内看到文件。
在这里插入图片描述点击文件可查看详情,支持预览、删除、分享等多种功能。这些操作较为直观,安装后各位读者可自行体验。本文重点关注不在控制台的操作,就不做过多赘述了。

🔑这里再强调一点:存储在里的文件通过API访问的端口和控制台是不一样的。如果你对这里感觉迷惑,可以回看一下上面我贴上的docker运行命令里配置了两个端口-90009001。如果要通过API访问查看这个文件的话,通过拼接地址/端口号/桶名/文件路径查看,那么刚测试上传的文件的访问API就是http://localhost:9000/test/1.gif,在浏览器地址栏输入后可以看到。
在这里插入图片描述# Spring Boot整合MinIO

这部分对于新建项目就不赘述了,直接说下我使用的 Spring boot 版本为3.2.3,供大家参考。

1.引入依赖

pom.xml引入minIO的依赖,版本大家自己使用你当前最新的版本即可。

<!-- minio -->
<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>${latest.version}</version>
</dependency>

2.添加配置

在yml配置文件中配置连接信息。

# minIO配置
minio:
  endpoint: http://127.0.0.1:9000     # MinIO服务地址
  fileHost: http://127.0.0.1:9000     # 文件地址host
  bucketName: wechat                  # 存储桶bucket名称
  accessKey: root                     # 用户名
  secretKey: 12345678                 # 密码

3.编写配置类

import com.pitayafruits.utils.MinIOUtils;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@Data
public class MinIOConfig {

    @Value("${minio.endpoint}")
    private String endpoint;
    @Value("${minio.fileHost}")
    private String fileHost;
    @Value("${minio.bucketName}")
    private String bucketName;
    @Value("${minio.accessKey}")
    private String accessKey;
    @Value("${minio.secretKey}")
    private String secretKey;

    @Bean
    public MinIOUtils creatMinioClient() {
        return new MinIOUtils(endpoint, fileHost, bucketName, accessKey, secretKey);
    }
}

4.引入工具类

这个工具类封装了MinIO的核心功能,为您提供了很多开箱即用的功能。通过引入它,可以轻松实现文件上传、下载等操作,让大家将更多精力集中在业务开发上。

import io.minio.*;
import io.minio.http.Method;
import io.minio.messages.Bucket;
import io.minio.messages.DeleteObject;
import io.minio.messages.Item;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.multipart.MultipartFile;

import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Optional;

/**
 * MinIO工具类
 */
@Slf4j
public class MinIOUtils {

    private static MinioClient minioClient;

    private static String endpoint;
    private static String fileHost;
    private static String bucketName;
    private static String accessKey;
    private static String secretKey;

    private static final String SEPARATOR = "/";

    public MinIOUtils() {
    }

    public MinIOUtils(String endpoint, String fileHost, String bucketName, String accessKey, String secretKey) {
        MinIOUtils.endpoint = endpoint;
        MinIOUtils.fileHost = fileHost;
        MinIOUtils.bucketName = bucketName;
        MinIOUtils.accessKey = accessKey;
        MinIOUtils.secretKey = secretKey;
        createMinioClient();
    }

    /**
     * 创建基于Java端的MinioClient
     */
    public void createMinioClient() {
        try {
            if (null == minioClient) {
                log.info("开始创建 MinioClient...");
                minioClient = MinioClient
                                .builder()
                                .endpoint(endpoint)
                                .credentials(accessKey, secretKey)
                                .build();
                createBucket(bucketName);
                log.info("创建完毕 MinioClient...");
            }
        } catch (Exception e) {
            log.error("MinIO服务器异常:{}", e);
        }
    }

    /**
     * 获取上传文件前缀路径
     * @return
     */
    public static String getBasisUrl() {
        return endpoint + SEPARATOR + bucketName + SEPARATOR;
    }

    /******************************  Operate Bucket Start  ******************************/

    /**
     * 启动SpringBoot容器的时候初始化Bucket
     * 如果没有Bucket则创建
     * @throws Exception
     */
    private static void createBucket(String bucketName) throws Exception {
        if (!bucketExists(bucketName)) {
            minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
        }
    }

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


    /**
     * 获得Bucket的策略
     * @param bucketName
     * @return
     * @throws Exception
     */
    public static String getBucketPolicy(String bucketName) throws Exception {
        String bucketPolicy = minioClient
                                .getBucketPolicy(
                                        GetBucketPolicyArgs
                                                .builder()
                                                .bucket(bucketName)
                                                .build()
                                );
        return bucketPolicy;
    }


    /**
     * 获得所有Bucket列表
     * @return
     * @throws Exception
     */
    public static List<Bucket> getAllBuckets() throws Exception {
        return minioClient.listBuckets();
    }

    /**
     * 根据bucketName获取其相关信息
     * @param bucketName
     * @return
     * @throws Exception
     */
    public static Optional<Bucket> getBucket(String bucketName) throws Exception {
        return getAllBuckets().stream().filter(b -> b.name().equals(bucketName)).findFirst();
    }

    /**
     * 根据bucketName删除Bucket,true:删除成功; false:删除失败,文件或已不存在
     * @param bucketName
     * @throws Exception
     */
    public static void removeBucket(String bucketName) throws Exception {
        minioClient.removeBucket(RemoveBucketArgs.builder().bucket(bucketName).build());
    }

    /******************************  Operate Bucket End  ******************************/


    /******************************  Operate Files Start  ******************************/

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

    /**
     * 判断文件夹是否存在
     * @param bucketName 存储桶
     * @param objectName 文件夹名称
     * @return
     */
    public static boolean isFolderExist(String bucketName, String objectName) {
        boolean exist = false;
        try {
            Iterable<Result<Item>> results = minioClient.listObjects(
                    ListObjectsArgs.builder().bucket(bucketName).prefix(objectName).recursive(false).build());
            for (Result<Item> result : results) {
                Item item = result.get();
                if (item.isDir() && objectName.equals(item.objectName())) {
                    exist = true;
                }
            }
        } catch (Exception e) {
            exist = false;
        }
        return exist;
    }

    /**
     * 根据文件前置查询文件
     * @param bucketName 存储桶
     * @param prefix 前缀
     * @param recursive 是否使用递归查询
     * @return MinioItem 列表
     * @throws Exception
     */
    public static List<Item> getAllObjectsByPrefix(String bucketName,
                                                   String prefix,
                                                   boolean recursive) throws Exception {
        List<Item> list = new ArrayList<>();
        Iterable<Result<Item>> objectsIterator = minioClient.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 objectName 文件名
     * @return 二进制流
     */
    public static InputStream getObject(String bucketName, String objectName) throws Exception {
        return minioClient.getObject(GetObjectArgs.builder().bucket(bucketName).object(objectName).build());
    }

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

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

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

    /**
     * 上传本地文件
     * @param bucketName 存储桶
     * @param objectName 对象名称
     * @param fileName 本地文件路径
     */
    public static String uploadFile(String bucketName, String objectName,
                                                 String fileName, boolean needUrl) throws Exception {
        minioClient.uploadObject(
                UploadObjectArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .filename(fileName)
                        .build());
        if (needUrl) {
            String imageUrl = fileHost
                    + "/"
                    + bucketName
                    + "/"
                    + objectName;
            return imageUrl;
        }
        return "";
    }

    /**
     * 通过流上传文件
     *
     * @param bucketName 存储桶
     * @param objectName 文件对象
     * @param inputStream 文件流
     */
    public static ObjectWriteResponse uploadFile(String bucketName, String objectName, InputStream inputStream) throws Exception {
        return minioClient.putObject(
                PutObjectArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .stream(inputStream, inputStream.available(), -1)
                        .build());
    }

    public static String uploadFile(String bucketName, String objectName, InputStream inputStream, boolean needUrl) throws Exception {
        minioClient.putObject(
                PutObjectArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .stream(inputStream, inputStream.available(), -1)
                        .build());
        if (needUrl) {
            String imageUrl = fileHost
                    + "/"
                    + bucketName
                    + "/"
                    + objectName;
            return imageUrl;
        }
        return "";
    }

    /**
     * 创建文件夹或目录
     * @param bucketName 存储桶
     * @param objectName 目录路径
     */
    public static ObjectWriteResponse createDir(String bucketName, String objectName) throws Exception {
        return minioClient.putObject(
                PutObjectArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .stream(new ByteArrayInputStream(new byte[]{}), 0, -1)
                        .build());
    }

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

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

    /**
     * 删除文件
     * @param bucketName 存储桶
     * @param objectName 文件名称
     */
    public static void removeFile(String bucketName, String objectName) throws Exception {
        minioClient.removeObject(
                RemoveObjectArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .build());
    }

    /**
     * 批量删除文件
     * @param bucketName 存储桶
     * @param keys 需要删除的文件列表
     * @return
     */
    public static void removeFiles(String bucketName, List<String> keys) {
        List<DeleteObject> objects = new LinkedList<>();
        keys.forEach(s -> {
            objects.add(new DeleteObject(s));
            try {
                removeFile(bucketName, s);
            } catch (Exception e) {
                log.error("批量删除失败!error:{}",e);
            }
        });
    }

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

    /**
     * 获得文件外链
     * @param bucketName
     * @param objectName
     * @return url
     * @throws Exception
     */
    public static String getPresignedObjectUrl(String bucketName, String objectName) throws Exception {
        GetPresignedObjectUrlArgs args = GetPresignedObjectUrlArgs.builder()
                                                                    .bucket(bucketName)
                                                                    .object(objectName)
                                                                    .method(Method.GET).build();
        return minioClient.getPresignedObjectUrl(args);
    }

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

    /******************************  Operate Files End  ******************************/


}

5.开发测试

我刚好在做练手项目,这里写个上传头像的接口。

import com.pitayafruits.base.BaseInfoProperties;
import com.pitayafruits.config.MinIOConfig;
import com.pitayafruits.grace.result.GraceJSONResult;
import com.pitayafruits.grace.result.ResponseStatusEnum;
import com.pitayafruits.utils.MinIOUtils;
import jakarta.annotation.Resource;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("file")
public class FileController extends BaseInfoProperties {

    @Resource
    private MinIOConfig minIOConfig;

    @PostMapping("uploadFace")
    public GraceJSONResult upload(@RequestParam MultipartFile file,
                                 String userId) throws Exception {
        if (StringUtils.isBlank(userId)) {
            return GraceJSONResult.errorCustom(ResponseStatusEnum.FILE_UPLOAD_FAILD);
        }

        String filename = file.getOriginalFilename();

        if (StringUtils.isBlank(filename)) {
            return GraceJSONResult.errorCustom(ResponseStatusEnum.FILE_UPLOAD_FAILD);
        }

        filename = "face" +  "/" + userId + "/" + filename;

        MinIOUtils.uploadFile(minIOConfig.getBucketName(), filename, file.getInputStream());

        String faceUrl = minIOConfig.getFileHost()
                + "/"
                + minIOConfig.getBucketName()
                + "/"
                + filename;

        return GraceJSONResult.ok(faceUrl);
    }

}

可以看到通过工具类只需要一行代码就可以实现上传文件,我们只需要在调用的时候做好文件的业务隔离即可。完成了接口的开发,这里我来通过Apifox调用测试一下。
在这里插入图片描述通过浏览器访问返回的图片链接会自动下载,我们再登录控制台看对应的桶下的这个路径,也可以看到这个文件。
在这里插入图片描述

小结

我们在集成第三方服务时应遵循一个核心原则:将API操作封装成通用工具类。这不仅让MinIO的集成更加优雅,也让代码具备更好的复用性和可维护性。这种思维方式同样适用于其他第三方服务的对接。