Contexte et Règles de l'Architecture
Dans les systèmes de gestion de documents à grande échelle, l'optimisation des coûts et des performances passe souvent par une stratégie de stockage hiérarchisé. L'objectif est de séparer les données fréquemment consultées (chaudes) des données archivées (froides). Voici les spécifications techniques pour une implémentation basée sur deux clusters MinIO distincts :
- Séparation physique : Un cluster MinIO dédié aux archives (froid) et un autre pour les données actives (chaud).
- Configuration centralisée : Le fichier YAML définit les points de terminaison (URL), les identifiants pour les deux clusters, ainsi qu'une date limite (cutoff date) déterminant la frontière entre le chaud et le froid.
- Structure des répetroires : Chaque projet géophysique possède un dossier dédié, nommé selon le format
YYYY-MM-DD_ProjectID. - Gestion des métadonnées : La base de données relationnelle ne stocke que le chemin relatif de l'objet à partir du dossier du projet.
- Routage dynamique : L'application évalue la date de création du projet. Si elle est antérieure à la date limite, les opérations ciblent le stockage froid ; sinon, elles ciblent le stockage chaud. Les opérations de maintenance garantissent que les anciens dossiers sont physiquement migrés vers le cluster froid.
Concepts Fondamentaux
Avant d'implémenter la solution, il est essentiel de rappeler deux concepts clés de l'API MinIO :
- Bucket : Un espace de noms logique isolé, agissant comme le répertoire racine du stockage.
- Object : L'entité de base représentant le fichier lui-même, identifié de manière unique au sein d'un bucket.
Configuration de l'Environnement
L'intégration nécessite l'ajout du SDK officiel MinIO dans le gestionnaire de dépendances. Pour les fichiers volumineux, le SDK gère nativement le multipart upload.
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.2</version>
</dependency>
Le fichier de configuration application.yml doit exposer les paramètres pour les deux environnements :
storage:
minio:
default-bucket: "geophysics-assets"
cutoff-date: "2023-01-01" # Séparation chaud/froid
hot:
endpoint: "http://hot-minio-cluster:9000"
access-key: "hot_admin_key"
secret-key: "hot_secret_pass"
cold:
endpoint: "http://cold-minio-cluster:9000"
access-key: "cold_archive_key"
secret-key: "cold_secret_pass"
Initialisation des Clients Multiples
Puisque l'application doit interagir avec deux serveurs différents, nous devons instancier deux beans MinioClient distincts. L'utilisation de classes de propriétés typées rend la configuration plus robuste.
import io.minio.MinioClient;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "storage.minio")
public class MinioStorageProperties {
private String defaultBucket;
private String cutoffDate;
private ClusterConfig hot;
private ClusterConfig cold;
// Getters et Setters omis pour la concision
public static class ClusterConfig {
private String endpoint;
private String accessKey;
private String secretKey;
// Getters et Setters
}
}
Ensuite, la classe de configuration Spring crée les clients en utilisant les propriétés injectées :
import io.minio.MinioClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MinioClientConfig {
private final MinioStorageProperties properties;
public MinioClientConfig(MinioStorageProperties properties) {
this.properties = properties;
}
@Bean(name = "hotStorageClient")
public MinioClient hotStorageClient() {
MinioStorageProperties.ClusterConfig hot = properties.getHot();
return MinioClient.builder()
.endpoint(hot.getEndpoint())
.credentials(hot.getAccessKey(), hot.getSecretKey())
.build();
}
@Bean(name = "coldStorageClient")
public MinioClient coldStorageClient() {
MinioStorageProperties.ClusterConfig cold = properties.getCold();
return MinioClient.builder()
.endpoint(cold.getEndpoint())
.credentials(cold.getAccessKey(), cold.getSecretKey())
.build();
}
}
Implémentation du Service de Routage Dynamique
Le cœur de la logique réside dans un service qui détermine dynamiquement quel client utiliser. Contrairement à l'ancienne approche utilisant SimpleDateFormat (qui n'est pas thread-safe), cette implémentation moderne utilise l'API java.time.
import io.minio.*;
import io.minio.http.Method;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
@Service
public class TieredStorageService {
private final MinioClient hotClient;
private final MinioClient coldClient;
private final MinioStorageProperties properties;
private final LocalDate cutoffDate;
public TieredStorageService(@Qualifier("hotStorageClient") MinioClient hotClient,
@Qualifier("coldStorageClient") MinioClient coldClient,
MinioStorageProperties properties) {
this.hotClient = hotClient;
this.coldClient = coldClient;
this.properties = properties;
this.cutoffDate = LocalDate.parse(properties.getCutoffDate(), DateTimeFormatter.ISO_LOCAL_DATE);
}
private MinioClient resolveClient(LocalDate projectCreationDate) {
return projectCreationDate.isBefore(cutoffDate) ? coldClient : hotClient;
}
public String uploadAsset(MultipartFile file, LocalDate projectDate, Long projectId, String subDirectory) {
String originalName = file.getOriginalFilename();
if (originalName == null || originalName.isEmpty()) {
throw new IllegalArgumentException("Le nom du fichier ne peut pas être nul.");
}
String extension = originalName.substring(originalName.lastIndexOf("."));
String uniqueFileName = UUID.randomUUID().toString() + extension;
// Format du chemin: YYYY-MM-DD_ProjectID/subDir/UUID.ext
String datePrefix = projectDate.format(DateTimeFormatter.ISO_LOCAL_DATE);
String objectPath = String.format("%s_%d/%s/%s", datePrefix, projectId, subDirectory, uniqueFileName).replace("//", "/");
MinioClient targetClient = resolveClient(projectDate);
try {
PutObjectArgs args = PutObjectArgs.builder()
.bucket(properties.getDefaultBucket())
.object(objectPath)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build();
targetClient.putObject(args);
return objectPath;
} catch (Exception e) {
throw new RuntimeException("Échec du téléversement vers le stockage MinIO", e);
}
}
public String generatePreviewUrl(String objectPath, LocalDate projectDate) {
MinioClient targetClient = resolveClient(projectDate);
try {
GetPresignedObjectUrlArgs args = GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(properties.getDefaultBucket())
.object(objectPath)
.build();
return targetClient.getPresignedObjectUrl(args);
} catch (Exception e) {
throw new RuntimeException("Impossible de générer l'URL de prévisualisation", e);
}
}
public InputStream downloadAsset(String objectPath, LocalDate projectDate) {
MinioClient targetClient = resolveClient(projectDate);
try {
GetObjectArgs args = GetObjectArgs.builder()
.bucket(properties.getDefaultBucket())
.object(objectPath)
.build();
return targetClient.getObject(args);
} catch (Exception e) {
throw new RuntimeException("Erreur lors du téléchargement de l'objet", e);
}
}
public void deleteAsset(String objectPath, LocalDate projectDate) {
MinioClient targetClient = resolveClient(projectDate);
try {
RemoveObjectArgs args = RemoveObjectArgs.builder()
.bucket(properties.getDefaultBucket())
.object(objectPath)
.build();
targetClient.removeObject(args);
} catch (Exception e) {
throw new RuntimeException("Échec de la suppression de l'objet", e);
}
}
}
Stratégie d'Intégration des API
Lors de la conception des contrôleurs REST, le module de gestion du stockage (souvent placé dans un module common ou infrastructure) ne doit pas avoir de dépendance directe envers la base de données des projets. Par conséquent, les contrôleurs situés dans le module métier (par exemple, system ou project-management) doivent d'abord interroger la base de données pour récupérer la date de création associée à l'identifiant du projet (projectId). Une fois cette date obtenue, elle est transimse en paramètre aux méthodes du TieredStorageService pour garantir que l'opération s'exécute sur le cluster MinIO approprié.