Minio的安装部署和SpringBoot整合

706 阅读22分钟

一、基本介绍

1.1 对象存储

1.1.1 为什么引入对象存储

由于块存储和文件存储的存储特点,并不适合公有云存储,一般只适合在局域网内部使用。另外随着互联网需求的发展,数据量爆炸式的增长,不断吞食着存储资源;数据类型也逐渐多元化,各类非结构化的数据占比显著增加。为了应对新的存储需求,对象存储应运而生。

image-20230601114812739

块存储

块存储像是一块块硬盘直接挂载在主机上,以卷或硬盘形式体现,对于存储的数据内容和格式一无所知,只关心读取和写入,不关心关系和用途,数据按字节来访问,性能很高。但是太偏向于底层,不利于扩展,常见的有DAS(直连式存储)、SAN(存储区域网络)。

  • DAS是指将存储设备通过SCSI接口直接连接到一台服务器上使用,存储介质直接挂载到内部总线上,数据存储是整个服务器结构的一部分。
  • SAN是通过高速网络将一个或多个存储设备和服务器连接起来的专用存储系统,可以把SAN理解成一个网络,里面包含着磁盘阵列、交换机等各种元素。

文件存储

文件存储一般以文件和目录形式体现,有多级访问路径和基于文件系统的目录结构,数据以文件的形式进行存取,也可以进行一些高级管理功能,比如文件层面的访问权限控制等。文件存储可以很方便的进行共享,用途也非常广泛,但是其读写速度相对较慢。常见的有NAS(网络附加存储服务器)。

  • NAS设备本质就是将本地主机的文件系统迁移至IP网络设备上,多个用户节点可以公用同一个NAS上的同一个文件系统。

1.1.2 什么是对象存储

对象存储是一种基于对象的存储设备,综合了NAS和SAN的优点,同时具有SAN的高速直接访问和NAS的分布式数据共享等优势。适合存储海量图片、视频、日志文件、备份数据、容器镜像等。

2006年美国亚马逊发布了AWS S3(Simple Storage Service)服务,正式将对象存储作为一种云存储服务,引入云计算领域,正式开启了对象存储的黄金年代,S3现在作为一种主流的对象存储协议标准,很多服务实现厂家对其都有很好的兼容。对象存储底层存储硬件介质,仍旧是硬盘,这一点和块存储、文件存储没有区别,但是底层硬件之上的系统和两者完全不同。

image-20230601120027350

对象存储非常简单,只有两个核心概念:桶(bucket)和对象(object),存储数据组织形式按照“租户-桶-对象”的方式进行组织:

  • 用户可以创建用于访问不同存储桶权限的租户。
  • 租户可以隔离存储资源,创建不同的存储桶和对象。
  • 存储桶是承装对象的容器集合,一个存储桶可以有很多对象,这些对象都是平级的(扁平化)。
  • 对象是数据存储的实体单位,类似于hash表项,key为对象名称,value为对象内容,以一种KV方式存储。

对象存储通常是通过REST API接口(HTTP动作)去处理资源信息,接口命令也十分简洁,存储协议是S3、swift等,以S3为例,主要接口命令有PUT/GET/DELETE等;扁平化的数据组织结构,也带来了极高的可扩展性。

1.2 Minio原理

MINIO整个集群是由多个角色完全相同的节点组成,没有特殊节点,任何一个节点宕机,都不会影响整个集群,对象被分片打散之后存放在不同节点的多块硬盘上,对外提供统一的命名空间,通过Web负载均衡或DNS轮询(Round Robin)的方式在各个节点上实现负载均衡。每个节点都能兼容S3接口。

1.2.1 节点存储概念

Drive: 存储数据的磁盘称之为Drive。

Set: 一组Drive的集合构成一个Set,每个Set的Drive尽可能分布在不同节点上,一个对象存储在一个Set上;

Bucket: 文件对象存储的逻辑位置。对于客户端而言,相当于存放文件的顶层文件夹;

MINIO不是以多副本的形式存储,而是通过数据编码,将原来的数据编码成多份,然后通过Set的形式落地存储在对应的Drive上。

一个集群包含多个Set,具体存储到哪个set是通过对象名称进行哈希,映射到唯一一个Set上,这种方式可以使得数据均匀分布在所有的Drive上。

一个集群具体包含多少个Set,minio默认会根据集群规模自动计算得出,也可以自行指定配置。本着鸡蛋放在多个篮子,保证数据可靠性的原则,一个Set的Drive尽可能分布在不同的节点上。

1.2.2 数据可靠性保证

MINIO通过使用纠删码Erasure code和校验和Checksum来保证数据的可靠性,即使丢掉一半数量(N/2)的硬盘,仍可恢复数据。

首先把对象分成若干等长的片段,之后通过纠删码算法分成若干数据分片和校验分片,每个分片都存储在一个Drive上。

纠删码(ERASURE-CODED),将存储机器空闲的CPU利用起来,通过纠删码特定算法的计算处理,保证数据的可靠性。

将一个对象编码成若干数据块和校验块,为了方便,将数据块和校验块统称为编码块,纠删码就是可以根据编码块的一部分还原出整个对象的机制,当然前提需要保证故障磁盘的数量小于等于校验盘的数量。

一般情况下,一个Set中会有一半的Drive作为数据块,一半的Drive作为校验块,这种方式可靠性最强,且冗余度最高,所有的编码块大小加起来是源文件大小的2倍。但是和多副本存储的方式相比,冗余度大大降低了(只冗余多存了一份数据),且可靠性最高,一般情况下可容忍一半磁盘的丢失和损坏。

位衰减(BitRot)

存储介质可能存在的另一个问题就是位衰减问题(bit rot),它是指在遇到磨损、灰尘、辐射、高热等因素下,存储介质中的数据性能和完整性缓慢恶化的问题,这些细微的损坏,可能常常不会被OS和硬件所觉察,但是会导致很严重的后果,比如向存储介质写入一段比特流,一段时间再读出来,二者不一致的现象。

为了应对bit rot问题,MINIO将之前的编码块通过HighwayHash算法计算校验和,以保证正确性,同时提供了一个管理工具,对有问题的编码块进行修复。

1.2.3 数据存放形式

假设MINIO集群内纠删组包含4块硬盘,我们要存储的对象名为MyObject,其隶属的存储桶名为MyBucket,哈希计算得到的磁盘为Disk1-4,那么在这4个磁盘上都会生成MyBucket/MyObject的子路径,MyObject路径目录下包含两个文件,分别是存储元数据信息的xl.json和对应的分片part.1。

image-20230601121053876

1.2.4 对象的元数据

对象存储使用对象元数据来管理数据的存储、分片以及数据本身的信息。而且元数据单独存储,我们可以为一个对象扩展任意多的元数据。

对象存储是扁平化存储(所有对象平铺在桶中),通过将key设置成带层级的方式来使对象像文件系统那样看起来有目录层级结构。如:test/image/1.jpg

1.3 临时凭证STS

MinIO安全令牌服务(STS)是一种终结点服务,使客户端可以请求MinIO资源的临时凭据。临时凭据的工作原理几乎与默认管理员凭据相同,但有一些区别:

  • 临时证书是短期的。可以将它们配置为持续几分钟到几小时的时间。凭证过期后,MinIO将不再识别它们或允许使用它们发出的API请求进行任何类型的访问。
  • 临时凭证不需要与应用程序一起存储,而是动态生成的,并在请求时提供给应用程序。当临时凭证到期时,应用程序可以请求新凭证。
  • 无需在应用程序中嵌入长期凭证。

在使用Java客户端获取STS时,需要在创建一个新的user用来生成STS,不能使用初始化的超级管理员账号。不然会抛出异常。

java.security.ProviderException: STS service failed with HTTP status code 403

1.4 Minio官方网站

二、安装部署

Minio生产至少部署4个机器4个节点,每个节点4driver,挂掉一台机器集群依然可以读写,挂掉两台机器集群依然可读。

2.1 直装单点

2.1.1 Server

下载,下载二进制文件

#amd64
wget https://dl.minio.org.cn/server/minio/release/linux-amd64/minio
#arm64
wget https://dl.minio.org.cn/server/minio/release/linux-arm64/minio
#执行权限
chmod +x minio
#放入执行路径
sudo mv minio /usr/local/bin/

启动

#创建目录
mkdir /root/minio
#启动服务端,设置管理员账户密码,暴露服务端口9000,/opt/minio文件目录,控制台端口9001
MINIO_ROOT_USER=admin MINIO_ROOT_PASSWORD=xrj12345678 minio server /opt/minio --address '192.168.10.109:9000' --console-address :9001

2.1.2 Client

下载

wget https://dl.minio.org.cn/client/mc/release/linux-amd64/mc
chmod +x mc
sudo mv mc /usr/local/bin/mc

别名设置

#将mc命令时local指定http://127.0.0.1:9000 admin xrj12345678
mc alias set local http://127.0.0.1:9000 admin xrj12345678
#查看服务端信息
mc admin info local

2.2 直装集群

2.2.1 部署方式

两台机器部署2节点,一个节点一个driver挂掉一台能读不能写。

#机器1,/opt/minio作为文件存储目录
192.168.10.109  /data/minio
#机器2
192.168.10.110 /data/minio

2.2.2 配置服务器

创建挂载目录和配置目录,两台机器都执行

新版要求挂载目录不能是根目录,新建磁盘分区挂载到/data

mkdir -p /data/minio && mkdir -p /etc/minio

下载minio执行文件,和单节点操作一致

wget https://dl.minio.org.cn/server/minio/release/linux-amd64/minio
#执行权限
chmod +x minio
#放入执行路径
sudo mv minio /usr/local/bin/

集群启动脚本。192.168.10.109和192.168.10.110分别创建run.sh。--config-dir指定集群配置目录

mkdir /root/minio
vim /root/minio/run.sh
chmod +x /root/minio/run.sh
#!/bin/bash

export MINIO_ROOT_USER=admin
export MINIO_ROOT_PASSWORD=xrj12345678
minio server --config-dir /etc/minio \
--address "0.0.0.0:9000" --console-address ":9001" \
http://192.168.10.109/data/minio \
http://192.168.10.110/data/minio

系统service

vim /usr/lib/systemd/system/minio.service
[Unit]
Description=Minio service
Documentation=https://docs.minio.io/

[Service]
WorkingDirectory=/root/minio/
ExecStart=/root/minio/run.sh
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

启动服务

#加载并启动服务
systemctl daemon-reload && systemctl start minio
#查看服务信息
systemctl status minio
#关闭服务
systemctl stop minio
#服务开机启动
systemctl enable minio
#关闭服务开机启动
systemctl disable minio

2.3 Docker单点

2.3.1 拉取镜像

docker pull minio/minio:RELEASE.2023-05-27T05-56-19Z

2.3.2 运行服务

自定义密码最小8位

docker run -d --restart always \
  -p 9000:9000 \
  -p 9001:9001 \
  --name minio \
  -v /opt/minio/data:/data \
  -e "MINIO_ROOT_USER=admin" \
  -e "MINIO_ROOT_PASSWORD=xrj12345678" \
  minio/minio:RELEASE.2023-05-27T05-56-19Z \
  server /data --console-address ":9001"

2.4 Docker-Compose单机器集群

在192.168.10.109上启动4个minio实例,每个实例两个driver,使用nginx负载均衡。

2.4.1 docker-compose.yml

version: '3.3'

services:
  minio1:
    image: minio/minio:RELEASE.2023-05-27T05-56-19Z
    command: server --console-address ":9001" http://minio{1...4}/data{1...2}
    expose:
      - "9000"
      - "9001"
    environment:
      MINIO_ROOT_USER: admin
      MINIO_ROOT_PASSWORD: xrj12345678
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 20s
      retries: 3
    hostname: minio1
    volumes:
      - data1-1:/data1
      - data1-2:/data2

  minio2:
    image: minio/minio:RELEASE.2023-05-27T05-56-19Z
    command: server --console-address ":9001" http://minio{1...4}/data{1...2}
    expose:
      - "9000"
      - "9001"
    environment:
      MINIO_ROOT_USER: admin
      MINIO_ROOT_PASSWORD: xrj12345678
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 20s
      retries: 3
    hostname: minio2
    volumes:
      - data2-1:/data1
      - data2-2:/data2

  minio3:
    image: minio/minio:RELEASE.2023-05-27T05-56-19Z
    command: server --console-address ":9001" http://minio{1...4}/data{1...2}
    expose:
      - "9000"
      - "9001"
    environment:
      MINIO_ROOT_USER: admin
      MINIO_ROOT_PASSWORD: xrj12345678
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 20s
      retries: 3
    hostname: minio3
    volumes:
      - data3-1:/data1
      - data3-2:/data2

  minio4:
    image: minio/minio:RELEASE.2023-05-27T05-56-19Z
    command: server --console-address ":9001" http://minio{1...4}/data{1...2}
    expose:
      - "9000"
      - "9001"
    environment:
      MINIO_ROOT_USER: admin
      MINIO_ROOT_PASSWORD: xrj12345678
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 20s
      retries: 3
    hostname: minio4
    volumes:
      - data4-1:/data1
      - data4-2:/data2

  nginx:
    image: nginx:1.19.2-alpine
    hostname: nginx
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    ports:
      - "9000:9000"
      - "9001:9001"
    depends_on:
      - minio1
      - minio2
      - minio3
      - minio4

volumes:
  data1-1:
  data1-2:
  data2-1:
  data2-2:
  data3-1:
  data3-2:
  data4-1:
  data4-2:

2.4.2 nginx.conf

放在和docker-compose.yml同级目录,这里nginx所在的节点无法高可用,配置文件只在109主机上,只会调度到109,109挂了就没法访问了。

可以考虑将nginx配置构建一个新的镜像在每个主机节点上都起一个副本,api访问配置所有的nginx。

user  nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events {
    worker_connections  4096;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;
    sendfile        on;
    keepalive_timeout  65;

    # include /etc/nginx/conf.d/*.conf;

    upstream minio {
        server minio1:9000;
        server minio2:9000;
        server minio3:9000;
        server minio4:9000;
    }

    upstream console {
        ip_hash;
        server minio1:9001;
        server minio2:9001;
        server minio3:9001;
        server minio4:9001;
    }

    server {
        listen       9000;
        listen  [::]:9000;
        server_name  localhost;

        #允许特殊请求头
        ignore_invalid_headers off;
        #不限制上传文件大小
        client_max_body_size 1000m;
        # To disable buffering
        proxy_buffering off;
        proxy_request_buffering off;

        location / {
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_connect_timeout 300;
            # Default is HTTP/1, keepalive is only enabled in HTTP/1.1
            proxy_http_version 1.1;
            proxy_set_header Connection "";
            chunked_transfer_encoding off;

            proxy_pass http://minio;
        }
    }

    server {
        listen       9001;
        listen  [::]:9001;
        server_name  localhost;

        ignore_invalid_headers off;
        client_max_body_size 0;
        proxy_buffering off;
        proxy_request_buffering off;

        location / {
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-NginX-Proxy true;

            real_ip_header X-Real-IP;

            proxy_connect_timeout 300;
            
            # To support websocket
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            
            chunked_transfer_encoding off;

            proxy_pass http://console;
        }
    }
}

2.4.3 启动服务

docker compose up -d
docker compose down

2.5 Docker-Swarm多机器集群

这里使用Swarm部署集群,4机器4节点,每个节点2个driver,使用nginx负载均衡。使用node.labels中的自定义属性配合deploy的placement属性的约束条件让不同minio服务节点调度到固定的节点。如:minio1服务固定调度到主机minio-1,其他节点类似,当节点故障恢复后不会自动负载服务节点导致数据漂移丢失。

2.5.1 配置服务器

minio-1 192.168.10.109
minio-2 192.168.10.110
minio-3 192.168.10.111
minio-4 192.168.10.112
hostnamectl set-hostname minio-1
hostnamectl set-hostname minio-2
hostnamectl set-hostname minio-3
hostnamectl set-hostname minio-4
cat >> /etc/hosts <<EOF
minio-1 192.168.10.109
minio-2 192.168.10.110
minio-3 192.168.10.111
minio-4 192.168.10.112
EOF

2.5.2 初始化Swarm集群

192.168.10.109当主节点。

主节点初始化集群

docker swarm init --advertise-addr 192.168.10.109

从节点加入集群

docker swarm join --token SWMTKN-1-5w1sqjo4pllh3btuihkpd027p0dnjpsd2t65zcoi9wrq2ahysa-5kh17licwa5069xoyah1tlbt7 192.168.10.109:2377

image-20230602014055432

将节点2和3升级成master节点,升级的worker节点管理状态从空成为Reachable

docker node promote mi78x5l6hk8dbx31m4suir67x
docker node promote ihdukwd6vp84fvy3ida89fm1a

2.5.3 docker-swarm.yml

使用docker volumes进行数据挂载

version: '3.7'

x-minio-common: &minio-common
  image: minio/minio:RELEASE.2023-05-27T05-56-19Z
  command: server --console-address ":9001" http://minio{1...4}/data{1...2}
  expose:
    - "9000"
    - "9001"
  environment:
    MINIO_ROOT_USER: admin
    MINIO_ROOT_PASSWORD: xrj12345678
  deploy:
    update_config:
      parallelism: 1
      delay: 10s
      order: stop-first
    restart_policy:
      condition: on-failure
  healthcheck:
    test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
    interval: 30s
    timeout: 20s
    retries: 3

services:
  minio1:
    <<: *minio-common
    hostname: minio1
    deploy:
      placement:
        constraints:
          - "node.labels.minio1==true"
    volumes:
      - data1-1:/data1
      - data1-2:/data2

  minio2:
    <<: *minio-common
    hostname: minio2
    deploy:
      placement:
        constraints:
          - "node.labels.minio2==true"
    volumes:
      - data2-1:/data1
      - data2-2:/data2
      
  minio3:
    <<: *minio-common
    hostname: minio3
    deploy:
      placement:
        constraints:
          - "node.labels.minio3==true"
    volumes:
      - data3-1:/data1
      - data3-2:/data2 
      
  minio4:
    <<: *minio-common
    hostname: minio4
    deploy:
      placement:
        constraints:
          - "node.labels.minio4==true"
    volumes:
      - data4-1:/data1
      - data4-2:/data2      

  nginx:
    image: nginx:1.19.2-alpine
    hostname: nginx
    volumes:
      - /root/minio/swarm/nginx.conf:/etc/nginx/nginx.conf:ro
    ports:
      - "9000:9000"
      - "9001:9001"
    deploy:
      update_config:
        parallelism: 1
        delay: 10s
        order: stop-first
      restart_policy:
        condition: on-failure
    depends_on:
      - minio1
      - minio2
      - minio3
      - minio4

volumes:
  data1-1:
  data1-2:
  data2-1:
  data2-2:
  data3-1:
  data3-2:
  data4-1:
  data4-2:

2.5.4 nginx.conf

user  nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events {
    worker_connections  4096;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;
    sendfile        on;
    keepalive_timeout  65;

    # include /etc/nginx/conf.d/*.conf;

    upstream minio {
        server minio1:9000;
        server minio2:9000;
        server minio3:9000;
        server minio4:9000;
    }

    upstream console {
        ip_hash;
        server minio1:9001;
        server minio2:9001;
        server minio3:9001;
        server minio4:9001;
    }

    server {
        listen       9000;
        listen  [::]:9000;
        server_name  localhost;

        #允许特殊请求头
        ignore_invalid_headers off;
        #不限制上传文件大小
        client_max_body_size 1000m;
        # To disable buffering
        proxy_buffering off;
        proxy_request_buffering off;

        location / {
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_connect_timeout 300;
            # Default is HTTP/1, keepalive is only enabled in HTTP/1.1
            proxy_http_version 1.1;
            proxy_set_header Connection "";
            chunked_transfer_encoding off;

            proxy_pass http://minio;
        }
    }

    server {
        listen       9001;
        listen  [::]:9001;
        server_name  localhost;

        ignore_invalid_headers off;
        client_max_body_size 0;
        proxy_buffering off;
        proxy_request_buffering off;

        location / {
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-NginX-Proxy true;

            real_ip_header X-Real-IP;

            proxy_connect_timeout 300;
            
            # To support websocket
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            
            chunked_transfer_encoding off;

            proxy_pass http://console;
        }
    }
}

2.5.5 启动服务

配置服务调度标签,会在对应的node上的labels属性添加上属性,使用部署文件中下面的约束来让对应的服务调度到固定的节点。

deploy:
      placement:
        constraints:
          - "node.labels.minio1==true"

给node加上标签值,如minio-1节点的标签为minio1==true

docker node update --label-add minio1=true minio-1
docker node update --label-add minio2=true minio-2
docker node update --label-add minio3=true minio-3
docker node update --label-add minio4=true minio-4

启动服务

docker stack deploy -c docker-swarm.yml minio
docker stack rm minio

三、SpringBoot整合

3.1 配置

3.1.1 POM依赖

服务端版本:2023-05-27T05-56-19Z

客户端使用最新版的8.5.2,客户端内部http调用使用的okhttp,但是版本太低3.14.9导致直接项目起不来,指定okhttp版本到4.11.0

回退版本可以让项目起来,但是有一些功能会有问题,所以还是需要升级okhttp的版本。

<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.2</version>
    <exclusions>
        <exclusion>
            <groupId>com.squareup.okhttp3</groupId>
            <artifactId>okhttp</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>okhttp</artifactId>
    <version>4.11.0</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>

3.1.2 SpringBoot配置

定义外部化配置类

@Data
@ConfigurationProperties(prefix = "minio")
public class MinioProperty implements Serializable {

    private static final long serialVersionUID = 8158370057432113158L;
    /**
     * minio服务端地址
     */
    private String endpoint;
    /**
     * username/访问key
     */
    private String accessKey;
    /**
     * pwd/密钥key
     */
    private String secretKey;
    /**
     * 生成sts临时凭证的子用户用户名,有此参数就提供sts生成实例
     */
    private String stsUsername;
    /**
     * 生成sts临时凭证的子用户密码
     */
    private String stsPassword;
    /**
     * STS有效时间,小于3600默认设置为3600
     */
    private Integer stsDurationSeconds;
    /**
     * STS凭证策略,二次限定
     * 可限定test-spring桶的只写
     * {"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": ["s3:PutObject"], "Resource": ["arn:aws:s3:::test-spring/*"]}]}
     */
    String stsPolicy;
    /**
     * STS凭证区域,默认为空串
     */
    String stsRegion;
    /**
     * STS凭证roleArn参数,默认为null
     */
    String stsRoleArn;
    /**
     * STS凭证roleSessionName参数,默认为null
     */
    String stsRoleSessionName;
}

定义AutoConfiguration类

@Configuration
@ConditionalOnProperty(prefix = "minio", name = "endpoint")
@EnableConfigurationProperties(value = MinioProperty.class)
public class MinioAutoConfiguration {

    private final MinioProperty minioProperty;

    public MinioAutoConfiguration(MinioProperty minioProperty) {
        this.minioProperty = minioProperty;
    }
	//默认客户端
    @Bean
    @ConditionalOnMissingBean
    public MinioClient minioClient() {
        return MinioClient.builder().
                endpoint(minioProperty.getEndpoint()).
                credentials(minioProperty.getAccessKey(), minioProperty.getSecretKey()).build();
    }
	//默认工具类
    @Bean
    @ConditionalOnMissingBean
    public MinioTemplate minioTemplate(MinioClient client, ObjectProvider<Provider> provider) {
        return new MinioTemplate(client, minioProperty, provider.getIfAvailable());
    }
	//默认sts临时凭证提供实例
    @Bean
    @ConditionalOnProperty(value = "minio.sts-username")
    @ConditionalOnMissingBean
    public Provider provider() throws NoSuchAlgorithmException {
        return new AssumeRoleProvider(
                minioProperty.getEndpoint(),
                minioProperty.getStsUsername(),
                minioProperty.getStsPassword(),
                minioProperty.getStsDurationSeconds(),
                minioProperty.getStsPolicy(),
                minioProperty.getStsRegion(),
                minioProperty.getStsRoleArn(),
                minioProperty.getStsRoleSessionName(),
                null,
                null);
    }
}

定义META-INF/spring.factories

org.springframework.boot.autoconfigure.EnableAutoConfiguration=org.xrj.config.MinioAutoConfiguration

定义yml

spring:
  #配置文件上传大小限制
  servlet:
    multipart:
      max-file-size: 100MB
      max-request-size: 100MB

minio:
  endpoint: http://192.168.10.109:9000
  access-key: uX3AwnHq8UzhjKDqWXvY
  secret-key: M0RrCGfPShQ2SfUU9svb0RPuxUxENTlsqmTrrycG
  sts-username: sts_user
  sts-password: sts_user12345678
  sts-role-arn: arn:aws:s3:::*
  sts-role-session-name: StsToken
  sts-duration-seconds: 3600
  sts-policy: |
    {"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": ["s3:PutObject"], "Resource": ["arn:aws:s3:::test-spring/*"]}]}

3.2 MinioTemplate

@Slf4j
public class MinioTemplate {
    
	//预签名url默认有效时间
    private static final int DEFAULT_EXPIRY_TIME = (int) TimeUnit.MINUTES.toSeconds(5);

    private final MinioClient minioClient;
    private final Provider provider;
    private final MinioProperty minioProperty;

    public MinioTemplate(MinioClient minioClient, MinioProperty minioProperty, Provider provider) {
        this.minioClient = minioClient;
        this.minioProperty = minioProperty;
        this.provider = provider;
    }


    /**
     * 创建bucket
     */
    public void createBucket(String bucketName) {
        try {
            if (!bucketExists(bucketName)) {
                minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
            }
        } catch (Exception e) {
            log.error("创建bucket异常,bucket:{}", bucketName);
            throw new MinioException("创建bucket异常,bucket:" + bucketName, e);
        }
    }

    /**
     * 判断Bucket是否存在,true:存在,false:不存在
     */
    public boolean bucketExists(String bucketName) {
        try {
            return minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
        } catch (Exception e) {
            log.error("判断bucket存在异常,bucket:{}", bucketName);
            throw new MinioException("判断bucket存在异常,bucket:" + bucketName, e);
        }
    }


    /**
     * 获得Bucket的策略
     * 可以配置对应bucket的访问策略
     */
    public String getBucketPolicy(String bucketName) {
        try {
            return minioClient.getBucketPolicy(GetBucketPolicyArgs.builder().bucket(bucketName).build());
        } catch (Exception e) {
            log.error("获得bucket的策略异常,bucket:{}", bucketName);
            throw new MinioException("获得bucket的策略异常,bucket:" + bucketName, e);
        }
    }


    /**
     * 获得所有Bucket列表
     */
    public List<Bucket> getAllBuckets() {
        try {
            return minioClient.listBuckets();
        } catch (Exception e) {
            log.error("查询bucket列表方法异常,{}", e.getMessage());
            throw new MinioException("查询bucket列表方法异常", e);
        }
    }

    /**
     * 获取指定bucket信息
     */
    public Optional<Bucket> getBucket(String bucketName) {
        return getAllBuckets().stream().filter(b -> b.name().equals(bucketName)).findFirst();
    }

    /**
     * 默认删除桶方法,不删除内部文件
     *
     * @param bucketName 桶名称
     */
    public void removeBucket(String bucketName) {
        removeBucket(bucketName, false);
    }

    /**
     * @param bucketName 删除桶名称
     * @param force      强制删除 递归删除bucket内文件
     * @description 无法删除非空bucket, 强制删除时先递归递归删除桶内所有内容
     */
    public void removeBucket(String bucketName, boolean force) {
        try {
            if (force) {
                Iterable<Result<Item>> results = listObjects(bucketName, null, false);
                for (Result<Item> result : results) {
                    Item item = result.get();
                    removeFile(bucketName, item.objectName(), item.isDir());
                }
            }
            minioClient.removeBucket(RemoveBucketArgs.builder().bucket(bucketName).build());
            log.info("bucket删除成功,bucketName:{}", bucketName);
        } catch (Exception e) {
            log.error("删除bucket异常,bucketName:{}", bucketName, e);
            throw new MinioException("删除bucket异常", e);
        }
    }

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

    /**
     * 判断文件夹是否存在
     *
     * @param bucketName 存储桶
     * @param objectName 文件夹名称
     */
    public boolean isFolderExist(String bucketName, String objectName) {
        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())) {
                    return true;
                }
            }
        } catch (Exception e) {
            log.error("判断目录存在异常,bucket:{},objectName:{}", bucketName, objectName, e);
        }
        return false;
    }

    /**
     * 根据文件前置查询文件
     *
     * @param bucketName 存储桶
     * @param prefix     前缀,从bucket的一级目录开始,如:test/1.jpg,test是一级目录
     * @param recursive  是否使用递归查询
     * @return MinioItem 列表
     */
    public List<Item> getAllObjectsByPrefix(String bucketName, String prefix, boolean recursive) {
        List<Item> itemList = new ArrayList<>();
        Iterable<Result<Item>> itemIterable = listObjects(bucketName, prefix, recursive);
        try {
            if (itemIterable != null) {
                for (Result<Item> itemResult : itemIterable) {
                    itemList.add(itemResult.get());
                }
            }
        } catch (Exception e) {
            log.error("前缀查询获取item对象异常,bucketName:{},prefix:{},recursive:{}", bucketName, prefix, recursive);
            throw new MinioException("前缀查询获取item对象异常", e);
        }
        return itemList;
    }

    /**
     * 获取文件流
     *
     * @param bucketName 存储桶
     * @param objectName 文件名
     * @return 二进制流
     */
    public InputStream getObject(String bucketName, String objectName) {
        try {
            return minioClient.getObject(GetObjectArgs.builder().bucket(bucketName).object(objectName).build());
        } catch (Exception e) {
            log.error("获取文件流异常,bucketName:{},objectName:{}", bucketName, objectName);
            throw new MinioException("获取文件流异常", e);
        }
    }

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

        try {
            return minioClient.getObject(GetObjectArgs.builder().bucket(bucketName).object(objectName).offset(offset).length(length).build());
        } catch (Exception e) {
            log.error("断点下载异常,bucketName:{},objectName:{},offset:{},length:{}", bucketName, objectName, offset, length);
            throw new MinioException("断点下载异常", e);
        }
    }

    /**
     * 获取路径下文件列表迭代器
     *
     * @param bucketName 存储桶
     * @param prefix     文件名称
     * @param recursive  是否递归查找,false:模拟文件夹结构查找
     * @return 迭代器对象
     */
    public 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 类型
     */
    public ObjectWriteResponse uploadFile(String bucketName, MultipartFile file, String objectName, String contentType) {
        try {
            InputStream inputStream = file.getInputStream();
            return minioClient.putObject(PutObjectArgs.builder().bucket(bucketName).object(objectName).contentType(contentType).stream(inputStream, inputStream.available(), -1).build());
        } catch (Exception e) {
            log.error("MultipartFile文件上传异常,bucketName:{},objectName:{},contentType:{}", bucketName, objectName, contentType);
            throw new MinioException("MultipartFile文件上传异常", e);
        }
    }

    /**
     * 上传本地文件
     *
     * @param bucketName 存储桶
     * @param objectName 对象名称
     * @param fileName   本地文件路径
     */
    public ObjectWriteResponse uploadFile(String bucketName, String objectName, String fileName) {
        try {
            return minioClient.uploadObject(UploadObjectArgs.builder().bucket(bucketName).object(objectName).filename(fileName).build());
        } catch (Exception e) {
            log.error("本地文件上传异常,bucketName:{},objectName:{},fileName:{}", bucketName, objectName, fileName);
            throw new MinioException("本地文件上传异常", e);
        }

    }

    /**
     * 通过流上传文件
     *
     * @param bucketName  存储桶
     * @param objectName  文件对象
     * @param inputStream 文件流
     */
    public ObjectWriteResponse uploadFile(String bucketName, String objectName, InputStream inputStream) {
        try {
            return minioClient.putObject(PutObjectArgs.builder().bucket(bucketName).object(objectName).stream(inputStream, inputStream.available(), -1).build());
        } catch (Exception e) {
            log.error("流文件上传异常,bucketName:{},objectName:{}", bucketName, objectName);
            throw new MinioException("流文件上传异常", e);
        }
    }

    /**
     * 创建文件夹或目录
     *
     * @param bucketName 存储桶
     * @param objectName 目录路径
     */
    public ObjectWriteResponse createDir(String bucketName, String objectName) {
        try {
            return minioClient.putObject(PutObjectArgs.builder().bucket(bucketName).object(objectName).stream(new ByteArrayInputStream(new byte[]{}), 0, -1).build());
        } catch (Exception e) {
            log.error("创建目录对象异常,bucketName:{},objectName:{}", bucketName, objectName);
            throw new MinioException("创建目录对象异常", e);
        }

    }

    /**
     * 获取文件信息
     *
     * @param bucketName 存储桶
     * @param objectName 文件名称
     */
    public String getFileStatusInfo(String bucketName, String objectName) {
        try {
            //不存在抛异常
            return minioClient.statObject(StatObjectArgs.builder().bucket(bucketName).object(objectName).build()).toString();
        } catch (Exception e) {
            log.error("文件信息获取失败,bucketName:{},objectName:{}", bucketName, objectName);
            throw new MinioException("文件信息获取失败", e);
        }

    }

    /**
     * 拷贝文件
     *
     * @param destBucketName 目标存储桶
     * @param destObjectName 目标文件名
     * @param srcBucketName  源存储桶
     * @param srcObjectName  源目标文件名
     */
    public ObjectWriteResponse copyFile(String destBucketName, String destObjectName, String srcBucketName, String srcObjectName) {
        try {
            return minioClient.copyObject(CopyObjectArgs.builder().source(CopySource.builder().bucket(srcBucketName).object(srcObjectName).build()).bucket(destBucketName).object(destObjectName).build());
        } catch (Exception e) {
            log.error("拷贝文件失败,destBucketName:{},destObjectName:{},srcBucketName:{},srcObjectName:{}", destBucketName, destObjectName, srcBucketName, srcObjectName);
            throw new MinioException("拷贝文件失败", e);
        }

    }

    /**
     * 删除文件
     *
     * @param bucketName 存储桶
     * @param objectName 文件名称
     * @description 默认删除非目录文件
     */
    public void removeFile(String bucketName, String objectName) {
        removeFile(bucketName, objectName, false);
    }

    /**
     * 删除文件
     *
     * @param bucketName 存储桶
     * @param objectName 文件名称
     * @param isDir      目录标识
     * @description 目录文件先递归删除内部文件
     */
    public void removeFile(String bucketName, String objectName, boolean isDir) {
        try {
            if (isDir) {
                Iterable<Result<Item>> results = listObjects(bucketName, objectName, false);
                for (Result<Item> result : results) {
                    Item item = result.get();
                    removeFile(bucketName, item.objectName(), item.isDir());
                }
            }
            minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(objectName).build());
            log.info("文件删除成功,objectName:{}", objectName);
        } catch (Exception e) {
            log.error("删除文件失败,bucketName:{},objectName:{}", bucketName, objectName);
            throw new MinioException("删除文件失败", e);
        }
    }

    /**
     * 批量删除文件
     *
     * @param bucketName     存储桶
     * @param objectNameList 需要删除的文件列表
     */
    public void removeFiles(String bucketName, List<String> objectNameList) {
        objectNameList.forEach(objectName -> removeFile(bucketName, objectName));
    }

    /**
     * 获取文件外链
     *
     * @param bucketName 存储桶
     * @param objectName 文件名
     * @param expires    过期时间秒 默认设置5分钟
     * @param method     签名方法,GET用于临时预览,PUT用于临时上传
     * @return 外链url
     */
    public String getPresignedObjectUrl(String bucketName, String objectName, int expires, Method method) {
        try {
            return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder().expiry(expires).bucket(bucketName).object(objectName).method(method).build());
        } catch (Exception e) {
            log.error("获取文件外链失败,bucketName:{},objectName:{}", bucketName, objectName);
            throw new MinioException("获取文件外链失败", e);
        }

    }

    /**
     * 获取文件预览外链,设置过期时间
     */
    public String getPresignedObjectUrl(String bucketName, String objectName, int expires) {
        return getPresignedObjectUrl(bucketName, objectName, expires, Method.GET);
    }

    /**
     * 获取文件外链,设置签名Method
     */
    public String getPresignedObjectUrl(String bucketName, String objectName, Method method) {
        return getPresignedObjectUrl(bucketName, objectName, DEFAULT_EXPIRY_TIME, method);
    }

    /**
     * 获得预览文件外链
     */
    public String getPresignedObjectUrl(String bucketName, String objectName) {
        return getPresignedObjectUrl(bucketName, objectName, DEFAULT_EXPIRY_TIME);
    }

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

    /**
     * 获取sts临时凭证
     */
    public StsCredentials getStsCredentials() {
        Credentials credentials = getCredentials();
        return StsCredentials.builder().accessKey(credentials.accessKey())
                .secretKey(credentials.secretKey())
                .sessionToken(credentials.sessionToken())
                .isExpired(credentials.isExpired()).build();
    }

    /**
     * 获取sts生成的客户端实例
     */
    public MinioClient getStsClient() {
        Credentials credentials = getCredentials();
        StaticProvider staticProvider = new StaticProvider(credentials.accessKey(), credentials.secretKey(), credentials.sessionToken());
        return MinioClient.builder().endpoint(minioProperty.getEndpoint()).credentialsProvider(staticProvider).build();
    }

    private Credentials getCredentials() {
        if (provider == null){
            throw new MinioException("请设置sts参数再使用相关功能",null);
        }
        return provider.fetch();
    }

}

3.2.1 依赖Model

STS临时凭证包装,方便返回给前端使用

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class StsCredentials implements Serializable {

    private static final long serialVersionUID = 8484283180967327036L;

    private String accessKey;

    private String secretKey;

    private String sessionToken;

    private Boolean isExpired;
}

3.2.2 依赖异常

MinioException包装minio客户端实例抛出的受检异常,方便接口调用。

public class MinioException extends RuntimeException {
    public MinioException(String message, Throwable e) {
        super(message, e);
    }
}

3.3 接口测试

@RestController
@Slf4j
public class TestController {

    @Autowired
    private MinioTemplate minioTemplate;
    String bucketName = "test-spring";

    @PostMapping("createBucket")
    public void createBucket() {
        minioTemplate.createBucket(bucketName);
    }

    @PostMapping("existBucket")
    public Boolean existBucket() {
        return minioTemplate.bucketExists(bucketName);
    }

    @PostMapping("getBucketPolicy")
    public String getBucketPolicy() {
        return minioTemplate.getBucketPolicy(bucketName);
    }

    @PostMapping("getAllBucket")
    public List<String> getAllBucket() {
        List<Bucket> allBuckets = minioTemplate.getAllBuckets();
        return allBuckets.stream().map(Bucket::name).collect(Collectors.toList());
    }

    @PostMapping("getBucket")
    public String getBucket() {
        return minioTemplate.getBucket(bucketName).orElse(new Bucket()).name();
    }

    @PostMapping("delBucket")
    public void delBucket() {
        minioTemplate.removeBucket(bucketName);
    }

    @PostMapping("uploadMultipartFile")
    public void uploadMultipartFile(MultipartFile file) {
        minioTemplate.uploadFile(bucketName, file, file.getOriginalFilename(), file.getContentType());
    }

    @PostMapping("deleteFile")
    public void deleteFile() {
        minioTemplate.removeFile(bucketName, "file");
    }

    @PostMapping("copyFile")
    public void copyFile() {
        minioTemplate.copyFile(bucketName, "test/copy.md", bucketName, "spring项目问题.md");
    }

    @PostMapping("getPreSignedUrl")
    public String getSignedObjectUrl() {
        //预览纯文本时浏览器可能中文乱码,需要设置浏览器的字符集编码为utf-8,和后端没有关系
        return minioTemplate.getPresignedObjectUrl(bucketName, "spring项目问题.md");
    }

    @PostMapping("putPreSignedUrl")
    public String postPreSignedUrl() {
        return minioTemplate.getPresignedObjectUrl(bucketName, "spring项目问题.md", Method.PUT);
    }

    @GetMapping(value = "getSts")
    public StsCredentials getSts() {
        return minioTemplate.getStsCredentials();

    }

    @SneakyThrows
    @GetMapping(value = "testSts")
    public void testSts() {
        MinioClient stsClient = minioTemplate.getStsClient();
        GetObjectResponse object = stsClient.getObject(GetObjectArgs.builder().bucket(bucketName).object("spring项目问题.md").build());
        log.info("sts测试生效:{}", object);

    }
}

四、使用问题

4.1 预览出现中文乱码

4.1.1 场景

通过getPresignedObjectUrl接口获得private桶的预签名链接,以方便临时访问目标资源。签名后拿到访问地址如下:

http://192.168.10.109:9000/test-spring/spring%E9%A1%B9%E7%9B%AE%E9%97%AE%E9%A2%98.md?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=uX3AwnHq8UzhjKDqWXvY%2F20230602%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20230602T074052Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=0bad313b8f9eac07a9db67cf5bd99bdbab47aa0a9c06b96c36339663fd4e23fe

在浏览器直接访问地址,页面显示访问中文乱码。

image-20230602154212311

4.1.2 原因

浏览器发起调用时没有指定响应编码,响应体也没有指定编码,导致浏览器使用系统默认的编码集,WIN电脑默认GBK,导致乱码。

4.1.3 解决办法

下载一个浏览器插件Set Character Encoding,插件支持手动指定浏览器页面编码。

image-20230602154732157

设置后中文正常展示

image-20230602154755884