Conception du système
Cette approche de téléchargement par morceaux (chunking) assistée par Redis fournit une solution robuste pour gérer les fichiers volumineux. Elle offre des fonctionnalités essentielles telles que la reprise après interrruption, le téléchargement instantané pour les fichiers déjà présents et un contrôle de concurrence efficace.
- Gestion de fichiers de grande taille (supérieurs à 1 Go).
- Capacité de reprise en cas de coupure réseau.
- Détection et validation des fichiers existants pour un transfert instantané.
- Utilisation de Redis pour stocker l'état des fragments, assurant des performances élevées sans opérations disque coûteuses.
Flux d'opérations
Processus côté client
Le client découpe le fichier en segments plus petits (généralement entre 5 Mo et 10 Mo). Il calcule ensuite un identifiant unique pour le fichier complet, comme un hash MD5 ou SHA256. Lors de l'envoi de chaque morceau, les métadonnées suivantes sont transmises : l'identifiant du fichier, l'index du fragment, le nombre total de fragments, le nom du fichier, la taille du morceau et la taille totale.
Endpoints du serveur (Java)
Le service backend expose trois points d'accès principaux pour orchestrer le processus :
| Endpoint | Responsabilité | Rôle de Redis |
|---|---|---|
/upload/check |
Vérifie si le fichier ou des fragments existent déjà (déclencheur de téléchargement instantané). | Mise en cache de l'état du fichier. |
/upload/chunk |
Reçoit et stocke un fragment individuel. | Marque le fragment comme téléchargé. |
/upload/merge |
Assemble tous les fragments en un fichier final. | Nettoie les données temporaires de Redis. |
Structure des données dans Redis
L'état d'un téléchargement en cours est maintenu dans Redis avec la structure suivante :
Clé : upload:file:{identifiantFichier}
Valeur :
{
"nomFichier": "archive.zip",
"totalFragments": 10,
"telecharges": [0,1,3,5],
"chemin": "/data/tmp/{identifiantFichier}/"
}
Durée de vie (TTL) : 1 jour (nettoyage automatique après complétion)
Implémentation technique
Configuration des dépendances (Maven)
<!-- Client Java pour MinIO -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.7</version>
</dependency>
<!-- OkHttp3, requise par le SDK MinIO -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<!-- Validation Jakarta -->
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<version>3.0.2</version>
</dependency>
<!-- Sérialisation JSON -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
Classe Controller
package com.example.transfer.controller;
import jakarta.annotation.Resource;
import com.example.transfer.model.MergeRequest;
import com.example.transfer.service.FragmentService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.Map;
@RestController
@RequestMapping("/upload")
public class TransferController {
@Resource
private FragmentService fragmentService;
@GetMapping("/check")
public ResponseEntity> verifierFichier(@RequestParam String fileHash) {
return ResponseEntity.ok(fragmentService.verifierExistence(fileHash));
}
@PostMapping("/chunk")
public ResponseEntity> envoyerFragment(@RequestParam String fileHash,
@RequestParam Integer indexFragment,
@RequestParam Integer fragmentsTotaux,
@RequestParam("file") MultipartFile file) {
fragmentService.stockerFragment(fileHash, indexFragment, fragmentsTotaux, file);
return ResponseEntity.ok(Map.of("fragment", indexFragment, "statut", "ok"));
}
@PostMapping("/merge")
public ResponseEntity> fusionnerFragments(@RequestBody MergeRequest request) {
String url = fragmentService.fusionnerFragments(request.getFileHash(), request.getFileName());
return ResponseEntity.ok(Map.of("url", url));
}
}
Classe Service
package com.example.transfer.service;
import io.minio.*;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import com.example.transfer.config.StorageProperties;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
public class FragmentService {
@Resource
private RedisTemplate<String, Object> redisOps;
@Resource
private MinioClient storageClient;
@Resource
private StorageProperties storageConfig;
private String construireCle(String hash) {
return "transfer:etat:" + hash;
}
public Map<String, Object> verifierExistence(String hash) {
boolean presence = redisOps.hasKey("transfer:check_" + hash);
if (!presence) {
redisOps.opsForValue().set("transfer:check_" + hash, hash, 24, TimeUnit.HOURS);
}
return Map.of("existe", presence);
}
public void stockerFragment(String hash, int index, int total, MultipartFile morceau) {
String objectPath = String.format("%s/part-%05d", hash, index);
try {
storageClient.putObject(
PutObjectArgs.builder()
.bucket(storageConfig.getBucketName())
.object(objectPath)
.stream(morceau.getInputStream(), morceau.getSize(), -1)
.contentType("application/octet-stream")
.build()
);
String cle = construireCle(hash);
redisOps.opsForHash().putIfAbsent(cle, "fragmentsTotaux", total);
redisOps.opsForSet().add(cle + ":recus", index);
redisOps.expire(cle, Duration.ofDays(1));
redisOps.expire(cle + ":recus", Duration.ofDays(1));
log.info("Fragment {} pour le fichier {} stocké.", index, hash);
} catch (Exception e) {
log.error("Échec du stockage du fragment : {}", e.getMessage());
throw new RuntimeException("Erreur de stockage du fragment", e);
}
}
public String fusionnerFragments(String hash, String nomFichier) {
String cle = construireCle(hash);
Object total = redisOps.opsForHash().get(cle, "fragmentsTotaux");
if (total == null) {
throw new IllegalStateException("État du téléchargement introuvable dans Redis.");
}
int nbFragments = Integer.parseInt(total.toString());
try {
List<ComposeSource> parties = new ArrayList<>();
for (int i = 0; i < nbFragments; i++) {
parties.add(
ComposeSource.builder()
.bucket(storageConfig.getBucketName())
.object(String.format("%s/part-%05d", hash, i))
.build()
);
}
String cheminFinal = "finaux/" + nomFichier;
storageClient.composeObject(
ComposeObjectArgs.builder()
.bucket(storageConfig.getBucketName())
.object(cheminFinal)
.sources(parties)
.build()
);
for (ComposeSource part : parties) {
storageClient.removeObject(
RemoveObjectArgs.builder()
.bucket(storageConfig.getBucketName())
.object(part.object())
.build()
);
}
redisOps.delete(cle);
redisOps.delete(cle + ":recus");
String urlComplete = String.format("%s/%s/%s",
storageConfig.getEndpoint(), storageConfig.getBucketName(), cheminFinal);
log.info("Fusion terminée. URL : {}", urlComplete);
return urlComplete;
} catch (Exception e) {
log.error("Échec de la fusion : {}", e.getMessage());
throw new RuntimeException("Impossible de fusionner les fragments", e);
}
}
}
Configuration de l'application
application.yml
spring:
redis:
host: localhost
port: 6379
servlet:
multipart:
max-file-size: 1GB
max-request-size: 10GB
server:
tomcat:
max-swallow-size: -1
max-http-form-post-size: -1
storage:
endpoint: http://storage-server:9000
accessKey: VOTRE_CLE_ACCES
secretKey: VOTRE_CLE_SECRETE
bucketName: mes-fichiers
Classe de configuration pour MinIO
package com.example.transfer.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "storage")
public class StorageProperties {
private String endpoint;
private String accessKey;
private String secretKey;
private String bucketName;
}
Classe de configuration pour Redis
package com.example.transfer.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
}
Exemple client (JavaScript)
Le script suivant illustre un client complet réalisant les actions suivantes : calcul automatique du hash MD5, découpage du fichier, envoi concurrent des fragments avec suivi de progression, et appel aux endpoints de vérification et de fusion.
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
</head>
<body>
<input type="file" id="fileSelect"/>
<button onclick="demarrerTransfert()">Envoyer</button>
<div id="progression">0%</div>
<script>
const TAILLE_MORCEAU = 10 * 1024 * 1024;
const API_BASE = "http://votre-serveur/upload";
async function demarrerTransfert() {
const fichier = document.getElementById('fileSelect').files[0];
if (!fichier) return;
const hash = await calculerHashMD5(fichier);
const reponseCheck = await axios.get(`${API_BASE}/check?fileHash=${hash}`);
if (reponseCheck.data.existe) {
alert("Le fichier existe déjà sur le serveur.");
return;
}
const nbMorceaux = Math.ceil(fichier.size / TAILLE_MORCEAU);
for (let i = 0; i < nbMorceaux; i++) {
const debut = i * TAILLE_MORCEAU;
const fin = Math.min(fichier.size, debut + TAILLE_MORCEAU);
const morceau = fichier.slice(debut, fin);
let formData = new FormData();
formData.append('file', morceau);
formData.append('fileHash', hash);
formData.append('indexFragment', i);
formData.append('fragmentsTotaux', nbMorceaux);
await axios.post(`${API_BASE}/chunk`, formData, {
onUploadProgress: progression => {
const pourcentage = Math.round((progression.loaded * 100) / fichier.size);
document.getElementById('progression').innerText = `${pourcentage}%`;
}
});
}
const reponseFusion = await axios.post(`${API_BASE}/merge`, {
fileHash: hash,
fileName: fichier.name
});
alert("Transfert terminé ! URL : " + reponseFusion.data.url);
}
async function calculerHashMD5(fichier) {
return new Promise(resolve => {
const lecteur = new FileReader();
const decoupeur = new SparkMD5.ArrayBuffer();
let morceauActuel = 0;
const nbMorceaux = Math.ceil(fichier.size / TAILLE_MORCEAU);
lecteur.onload = (e) => {
decoupeur.append(e.target.result);
morceauActuel++;
if (morceauActuel < nbMorceaux) {
chargerProchainMorceau();
} else {
resolve(decoupeur.end());
}
};
function chargerProchainMorceau() {
const start = morceauActuel * TAILLE_MORCEAU;
const end = Math.min(fichier.size, start + TAILLE_MORCEAU);
lecteur.readAsArrayBuffer(fichier.slice(start, end));
}
chargerProchainMorceau();
});
}
</script>
</body>
</html>