Développement d'un utilitaire de comparaison de répertoires concurrent en Go

Contexte et Architecture

Lors de la restauration de sauvegardse ou du diagnostic de pannes système, il est fréquent de devoir identifier les divergences entre deux arborescences volumineuses (par exemple, un dossier corrompu de plusieurs gigaoctets et sa sauvegarde valide). Un utilitaire en ligne de commande performant peut être conçu en Go pour résoudre ce problème, en tirant parti de son modèle de concurrence basé sur les CSP (Communicating Sequential Processes).

L'architecture de la solution repose sur un pipeline de traitement asynchrone :

  • Producteurs : Des routines parcourent récursivement les répertoires cibles.
  • Calcul de signature : Une empreinte unique (hash MD5) est générée pour chaque fichier en combinant son chemin relatif, sa taille et sa date de modification.
  • Consommateurs : Un pool de workers lit les métadonnées via des canaux (channels) et les écrit de manière sécurisée dans un fichier journal.
  • Analyse différentielle : Les journaux sont chargés en mémoire dans des structures de type Map pour calculer les ensembles disjoints (fichiers ajoutés, supprimés ou modifiés).

Implémentation du Parcours et du Hachage Concurrent

Pour optimiser les performances d'entrée/sortie, l'approche traditionnelle et bloquante est remplacée par une diffusion en continu des métadonnées vers un canal. L'utilisation de filepath.WalkDir (introduit dans Go 1.16) est privilégiée par rapport aux anciennes méthodes pour sa gestion efficace de la mémoire.

package main

import (
	"crypto/md5"
	"encoding/hex"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"sync"
	"time"
)

// FileMetadata encapsule les attributs essentiels d'un fichier pour la comparaison
type FileMetadata struct {
	RelPath string
	Size    int64
	Hash    string
}

// ScanDirectory parcourt un répertoire et alimente le canal avec les métadonnées hachées
func ScanDirectory(root string, out chan<- FileMetadata, wg *sync.WaitGroup) {
	defer wg.Done()
	
	err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil || d.IsDir() {
			return nil // Ignorer les erreurs de permission et les répertoires
		}
		
		info, err := d.Info()
		if err != nil {
			return nil
		}

		relPath, _ := filepath.Rel(root, path)
		
		// Création d'une signature composite pour le hachage
		signature := fmt.Sprintf("%s|%d|%d", relPath, info.Size(), info.ModTime().Unix())
		hash := md5.Sum([]byte(signature))

		out <- FileMetadata{
			RelPath: relPath,
			Size:    info.Size(),
			Hash:    hex.EncodeToString(hash[:]),
		}
		return nil
	})
	
	if err != nil {
		fmt.Fprintf(os.Stderr, "Erreur critique lors du parcours de %s : %v\n", root, err)
	}
}

// LogWriter agit comme un consommateur, lisant le canal pour écrire les résultats sur disque
func LogWriter(logPath string, in <-chan FileMetadata, wg *sync.WaitGroup) {
	defer wg.Done()
	file, err := os.Create(logPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Impossible d'initialiser le journal %s : %v\n", logPath, err)
		return
	}
	defer file.Close()

	for meta := range in {
		// Format structuré pour une analyse ultérieure
		fmt.Fprintf(file, "%s|%s|%d\n", meta.Hash, meta.RelPath, meta.Size)
	}
}

// ExecutePipeline orchestre la concurrence entre la lecture et l'écriture
func ExecutePipeline(targetDir string, logFile string, workerCount int) {
	startTime := time.Now()
	metadataChan := make(chan FileMetadata, 1000) // Canal avec tampon pour éviter le blocage
	var scanWg, writeWg sync.WaitGroup

	// Démarrage des workers d'écriture
	for i := 0; i < workerCount; i++ {
		writeWg.Add(1)
		go LogWriter(logFile, metadataChan, &writeWg)
	}

	// Démarrage du producteur
	scanWg.Add(1)
	go func() {
		ScanDirectory(targetDir, metadataChan, &scanWg)
		close(metadataChan) // Fermeture du canal une fois le parcours terminé
	}()

	scanWg.Wait()
	writeWg.Wait()
	
	fmt.Printf("Analyse terminée en %s. Journal généré : %s\n", time.Since(startTime), logFile)
}

Logique de Comparaison Différentielle

Une fois les deux journaux générés, l'étape suivante consiste à extraire les différences. Au lieu de comparer des tableaux non structurés, les données sont indexées dans des dictionnaires (Maps) où la clé est le chemin relatif du fichier. Cela permet des recherches en temps constant O(1).

// LoadLogFile lit un journal et construit une carte indexée par chemin relatif
func LoadLogFile(logPath string) (map[string]string, error) {
	data, err := os.ReadFile(logPath)
	if err != nil {
		return nil, err
	}

	fileMap := make(map[string]string)
	lines := strings.Split(string(data), "\n")
	
	for _, line := range lines {
		if line == "" {
			continue
		}
		parts := strings.Split(line, "|")
		if len(parts) >= 2 {
			hash := parts[0]
			relPath := parts[1]
			fileMap[relPath] = hash
		}
	}
	return fileMap, nil
}

// GenerateDiffReport compare deux cartes et génère un rapport de divergence
func GenerateDiffReport(sourceMap, targetMap map[string]string, reportPath string) error {
	report, err := os.Create(reportPath)
	if err != nil {
		return fmt.Errorf("échec de création du rapport : %w", err)
	}
	defer report.Close()

	// Identification des fichiers supprimés ou modifiés
	for path, srcHash := range sourceMap {
		if tgtHash, exists := targetMap[path]; !exists {
			fmt.Fprintf(report, "ACTION: SUPPRESSION | FICHIER: %s\n", path)
		} else if srcHash != tgtHash {
			fmt.Fprintf(report, "ACTION: MODIFICATION | FICHIER: %s\n", path)
		}
	}

	// Identification des fichiers nouvellement ajoutés
	for path := range targetMap {
		if _, exists := sourceMap[path]; !exists {
			fmt.Fprintf(report, "ACTION: AJOUT | FICHIER: %s\n", path)
		}
	}
	
	return nil
}

Optimisations et Gestion des Erreurs

Lors de la manipulation de systèmes de fichiers à grande échelle, plusieurs aspects techniques nécessitent une attention particulière :

  • Tampon des canaux : L'initialisation du channel avec une capacité (ex: make(chan FileMetadata, 1000)) empêche la routine de parcours d'être bloquée en attente d'un worker d'écriture disponible.
  • Gestion gracieuse des erreurs : L'utilisation de panic doit être proscrite dans les outils de production. Les erreurs de lecture de répertoires (liées aux permissions) doivent être journalisées et ignorées pour peremttre la continuation du processus.
  • Limitation de la mémoire : Le stockage de milliers de structures en mémoire avant leur traitement peut entraîner une surconsommation. Le modèle de flux (streaming) via les channels garantit une empreinte mémoire stable, quelle que soit la taille du répertoire analysé.
  • Intégration CLI : Des bibliothèques telles que flag (standard) ou go-flags (tierce) sont recommandées pour exposer les paramètres de configuration (chemins source/cible, nombre de workers, règles de hachage) aux utilisateurs finaux.

Étiquettes: Go Goroutines Channels Sync MD5

Publié le 20 juin à 20h16