Verzeichnisbäume in Java durchlaufen
Verzeichnisse in Java rekursiv durchlaufen mit Files.walk, Files.find und dem FileVisitor-Interface.
Das vorherige Kapitel endete mit Files.walk(dir) — der Stream<Path>-Form für "gib mir alle Dateien unter diesem Verzeichnis." Das ist das schnelle Werkzeug für den häufigen Anwendungsfall. Dieses Kapitel behandelt die Low-Level-Alternative Files.walkFileTree, die eine genauere Steuerung des Durchlaufs ermöglicht: I/O-Fehler pro Datei behandeln, ganze Teilbäume mitten im Durchlauf überspringen, Code beim Verlassen eines Verzeichnisses ausführen sowie den Durchlauf bei einem Treffer abbrechen.
Verwende Files.walk für "alles auflisten." Verwende Files.walkFileTree für "bei jedem Schritt etwas tun, mit Kontrolle über den Schritt."
Drei Walking-APIs
Die Übersicht, geordnet nach der Häufigkeit ihrer Verwendung:
| API | Gibt zurück | Wann |
|---|---|---|
Files.walk(dir) | Stream<Path> | Am häufigsten — filter/map/foreach über jeden Eintrag |
Files.find(dir, depth, biPredicate) | Stream<Path> | Dasselbe, mit einem attributbasierten Prädikat (isDirectory, mtime) |
Files.walkFileTree(dir, visitor) | Path (der Start) | Pre/Post-Visit-Hooks, Fehlerbehandlung pro Datei oder Abbruch des Durchlaufs benötigt |
Die ersten beiden reichen für 90 % des "finde mir alle .log-Dateien"-Codes aus. walkFileTree greift man dann, wenn die Antwort "und das Verzeichnis danach löschen" oder "aufhören, sobald ich das Gesuchte gefunden habe" lautet.
FileVisitor und SimpleFileVisitor
Files.walkFileTree nimmt einen FileVisitor<Path> — ein Interface mit vier Methoden, die der Walker zu bestimmten Zeitpunkten aufruft:
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)Die Reihenfolge ist wichtig: Bei einem Verzeichnis d mit Kindelementen [a, b/, c] sind die Aufrufe preVisitDirectory(d), visitFile(a), preVisitDirectory(b), ... postVisitDirectory(b), visitFile(c), postVisitDirectory(d). Der post*-Hook macht rekursives Löschen erst möglich — ein Verzeichnis kann erst gelöscht werden, nachdem sein Inhalt gelöscht wurde.
SimpleFileVisitor<Path> ist die Hilfsklasse, die alle vier Methoden mit sinnvollen Standardwerten implementiert (bei Erfolg fortfahren, bei Fehler werfen). Leite davon ab und überschreibe nur die Methoden, die du benötigst:
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());Das ist der minimal funktionsfähige Visitor.
FileVisitResult: vier Signale
Jede Visitor-Methode gibt ein FileVisitResult zurück, das dem Walker mitteilt, was als Nächstes zu tun ist:
| Wert | Wirkung |
|---|---|
CONTINUE | Normal — zum nächsten Eintrag gehen |
SKIP_SUBTREE | (nur aus preVisitDirectory) Dieses Verzeichnis und seine Kindelemente vollständig überspringen |
SKIP_SIBLINGS | Aufhören, den Rest des aktuellen Verzeichnisses zu besuchen; beim nächsten Geschwisterelement des Elternverzeichnisses weitermachen |
TERMINATE | Den Durchlauf vollständig stoppen |
SKIP_SUBTREE ist dasjenige, das man am häufigsten braucht: "nicht in .git/ oder node_modules/ absteigen." Gib es aus preVisitDirectory zurück, wenn der Verzeichnisname passt, und der Walker überspringt sowohl das Verzeichnis als auch seine Kindelemente:
@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 ist das "gefunden, stoppen"-Signal — nützlich, wenn man nach der ersten passenden Datei sucht und den Rest nicht durchlaufen möchte:
@Override public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
if (f.getFileName().toString().equals("target.txt")) {
found = f;
return FileVisitResult.TERMINATE;
}
return FileVisitResult.CONTINUE;
}Die Stream-Form kann das nicht — Files.walk(...).filter(...).findFirst() bricht zwar ab, aber erst nachdem der Walker jeden Verzeichniseintrag in den Stream aufgenommen hat. Bei einem tiefen Baum, bei dem der Treffer flach liegt, ist walkFileTree merklich schneller.
Fehlerbehandlung pro Datei
visitFile und preVisitDirectory werden nur aufgerufen, wenn das JDK den Eintrag lesen konnte. Wenn eine einzelne Datei nicht lesbar ist (fehlende Berechtigung, hängender Symlink, Race-Condition weil sie mitten im Durchlauf gelöscht wurde), wird stattdessen visitFileFailed mit der Exception aufgerufen. Standardmäßig wirft SimpleFileVisitor weiter — das bricht den Durchlauf ab:
@Override public FileVisitResult visitFileFailed(Path f, IOException e) throws IOException {
throw e; // default behaviour
}Für einen toleranten Walker (protokollieren und weitermachen), überschreibe es:
@Override public FileVisitResult visitFileFailed(Path f, IOException e) {
System.err.println("skipping " + f + ": " + e.getMessage());
return FileVisitResult.CONTINUE;
}Files.walk(...) hat diesen Hook nicht — es wirft eine UncheckedIOException aus dem Stream, sobald es auf einen fehlerhaften Eintrag trifft, und der Stream ist danach ungültig. Bei lang laufenden Scannern über Dateisysteme, die man nicht vollständig kontrolliert, ist das ein weiterer Grund für walkFileTree.
Der klassische Anwendungsfall: rekursives Löschen
Files.delete funktioniert nur bei leeren Verzeichnissen. Um einen Baum zu entfernen, müssen zuerst die Blätter gelöscht werden, dann die Verzeichnisse, die sie enthielten. walkFileTree ist dafür die richtige Form — visitFile löscht die Datei, postVisitDirectory löscht das Verzeichnis, sobald alle seine Kindelemente weg sind:
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;
}
});Das ist das JDK-Rezept für "ein Verzeichnis-Tree löschen." Jede Codebasis, die das braucht, endet mit irgendeiner Version dieses 10-Zeilen-Blocks. Einmal in einer Hilfsklasse speichern und wiederverwenden.
Symbolische Links
Standardmäßig folgen Files.walkFileTree und Files.walk keinen symbolischen Links. Das ist der sichere Standard: Er verhindert Endlosschleifen bei einem Symlink, der auf seinen eigenen Vorfahren zeigt. Um ihnen zu folgen, übergib FileVisitOption.FOLLOW_LINKS:
Files.walkFileTree(root, EnumSet.of(FileVisitOption.FOLLOW_LINKS),
Integer.MAX_VALUE, visitor);Wenn man sich dafür entscheidet, erkennt der Walker Zyklen automatisch — er verfolgt besuchte Verzeichnisschlüssel und bricht mit einer FileSystemLoopException ab, wenn derselbe erneut auftaucht. Das ist der einzige Weg, einen Baum mit Links zu durchlaufen, ohne die Zyklenerkennung selbst zu schreiben.
Ein durchgearbeitetes Beispiel: Baum ausgeben, Teilbäume überspringen, rekursiv löschen
Das folgende Programm erstellt einen kleinen Verzeichnisbaum mit einigen Unterverzeichnissen (eines davon soll übersprungen werden), Dateien auf mehreren Ebenen, und durchläuft ihn auf drei Arten. Erstens ein Baum-Printer mit SimpleFileVisitor, der .git überspringt. Zweitens ein "ersten Treffer finden" mit TERMINATE. Drittens das klassische rekursive-Lösch-Muster, das den gesamten Baum am Ende entfernt.
Was man aus dem Durchlauf mitnehmen kann:
- Der
preVisitDirectory-Hook gabSKIP_SUBTREEzurück, sobald er.gitsah. Der Walker stieg nie in das Verzeichnis ab; dieconfig-Datei darunter wurde nie besucht. Das ist das richtige Werkzeug für "diese konventionellen Verzeichnisse ignorieren" —.git,node_modules,target,distund alles andere, was das Projekt nicht durchlaufen soll. DieStream<Path>-Form kann das nicht tun, ohne die Einträge zu erzeugen und herauszufiltern, was immer noch den Verzeichnis-Read kostet. - Die Reihenfolge der Aufrufe für
sub/warpreVisitDirectory(sub)→visitFile(b.txt)→preVisitDirectory(nested)→visitFile(c.txt)→postVisitDirectory(nested)→postVisitDirectory(sub). Diepost*-Hooks feuern nach der Verarbeitung aller Nachkommen — das ist der Tiefensuche-Vertrag, und er macht das rekursive-Lösch-Muster erst möglich. - Der "ersten Treffer finden"-Durchlauf gab
TERMINATEausvisitFilezurück, sobaldc.txtauftauchte. Alles danach — die verbleibenden Einträge innested/, der Rest vonsub/, der Rest vonroot/— wurde nie besucht. Bei einem kleinen Baum ist die Ersparnis unsichtbar; bei einem tiefen Baum, bei dem der Treffer flach liegt, ist es der Unterschied zwischen O(n) und O(Tiefe-des-Treffers). - Das rekursive Löschen hatte zwei Hälften.
visitFilelöschte die Blätter;postVisitDirectorylöschte die (nun leeren) Verzeichnisse. Die Tiefensuche-Reihenfolge des Walkers garantierte, dass jedes Kind vor dempostVisitDirectoryseines Elternteils besucht wurde, sodassFiles.delete(d)immer ein leeres Verzeichnis vorfand. Der Versuch, das Verzeichnis inpreVisitDirectoryzu löschen, würde scheitern, weil die Kinder noch vorhanden sind; der Versuch, es mitFiles.delete(root)am Ende zu löschen, würde aus demselben Grund scheitern. Derpost*-Hook ist der eigentliche Sinn der Visitor-API. - Durchgehend war
SimpleFileVisitordie Basisklasse, und wir haben nur die Methoden überschrieben, die wir brauchten.visitFileFailedwurde bei seiner Standardimplementierung belassen (werfen), was für diese Temp-Datei-Demos in Ordnung ist. Für einen Scanner über ein echtes Dateisystem, das man nicht vollständig kontrolliert — etwa ein Virenscanner, der/durchläuft, wobei Dateien möglicherweise unter einem weggelöscht werden — überschreibevisitFileFailed, um zu protokollieren undCONTINUEzurückzugeben.
Was kommt als Nächstes
Teil 13 endet hier. Dateien wurden geschrieben, gelesen, geöffnet, kopiert, verschoben, gelöscht, durchlaufen, serialisiert. Streams wurden gepuffert, dekoriert, formatiert, gemappt, in Kanäle geleitet. Der nächste Teil, Date and Time, wechselt zu einem völlig anderen Problem: die Darstellung von Zeitpunkten, Dauern, Kalenderdaten, Zeitzonen sowie deren Formatierung und Analyse — java.time, die moderne API, die java.util.Date und Calendar ersetzt hat.