常见对象存储技术选型。

存储的方案分成两种:一种是可以自定对象名称的,另一种是系统自动生成对象名称。

  • 不能自定义名称的有领英的AmbryMogileFS
  • TFS 是淘宝开源的,但是目前已经很少有人维护它并且也不是很活跃。
  • ceph 是一个比较强大的分布式存储,但是它整个系统非常复杂需要大量的人力进行维护。
  • GlusterFS 为本身是一个非常成熟的对象存储的方案,2011被收购了,原班的人马又做了另外一个存储系统MINIO

各家云厂商的对象存储服务

  • AWS: Simple Storage Service(简称 S3)
  • Azure: Azure Blob Stroage
  • Google Cloud: Google Cloud Storage
  • 阿里云: 对象存储 OSS(Object Storage Service)
  • 腾讯云:对象存储 COS(Cloud Object Storage)

minioAPI文档地址:https://min.io/docs/minio/kubernetes/upstream/index.html?ref=docs-redirect

对象存储

  • 它有什么特点?

    • 容量无限大:可以到 EB 级,多少数据都能存的下
    • 持久可靠:11个 9 甚至以上的可靠性,数据丢失的概率比中五百万的概率还要低 2-3 个量级
    • 低成本:1 部高清电影存 1 年,差不多也就几块钱人民币
    • 使用方便:支持 REST 接口,主要操作为 PUT/GET/DELETE等,使用非常简单。
  • 对象存储的应用场景:

    应用于各种类型的海量数据的存储。早期常见于非结构化数据的存储,比如日志、文本、音频、视频等,也正是为了实现这些海量非结构化数据的低成本存储,才催生了对象存储这个技术;近年来随着数据湖和湖仓一体架构的流行,对象存储也被越来越多的被用于结构化数据的存储。

MINIO

  • minio是一个基于Apache License V2.0开源协议的对象存储服务,它兼容亚马逊S3云存储服务,非常适合于存储大容量非结构化的数据,如图片,视频,日志文件等。而一个对象文件可以任意大小,从几KB到最大的5T不等。它是一个非常轻量级的服务,可以很简单的和其它的应用结合,类似于NodeJS, Redis或者MySQL。
  • minio默认不计算MD5,除非传输给客户端的时候,所以很快,支持windows,有web页进行管理。
  • 推荐一个比较好的实践案例:基于 Go 开源项目 MIMIO 的对象存储方案在探探的实践:https://mp.weixin.qq.com/s/YIKB

springboot集成minio

依赖

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

配置文件

minio.url=172.21.72.226
minio.port=9000
minio.accessKey=minioadmin
minio.secretKey=minioadmin
# 桶名称,一般一个项目一个桶
minio.bucketName=xxx 
minio.secure=false

实现配置类,加载以上配置后实例一个minio客户端注册到spring容器供应用使用

@Configuration
@ConfigurationProperties(prefix = "minio")
public class MinioConfig {

    /**
     * 服务器地址:域名,IPv4或者IPv6地址
     */
    private String url;

    /**
     * 端口
     */
    private Integer port;

    /**
     * accessKey类似于用户ID,用于唯一标识你的账户
     */
    private String accessKey;

    /**
     * secretKey是你账户的密码
     */
    private String secretKey;

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

    /**
     * 默认存储桶
     */
    private String bucketName;

    @Bean
    public MinioClient getMinClient() {
        return MinioClient.builder()
                .endpoint(url, port, secure)
                .credentials(accessKey, secretKey)
                .build();
    }

    public String getUrl() {
        return url;
    }

    public void setUrl(String url) {
        this.url = url;
    }

    public int getPort() {
        return port;
    }

    public void setPort(int port) {
        this.port = port;
    }

    public String getAccessKey() {
        return accessKey;
    }

    public void setAccessKey(String accessKey) {
        this.accessKey = accessKey;
    }

    public String getSecretKey() {
        return secretKey;
    }

    public void setSecretKey(String secretKey) {
        this.secretKey = secretKey;
    }

    public Boolean getSecure() {
        return secure;
    }

    public void setSecure(Boolean secure) {
        this.secure = secure;
    }

    public String getBucketName() {
        return bucketName;
    }

    public void setBucketName(String bucketName) {
        this.bucketName = bucketName;
    }
}

有了MinioClient之后,就可以根据其api进行对象存储操作了。通常不建议直接操作其原生api,可以封装一个工具辅助类

以下为示例代码:

@Component
public class MinioClientHelper {

    private static final ZLogger logger = ZLoggerFactory.getLogger(MinioClientHelper.class, KeyConsts.LOG_MODULE);

    @Autowired
    private MinioClient minioClient;

    /**
     * 默认存储桶名称
     */
    @Value("${minio.bucketName}")
    private String defaultBucketName;
    
    

    /**
     * 上传
     * 
     * @param filePath 上传文件路径
     */
    public void upload(String filePath) {
        uploadFile(filePath, "", "", defaultBucketName);
    }

    /**
     * 上传
     * 
     * @param filePath 上传文件路径
     * @param minioFileName 上传文件在minio中的名称(带文件后缀名)
     */
    public void upload(String filePath, String minioFileName) {
        uploadFile(filePath, "", minioFileName, defaultBucketName);
    }

    /**
     * 上传
     * 
     * @param filePath 上传文件路径
     * @param minioFilePath 上传文件在minio中的路径(如:/001/002/)
     * @param minioFileName 上传文件在minio中的名称(带文件后缀名)
     */
    public void upload(String filePath, String minioFilePath, String minioFileName) {
        uploadFile(filePath, minioFilePath, minioFileName, defaultBucketName);
    }

    /**
     * 上传
     * 
     * @param minioFileName 上传文件在minio中的名称(带文件后缀名)
     * @param in 文件流
     */
    public void upload(String minioFileName, InputStream in) {
        uploadFile("", minioFileName, in, defaultBucketName);
    }

    /**
     * 上传
     * 
     * @param minioFilePath 上传文件在minio中的路径(如:/001/002/)
     * @param minioFileName 上传文件在minio中的名称(带文件后缀名)
     * @param in 文件流
     */
    public void upload(String minioFilePath, String minioFileName, InputStream in) {
        uploadFile(minioFilePath, minioFileName, in, defaultBucketName);
    }

    /**
     * 下载
     * 
     * @param minioFileName 上传文件在minio中的名称(带文件后缀名)
     * @return
     */
    public byte[] download(String minioFileName) {
        return download("", minioFileName, defaultBucketName);
    }

    /**
     * 下载
     * 
     * @param minioFilePath 上传文件在minio中的路径(如:/001/002/)
     * @param minioFileName 上传文件在minio中的名称(带文件后缀名)
     * @return
     */
    public byte[] download(String minioFilePath, String minioFileName) {
        return download(minioFilePath, minioFileName, defaultBucketName);
    }

    /**
     * 获取预览文件绝对路径
     * 
     * @param minioFileName 上传文件在minio中的名称(带文件后缀名)
     * @return
     */
    public String getPreviewFileUrl(String minioFileName) {
        return getPreviewFileUrl("", minioFileName, defaultBucketName);
    }

    /**
     * 获取预览文件绝对路径
     * 
     * @param minioFilePath 上传文件在minio中的路径(如:001/002/)
     * @param minioFileName 上传文件在minio中的名称(带文件后缀名)
     * @return
     */
    public String getPreviewFileUrl(String minioFilePath, String minioFileName) {
        return getPreviewFileUrl(minioFilePath, minioFileName, defaultBucketName);
    }

    /**
     * 删除
     * 
     * @param minioFileName 上传文件在minio中的名称(带文件后缀名)
     * @return
     */
    public void remove(String minioFileName) {
        remove("", minioFileName, defaultBucketName);
    }

    /**
     * 删除
     * 
     * @param minioFilePath 上传文件在minio中的路径(如:/001/002/)
     * @param minioFileName 上传文件在minio中的名称(带文件后缀名)
     * @return
     */
    public void remove(String minioFilePath, String minioFileName) {
        remove(minioFilePath, minioFileName, defaultBucketName);
    }
    
    /**
     * 删除文件夹
     * @param minioFilePath
     */
    public void removeFolder(String minioFilePath) {
        if (minioFilePath.startsWith(SymbolConsts.SLASH)) {
            minioFilePath = minioFilePath.substring(1);
        }
        if (!minioFilePath.endsWith(SymbolConsts.SLASH)) {
            minioFilePath = minioFilePath + SymbolConsts.SLASH;
        }
        try {
            bucketFound(defaultBucketName);
            
            Iterable<Result<Item>> objects = minioClient.listObjects(ListObjectsArgs.builder()
                .bucket(defaultBucketName)
                .startAfter(minioFilePath)
                .recursive(true)
                .build());
            
            for (Result<Item> result : objects) {
                minioClient.removeObject(RemoveObjectArgs.builder().bucket(defaultBucketName)
                    .object(result.get().objectName()).build());
            }
        }
        catch (Exception e) {
            logger.error(ErrorConsts.SYSTEM_ERROR.getErrorCode(), e, e.getMessage());
        }
    }
    
    /**
     * 获取文件信息
     * 
     * @param minioFileName 上传文件在minio中的名称(带文件后缀名)
     */
    public StatObjectResponse getFileInfo(String minioFileName) {
        return getFileInfo("", minioFileName, defaultBucketName);
    }

    /**
     * 获取文件信息
     * 
     * @param minioFilePath 上传文件在minio中的路径(如:/001/002/)
     * @param minioFileName 上传文件在minio中的名称(带文件后缀名)
     */
    public StatObjectResponse getFileInfo(String minioFilePath, String minioFileName) throws Exception {
        return getFileInfo(minioFilePath, minioFileName, defaultBucketName);
    }

    /**
     * 上传文件
     * 
     * @param filePath 上传文件名称
     * @param minioFilePath 上传文件在minio中的路径(如:/001/002/)
     * @param minioFileName 上传文件在minio中的名称(带文件后缀名)
     * @param bucket mini所在空间名称
     */
    private void uploadFile(String filePath, String minioFilePath, String minioFileName, String bucket) {
        if (UddpStringUtils.isAnyBlank(filePath, bucket)) {
            return;
        }
        try {
            // 存储桶构建
            bucketBuild(bucket);
            // 原始文件名称
            String fileName = filePath.substring(
                (filePath.lastIndexOf("/") > 0 ? filePath.lastIndexOf("/") : filePath.lastIndexOf("\\")) + 1);
            // 保存的文件名称
            minioFileName = minioFilePath + SymbolConsts.SLASH
                + (UddpStringUtils.isBlank(minioFileName) ? fileName : minioFileName);

            minioClient.uploadObject(
                UploadObjectArgs.builder().bucket(bucket).object(minioFileName).filename(filePath).build());
        }
        catch (Exception e) {
            logger.error(ErrorConsts.INF_FILE_UPLOAD.getErrorCode(), e, e.getMessage());
        }
    }

    /**
     * 上传文件
     * 
     * @param minioFilePath 上传文件在minio中的路径(如:/001/002/)
     * @param minioFileName 上传文件在minio中的名称(带文件后缀名)
     * @param in 文件流
     * @param bucket
     */
    private void uploadFile(String minioFilePath, String minioFileName, InputStream in, String bucket) {
        try {
            // 存储桶构建
            bucketBuild(bucket);
            // 原始文件名称
            // 保存的文件名称
            minioFileName = minioFilePath + SymbolConsts.SLASH
                + (UddpStringUtils.isBlank(minioFileName) ? System.currentTimeMillis() : minioFileName);

            minioClient.putObject(
                PutObjectArgs.builder().bucket(bucket).object(minioFileName).stream(in, in.available(), -1).build());
        }
        catch (Exception e) {
            logger.error(ErrorConsts.INF_FILE_UPLOAD.getErrorCode(), e, e.getMessage());
        }
    }

    /**
     * 复制对象
     *
     * @param sourcePath
     * @param destPath
     * @param destName
     */
    public void copyFile(String sourcePath, String destPath, String destName) {
        try {
            minioClient.copyObject(
                CopyObjectArgs.builder().bucket(defaultBucketName).object(destPath + SymbolConsts.SLASH + destName)
                    .source(CopySource.builder().bucket(defaultBucketName).object(sourcePath).build()).build());
        }
        catch (Exception e) {
            logger.error(ErrorConsts.INF_FILE_UPLOAD.getErrorCode(), e, e.getMessage());
        }
    }

    /**
     * 存储桶构建
     * 
     * @param bucketName
     */
    private void bucketBuild(String bucketName) {
        try {
            boolean found = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
            if (!found) {
                minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
                logger.info("minio存储桶Bucket【" + bucketName + "】创建成功!");
            }
        }
        catch (Exception e) {
            logger.error(ErrorConsts.INF_FILE_UPLOAD.getErrorCode(), e, e.getMessage());
        }
    }

    /**
     * 查找存储桶
     * 
     * @param bucketName
     */
    private void bucketFound(String bucketName) {
        try {
            boolean found = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
            if (!found) {
                ErrorConsts.INF_FILE_UPLOAD.throwOut("minio存储桶Bucket【" + bucketName + "】不存在");
            }
        }
        catch (Exception e) {
            logger.error(ErrorConsts.INF_FILE_UPLOAD.getErrorCode(), e, e.getMessage());
        }
    }

    /**
     * 下载
     * 
     * @param minioFilePath 上传文件在minio中的路径(如:/001/002/)
     * @param minioFileName 上传文件在minio中的名称(带文件后缀名)
     * @param bucket minio所在空间名称
     * @return
     */
    private byte[] download(String minioFilePath, String minioFileName, String bucket) {
        if (minioFilePath.startsWith(SymbolConsts.SLASH)) {
            minioFilePath = minioFilePath.substring(1);
        }
        if (!minioFilePath.endsWith(SymbolConsts.SLASH)) {
            minioFilePath = minioFilePath + SymbolConsts.SLASH;
        }
        InputStream in = null;
        try {
            bucketFound(bucket);
            in = minioClient
                .getObject(GetObjectArgs.builder().bucket(bucket).object(minioFilePath + minioFileName).build());
            if (in != null) {
                return ByteUtil.inputStreamToByteArray(in);
            }
        }
        catch (Exception e) {
            logger.error(ErrorConsts.SYSTEM_ERROR.getErrorCode(), e, "获取minio下载流异常");
        }
        finally {
            if (in != null) {
                try {
                    in.close();
                }
                catch (IOException e) {
                    logger.error(ErrorConsts.SYSTEM_ERROR.getErrorCode(), e, "关闭minio下载流异常");
                }
            }
        }
        return new byte[0];
    }

    /**
     * 获取预览文件绝对路径(expiry:多少秒后链接失效,1800s后失效)
     * 
     * @param minioFilePath 上传文件在minio中的路径(如:/001/002/)
     * @param minioFileName 上传文件在minio中的名称(带文件后缀名)
     * @param bucket minio所在空间名称
     * @return
     */
    private String getPreviewFileUrl(String minioFilePath, String minioFileName, String bucket) {
        if (minioFilePath.startsWith(SymbolConsts.SLASH)) {
            minioFilePath = minioFilePath.substring(1);
        }
        if (!minioFilePath.endsWith(SymbolConsts.SLASH)) {
            minioFilePath = minioFilePath + SymbolConsts.SLASH;
        }
        String filePath = "";
        try {
            bucketFound(bucket);
            filePath = minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder().bucket(bucket)
                .object(minioFilePath + minioFileName).method(Method.GET).expiry(1800).build());
        }
        catch (Exception e) {
            logger.error(ErrorConsts.SYSTEM_ERROR.getErrorCode(), e, "获取minio文件绝对路径异常");
        }
        return filePath;
    }

    /**
     * 删除
     * 
     * @param minioFilePath 上传文件在minio中的路径(如:/001/002/)
     * @param minioFileName 上传文件在minio中的名称(带文件后缀名)
     * @param bucket minio所在空间名称
     */
    private void remove(String minioFilePath, String minioFileName, String bucket) {
        if (minioFilePath.startsWith(SymbolConsts.SLASH)) {
            minioFilePath = minioFilePath.substring(1);
        }
        if (!minioFilePath.endsWith(SymbolConsts.SLASH)) {
            minioFilePath = minioFilePath + SymbolConsts.SLASH;
        }
        try {
            bucketFound(bucket);
            minioClient
                .removeObject(RemoveObjectArgs.builder().bucket(bucket).object(minioFilePath + minioFileName).build());
        }
        catch (Exception e) {
            logger.error(ErrorConsts.SYSTEM_ERROR.getErrorCode(), e, "获取minio文件绝对路径异常");
        }
    }

    /**
     * 获取文件信息
     * 
     * @param minioFilePath 上传文件在minio中的路径(如:/001/002/)
     * @param minioFileName 上传文件在minio中的名称(带文件后缀名)
     * @param bucket minio所在空间名称
     * @return
     */
    private StatObjectResponse getFileInfo(String minioFilePath, String minioFileName, String bucket) {
        if (minioFilePath.startsWith(SymbolConsts.SLASH)) {
            minioFilePath = minioFilePath.substring(1);
        }
        if (!minioFilePath.endsWith(SymbolConsts.SLASH)) {
            minioFilePath = minioFilePath + SymbolConsts.SLASH;
        }
        StatObjectResponse statObjectResponse = null;
        try {
            statObjectResponse = minioClient
                .statObject(StatObjectArgs.builder().bucket(bucket).object(minioFilePath + minioFileName).build());
        }
        catch (Exception e) {
            logger.error(ErrorConsts.SYSTEM_ERROR.getErrorCode(), e, "获取minio文件信息异常");
        }
        return statObjectResponse;
    }

}

顺便提一下扩展写法,通过配置支持多种oss存储方案

配置

# 文件系统类型:fdfs、ctdfs、minio、oss
dfs.type=minio

# fdfs
fdfs.tracker-list[0]=10.45.46.235:22122
fdfs.tracker-list[1]=10.45.46.236:22122
fdfs.so-timeout=1000
fdfs.connect-timeout=3000
fdfs.thumb-image.width=150
fdfs.thumb-image.height=150
# 从池中借出的对象的最大数目(配置为-1表示不限制)
fdfs.pool.max-total=-1
# 获取连接时的最大等待时间
fdfs.pool.max-wait-millis=5000
# 每个key对应的池最大连接数
fdfs.pool.max-total-per-key=50
# 每个key对应的连接池最大空闲连接数
fdfs.pool.max-idle-per-key=10
# 每个key对应的连接池最小空闲连接数
fdfs.pool.min-idle-per-key=5

# ctdfs
ctdfs.url=
ctdfs.username=
ctdfs.passowrd=
ctdfs.scheme=
ctdfs.point=
ctdfs.timeout=3000
ctdfs.thumb-image.width=150
ctdfs.thumb-image.height=150
# 是否加密passowrd
ctdfs.decrypt=true

# minio
minio.endpoint=http://172.21.72.226:9000
minio.access-key=minioadmin
minio.secret-key=minioadmin
minio.bucket=autotest
minio.timeout=3000
minio.thumb-image.width=150
minio.thumb-image.height=150
# 是否加密secret-key
minio.decrypt=false

# oss
oss.endpoint=
oss.access-key-id=
oss.access-key-secret=
oss.bucket=autotest
oss.timeout=3000
oss.thumb-image.width=150
oss.thumb-image.height=150
# 是否加密access-key-secret
oss.decrypt=true

定义客户端接口

package com.iwhalecloud.autotest.common.dfs;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

import org.apache.commons.io.FileExistsException;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.Assert;

import com.iwhalecloud.autotest.common.util.ExceptionUtil;

/**
 * 文件系统客户端
 * @author Admin
 */
public interface IDfsClient {

    Logger logger = LoggerFactory.getLogger(IDfsClient.class);

    /**
     * 上传文件
     * @param inputStream
     * @param fileSize
     * @param fileName
     * @param fileInfoId
     * @param datePath
     * @return
     */
    default String upload(InputStream inputStream, long fileSize, String fileName, String fileInfoId, boolean datePath) {
        Assert.notNull(inputStream, "inputStream must not be null");

        try {
            byte[] bytes = IOUtils.toByteArray(inputStream);
            return upload(bytes, fileName, fileInfoId, datePath);
        }
        catch (IOException e) {
            throw ExceptionUtil.wrapToRuntimeException(e);
        }
        finally {
            IOUtils.closeQuietly(inputStream, e -> {
                logger.error(e.getMessage(), e);
            });
        }
    }

    /**
     * 上传文件
     * @param bytes
     * @param fileName
     * @param fileInfoId
     * @param datePath
     * @return
     */
    String upload(byte[] bytes, String fileName, String fileInfoId, boolean datePath);

    /**
     * 下载文件
     * @param bucket
     * @param path
     * @return
     */
    default byte[] download(String bucket, String path) {
        Assert.hasText(path, "path must not be empty");

        try (InputStream input = downloadInputStream(bucket, path)) {
            return IOUtils.toByteArray(input);
        }
        catch (IOException e) {
            throw ExceptionUtil.wrapToRuntimeException(e);
        }
    }

    /**
     * 下载文件流
     * @param bucket
     * @param path
     * @return
     */
    default InputStream downloadInputStream(String bucket, String path) {
        Assert.hasText(path, "path must not be empty");

        byte[] bytes = download(bucket, path);
        return new ByteArrayInputStream(bytes);
    }

    /**
     * 下载并创建本地文件
     * @param bucket
     * @param path
     * @param pathname
     * @return
     * @throws IOException
     */
    default File download(String bucket, String path, String pathname) throws IOException {
        Assert.hasText(path, "path must not be empty");
        Assert.hasText(pathname, "pathname must not be empty");
        Path filePath = Paths.get(pathname);
        if (Files.exists(filePath)) {
            throw new FileExistsException(filePath.toFile());
        }

        Files.createFile(filePath);
        try (InputStream input = downloadInputStream(bucket, path)) {
            File file = filePath.toFile();
            FileUtils.copyInputStreamToFile(input, file);
            return file;
        }
    }

    /**
     * 删除文件
     * @param bucket
     * @param path
     * @param name
     * @return
     */
    boolean delete(String bucket, String path, String name);

    /**
     * 获取Bucket名称
     * @return
     */
    default String getBucket() {
        return null;
    }
}

提供各自实现

image