1. 容器准备
1.1 容器结构
1.2 启动容器
1.3 docker-compose.yml
XML
version: '3.8' # 指定 Docker Compose 文件的版本,这里使用版本 3.8
services:
minio1:
image: minio/minio:latest # 使用最新的 MinIO 镜像来创建 MinIO 服务的容器
volumes:
- ./data1-1:/data1 # 将当前目录下的 data1-1 文件夹挂载到容器内的 /data1 目录
- ./data1-2:/data2 # 将当前目录下的 data1-2 文件夹挂载到容器内的 /data2 目录
ports:
- "9001:9000" # 将主机的 9001 端口映射到容器的 9000 端口,这是 MinIO 数据服务端口
- "9005:9001" # 将主机的 9005 端口映射到容器的 9001 端口,这是 MinIO 控制台端口
environment:
MINIO_ROOT_USER: minioadmin # 设置 MinIO 的根用户用户名
MINIO_ROOT_PASSWORD: minioadminpassword # 设置 MinIO 的根用户密码
command: server http://minio{1...4}/data{1...2} --console-address ":9001" # 启动 MinIO 服务并指定控制台端口
networks:
- minio-net # 将 MinIO 服务加入到名为 minio-net 的网络中
minio2:
image: minio/minio:latest # 使用最新的 MinIO 镜像来创建第二个 MinIO 服务的容器
volumes:
- ./data2-1:/data1 # 将当前目录下的 data2-1 文件夹挂载到容器内的 /data1 目录
- ./data2-2:/data2 # 将当前目录下的 data2-2 文件夹挂载到容器内的 /data2 目录
ports:
- "9002:9000" # 将主机的 9002 端口映射到容器的 9000 端口,这是 MinIO 数据服务端口
- "9006:9001" # 将主机的 9006 端口映射到容器的 9001 端口,这是 MinIO 控制台端口
environment:
MINIO_ROOT_USER: minioadmin # 设置 MinIO 的根用户用户名
MINIO_ROOT_PASSWORD: minioadminpassword # 设置 MinIO 的根用户密码
command: server http://minio{1...4}/data{1...2} --console-address ":9001" # 启动 MinIO 服务并指定控制台端口
networks:
- minio-net # 将 MinIO 服务加入到名为 minio-net 的网络中
minio3:
image: minio/minio:latest # 使用最新的 MinIO 镜像来创建第三个 MinIO 服务的容器
volumes:
- ./data3-1:/data1 # 将当前目录下的 data3-1 文件夹挂载到容器内的 /data1 目录
- ./data3-2:/data2 # 将当前目录下的 data3-2 文件夹挂载到容器内的 /data2 目录
ports:
- "9003:9000" # 将主机的 9003 端口映射到容器的 9000 端口,这是 MinIO 数据服务端口
- "9007:9001" # 将主机的 9007 端口映射到容器的 9001 端口,这是 MinIO 控制台端口
environment:
MINIO_ROOT_USER: minioadmin # 设置 MinIO 的根用户用户名
MINIO_ROOT_PASSWORD: minioadminpassword # 设置 MinIO 的根用户密码
command: server http://minio{1...4}/data{1...2} --console-address ":9001" # 启动 MinIO 服务并指定控制台端口
networks:
- minio-net # 将 MinIO 服务加入到名为 minio-net 的网络中
minio4:
image: minio/minio:latest # 使用最新的 MinIO 镜像来创建第四个 MinIO 服务的容器
volumes:
- ./data4-1:/data1 # 将当前目录下的 data4-1 文件夹挂载到容器内的 /data1 目录
- ./data4-2:/data2 # 将当前目录下的 data4-2 文件夹挂载到容器内的 /data2 目录
ports:
- "9004:9000" # 将主机的 9004 端口映射到容器的 9000 端口,这是 MinIO 数据服务端口
- "9008:9001" # 将主机的 9008 端口映射到容器的 9001 端口,这是 MinIO 控制台端口
environment:
MINIO_ROOT_USER: minioadmin # 设置 MinIO 的根用户用户名
MINIO_ROOT_PASSWORD: minioadminpassword # 设置 MinIO 的根用户密码
command: server http://minio{1...4}/data{1...2} --console-address ":9001" # 启动 MinIO 服务并指定控制台端口
networks:
- minio-net # 将 MinIO 服务加入到名为 minio-net 的网络中
nginx:
image: nginx:latest # 使用最新的 Nginx 镜像来创建 Nginx 服务的容器
container_name: nginx # 为容器命名为 nginx
ports:
- "80:80" # 将主机的 80 端口映射到容器的 80 端口,这是 HTTP 服务端口
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro # 将本地的 nginx 配置文件挂载到容器内的 nginx 配置目录,设置为只读
networks:
- minio-net # 将 Nginx 服务加入到名为 minio-net 的网络中
volumes:
data1-1:
data1-2:
data2-1:
data2-2:
data3-1:
data3-2:
data4-1:
data4-2:
networks:
minio-net: # 定义一个名为 minio-net 的网络,用于连接 MinIO 和 Nginx 服务
1.4 nginx.conf
XML
# 设置 Nginx 进程数,这里设置为 1。
worker_processes 1;
# 配置 Nginx 的事件模块,处理基本的事件逻辑。
events {
# 设置每个工作进程的最大连接数。
worker_connections 1024;
}
# 配置 Nginx 的 HTTP 模块。
http {
# 定义一个上游服务器组,名为 minio,包含四个 MinIO 实例。
upstream minio {
# 添加 MinIO 实例到上游服务器组,所有请求将被均衡地分配到这些实例上。
server minio1:9000;
server minio2:9000;
server minio3:9000;
server minio4:9000;
}
# 定义一个服务器块,处理来自客户端的 HTTP 请求。
server {
# 监听 80 端口,这里是 HTTP 默认端口。
listen 80;
# 定义一个位置块,处理所有的 URL 路径(即 '/' 开始的路径)。
location / {
# 将所有请求转发到上游服务器组 minio。
proxy_pass http://minio;
# 设置主机头信息为客户端请求中的 Host 头信息。
proxy_set_header Host $host;
# 设置客户端的真实 IP 地址。
proxy_set_header X-Real-IP $remote_addr;
# 设置客户端请求经过的代理服务器地址。
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 设置客户端请求的协议(HTTP 或 HTTPS)。
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
1.5 运行结果
1.5.1 自动创建目录
1.5.2 访问MinIO
说明:
http://192.168.186.77:9005/login
http://192.168.186.77:9006/login
http://192.168.186.77:9007/login
http://192.168.186.77:9008/login
访问4个不同的端口代表不同的MinIO,我是在Ubuntu24.04 TLS版安装的,你可以通过ifconfig命令查看自己本机的IP,也就是NGINX对外的IP,自行替换192.168.186.77。
1.5.3 登录MinIO
说明:用户名和密码是你自己在docker-compose.yml里面配置的用户名和密码。
1.5.4 创建Buckets
桶命名规则:桶名称必须在3到63个字符之间,只能使用小写字母、数字、连字符(-)和点(.),名称不能以点(.)或者连字符(-)开始或结束,桶名称不能是IP地址格式,在一个MinIO实例中,桶名称必须是唯一的,不同用户在同一个MinIO实例中也不能使用同名桶。。
1.5.5 开放访问权限
操作:在创建的桶旁边点击Manage,把Access Policy修改为Public,你可以自行查找更安全的方式进行访问。
2. 项目结构
3. pom.xml
XML
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.2</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>org.example</groupId>
<artifactId>minio</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- https://mvnrepository.com/artifact/io.minio/minio -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.11</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
说明:通过Maven引入MinIO 和 Spring Boot 以及数据库相关的依赖。
4. application.yml
XML
spring:
application:
name: spring_minio
datasource:
url: jdbc:mysql://localhost:3306/minio
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
open-in-view: false
minio:
url: http://192.168.186.77 # 使用nginx的地址,因为它会代理到MinIO集群
access-key: minioadmin
secret-key: minioadminpassword
bucket: bucket
base-url: http://192.168.186.77/bucket/ # 代理后的base url
说明:每个 MinIO 实例的实际数据服务端口是 9000
(在 Docker Compose 中映射为 9001
, 9002
, 9003
, 9004
)。你的 Spring Boot 应用程序通过 Nginx 代理访问 MinIO 集群,因此配置中的 URL 和 base-url 应该指向 Nginx。
5. SpringMinioApplication.java
XML
package org.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SpringMinioApplication {
public static void main(String[] args) {
SpringApplication.run(SpringMinioApplication.class, args);
}
}
6. User.java
XML
package org.example.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Data;
@Data
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String email;
private String password;
private String imageUrl;
}
7. UserRepository.java
XML
package org.example.repository;
import org.example.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
User findByUsername(String username);
}
8. UserService.java
XML
package org.example.service;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import io.minio.GetObjectArgs;
import io.minio.RemoveObjectArgs;
import org.example.entity.User;
import org.example.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.util.UUID;
import java.io.InputStream;
@Service
public class UserService {
@Autowired
private MinioClient minioClient;
@Autowired
private UserRepository userRepository;
@Value("${minio.bucket}")
private String bucketName;
@Value("${minio.base-url}")
private String baseUrl;
public User updateUserAvatar(Long userId, MultipartFile file) {
User user = userRepository.findById(userId).orElseThrow(() -> new RuntimeException("User not found"));
String originalFileName = file.getOriginalFilename();
String extension = null;
if (originalFileName != null) {
extension = originalFileName.substring(originalFileName.lastIndexOf('.'));
}
String fileName = UUID.randomUUID() + extension;
try {
// 上传新的头像
minioClient.putObject(
PutObjectArgs.builder().bucket(bucketName).object(fileName).stream(
file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build());
// 保存图片文件名到数据库
String oldFileName = user.getImageUrl();
user.setImageUrl(fileName);
userRepository.save(user);
// 删除旧的头像
if (oldFileName != null && !oldFileName.isEmpty()) {
minioClient.removeObject(
RemoveObjectArgs.builder().bucket(bucketName).object(oldFileName).build());
}
// 返回对象信息 拼接的图片路径 + 空密码
String imageUrl = baseUrl + fileName;
user.setImageUrl(imageUrl);
user.setPassword(null);
return user;
} catch (Exception e) {
// 如果上传新头像失败,抛出异常
throw new RuntimeException("Error updating user avatar", e);
}
}
public InputStream downloadFile(String fileName) throws Exception {
return minioClient.getObject(
GetObjectArgs.builder()
.bucket(bucketName)
.object(fileName)
.build());
}
public void register(User user) {
User existingUser = userRepository.findByUsername(user.getUsername());
if (existingUser != null) {
throw new IllegalStateException("Username already exists.");
}
userRepository.save(user);
}
public User login(User user) {
User foundUser = userRepository.findByUsername(user.getUsername());
if (foundUser != null && foundUser.getPassword().equals(user.getPassword())) {
foundUser.setPassword(null);
foundUser.setImageUrl(baseUrl + foundUser.getImageUrl());
return foundUser;
}
return null;
}
}
说明:只做简单的登录,注册操作并没有进行严格的认证,比如token认证或有权访问,只是模拟图片的上传和下载,通过**MultipartFile
** 接收上传的文件。使用**UUID
** 生成唯一的文件名,以避免文件名冲突。使用**MinioClient.putObject
** 方法将文件上传到MinIO桶中。将新上传的文件名保存到用户数据库中。删除旧的头像文件,以节省存储空间。使用**MinioClient.getObject
**方法从MinIO桶中下载指定文件。
9. UserController.java
XML
package org.example.controer;
import org.example.entity.User;
import org.example.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
@Controller
public class UserController {
@Autowired
private UserService userService;
@PostMapping("/login")
@ResponseBody
public ResponseEntity<User> login(@RequestBody User user) {
User pass = userService.login(user);
if (pass!=null) {
return ResponseEntity.ok(pass);
}
return ResponseEntity.status(400).body(null);
}
@PostMapping("/register")
@ResponseBody
public ResponseEntity<String> registerUser(@RequestBody User user) {
try {
userService.register(user);
return ResponseEntity.ok("注册成功! ");
} catch (IllegalStateException e) {
return ResponseEntity.status(400).body(e.getMessage());
} catch (Exception e) {
return ResponseEntity.status(500).body("注册失败! ");
}
}
@PostMapping("/upload/{id}")
@ResponseBody
public ResponseEntity<User> uploadUserAvatar(@PathVariable Long id, @RequestParam("file") MultipartFile file) {
try {
return ResponseEntity.ok(userService.updateUserAvatar(id, file));
} catch (Exception e) {
return ResponseEntity.status(500).body(null);
}
}
@GetMapping("/download/{fileName}")
@ResponseBody
public ResponseEntity<InputStreamResource> downloadFile(@PathVariable String fileName) {
try {
InputStream inputStream = userService.downloadFile(fileName);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"")
.body(new InputStreamResource(inputStream));
} catch (Exception e) {
return ResponseEntity.status(500).body(null);
}
}
}
10. MinioConfig.java
XML
package org.example.config;
import io.minio.MinioClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MinioConfig {
@Value("${minio.url}")
private String minioUrl;
@Value("${minio.access-key}")
private String accessKey;
@Value("${minio.secret-key}")
private String secretKey;
@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.endpoint(minioUrl)
.credentials(accessKey, secretKey)
.build();
}
}
11. index.html
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>用户管理</title>
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.1.3/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<style>
body {
background-color: #f5f5f5;
font-family: 'Arial', sans-serif;
}
.container {
max-width: 600px;
margin-top: 50px;
}
.card {
border-radius: 15px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.card-header {
background-color: #007bff;
color: white;
text-align: center;
font-size: 1.5rem;
padding: 1rem;
}
.nav-tabs {
border-bottom: none;
}
.nav-tabs .nav-link {
border: none;
color: #007bff;
}
.nav-tabs .nav-link.active {
color: white;
background-color: #007bff;
border-radius: 0;
}
.form-control {
border-radius: 50px;
padding-left: 2.5rem;
}
.form-icon {
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
color: #007bff;
}
.btn-primary {
border-radius: 50px;
background-color: #007bff;
border: none;
}
.btn-primary:hover {
background-color: #0056b3;
}
.avatar {
width: 150px;
height: 150px;
border-radius: 50%;
margin: 20px auto;
display: block;
object-fit: cover;
}
.text-center {
text-align: center;
}
.mt-4 {
margin-top: 1.5rem;
}
.position-relative {
position: relative;
}
</style>
</head>
<body>
<div class="container">
<div class="card" id="auth-card">
<div class="card-header">
用户管理
</div>
<div class="card-body">
<ul class="nav nav-tabs justify-content-center" id="authTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="login-tab" data-bs-toggle="tab" data-bs-target="#login" type="button" role="tab" aria-controls="login" aria-selected="true">登录</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="register-tab" data-bs-toggle="tab" data-bs-target="#register" type="button" role="tab" aria-controls="register" aria-selected="false">注册</button>
</li>
</ul>
<div class="tab-content mt-4" id="authTabsContent">
<div class="tab-pane fade show active" id="login" role="tabpanel" aria-labelledby="login-tab">
<form id="loginForm">
<div class="mb-3 position-relative">
<i class="fas fa-user form-icon"></i>
<input type="text" class="form-control" id="loginUsername" placeholder="用户名" required>
</div>
<div class="mb-3 position-relative">
<i class="fas fa-lock form-icon"></i>
<input type="password" class="form-control" id="loginPassword" placeholder="密码" required>
</div>
<button type="button" class="btn btn-primary w-100" onclick="login()">登录</button>
</form>
</div>
<div class="tab-pane fade" id="register" role="tabpanel" aria-labelledby="register-tab">
<form id="registerForm">
<div class="mb-3 position-relative">
<i class="fas fa-user form-icon"></i>
<input type="text" class="form-control" id="registerUsername" placeholder="用户名" required>
</div>
<div class="mb-3 position-relative">
<i class="fas fa-lock form-icon"></i>
<input type="password" class="form-control" id="registerPassword" placeholder="密码" required>
</div>
<div class="mb-3 position-relative">
<i class="fas fa-envelope form-icon"></i>
<input type="email" class="form-control" id="registerEmail" placeholder="电子邮箱" required>
</div>
<button type="button" class="btn btn-primary w-100" onclick="register()">注册</button>
</form>
</div>
</div>
</div>
</div>
<div class="card d-none mt-4" id="user-card">
<div class="card-header">
用户信息
</div>
<div class="card-body text-center">
<img id="userAvatar" class="avatar" src="" alt="用户头像">
<p><strong>用户名:</strong> <span id="displayUsername"></span></p>
<p><strong>电子邮箱:</strong> <span id="displayEmail"></span></p>
<form id="uploadForm">
<div class="mb-3">
<input type="file" class="form-control" id="file" required>
</div>
<button type="button" class="btn btn-primary w-100" onclick="upload()">上传头像</button>
</form>
<a id="downloadLink" href="#" class="btn btn-secondary w-100 mt-3" download>下载头像</a>
</div>
</div>
</div>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/0.21.1/axios.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.1.3/js/bootstrap.bundle.min.js"></script>
<script>
function login() {
const username = document.getElementById('loginUsername').value;
const password = document.getElementById('loginPassword').value;
axios.post('/login', { username, password })
.then(response => {
alert('登录成功');
// 保存用户信息到localStorage
localStorage.setItem('username', username);
localStorage.setItem('email', response.data.email);
localStorage.setItem('userId', response.data.id);
localStorage.setItem('imageUrl', response.data.imageUrl);
// 显示用户信息
displayUserInfo();
})
.catch(error => {
alert('登录失败: ' + error.response.data);
});
}
function register() {
const username = document.getElementById('registerUsername').value;
const password = document.getElementById('registerPassword').value;
const email = document.getElementById('registerEmail').value;
axios.post('/register', { username, password, email })
.then(response => {
alert('注册成功');
})
.catch(error => {
alert('注册失败: ' + error.response.data);
});
}
function upload() {
const userId = localStorage.getItem('userId');
const file = document.getElementById('file').files[0];
const formData = new FormData();
formData.append('file', file);
axios.post(`/upload/${userId}`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
.then(response => {
alert('上传成功');
localStorage.setItem('imageUrl', response.data.imageUrl);
displayUserInfo();
})
.catch(error => {
alert('上传失败: ' + error.response.data);
});
}
function displayUserInfo() {
const username = localStorage.getItem('username');
const email = localStorage.getItem('email');
const imageUrl = localStorage.getItem('imageUrl');
if (username && email && imageUrl) {
document.getElementById('displayUsername').innerText = username;
document.getElementById('displayEmail').innerText = email;
document.getElementById('userAvatar').src = imageUrl;
document.getElementById('auth-card').classList.add('d-none');
document.getElementById('user-card').classList.remove('d-none');
let filename = imageUrl.substring(imageUrl.lastIndexOf('/') + 1)
document.getElementById('downloadLink').href = `/download/${filename}`;
}
}
// 登录后显示用户信息
window.onload = displayUserInfo;
</script>
</body>
</html>
12. 测试验证
12.1.1 注册账号
说明:先注册一个账号,刚开始图片是NULL的。
12.1.2 登录账号
说明:进行登录,因为之前上传过图片,所以有图片,如果你想退出登录调出控制台清除下面字段刷新即可返回登录界面。
12.1.3 文件上传测试
说明:先选择一张图片,点击上传头像,我上传了一张图片,图片进行回显。
12.1.4 文件下载测试
说明:点击下载头像,头像进行下载。
12.1.5 MinIO控制台预览
12.1.6 数据库数据
13. 总结
通过docker-compose实现了MinIO集群和Nginx的负载均衡,通过Spring Boot 整合MinIo实现图片的上传和下载。