Scorrere gli alberi di file in Java
Attraversa ricorsivamente le directory in Java con Files.walk, Files.find e l'interfaccia FileVisitor.
Il capitolo precedente si è concluso con Files.walk(dir) — la forma Stream<Path> di "dammi ogni file in questa directory." È lo strumento veloce per il caso comune. Questo capitolo tratta l'alternativa di livello più basso, Files.walkFileTree, che permette di controllare l'attraversamento in modi non consentiti dalla forma stream: gestire errori I/O per ogni file, saltare interi sottoalberi a metà percorso, eseguire codice all'uscita da una directory oltre che all'ingresso, e interrompere al primo risultato trovato.
Usa Files.walk per "elenca tutto." Usa Files.walkFileTree per "esegui qualcosa a ogni passo, con controllo sulla fase."
Tre API di scorrimento
Il catalogo, in ordine di frequenza d'uso:
| API | Restituisce | Quando |
|---|---|---|
Files.walk(dir) | Stream<Path> | Più comune — filter/map/foreach su ogni voce |
Files.find(dir, depth, biPredicate) | Stream<Path> | Come sopra, con un predicato sensibile agli attributi (isDirectory, mtime) |
Files.walkFileTree(dir, visitor) | Path (il punto di partenza) | Servono hook pre/post-visita, gestione errori per file, o interruzione del percorso |
Le prime due sono sufficienti per il 90% del codice "trovami tutti i file .log". walkFileTree è quella a cui ricorrere quando la risposta è "e poi elimina la directory" o "smetti di scorrere non appena trovi quello che cerco."
FileVisitor e SimpleFileVisitor
Files.walkFileTree accetta un FileVisitor<Path> — un'interfaccia con quattro metodi che il walker chiama in momenti specifici:
FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs); // entering a directory
FileVisitResult visitFile(Path file, BasicFileAttributes attrs); // each non-directory entry
FileVisitResult visitFileFailed(Path file, IOException exc); // I/O failure on a specific file
FileVisitResult postVisitDirectory(Path dir, IOException exc); // leaving the directory (after all children)L'ordine è importante: per una directory d con figli [a, b/, c], le chiamate sono preVisitDirectory(d), visitFile(a), preVisitDirectory(b), ... postVisitDirectory(b), visitFile(c), postVisitDirectory(d). L'hook post* è ciò che rende possibile l'eliminazione ricorsiva — non puoi eliminare una directory prima di averne eliminato il contenuto.
SimpleFileVisitor<Path> è la classe helper che implementa tutti e quattro i metodi con valori predefiniti ragionevoli (continua in caso di successo, lancia eccezione in caso di fallimento). Estendila e sovrascrivi solo i metodi che ti interessano:
class LogPrinter extends SimpleFileVisitor<Path> {
@Override public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
System.out.println(f);
return FileVisitResult.CONTINUE;
}
}
Files.walkFileTree(root, new LogPrinter());Questo è il visitor minimo funzionante.
FileVisitResult: quattro segnali
Ogni metodo del visitor restituisce un FileVisitResult che indica al walker cosa fare in seguito:
| Valore | Effetto |
|---|---|
CONTINUE | Normale — passa alla voce successiva |
SKIP_SUBTREE | (solo da preVisitDirectory) Salta questa directory e tutti i suoi figli |
SKIP_SIBLINGS | Smetti di visitare il resto della directory corrente; riprendi dal prossimo fratello del genitore |
TERMINATE | Interrompi completamente il percorso |
SKIP_SUBTREE è quello a cui ricorrerai: "non scendere in .git/ o node_modules/." Restituiscilo da preVisitDirectory quando il nome della directory corrisponde e il walker salterà sia la directory che i suoi figli:
@Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes a) {
String name = dir.getFileName() == null ? "" : dir.getFileName().toString();
if (name.equals(".git") || name.equals("node_modules")) {
return FileVisitResult.SKIP_SUBTREE;
}
return FileVisitResult.CONTINUE;
}TERMINATE è il segnale "trovato, stop" — utile quando stai cercando il primo file corrispondente e non vuoi scorrere il resto:
@Override public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
if (f.getFileName().toString().equals("target.txt")) {
found = f;
return FileVisitResult.TERMINATE;
}
return FileVisitResult.CONTINUE;
}La forma Stream non può fare questo — Files.walk(...).filter(...).findFirst() si interrompe anticipatamente, ma solo dopo che il walker ha già enumerato ogni voce della directory nello stream. Per un albero profondo dove la corrispondenza è superficiale, walkFileTree è significativamente più veloce.
Gestione degli errori per file
visitFile e preVisitDirectory vengono chiamati solo quando il JDK riesce a leggere la voce. Se un singolo file non è leggibile (permesso negato, symlink pendente, condizione di gara in cui è stato eliminato durante il percorso), viene invece chiamato visitFileFailed con l'eccezione. Per impostazione predefinita SimpleFileVisitor rilancia — il che interrompe il percorso:
@Override public FileVisitResult visitFileFailed(Path f, IOException e) throws IOException {
throw e; // default behaviour
}Per un walker tollerante (registra e continua), sovrascrivilo:
@Override public FileVisitResult visitFileFailed(Path f, IOException e) {
System.err.println("skipping " + f + ": " + e.getMessage());
return FileVisitResult.CONTINUE;
}Files.walk(...) non ha questo hook — lancia una UncheckedIOException dall'interno dello stream nel momento in cui incontra una voce problematica, e lo stream è inutilizzabile dopo. Per scanner a lungo termine su filesystem che non controlli completamente, questo è un altro motivo per preferire walkFileTree.
Il caso d'uso canonico: eliminazione ricorsiva
Files.delete funziona solo su directory vuote. Per rimuovere un albero devi eliminare prima le foglie, poi le directory che le contenevano. walkFileTree è la forma giusta per questo — visitFile elimina il file, postVisitDirectory elimina la directory una volta che tutti i suoi figli sono stati rimossi:
Files.walkFileTree(root, new SimpleFileVisitor<Path>() {
@Override public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException {
Files.delete(f);
return FileVisitResult.CONTINUE;
}
@Override public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException {
if (e != null) throw e; // propagate I/O failures from descent
Files.delete(d);
return FileVisitResult.CONTINUE;
}
});Questa è la ricetta JDK per "eliminare un albero di directory." Ogni codebase che ne ha bisogno finisce per avere una versione di questo blocco di 10 righe. Salvane una copia in una classe di utilità e riusala.
Link simbolici
Per impostazione predefinita, Files.walkFileTree e Files.walk non seguono i link simbolici. È il comportamento predefinito sicuro: previene loop infiniti su un symlink che punta al proprio antenato. Per seguirli, passa FileVisitOption.FOLLOW_LINKS:
Files.walkFileTree(root, EnumSet.of(FileVisitOption.FOLLOW_LINKS),
Integer.MAX_VALUE, visitor);Quando lo abiliti, il walker rileva i cicli per te — tiene traccia delle chiavi delle directory visitate e si interrompe se la stessa riappare con FileSystemLoopException. È l'unico modo per scorrere un albero con link senza scrivere tu stesso il rilevamento dei cicli.
Un esempio pratico: stampa albero, salta sottoalbero, eliminazione ricorsiva
Il programma seguente costruisce un piccolo albero di directory con un paio di sottodirectory (una delle quali vogliamo saltare), file a più livelli di profondità, poi lo scorre in tre modi. Prima, un tree-printer con SimpleFileVisitor che salta .git. Secondo, un "trova prima corrispondenza" con TERMINATE. Terzo, il pattern canonico di eliminazione ricorsiva che rimuove l'intero albero alla fine.
Cosa ricavare dall'esecuzione:
- L'hook
preVisitDirectoryha restituitoSKIP_SUBTREEnel momento in cui ha visto.git. Il walker non è mai sceso nella directory; il fileconfigal suo interno non è mai stato visitato. È lo strumento giusto per "ignora queste directory convenzionali" —.git,node_modules,target,dist, qualsiasi altra cosa che il tuo progetto non vuole scorrere. La formaStream<Path>non può farlo senza produrre le voci e filtrarle, il che comporta comunque la lettura della directory. - L'ordine delle chiamate per
sub/è statopreVisitDirectory(sub)→visitFile(b.txt)→preVisitDirectory(nested)→visitFile(c.txt)→postVisitDirectory(nested)→postVisitDirectory(sub). Gli hookpost*si attivano dopo che tutti i discendenti sono stati elaborati — questo è il contratto depth-first, ed è ciò che rende possibile il pattern di eliminazione ricorsiva. - Il percorso "trova prima corrispondenza" ha restituito
TERMINATEdavisitFilenel momento in cui è apparsoc.txt. Tutto ciò che seguiva — le voci rimanenti innested/, il resto disub/, il resto diroot/— non è mai stato visitato. Su un albero piccolo il risparmio è invisibile; su un albero profondo dove la corrispondenza è superficiale, è la differenza tra O(n) e O(profondità-corrispondenza). - L'eliminazione ricorsiva aveva due fasi.
visitFileeliminava le foglie;postVisitDirectoryeliminava le directory (ora vuote). L'ordine depth-first del walker garantiva che ogni figlio fosse visitato prima delpostVisitDirectorydel genitore, quindiFiles.delete(d)trovava sempre una directory vuota. Tentare di eliminare la directory inpreVisitDirectoryfallirebbe perché i figli sono ancora presenti; tentare di eliminarla conFiles.delete(root)alla fine fallirebbe per lo stesso motivo. L'hookpost*è il punto centrale dell'API visitor. - In tutto,
SimpleFileVisitorera la classe base e abbiamo sovrascritto solo i metodi necessari.visitFileFailedè rimasto al suo comportamento predefinito (lancia eccezione), che per questi demo con file temporanei va bene. Per uno scanner su un filesystem reale che non controlli completamente — ad esempio, uno scanner antivirus che scorre/, dove i file potrebbero essere eliminati mentre sei in esecuzione — sovrascrivivisitFileFailedper registrare e fareCONTINUE.
Cosa fare dopo
La parte 13 termina qui. I file sono stati scritti, letti, aperti, copiati, spostati, eliminati, scorsi, serializzati. Gli stream sono stati bufferizzati, decorati, formattati, mappati, incanalati. La prossima parte, Date e ora, affronta un problema completamente diverso: rappresentare istanti, durate, date di calendario, fusi orari, e la loro formattazione e analisi — java.time, l'API moderna che ha sostituito java.util.Date e Calendar.