一.前言

hello,everyone。好久不见,相信大家日常开发工作中对于文件的存储,读取等都是有大大小小的需求的。在博主刚接触springboot的时候,比较喜欢把一些文件存储在linux的磁盘上,但是后面发现对于磁盘上的文件管理很麻烦。而且如果磁盘一旦损坏,那么存储在磁盘上的文件将会全部丢失。为了解决上面的需求与问题,本文将给大家带来分布式文件存储中间件-minio。

二.minio介绍

2.1.minio是什么?

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

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

2.2.minio单节点安装部署

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

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

接入minio我来帮你做_nginx

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

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

三. minio -JavaClient

3.1.maven

<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>7.1.0</version>
</dependency>
复制代码

3.2.配置类编写

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

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

贴上配置类代码

import 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在日常业务场景中实际使用的一种解决方案与相关的踩坑记录,希望能帮到大家。如果上述代码中存在部分工具类未找不到源码,可去我的github上寻找:分布式开发公有框架

六.参考

minio官方文档


作者:柏炎