Spring?Boot?3?整合?MinIO?實現(xiàn)分布式文件存儲的全過程
引言
文件存儲已成為一個做任何應用都不可回避的需求。傳統(tǒng)的單機文件存儲方案在面對大規(guī)模數(shù)據(jù)和高并發(fā)訪問時往往力不從心,而分布式文件存儲系統(tǒng)則提供了更好的解決方案。本篇文章我將基于Spring Boot 3 為大家講解如何基于MinIO來實現(xiàn)分布式文件存儲。
分布式存儲的出現(xiàn)
在探討核心內(nèi)容之前,我們不妨先回顧分布式存儲技術是如何伴隨系統(tǒng)架構演變發(fā)展的。在單體架構早期,文件直接存儲于應用服務器中,這種方式簡單直接,存取便捷。然而,隨著業(yè)務規(guī)模擴大和用戶量激增,系統(tǒng)架構逐步向分布式或微服務方向演進。此時,若仍將文件存儲在應用服務器中,在負載均衡機制下可能導致文件訪問異常 —— 用戶上傳的文件可能因路由到其他服務節(jié)點而無法訪問。

面對這個挑戰(zhàn),我們可以借鑒"分層解決"的架構思想:將文件存儲從應用服務中剝離,集中在獨立的存儲服務中統(tǒng)一管理。這便是分布式文件存儲系統(tǒng)的雛形。
技術選型
在了解了分布式存儲的演進背景后,讓我們來梳理當前主流的分布式存儲解決方案。
其他
- FastDFS -> 架構老舊,社區(qū)活躍度低,文檔資料匱乏
- Ambry -> 過度依賴 LinkedIn 技術棧,通用性不足
- MooseFS -> 部署配置繁瑣,運維門檻高
- MogileFS -> 性能一般,擴展性受限
- LeoFS -> 更新維護緩慢,生態(tài)系統(tǒng)不完善
- openstack -> 架構復雜重量級,不適合輕量級應用
- TFS -> 主要服務于阿里內(nèi)部,外部支持有限
- ceph -> 學習曲線陡峭,配置調(diào)優(yōu)復雜
- GlusterFS -> 架構復雜,問題定位困難
- OSS -> 商業(yè)收費服務,成本隨數(shù)據(jù)量增長
?MinIO
MinIO 是一款輕量級的分布式對象存儲系統(tǒng),完全兼容 Amazon S3 云存儲服務接口。其部署維護簡單,性能卓越,成為我們的首選方案。
MinIO安裝
MinIO 提供了多種部署方式,包括單機部署和分布式部署。本文主要關注 Spring Boot 與 MinIO 的整合實踐,因此我們選擇使用Docker(Ps:沒安裝Docker的同學速速去安裝,或者用別的方式只要本地部署的能跑就行)進行快速部署。
首先,通過命令拉取鏡像。
docker pull minio/minio
接著在 本地創(chuàng)建一個存儲文件的映射目錄 D:\minio\data(Ps:我當前演示的環(huán)境是win系統(tǒng),大家根據(jù)自己的操作系統(tǒng)建個目錄就行),使用以下命令啟動 MinIO:
?? 補充一個小細節(jié):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"
參數(shù)說明:
-d: 后臺運行容器--name: 容器名稱-p: 端口映射,9000用于API訪問,9001用于控制臺訪問-v: 目錄映射,將本地目錄映射到容器的 /data-e: 環(huán)境變量,設置管理員賬號和密碼--console-address: 指定控制臺端口--restart=always: 容器自動重啟策略--address ":9000": 顯式指定 API 端口
運行成功后訪問 http://localhost:9001,使用執(zhí)行命令中的憑據(jù)(Ps:大家在使用時可以修改為自己的用戶名和密碼)登錄:
- 用戶名:root
- 密碼:12345678

登錄系統(tǒng)后,界面會提示創(chuàng)建桶。熟悉云服務商OSS服務的讀者對此概念應該不陌生。對初次接觸的讀者,可以將桶理解為一個命名空間或文件夾,您可以創(chuàng)建多個桶,每個桶內(nèi)還能包含多層級的文件夾和文件。

這里我演示下控制臺如何建桶和上傳文件,方便大家理解文件在MinIO上的存儲結構。

只需要輸入名稱就可以,建好之后可以看到桶的使用狀態(tài)。

點擊它進入桶的內(nèi)部,這里大家需要關注一個設置- Access Policy,默認是Private。這個設置需要根據(jù)業(yè)務的實際情況來,如果你的業(yè)務是需要提供一些不需要鑒權的公共訪問的文件,就設為public;反之,就保持private。我這里把它修改為public。

然后點擊右上角的上傳按鈕進入上傳頁可以向桶內(nèi)上傳文件。

上傳成功后可以在桶內(nèi)看到文件。

點擊文件可查看詳情,支持預覽、刪除、分享等多種功能。這些操作較為直觀,安裝后各位讀者可自行體驗。本文重點關注不在控制臺的操作,就不做過多贅述了。
??這里再強調(diào)一點:存儲在桶里的文件通過API訪問的端口和控制臺是不一樣的。如果你對這里感覺迷惑,可以回看一下上面我貼上的docker運行命令里配置了兩個端口-9000和9001。如果要通過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的核心功能,為您提供了很多開箱即用的功能。通過引入它,可以輕松實現(xiàn)文件上傳、下載等操作,讓大家將更多精力集中在業(yè)務開發(fā)上。
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();
}
/**
* 創(chuàng)建基于Java端的MinioClient
*/
public void createMinioClient() {
try {
if (null == minioClient) {
log.info("開始創(chuàng)建 MinioClient...");
minioClient = MinioClient
.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
createBucket(bucketName);
log.info("創(chuàng)建完畢 MinioClient...");
}
} catch (Exception e) {
log.error("MinIO服務器異常:{}", e);
}
}
/**
* 獲取上傳文件前綴路徑
* @return
*/
public static String getBasisUrl() {
return endpoint + SEPARATOR + bucketName + SEPARATOR;
}
/****************************** Operate Bucket Start ******************************/
/**
* 啟動SpringBoot容器的時候初始化Bucket
* 如果沒有Bucket則創(chuàng)建
* @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();
}
/**
* 根據(jù)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();
}
/**
* 根據(jù)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;
}
/**
* 根據(jù)文件前置查詢文件
* @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 起始字節(jié)的位置
* @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 "";
}
/**
* 創(chuàng)建文件夾或目錄
* @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.開發(fā)測試
我剛好在做練手項目,這里寫個上傳頭像的接口。
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);
}
}可以看到通過工具類只需要一行代碼就可以實現(xiàn)上傳文件,我們只需要在調(diào)用的時候做好文件的業(yè)務隔離即可。完成了接口的開發(fā),這里我來通過Apifox調(diào)用測試一下。

通過瀏覽器訪問返回的圖片鏈接會自動下載,我們再登錄控制臺看對應的桶下的這個路徑,也可以看到這個文件。

小結
我們在集成第三方服務時應遵循一個核心原則:將API操作封裝成通用工具類。這不僅讓MinIO的集成更加優(yōu)雅,也讓代碼具備更好的復用性和可維護性。這種思維方式同樣適用于其他第三方服務的對接。
到此這篇關于Spring Boot 3 整合 MinIO 實現(xiàn)分布式文件存儲的文章就介紹到這了,更多相關Spring Boot MinIO 分布式文件存儲內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
Mybatis工具類JdbcTypeInterceptor運行時自動添加jdbcType屬性
今天小編就為大家分享一篇關于Mybatis工具類JdbcTypeInterceptor運行時自動添加jdbcType屬性,小編覺得內(nèi)容挺不錯的,現(xiàn)在分享給大家,具有很好的參考價值,需要的朋友一起跟隨小編來看看吧2018-12-12
SpringBoot實現(xiàn)統(tǒng)一功能處理的教程詳解
這篇文章主要為大家詳細介紹了SpringBoot如何實現(xiàn)統(tǒng)一功能處理,文中的示例代碼講解詳細,對大家學習或工作有一定借鑒價值,感興趣的同學可以參考閱讀下2023-05-05
Java?MethodHandles介紹與反射對比區(qū)別詳解
這篇文章主要為大家介紹了Java?MethodHandles介紹與反射對比區(qū)別詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-11-11

