W3docs

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:

APIGibt zurückWann
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:

WertWirkung
CONTINUENormal — zum nächsten Eintrag gehen
SKIP_SUBTREE(nur aus preVisitDirectory) Dieses Verzeichnis und seine Kindelemente vollständig überspringen
SKIP_SIBLINGSAufhören, den Rest des aktuellen Verzeichnisses zu besuchen; beim nächsten Geschwisterelement des Elternverzeichnisses weitermachen
TERMINATEDen 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.

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.

java— editable, runs on the server

Was man aus dem Durchlauf mitnehmen kann:

  • Der preVisitDirectory-Hook gab SKIP_SUBTREE zurück, sobald er .git sah. Der Walker stieg nie in das Verzeichnis ab; die config-Datei darunter wurde nie besucht. Das ist das richtige Werkzeug für "diese konventionellen Verzeichnisse ignorieren" — .git, node_modules, target, dist und alles andere, was das Projekt nicht durchlaufen soll. Die Stream<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/ war preVisitDirectory(sub)visitFile(b.txt)preVisitDirectory(nested)visitFile(c.txt)postVisitDirectory(nested)postVisitDirectory(sub). Die post*-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 TERMINATE aus visitFile zurück, sobald c.txt auftauchte. Alles danach — die verbleibenden Einträge in nested/, der Rest von sub/, der Rest von root/ — 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. visitFile löschte die Blätter; postVisitDirectory löschte die (nun leeren) Verzeichnisse. Die Tiefensuche-Reihenfolge des Walkers garantierte, dass jedes Kind vor dem postVisitDirectory seines Elternteils besucht wurde, sodass Files.delete(d) immer ein leeres Verzeichnis vorfand. Der Versuch, das Verzeichnis in preVisitDirectory zu löschen, würde scheitern, weil die Kinder noch vorhanden sind; der Versuch, es mit Files.delete(root) am Ende zu löschen, würde aus demselben Grund scheitern. Der post*-Hook ist der eigentliche Sinn der Visitor-API.
  • Durchgehend war SimpleFileVisitor die Basisklasse, und wir haben nur die Methoden überschrieben, die wir brauchten. visitFileFailed wurde 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 — überschreibe visitFileFailed, um zu protokollieren und CONTINUE zurü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.

Übungen

Übung
Du musst einen Verzeichnisbaum mit 50 Dateien in 10 verschachtelten Unterverzeichnissen löschen. Welche `FileVisitor`-Hook-Implementierung entfernt jedes Verzeichnis erst, nachdem seine Kindelemente weg sind?
Du musst einen Verzeichnisbaum mit 50 Dateien in 10 verschachtelten Unterverzeichnissen löschen. Welche `FileVisitor`-Hook-Implementierung entfernt jedes Verzeichnis erst, nachdem seine Kindelemente weg sind?
Was this page helpful?