Parcourir les arborescences de fichiers en Java
Parcourez récursivement les répertoires en Java avec Files.walk, Files.find et l'interface FileVisitor.
Le chapitre précédent s'est terminé avec Files.walk(dir) — la forme Stream<Path> de « donne-moi tous les fichiers sous ce répertoire ». C'est l'outil rapide pour le cas courant. Ce chapitre couvre l'alternative de plus bas niveau, Files.walkFileTree, qui vous permet de contrôler la traversée d'une manière que la forme flux ne peut pas : gérer les erreurs d'E/S fichier par fichier, ignorer des sous-arborescences entières en plein parcours, exécuter du code à la sortie d'un répertoire aussi bien qu'à l'entrée, et court-circuiter dès une correspondance trouvée.
Utilisez Files.walk pour « lister tout ». Utilisez Files.walkFileTree pour « effectuer une action à chaque étape, avec contrôle sur l'étape ».
Trois API de parcours
Le catalogue, dans l'ordre de fréquence d'utilisation :
| API | Retourne | Quand |
|---|---|---|
Files.walk(dir) | Stream<Path> | Le plus courant — filter/map/foreach sur chaque entrée |
Files.find(dir, depth, biPredicate) | Stream<Path> | Idem, avec un prédicat tenant compte des attributs (isDirectory, mtime) |
Files.walkFileTree(dir, visitor) | Path (le point de départ) | Besoin de hooks pré/post-visite, gestion d'erreur par fichier, ou pour interrompre le parcours |
Les deux premières suffisent pour 90 % du code « trouve-moi tous les fichiers .log ». walkFileTree est ce qu'il faut utiliser quand la réponse est « et ensuite supprimer le répertoire » ou « arrêter le parcours dès que le premier fichier correspondant est trouvé ».
FileVisitor et SimpleFileVisitor
Files.walkFileTree prend un FileVisitor<Path> — une interface avec quatre méthodes que le parcoureur appelle à des moments précis :
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'ordre est important : pour un répertoire d avec les enfants [a, b/, c], les appels sont preVisitDirectory(d), visitFile(a), preVisitDirectory(b), ... postVisitDirectory(b), visitFile(c), postVisitDirectory(d). Le hook post* est ce qui rend la suppression récursive possible — vous ne pouvez pas supprimer un répertoire avant d'en avoir supprimé le contenu.
SimpleFileVisitor<Path> est la classe utilitaire qui implémente les quatre méthodes avec des comportements par défaut sensés (continuer en cas de succès, lever une exception en cas d'échec). Héritez-en et ne surchargez que les méthodes qui vous intéressent :
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());C'est le visiteur minimal viable.
FileVisitResult : quatre signaux
Chaque méthode du visiteur retourne un FileVisitResult indiquant au parcoureur quoi faire ensuite :
| Valeur | Effet |
|---|---|
CONTINUE | Normal — passer à l'entrée suivante |
SKIP_SUBTREE | (depuis preVisitDirectory uniquement) Ignorer ce répertoire et ses enfants entièrement |
SKIP_SIBLINGS | Arrêter de visiter le reste du répertoire courant ; reprendre au prochain frère du parent |
TERMINATE | Arrêter complètement le parcours |
SKIP_SUBTREE est celui auquel vous ferez appel : « ne pas descendre dans .git/ ou node_modules/ ». Retournez-le depuis preVisitDirectory lorsque le nom du répertoire correspond et le parcoureur ignorera à la fois le répertoire et ses enfants :
@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 est le signal « trouvé, arrêt » — utile quand vous cherchez le premier fichier correspondant et ne souhaitez pas parcourir le reste :
@Override public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
if (f.getFileName().toString().equals("target.txt")) {
found = f;
return FileVisitResult.TERMINATE;
}
return FileVisitResult.CONTINUE;
}La forme Stream ne peut pas faire cela — Files.walk(...).filter(...).findFirst() court-circuite bien, mais seulement après que le parcoureur a déjà énuméré chaque entrée de répertoire dans le flux. Pour un arbre profond où la correspondance est peu profonde, walkFileTree est nettement plus rapide.
Gestion des erreurs par fichier
visitFile et preVisitDirectory ne sont appelées que lorsque le JDK a pu lire l'entrée. Si un fichier unique est illisible (permission refusée, lien symbolique brisé, situation de concurrence où il a été supprimé en plein parcours), visitFileFailed est appelée à la place avec l'exception. Par défaut, SimpleFileVisitor relève l'exception — ce qui interrompt le parcours :
@Override public FileVisitResult visitFileFailed(Path f, IOException e) throws IOException {
throw e; // default behaviour
}Pour un parcoureur tolérant (journaliser et continuer), surchargez-la :
@Override public FileVisitResult visitFileFailed(Path f, IOException e) {
System.err.println("skipping " + f + ": " + e.getMessage());
return FileVisitResult.CONTINUE;
}Files.walk(...) ne dispose pas de ce hook — il lève une UncheckedIOException depuis l'intérieur du flux dès qu'il rencontre une entrée problématique, et le flux est mort après cela. Pour les scanners longue durée sur des systèmes de fichiers que vous ne contrôlez pas entièrement, c'est une raison supplémentaire de préférer walkFileTree.
Le cas d'usage canonique : la suppression récursive
Files.delete ne fonctionne que sur les répertoires vides. Pour supprimer un arbre, vous devez d'abord supprimer les feuilles, puis les répertoires qui les contenaient. walkFileTree a la bonne forme pour cela — visitFile supprime le fichier, postVisitDirectory supprime le répertoire une fois tous ses enfants disparus :
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;
}
});C'est la recette JDK pour « supprimer un arbre de répertoires ». Tout projet qui en a besoin finit par avoir une version de ce bloc de 10 lignes. Enregistrez-en une copie dans une classe utilitaire et réutilisez-la.
Liens symboliques
Par défaut, Files.walkFileTree et Files.walk ne suivent pas les liens symboliques. C'est le comportement sûr par défaut : cela prévient les boucles infinies sur un lien symbolique qui pointe vers son propre ancêtre. Pour les suivre, passez FileVisitOption.FOLLOW_LINKS :
Files.walkFileTree(root, EnumSet.of(FileVisitOption.FOLLOW_LINKS),
Integer.MAX_VALUE, visitor);Lorsque vous activez cette option, le parcoureur détecte les cycles pour vous — il suit les clés de répertoires visités et abandonne si le même apparaît à nouveau avec FileSystemLoopException. C'est le seul moyen de parcourir un arbre avec des liens sans écrire soi-même la détection de cycles.
Un exemple complet : impression d'arbre, saut de sous-arborescence, suppression récursive
Le programme ci-dessous construit un petit arbre de répertoires avec quelques sous-répertoires (dont un que l'on veut ignorer), des fichiers à plusieurs profondeurs, puis le parcourt de trois façons. D'abord, une impression d'arbre avec SimpleFileVisitor qui ignore .git. Ensuite, un « trouver la première correspondance » avec TERMINATE. Enfin, le schéma canonique de suppression récursive qui supprime l'ensemble de l'arbre à la fin.
Ce que l'exécution nous enseigne :
- Le hook
preVisitDirectorya retournéSKIP_SUBTREEdès qu'il a vu.git. Le parcoureur n'est jamais descendu dans le répertoire ; le fichierconfigqu'il contient n'a jamais été visité. C'est le bon outil pour « ignorer ces répertoires conventionnels » —.git,node_modules,target,dist, et tout autre répertoire que votre projet ne veut pas parcourir. La formeStream<Path>ne peut pas faire cela sans produire les entrées et les filtrer, ce qui coûte quand même la lecture du répertoire. - L'ordre des appels pour
sub/étaitpreVisitDirectory(sub)→visitFile(b.txt)→preVisitDirectory(nested)→visitFile(c.txt)→postVisitDirectory(nested)→postVisitDirectory(sub). Les hookspost*se déclenchent après que tous les descendants ont été traités — c'est le contrat en profondeur d'abord, et c'est ce qui rend le schéma de suppression récursive possible. - Le parcours « trouver le premier » a retourné
TERMINATEdepuisvisitFiledès quec.txtest apparu. Tout ce qui suivait — les autres entrées dansnested/, le reste desub/, le reste deroot/— n'a jamais été visité. Sur un petit arbre l'économie est invisible ; sur un arbre profond où la correspondance est peu profonde, c'est la différence entre O(n) et O(profondeur-de-correspondance). - La suppression récursive comportait deux parties.
visitFilesupprimait les feuilles ;postVisitDirectorysupprimait les répertoires (désormais vides). L'ordre en profondeur d'abord du parcoureur garantissait que chaque enfant était visité avant lepostVisitDirectoryde son parent, doncFiles.delete(d)voyait toujours un répertoire vide. Tenter de supprimer le répertoire danspreVisitDirectoryéchouerait car les enfants sont encore présents ; tenter de le supprimer avecFiles.delete(root)à la fin échouerait pour la même raison. Le hookpost*est toute la raison d'être de l'API visiteur. - Tout au long,
SimpleFileVisitorétait la classe de base et nous n'avons surchargé que les méthodes dont nous avions besoin.visitFileFaileda été laissé à sa valeur par défaut (lever une exception), ce qui est correct pour ces démos avec des fichiers temporaires. Pour un scanner sur un vrai système de fichiers que vous ne contrôlez pas entièrement — disons, un antivirus parcourant/, où des fichiers pourraient être supprimés sous vos pieds — surchargezvisitFileFailedpour journaliser etCONTINUE.
La suite
La partie 13 se termine ici. Des fichiers ont été écrits, lus, ouverts, copiés, déplacés, supprimés, parcourus, sérialisés. Des flux ont été mis en mémoire tampon, décorés, formatés, mappés, canalisés. La partie suivante, Date et heure, aborde un problème totalement différent : représenter des instants, des durées, des dates calendaires, des fuseaux horaires, ainsi que leur formatage et leur analyse — java.time, l'API moderne qui a remplacé java.util.Date et Calendar.