Implémentation du téléchargement de gros fichiers par fragments vers MinIO avec assistance Redis

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>

Étiquettes: minio Redis chunked-upload Spring Boot JavaScript

Publié le 28 juillet à 22h06