minio的使用

发布时间 2023-03-23 14:50:34作者: kisshappyboy

minio介绍

1.1.minio是什么?

MinIO 是一个基于Apache License v2.0开源协议的对象存储服务。它兼容亚马逊S3云存储服务接口,非常适合于存储大容量非结构化的数据,例如图片、视频、日志文件、备份数据和容器/虚拟机镜像等,而一个对象文件可以是任意大小,从几kb到最大5T不等。

​ MinIO是一个非常轻量的服务,可以很简单的和其他应用的结合,类似 NodeJS, Redis 或者 MySQL。

1.2.minio单节点安装部署

1.官方文档部署快速入门

部署完成后即可请求 服务器ip:默认9000端口 进行访问,默认账号密码:minioadmin

2.官方文档分布式节点搭建快速入门

部署完成后即可请求 任意节点服务器ip:默认9000端口 进行访问,默认账号密码:minioadmin

三. minio -JavaClient

3.1.maven

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

3.2.配置类编写

考虑到minio这种通用类型的文件中心组建,各个业务端都会用到,那么可以吧minio加载的通用配置与文件操作的相关代码抽象成一个starter,业务应用如果有需要直接引用我们定义的starter,增加必要的配置就可以直接使用了。

对于starter制作与原理不太清楚的,可以阅读博主的手把手教你如何编写springboot中starter

​ 贴上配置类代码

mport lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

/**
 * minio配置类
 *
 * @author baiyan
 */
@Data
@ConfigurationProperties(prefix = "spring.minio")
public class MinioConfig {
    /**
     * ip:minio地址,分布式节点情况下推荐配置一个nginx路由,转接给nginx的负载均衡
     */
    private String endpoint;

    /**
     * 端口:minio地址,分布式节点情况推荐配置一个nginx路由,转接给nginx的负载均衡
     */
    private int port;

    /**
     * 账号
     */
    private String accessKey;

    /**
     * 秘钥
     */
    private String secretKey;

    /**
     * 如果是true,则用的是https而不是http,默认值是true
     */
    private Boolean secure;

    /**
     * 桶名称,默认为baiyan
     */
    private String bucketName = "baiyan";

    /**
     * 是否开启nginx路由,与nginxLoadUrl对应
     */
    private Boolean nginxLoadUrlEnable = false;

    /**
     * 预览的url在nginx中的前缀,minio中生成的文件预览或者下载的url是直接展示成ip:端口形式的,这个是不安全的,需要在nginx中做一层路由。保证安全性,默认不开启。
     */
    private String nginxLoadUrl = "api/9c16ff1ecec";
}

3.3.工具类

import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.MD5;
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 lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

/**
 * minio工具类
 *
 * @author baiyan
 */
@Slf4j
public class MinioUtil {

    @Autowired
    private MinioClient minioClient;

    @Autowired
    private NginxConfig nginxConfig;

    @Autowired
    private MinioConfig minioConfig;

    /**
     * 默认url过期时间
     */
    public static final int DEFAULT_EXPIRY_TIME = 7 * 24 * 3600;

    /**
     * 默认最大文件上传为500M
     */
    public static final int MAX_UPLOAD_FILE_SIZE = 1024*1024*500;

    /**
     * 检查存储桶是否存在
     *
     * @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)) {
            MakeBucketArgs.builder().bucket(bucketName).build();
        }
    }

    /**
     * 列出所有存储桶
     *
     * @return
     */
    @SneakyThrows
    public List<Bucket> listBuckets() {
        return minioClient.listBuckets();
    }

    /**
     * 列出所有存储桶名称
     *
     * @return
     */
    @SneakyThrows
    public List<String> listBucketNames() {
        List<Bucket> bucketList = listBuckets();
        return CollectionUtils.isNotEmpty(bucketList) ?
                bucketList.stream().map(Bucket::name).collect(Collectors.toList()) : new ArrayList<>();
    }

    /**
     * 删除存储桶
     *
     * @param bucketName 存储桶名称
     * @return
     */
    @SneakyThrows
    public boolean removeBucket(String bucketName) {
        boolean flag = bucketExists(bucketName);
        if (flag) {
            Iterable<Result<Item>> myObjects = listObjects(bucketName);
            for (Result<Item> result : myObjects) {
                Item item = result.get();
                // 有对象文件,则删除失败
                if (item.size() > 0) {
                    return false;
                }
            }
            // 删除存储桶,注意,只有存储桶为空时才能删除成功。
            minioClient.removeBucket(RemoveBucketArgs.builder().bucket(bucketName).build());
            flag = bucketExists(bucketName);
            if (!flag) {
                return true;
            }
        }
        return false;
    }

    /**
     * 列出存储桶中的所有对象名称
     *
     * @param bucketName 存储桶名称
     * @return
     */
    @SneakyThrows
    public List<String> listObjectNames(String bucketName) {
        List<String> listObjectNames = new ArrayList<>();
        boolean flag = bucketExists(bucketName);
        if (flag) {
            Iterable<Result<Item>> myObjects = listObjects(bucketName);
            for (Result<Item> result : myObjects) {
                Item item = result.get();
                listObjectNames.add(item.objectName());
            }
        }
        return listObjectNames;
    }

    /**
     * 列出存储桶中的所有对象
     *
     * @param bucketName 存储桶名称
     * @return
     */
    @SneakyThrows
    public Iterable<Result<Item>> listObjects(String bucketName) {
        boolean flag = bucketExists(bucketName);
        if (flag) {
            return minioClient.listObjects(ListObjectsArgs.builder().bucket(bucketName).build());
        }
        return null;
    }

    /**
     * 获取文件md5
     *
     * @param stream
     * @return
     */
    public String getFileMd5(InputStream stream){
        return MD5.create().digestHex(stream);
    }

    /**
     * 获取文件md5
     *
     * @param multipartFile
     * @return
     */
    @SneakyThrows
    public String getFileMd5(MultipartFile multipartFile) {
        return this.getFileMd5(multipartFile.getInputStream());
    }

    /**
     * 文件上传
     *    
     * @param bucketName
     * @param multipartFile
     */
    @SneakyThrows
    public String putObject(String bucketName, MultipartFile multipartFile) {
        ValidationUtil.isTrue(multipartFile.getSize()<=MAX_UPLOAD_FILE_SIZE,"minio.upload.file.is.too.big");
        ValidationUtil.isTrue(bucketExists(bucketName),"minio.bucket.is.not.exist");
        String objectName = this.getFileMd5(multipartFile);
        return this.putObject(bucketName,multipartFile.getInputStream(),objectName,multipartFile.getContentType());
    }

    /**
     * 通过InputStream上传对象,远端文件中心中存储的的文件名为上传流文件的md5值,保证远端存储的文件唯一性,业务端使用的使用可以根据md5进行文件的预览url获取或者流获取。
     *
     * @param bucketName 存储桶名称
     * @param stream 要上传的流
     * @param objectName minio中文件名:取MD5
     * @param contentType 文件类型
     * @return
     */
    @SneakyThrows
    public String putObject(String bucketName, InputStream stream,String objectName,String contentType) {
        ValidationUtil.isTrue(bucketExists(bucketName),"minio.bucket.is.not.exist");
        ValidationUtil.isTrue(StringUtil.isNotBlank(objectName),"minio.objectName.is.not.exist");
        ObjectWriteResponse objectWriteResponse = minioClient.putObject(
                PutObjectArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .contentType(contentType)
                        .stream(stream, stream.available(), -1)
                        .build()
        );
        return objectWriteResponse.object();
    }

    /**
     * 以流的形式获取一个文件对象
     *
     * @param bucketName 存储桶名称
     * @param objectName 存储桶里的对象名称
     * @return
     */
    @SneakyThrows
    public InputStream getObject(String bucketName, String objectName) {
        boolean flag = bucketExists(bucketName);
        if (flag) {
            ObjectStat statObject = statObject(bucketName, objectName);
            if (statObject != null && statObject.length() > 0) {
                return minioClient.getObject(GetObjectArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .build()
                );
            }
        }
        return null;
    }

    /**
     * 以流的形式获取一个文件对象(断点下载)
     *
     * @param bucketName 存储桶名称
     * @param objectName 存储桶里的对象名称
     * @param offset     起始字节的位置
     * @param length     要读取的长度 (可选,如果无值则代表读到文件结尾)
     * @return
     */
    @SneakyThrows
    public InputStream getObject(String bucketName, String objectName, long offset, Long length) {
        boolean flag = bucketExists(bucketName);
        if (flag) {
            ObjectStat statObject = statObject(bucketName, objectName);
            if (statObject != null && statObject.length() > 0) {
                return minioClient.getObject(GetObjectArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .offset(offset)
                        .length(length)
                        .build()
                );
            }
        }
        return null;
    }

    /**
     * 删除一个对象
     *
     * @param bucketName 存储桶名称
     * @param objectName 存储桶里的对象名称
     */
    @SneakyThrows
    public boolean removeObject(String bucketName, String objectName) {
        boolean flag = bucketExists(bucketName);
        if (flag) {
            minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(objectName).build());
            return true;
        }
        return false;
    }

    /**
     * 删除指定桶的多个文件对象,返回删除错误的对象列表,全部删除成功,返回空列表
     *
     * @param bucketName  存储桶名称
     * @param objectNames 含有要删除的多个object名称的迭代器对象
     * @return
     */
    @SneakyThrows
    public List<String> removeObject(String bucketName, List<String> objectNames) {
        ValidationUtil.isTrue(CollectionUtils.isNotEmpty(objectNames),"minio.delete.object.name.can.not.empty");
        List<String> deleteErrorNames = new ArrayList<>();
        boolean flag = bucketExists(bucketName);
        if (flag) {
            List<DeleteObject> objects = objectNames.stream().map(DeleteObject::new).collect(Collectors.toList());
            Iterable<Result<DeleteError>> results = minioClient
                    .removeObjects(RemoveObjectsArgs.builder().bucket(bucketName).objects(objects).build());
            for (Result<DeleteError> result : results) {
                DeleteError error = result.get();
                deleteErrorNames.add(error.objectName());
            }
        }
        return deleteErrorNames;
    }

    /**
     * 生成一个给HTTP GET请求用的presigned URL。
     * 浏览器/移动端的客户端可以用这个URL进行下载,即使其所在的存储桶是私有的。这个presigned URL可以设置一个失效时间,默认值是7天。
     *
     * @param bucketName 存储桶名称
     * @param objectName 存储桶里的对象名称
     * @param expires    失效时间(以秒为单位),默认是7天,不得大于七天
     * @return
     */
    @SneakyThrows
    public String preSignedGetObject(String bucketName, String objectName, Integer expires) {
        boolean flag = bucketExists(bucketName);
        String url = "";
        if (flag) {
            url = minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
                    .method(Method.GET)
                    .bucket(bucketName)
                    .object(objectName)
                    .expiry(Objects.isNull(expires) ? DEFAULT_EXPIRY_TIME : expires)
                    .build()
            );
        }
        if(StringUtil.isNotBlank(url)){
            String sourceAddress = "http://" + minioConfig.getEndpoint() + ":" + minioConfig.getPort() + "/" + minioConfig.getBucketName();
            String targetAddress = nginxConfig.getProtocol() + "://" + nginxConfig.getEndpoint() + ":" + nginxConfig.getPort() + "/" + minioConfig.getNginxLoadUrl();
            url = url.replace(sourceAddress,targetAddress);
        }
        return url;
    }

    /**
     * 生成一个给HTTP PUT请求用的presigned URL。
     * 浏览器/移动端的客户端可以用这个URL进行上传,即使其所在的存储桶是私有的。这个presigned URL可以设置一个失效时间,默认值是7天。
     *
     * @param bucketName 存储桶名称
     * @param objectName 存储桶里的对象名称
     * @param expires    失效时间(以秒为单位),默认是7天,不得大于七天
     * @return
     */
    @SneakyThrows
    public String preSignedPutObject(String bucketName, String objectName, Integer expires) {
        boolean flag = bucketExists(bucketName);
        String url = "";
        if (flag) {
            url = minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
                    .method(Method.PUT)
                    .bucket(bucketName)
                    .object(objectName)
                    .expiry(Objects.isNull(expires) ? DEFAULT_EXPIRY_TIME : expires)
                    .build()
            );
        }
        return url;
    }

    /**
     * 获取对象的元数据
     *
     * @param bucketName 存储桶名称
     * @param objectName 存储桶里的对象名称
     * @return
     */
    @SneakyThrows
    public ObjectStat statObject(String bucketName, String objectName) {
        boolean flag = bucketExists(bucketName);
        if (flag) {
            return minioClient.statObject(StatObjectArgs.builder().bucket(bucketName).object(objectName).build());
        }
        return null;
    }

    /**
     * 文件访问路径
     *
     * @param bucketName 存储桶名称
     * @param objectName 存储桶里的对象名称
     * @return
     */
    @SneakyThrows
    public String getObjectUrl(String bucketName, String objectName) {
        boolean flag = bucketExists(bucketName);
        String url = "";
        if (flag) {
            url = minioClient.getObjectUrl(bucketName, objectName);
        }
        return url;
    }


    /**
     * 文件下载
     * @param bucketName 桶名称
     * @param objectName 桶中文件名
     * @param originalName 下载文件的名称
     * @param request 请求
     * @param response 请求响应
     */
    public void downloadFile(String bucketName,
                             String objectName,
                             String originalName,
                             HttpServletRequest request,
                             HttpServletResponse response) {
        try {
            InputStream file = getObject(bucketName, objectName);
            String fileName = StrUtil.isNotEmpty(originalName) ? originalName : objectName;
            fileName = fileName.replace(" ", "");
            //文件名乱码处理
            String useragent = request.getHeader("USER-AGENT").toLowerCase();
            if(useragent.contains("msie")||useragent.contains("like gecko")||useragent.contains("trident")){
                fileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.displayName());
            } else {
                fileName = new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
            }
            response.setCharacterEncoding("utf-8");
            response.setHeader("Content-Disposition", "attachment;filename=" + fileName );
            ServletOutputStream servletOutputStream = response.getOutputStream();
            int len;
            byte[] buffer = new byte[1024];
            while ((len = file.read(buffer)) > 0) {
                servletOutputStream.write(buffer, 0, len);
            }
            servletOutputStream.flush();
            file.close();
            servletOutputStream.close();
        } catch (Exception e) {
            log.error(String.format("下载文件:%s异常",objectName),e);
        }
    }

}

3.4.nginx配置

@Data
@Configuration
@ConfigurationProperties(prefix = "baiyan.nginx")
public class NginxConfig {

    /**
     * ip
     */
    private String endpoint;

    /**
     * 端口
     */
    private int port;

    /**
     * 协议
     * http或者https
     */
    private String protocol;

}

/resoureces/META-INF/spring.factories文件中增加NginxConfig配置

3.5.自动配置类

import io.minio.MinioClient;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

/**
 * minio自动配置类
 *
 * @author baiyan
 */
@Configuration
@Import({MinioUtil.class})
@ConditionalOnProperty(value = "spring.minio.enable",havingValue = "true")
@EnableConfigurationProperties(MinioConfig.class)
public class MinioAutoConfiguration {

    @Bean
    public MinioClient getMinioClient(MinioConfig minioConfig) {
        return MinioClient.builder()
                .endpoint(minioConfig.getEndpoint(),minioConfig.getPort(),minioConfig.getSecure())
                .credentials(minioConfig.getAccessKey(),minioConfig.getSecretKey())
                .build();
    }

}

/resoureces/META-INF/spring.factories文件中增加MinioAutoConfiguration配置

至此为止,starter就已经做好了。

3.6.业务端使用

业务应用引入上述starter后,配置文件中增加配置

baiyan:
  nginx:
    endpoint: nginx地址
    port: 443
    protocol: https
spring:
  minio:
    enable: true
    endpoint: minio安装的id
    port: minio安装的id
    accessKey: key
    secretKey: 秘钥
    secure: false #使用http
    nginxLoadUrlEnable: true
    nginxLoadUrl: api/9c16ff1ecec

四.踩坑

4.1.空文件上传失败

空文件上传在官方默认文档中的版本7.0.2中是不支持的,本文使用了较新的7.1.0支持上传空文件

4.2.nginx路由访问minio生成的链接报签名无效

minio的文件可以通过上面minioUtil.preSignedGetObject方法进行获取下载链接。pdf,图片,txt等文件支持直接预览。

​ 我直接访问生成的url时,url可以帮我展示对应的文件或者下载。但是将minio服务的ip与端口暴露肯定是不安全的事情,所以我通过nginx路由了一层。但是这个是否访问链接就提示了签名失效。

​ 查看minioclint内的源码发现,预览的url为AWS4-HMAC-SHA256加密,其实加密头源码中写死了host的值。

查看源码Signer.setPresignCanonicalRequest

private void setPresignCanonicalRequest(int expires) throws NoSuchAlgorithmException {
  this.canonicalHeaders = new TreeMap<>();
  this.canonicalHeaders.put("host", this.request.headers().get("Host"));
  this.signedHeaders = "host";

     //省略无关源码
}

引入如果按照博主的方式需要对生成的预览url进行路由的话,需要在nginx的配置中增加如下配置

 #minio文件预览路由
 location /api/9c16ff1ecec
 {
     proxy_pass http://localhost:9000/baiyan;
     proxy_set_header Host $http_host;
 }
 其中如果上面这样配置了还不对的话,看一下直接预览的ip:端口是多少,将$http_host替换写死为直接预览的ip:端口

4.3.文件无法预览

  由于为了保证上传在minio中的文件的唯一性,minioUtil中在存储桶中文件名记录为文件流的md5值。这时候通过流上传文件时必须执行文件的ContentType属性,否则默认情况minio认为文件为二进制文件,而非你上传的文件类型。

​ 例如你通过流上传方法上传图片1.jpg.

​ 未指定contentType,通过minioUtil.preSignedGetObject访问1.jpg时,浏览器会直接下载

​ 指定contentType,通过minioUtil.preSignedGetObject访问1.jpg时,浏览器将会生成预览图

 

4.4.上传文件限制

minioutil默认情况下上传最大的文件大小为5TB,如果要限制上传文件的大小。

​ 有两种途径:

​ 一种通过spring配置

  servlet:
    multipart:
      max-file-size: 2048MB
      max-request-size: 2048MB

 一种则为工具类中的方式实现.

五.总结

本文提供了minio在日常业务场景中实际使用的一种解决方案与相关的踩坑记录,希望能帮到大家

六.参考

minio官方文档