W3docs

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:

APIRetornaCuá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:

ValorEfecto
CONTINUENormal — ir a la siguiente entrada
SKIP_SUBTREE(solo desde preVisitDirectory) Omite este directorio y sus hijos completamente
SKIP_SIBLINGSDeja de visitar el resto del directorio actual; continúa en el siguiente hermano del padre
TERMINATEDetiene 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() 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.

java— editable, runs on the server

Qué aprender de la ejecución:

  • El hook preVisitDirectory retornó SKIP_SUBTREE en el momento en que vio .git. El recorredor nunca descendió al directorio; el archivo config bajo é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 forma Stream<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/ fue preVisitDirectory(sub)visitFile(b.txt)preVisitDirectory(nested)visitFile(c.txt)postVisitDirectory(nested)postVisitDirectory(sub). Los hooks post* 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ó TERMINATE desde visitFile en el momento en que apareció c.txt. Todo lo que vino después — las entradas restantes en nested/, el resto de sub/, el resto de root/ — 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. visitFile eliminó las hojas; postVisitDirectory eliminó los directorios (ya vacíos). El orden de profundidad primero del recorredor garantizó que cada hijo fue visitado antes del postVisitDirectory de su padre, por lo que Files.delete(d) siempre encontró un directorio vacío. Intentar eliminar el directorio en preVisitDirectory fallaría porque los hijos siguen ahí; intentar eliminarlo con Files.delete(root) al final fallaría por la misma razón. El hook post* es el punto central de la API visitor.
  • A lo largo de todo, SimpleFileVisitor fue la clase base y sobreescribimos solo los métodos que necesitábamos. visitFileFailed se 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 — sobreescribe visitFileFailed para registrar y CONTINUE.

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.

Práctica

Práctica
Necesitas eliminar un árbol de directorios que contiene 50 archivos en 10 subdirectorios anidados. ¿Qué implementación del hook `FileVisitor` elimina cada directorio solo después de que sus hijos han desaparecido?
Necesitas eliminar un árbol de directorios que contiene 50 archivos en 10 subdirectorios anidados. ¿Qué implementación del hook `FileVisitor` elimina cada directorio solo después de que sus hijos han desaparecido?
Was this page helpful?