CodeGym /Corsi /JAVA 25 SELF /FileVisitor — attraversamento del file system e operazion...

FileVisitor — attraversamento del file system e operazioni ricorsive

JAVA 25 SELF
Livello 39 , Lezione 3
Disponibile

1. Introduzione

Ripassiamo un esempio di attraversamento con Files.walk():

Path start = Paths.get("my-folder");
try (Stream<Path> stream = Files.walk(start)) {
    stream.forEach(System.out::println);
}

Questo funziona benissimo se bisogna semplicemente scorrere file e cartelle e fare qualcosa con essi. Ma cosa succede se il compito è più complesso?

  • È necessario eliminare ricorsivamente una cartella insieme a tutti i file e alle sottodirectory (non solo quando è vuota).
  • È necessario copiare o spostare ricorsivamente una directory.
  • È necessario raccogliere statistiche (ad esempio calcolare la dimensione totale di tutti i file, raggruppare i file per estensione).
  • È necessario gestire gli errori (ad esempio, se non si ha accesso a una cartella, non vogliamo che tutto vada in crash).

In questi casi lo Stream API non è così comodo: tocca incastrare try-catch nelle lambda, controllare l'ordine di attraversamento (ad esempio, prima eliminare i file e poi le cartelle) e il codice diventa poco leggibile.

Per questi compiti è stato pensato il meccanismo di attraversamento dell'albero del file system tramite FileVisitor.

2. Interfaccia FileVisitor: com'è strutturata

L'interfaccia FileVisitor<T> è una sorta di «gestore di eventi» che riceve notifiche su ogni file e cartella visitati durante l'attraversamento del file system.

Quando si chiama Files.walkFileTree(start, visitor), Java inizia l'attraversamento dell'albero a partire dal percorso indicato e a ogni fase invoca il metodo corrispondente del tuo FileVisitor.

Metodi principali dell'interfaccia FileVisitor

Ecco come appare l'interfaccia:

public interface FileVisitor<T> {
    FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs) throws IOException;
    FileVisitResult visitFile(T file, BasicFileAttributes attrs) throws IOException;
    FileVisitResult visitFileFailed(T file, IOException exc) throws IOException;
    FileVisitResult postVisitDirectory(T dir, IOException exc) throws IOException;
}
  • preVisitDirectory — viene chiamato prima di entrare nella directory.
  • visitFile — viene chiamato per ogni file.
  • visitFileFailed — viene chiamato se non è stato possibile accedere al file.
  • postVisitDirectory — viene chiamato dopo l'uscita dalla directory (cioè dopo aver elaborato tutti i suoi file e sottodirectory).

Ognuno di questi metodi restituisce un valore di tipo FileVisitResult, che determina come continuare l'attraversamento:

  • FileVisitResult.CONTINUE — continuare l'attraversamento.
  • FileVisitResult.SKIP_SUBTREE — saltare la directory corrente e tutto ciò che contiene.
  • FileVisitResult.SKIP_SIBLINGS — saltare gli altri «fratelli» (file e cartelle allo stesso livello).
  • FileVisitResult.TERMINATE — interrompere completamente l'attraversamento.

Classe SimpleFileVisitor

Implementare ogni volta tutti e quattro i metodi è faticoso, soprattutto se te ne servono solo uno o due. Per questo in Java esiste un comodo adapter SimpleFileVisitor: implementa già tutti i metodi con un comportamento «predefinito» (semplicemente CONTINUE) e puoi sovrascrivere solo quelli necessari.

Esempio:

import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;

public class MyVisitor extends SimpleFileVisitor<Path> {
    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
        System.out.println("File: " + file);
        return FileVisitResult.CONTINUE;
    }
}

3. Uso di Files.walkFileTree: esempio di base

Esempio 1: stampiamo tutti i file e le cartelle

import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;

public class TreePrinter {
    public static void main(String[] args) throws IOException {
        Path start = Paths.get("my-folder");

        Files.walkFileTree(start, new SimpleFileVisitor<Path>() {
            @Override
            public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
                System.out.println("Cartella: " + dir);
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                System.out.println("  File: " + file);
                return FileVisitResult.CONTINUE;
            }
        });
    }
}

Output:

Cartella: my-folder
  File: my-folder/file1.txt
  File: my-folder/file2.txt
Cartella: my-folder/subdir
  File: my-folder/subdir/nested.txt

Come si vede, l'attraversamento avviene «in profondità»: prima si entra nella cartella, poi si elaborano i suoi file, quindi si passa alle sottocartelle.

4. Esempio: eliminazione ricorsiva di una directory

Uno dei compiti più frequenti: eliminare una cartella insieme a tutto il suo contenuto. Se si prova a eliminare una cartella con Files.delete(path) e non è vuota, si ottiene un'eccezione. Bisogna prima eliminare tutti i file e le sottocartelle, e solo dopo la cartella stessa.

Ecco come farlo con FileVisitor:

import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;

public class RecursiveDelete {
    public static void main(String[] args) throws IOException {
        Path dirToDelete = Paths.get("test-folder");

        Files.walkFileTree(dirToDelete, new SimpleFileVisitor<Path>() {
            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                Files.delete(file); // eliminiamo il file
                System.out.println("File eliminato: " + file);
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
                Files.delete(dir); // eliminiamo la cartella dopo aver eliminato tutto al suo interno
                System.out.println("Cartella eliminata: " + dir);
                return FileVisitResult.CONTINUE;
            }
        });
    }
}

Punto importante:
L'eliminazione delle cartelle avviene in postVisitDirectory, cioè dopo aver eliminato tutto il contenuto. Se provassimo a eliminare la cartella prima di eliminare i file al suo interno, otterremmo un errore.

5. Esempio: calcolo della dimensione totale di tutti i file in una directory

Scriviamo ora un FileVisitor che calcoli la dimensione complessiva di tutti i file nella cartella e nelle sue sottocartelle.

import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;

public class DirectorySizeCalculator {
    private static long totalSize = 0;

    public static void main(String[] args) throws IOException {
        Path start = Paths.get("my-folder");

        Files.walkFileTree(start, new SimpleFileVisitor<Path>() {
            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                totalSize += attrs.size();
                return FileVisitResult.CONTINUE;
            }
        });

        System.out.println("Dimensione totale: " + totalSize + " byte");
    }
}

Nota:
Usiamo il campo totalSize per accumulare la dimensione. Nelle applicazioni reali è meglio evitare campi statici e passare le variabili tramite oggetti, ma per semplicità dell'esempio va bene così.

6. Esempio: ricerca di file per pattern (estensione) con FileVisitor

Supponiamo di dover trovare tutti i file .txt nella directory e nelle sottodirectory e stamparne l'elenco.

import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;

public class TxtFileFinder {
    public static void main(String[] args) throws IOException {
        Path start = Paths.get("my-folder");

        Files.walkFileTree(start, new SimpleFileVisitor<Path>() {
            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                if (file.getFileName().toString().endsWith(".txt")) {
                    System.out.println("Trovato file .txt: " + file);
                }
                return FileVisitResult.CONTINUE;
            }
        });
    }
}

Se vuoi raccogliere l'elenco dei file trovati, puoi creare una lista:

import java.util.ArrayList;
import java.util.List;

// all'interno di main:
List<Path> txtFiles = new ArrayList<>();
Files.walkFileTree(start, new SimpleFileVisitor<Path>() {
    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
        if (file.getFileName().toString().endsWith(".txt")) {
            txtFiles.add(file);
        }
        return FileVisitResult.CONTINUE;
    }
});
System.out.println("Trovati in totale: " + txtFiles.size());

7. Gestione degli errori e peculiarità dell'attraversamento

Cosa fare se non si ha accesso a un file o a una cartella?

A volte durante l'attraversamento si incontrano file o cartelle a cui non si ha accesso (ad esempio, se non si hanno i permessi o il file è occupato da un altro processo). In questo caso verrà chiamato il metodo visitFileFailed.

Esempio:

@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) {
    System.err.println("Errore di accesso al file: " + file + " (" + exc + ")");
    return FileVisitResult.CONTINUE; // continuiamo l'attraversamento nonostante l'errore
}

Se desideri interrompere l'attraversamento in caso di errore, restituisci TERMINATE.

Come saltare un'intera cartella?

Se non vuoi entrare in una determinata cartella (ad esempio la cartella .git o node_modules), puoi restituire SKIP_SUBTREE nel metodo preVisitDirectory:

@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
    if (dir.getFileName().toString().equals("node_modules")) {
        return FileVisitResult.SKIP_SUBTREE; // non entrare in questa cartella e nelle sue sottocartelle
    }
    return FileVisitResult.CONTINUE;
}

8. Pratica: implementiamo un FileVisitor personalizzato

Implementiamo un FileVisitor che:

  • Trova tutti i file con estensione .java nella directory e nelle sottodirectory,
  • Ne conta il numero,
  • Calcola la dimensione complessiva di tali file.
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;

public class JavaFilesStats {
    public static void main(String[] args) throws IOException {
        Path start = Paths.get("src"); // ad esempio, il codice sorgente del progetto

        class JavaFileVisitor extends SimpleFileVisitor<Path> {
            int count = 0;
            long totalSize = 0;

            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                if (file.getFileName().toString().endsWith(".java")) {
                    count++;
                    totalSize += attrs.size();
                    System.out.println("Trovato file .java: " + file);
                }
                return FileVisitResult.CONTINUE;
            }
        }

        JavaFileVisitor visitor = new JavaFileVisitor();
        Files.walkFileTree(start, visitor);

        System.out.println("Totale file .java: " + visitor.count);
        System.out.println("Dimensione totale: " + visitor.totalSize + " byte");
    }
}

9. Errori tipici nell'uso di FileVisitor

Errore n. 1: tentare di eliminare una directory prima di eliminare i file al suo interno. Se chiami Files.delete(dir) in preVisitDirectory, otterrai un'eccezione: prima bisogna eliminare tutti i file e le sottocartelle, poi la directory stessa (fallo in postVisitDirectory).

Errore n. 2: dimenticare di gestire gli errori di accesso. Se non sovrascrivi visitFileFailed, il programma può terminare inaspettatamente quando incontra un file protetto. È meglio stampare esplicitamente l'errore e continuare l'attraversamento.

Errore n. 3: aspettarsi che l'operazione “nascondi file” funzioni allo stesso modo su tutti i sistemi operativi. Su Linux e macOS è considerato nascosto un file che inizia con un punto (.gitignore), mentre su Windows è necessario impostare un attributo specifico. Non confondere questi approcci.

Errore n. 4: usare campi statici per accumulare il risultato in applicazioni multithread. Se esegui più attraversamenti in parallelo, i campi statici porteranno a confusione. È meglio usare campi di istanza (o di una classe locale, come nell'esempio sopra).

Errore n. 5: dimenticare di chiudere le risorse quando si lavora con stream all'interno di FileVisitor. Se il tuo FileVisitor legge/scrive file, usa try-with-resources per evitare perdite.

Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION