Recorrido de árboles de archivos en Java
Recorre directorios recursivamente en Java con Files.walk, Files.find y la interfaz FileVisitor.
El capítulo anterior terminó con Files.walk(dir) — la forma Stream<Path> de "dame todos los archivos bajo este directorio." Esa es la herramienta rápida para el caso común. Este capítulo cubre la alternativa de más bajo nivel, Files.walkFileTree, que te permite controlar el recorrido de formas que la forma de stream no puede: manejar errores de E/S por archivo, omitir subárboles enteros a mitad del recorrido, ejecutar código al salir de un directorio además de al entrar, y cortocircuitar al encontrar una coincidencia.
Usa Files.walk para "listar todo." Usa Files.walkFileTree para "hacer algo en cada paso, con control sobre el paso."
Tres APIs de recorrido
El catálogo, en orden de frecuencia de uso:
| API | Retorna | Cuándo |
|---|---|---|
Files.walk(dir) | Stream<Path> | El más común — filter/map/foreach sobre cada entrada |
Files.find(dir, depth, biPredicate) | Stream<Path> | Igual, con un predicado consciente de atributos (isDirectory, mtime) |
Files.walkFileTree(dir, visitor) | Path (el inicio) | Necesitas hooks pre/post-visita, manejo de errores por archivo, o abortar el recorrido |
Los dos primeros son suficientes para el 90% del código de "encuentra todos los archivos .log". walkFileTree es al que recurres cuando la respuesta es "y luego elimina el directorio después" o "deja de recorrer en cuanto encuentre el que busco."
FileVisitor y SimpleFileVisitor
Files.walkFileTree recibe un FileVisitor<Path> — una interfaz con cuatro métodos que el recorredor llama en momentos específicos:
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)El orden importa: para un directorio d con hijos [a, b/, c], las llamadas son preVisitDirectory(d), visitFile(a), preVisitDirectory(b), ... postVisitDirectory(b), visitFile(c), postVisitDirectory(d). El hook post* es lo que hace posible la eliminación recursiva — no puedes eliminar un directorio hasta haber eliminado su contenido.
SimpleFileVisitor<Path> es la clase auxiliar que implementa los cuatro métodos con valores predeterminados razonables (continuar en éxito, lanzar excepción en fallo). Subclasifícala y sobreescribe solo los métodos que te interesan:
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());Ese es el visitor mínimo viable.
FileVisitResult: cuatro señales
Cada método del visitor retorna un FileVisitResult que le indica al recorredor qué hacer a continuación:
| Valor | Efecto |
|---|---|
CONTINUE | Normal — ir a la siguiente entrada |
SKIP_SUBTREE | (solo desde preVisitDirectory) Omite este directorio y sus hijos completamente |
SKIP_SIBLINGS | Deja de visitar el resto del directorio actual; continúa en el siguiente hermano del padre |
TERMINATE | Detiene el recorrido completamente |
SKIP_SUBTREE es el que más usarás: "no descender en .git/ ni en node_modules/." Retórnalo desde preVisitDirectory cuando el nombre del directorio coincida y el recorredor omitirá tanto el directorio como sus hijos:
@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 es la señal de "lo encontré, detente" — útil cuando buscas el primer archivo que coincide y no quieres recorrer el 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 no puede hacer esto — Files.walk(...).filter(...).findFirst() sí cortocircuita, pero solo después de que el recorredor ya enumeró cada entrada de directorio en el stream. Para un árbol profundo donde la coincidencia es superficial, walkFileTree es notablemente más rápido.
Manejo de errores por archivo
visitFile y preVisitDirectory solo se llaman cuando el JDK pudo leer la entrada. Si un archivo individual no es legible (permiso denegado, enlace simbólico colgante, condición de carrera donde fue eliminado a mitad del recorrido), en su lugar se llama visitFileFailed con la excepción. Por defecto SimpleFileVisitor relanza — eso aborta el recorrido:
@Override public FileVisitResult visitFileFailed(Path f, IOException e) throws IOException {
throw e; // default behaviour
}Para un recorredor tolerante (registrar y continuar), sobreescríbelo:
@Override public FileVisitResult visitFileFailed(Path f, IOException e) {
System.err.println("skipping " + f + ": " + e.getMessage());
return FileVisitResult.CONTINUE;
}Files.walk(...) no tiene este hook — lanza una UncheckedIOException desde dentro del stream en el momento en que encuentra una entrada problemática, y el stream queda inutilizable después de eso. Para escáneres de larga ejecución sobre sistemas de archivos que no controlas completamente, esa es otra razón para usar walkFileTree.
El caso de uso canónico: eliminación recursiva
Files.delete solo funciona en directorios vacíos. Para eliminar un árbol tienes que eliminar primero las hojas, luego los directorios que las contenían. walkFileTree tiene la forma correcta para esto — visitFile elimina el archivo, postVisitDirectory elimina el directorio una vez que todos sus hijos han desaparecido:
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;
}
});Esta es la receta del JDK para "eliminar un árbol de directorios." Toda base de código que lo necesita termina con alguna versión de este bloque de 10 líneas. Guarda una copia en una clase de utilidades y reutilízala.
Enlaces simbólicos
Por defecto, Files.walkFileTree y Files.walk no siguen los enlaces simbólicos. Ese es el comportamiento seguro predeterminado: previene bucles infinitos en un enlace simbólico que apunta a su propio ancestro. Para seguirlos, pasa FileVisitOption.FOLLOW_LINKS:
Files.walkFileTree(root, EnumSet.of(FileVisitOption.FOLLOW_LINKS),
Integer.MAX_VALUE, visitor);Cuando lo habilitas, el recorredor detecta ciclos por ti — rastrea las claves de directorios visitados y se detiene si el mismo aparece de nuevo con FileSystemLoopException. Es la única forma de recorrer un árbol con enlaces sin escribir tú mismo la detección de ciclos.
Un ejemplo completo: imprimir árbol, omitir subárbol, eliminación recursiva
El programa a continuación construye un pequeño árbol de directorios con un par de subdirectorios (uno de los cuales queremos omitir), archivos a múltiples profundidades, y lo recorre de tres formas. Primero, un impresor de árbol con SimpleFileVisitor que omite .git. Segundo, un "encontrar primera coincidencia" con TERMINATE. Tercero, el patrón canónico de eliminación recursiva que remueve el árbol completo al final.
Qué aprender de la ejecución:
- El hook
preVisitDirectoryretornóSKIP_SUBTREEen el momento en que vio.git. El recorredor nunca descendió al directorio; el archivoconfigbajo él nunca fue visitado. Esa es la herramienta correcta para "ignorar estos directorios convencionales" —.git,node_modules,target,dist, cualquier otro que tu proyecto no quiera recorrer. La formaStream<Path>no puede hacer esto sin producir las entradas y filtrarlas, lo que igual tiene el costo de la lectura del directorio. - El orden de llamadas para
sub/fuepreVisitDirectory(sub)→visitFile(b.txt)→preVisitDirectory(nested)→visitFile(c.txt)→postVisitDirectory(nested)→postVisitDirectory(sub). Los hookspost*se disparan después de que todos los descendientes han sido procesados — ese es el contrato de profundidad primero, y es lo que hace posible el patrón de eliminación recursiva. - El recorrido "encontrar el primero" retornó
TERMINATEdesdevisitFileen el momento en que aparecióc.txt. Todo lo que vino después — las entradas restantes ennested/, el resto desub/, el resto deroot/— nunca fue visitado. En un árbol pequeño el ahorro es invisible; en un árbol profundo donde la coincidencia es superficial, es la diferencia entre O(n) y O(profundidad-de-coincidencia). - La eliminación recursiva tuvo dos mitades.
visitFileeliminó las hojas;postVisitDirectoryeliminó los directorios (ya vacíos). El orden de profundidad primero del recorredor garantizó que cada hijo fue visitado antes delpostVisitDirectoryde su padre, por lo queFiles.delete(d)siempre encontró un directorio vacío. Intentar eliminar el directorio enpreVisitDirectoryfallaría porque los hijos siguen ahí; intentar eliminarlo conFiles.delete(root)al final fallaría por la misma razón. El hookpost*es el punto central de la API visitor. - A lo largo de todo,
SimpleFileVisitorfue la clase base y sobreescribimos solo los métodos que necesitábamos.visitFileFailedse dejó con su valor predeterminado (lanzar excepción), lo cual para estas demos con archivos temporales está bien. Para un escáner sobre un sistema de archivos real que no controlas completamente — digamos, un escáner de virus recorriendo/, donde los archivos podrían eliminarse mientras los analizas — sobreescribevisitFileFailedpara registrar yCONTINUE.
Qué sigue
La Parte 13 termina aquí. Los archivos han sido escritos, leídos, abiertos, copiados, movidos, eliminados, recorridos, serializados. Los streams han sido almacenados en búfer, decorados, formateados, mapeados, canalizados. La siguiente parte, Fecha y Hora, aborda un problema completamente diferente: representar instantes, duraciones, fechas de calendario, zonas horarias, y el formateo y análisis de los mismos — java.time, la API moderna que reemplazó a java.util.Date y Calendar.