W3docs

Обход файловых деревьев в Java

Рекурсивный обход каталогов в Java с Files.walk, Files.find и интерфейсом FileVisitor.

Предыдущая глава завершилась методом Files.walk(dir) — потоковой формой Stream<Path> для получения всех файлов в каталоге. Это быстрый инструмент для типичных задач. В этой главе рассматривается более низкоуровневая альтернатива — Files.walkFileTree, которая предоставляет больше контроля над обходом: обработку ошибок ввода-вывода для каждого файла, пропуск целых поддеревьев в процессе обхода, выполнение кода при выходе из каталога (а не только при входе) и досрочное завершение при нахождении нужного элемента.

Используйте Files.walk для «перечислить всё». Используйте Files.walkFileTree для «выполнить действие на каждом шаге с контролем над ним».

Три API для обхода

Полный перечень, в порядке частоты использования:

APIВозвращаетКогда использовать
Files.walk(dir)Stream<Path>Наиболее часто — filter/map/foreach по каждому элементу
Files.find(dir, depth, biPredicate)Stream<Path>То же, с предикатом, учитывающим атрибуты (isDirectory, mtime)
Files.walkFileTree(dir, visitor)Path (начальный путь)Нужны хуки до/после посещения, обработка ошибок на уровне файлов или прерывание обхода

Первые два достаточны для 90% задач вида «найти все .log-файлы». К walkFileTree обращаются, когда нужно «удалить каталог после обхода» или «остановиться сразу после нахождения нужного файла».

FileVisitor и SimpleFileVisitor

Files.walkFileTree принимает FileVisitor<Path> — интерфейс с четырьмя методами, которые обходчик вызывает в определённые моменты:

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)

Порядок вызовов имеет значение: для каталога d с дочерними элементами [a, b/, c] вызовы будут такими: preVisitDirectory(d), visitFile(a), preVisitDirectory(b), ... postVisitDirectory(b), visitFile(c), postVisitDirectory(d). Хук post* делает возможным рекурсивное удаление — каталог нельзя удалить, пока не удалено его содержимое.

SimpleFileVisitor<Path> — вспомогательный класс, реализующий все четыре метода с разумными значениями по умолчанию (продолжать при успехе, бросать исключение при ошибке). Унаследуйтесь от него и переопределите только нужные методы:

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());

Это минимально рабочий посетитель.

FileVisitResult: четыре сигнала

Каждый метод посетителя возвращает FileVisitResult, сообщающий обходчику, что делать дальше:

ЗначениеЭффект
CONTINUEОбычный — перейти к следующему элементу
SKIP_SUBTREE(только из preVisitDirectory) Пропустить этот каталог и всё его содержимое
SKIP_SIBLINGSПрекратить посещение остальных элементов текущего каталога; возобновить с следующего элемента родителя
TERMINATEПолностью остановить обход

SKIP_SUBTREE наиболее полезен: «не спускаться в .git/ или node_modules/». Верните его из preVisitDirectory, когда имя каталога совпадает — и обходчик пропустит и сам каталог, и все его дочерние элементы:

@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 — сигнал «нашли, останавливаемся» — полезен при поиске первого совпадающего файла без обхода остальных:

@Override public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
  if (f.getFileName().toString().equals("target.txt")) {
    found = f;
    return FileVisitResult.TERMINATE;
  }
  return FileVisitResult.CONTINUE;
}

Потоковая форма не может этого делать — Files.walk(...).filter(...).findFirst() действительно прерывается досрочно, но только после того, как обходчик уже перечислил каждый элемент каталога в поток. На глубоком дереве, где совпадение находится близко к началу, walkFileTree заметно быстрее.

Обработка ошибок на уровне файлов

visitFile и preVisitDirectory вызываются только тогда, когда JDK удалось прочитать элемент. Если отдельный файл недоступен (нет прав доступа, висячая символическая ссылка, состояние гонки при удалении во время обхода), вместо него вызывается visitFileFailed с исключением. По умолчанию SimpleFileVisitor повторно бросает исключение — это прерывает обход:

@Override public FileVisitResult visitFileFailed(Path f, IOException e) throws IOException {
  throw e;                                          // default behaviour
}

Для устойчивого обходчика (журналировать и продолжать) переопределите метод:

@Override public FileVisitResult visitFileFailed(Path f, IOException e) {
  System.err.println("skipping " + f + ": " + e.getMessage());
  return FileVisitResult.CONTINUE;
}

У Files.walk(...) нет этого хука — при встрече с недоступным элементом он бросает UncheckedIOException внутри потока, и после этого поток уже нельзя использовать. Для длительных сканеров файловых систем, которые вы не полностью контролируете, это ещё один аргумент в пользу walkFileTree.

Канонический вариант использования: рекурсивное удаление

Files.delete работает только с пустыми каталогами. Чтобы удалить дерево, нужно сначала удалить листья, а затем каталоги, которые их содержали. walkFileTree идеально подходит для этого — visitFile удаляет файл, postVisitDirectory удаляет каталог, когда все его дочерние элементы уже удалены:

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;
  }
});

Это рецепт JDK для «удаления дерева каталогов». Любая кодовая база, которой это нужно, в итоге содержит какую-то версию этого блока из 10 строк. Сохраните копию в утилитарный класс и используйте повторно.

Символические ссылки

По умолчанию Files.walkFileTree и Files.walk не следуют символическим ссылкам. Это безопасное поведение по умолчанию: оно предотвращает бесконечные циклы при наличии ссылки, указывающей на своего же предка. Чтобы следовать символическим ссылкам, передайте FileVisitOption.FOLLOW_LINKS:

Files.walkFileTree(root, EnumSet.of(FileVisitOption.FOLLOW_LINKS),
    Integer.MAX_VALUE, visitor);

При выборе этого режима обходчик сам обнаруживает циклы — он отслеживает ключи посещённых каталогов и прерывается с FileSystemLoopException, если один и тот же каталог встречается снова. Это единственный способ обходить дерево со ссылками без написания собственного обнаружения циклов.

Готовый пример: печать дерева, пропуск поддерева, рекурсивное удаление

Программа ниже создаёт небольшое дерево каталогов с несколькими подкаталогами (один из которых мы хотим пропустить) и файлами на разных уровнях, затем обходит его тремя способами. Первый — вывод дерева с помощью SimpleFileVisitor с пропуском .git. Второй — «найти первое совпадение» с использованием TERMINATE. Третий — канонический шаблон рекурсивного удаления, удаляющий всё дерево в конце.

java— editable, runs on the server

Что важно понять из этого примера:

  • Хук preVisitDirectory вернул SKIP_SUBTREE сразу при обнаружении .git. Обходчик никогда не спускался в этот каталог; файл config внутри него никогда не посещался. Это правильный инструмент для «игнорировать стандартные служебные каталоги» — .git, node_modules, target, dist и всё остальное, что ваш проект не хочет обходить. Потоковая форма Stream<Path> не может этого сделать без создания элементов и их последующей фильтрации, что всё равно требует чтения каталога.
  • Порядок вызовов для sub/ был таким: preVisitDirectory(sub)visitFile(b.txt)preVisitDirectory(nested)visitFile(c.txt)postVisitDirectory(nested)postVisitDirectory(sub). Хуки post* срабатывают после обработки всех потомков — это контракт обхода в глубину, и именно он делает возможным шаблон рекурсивного удаления.
  • Обход «найти первый» вернул TERMINATE из visitFile в момент обнаружения c.txt. Всё остальное — оставшиеся элементы в nested/, остаток sub/, остаток root/ — никогда не посещалось. На небольшом дереве экономия незаметна; на глубоком дереве, где совпадение близко к корню, это разница между O(n) и O(глубина-совпадения).
  • Рекурсивное удаление состояло из двух частей. visitFile удалял листья; postVisitDirectory удалял (теперь уже пустые) каталоги. Порядок обхода в глубину гарантировал, что каждый дочерний элемент посещался до вызова postVisitDirectory его родителя, поэтому Files.delete(d) всегда видел пустой каталог. Попытка удалить каталог в preVisitDirectory завершится неудачей, поскольку дочерние элементы ещё на месте; попытка удалить его через Files.delete(root) в конце — по той же причине. Хук post* и есть главный смысл API посетителя.
  • На протяжении всего примера SimpleFileVisitor был базовым классом, и мы переопределяли только нужные методы. visitFileFailed оставался с поведением по умолчанию (бросать исключение), что для демонстраций с временными файлами вполне допустимо. Для сканера реальной файловой системы, которую вы не полностью контролируете — например, антивирусного сканера, обходящего /, где файлы могут удаляться прямо во время обхода — переопределите visitFileFailed так, чтобы он журналировал ошибку и возвращал CONTINUE.

Что дальше

Часть 13 заканчивается здесь. Файлы были записаны, прочитаны, открыты, скопированы, перемещены, удалены, обойдены, сериализованы. Потоки были буферизованы, декорированы, форматированы, преобразованы, направлены через каналы. Следующая часть, Дата и время, переходит к совершенно другой задаче: представление моментов, длительностей, календарных дат, часовых поясов, их форматирование и разбор — java.time, современный API, заменивший java.util.Date и Calendar.

Практика

Практика
Вам нужно удалить дерево каталогов, содержащее 50 файлов в 10 вложенных подкаталогах. Какая реализация хука `FileVisitor` удаляет каждый каталог только после удаления его дочерних элементов?
Вам нужно удалить дерево каталогов, содержащее 50 файлов в 10 вложенных подкаталогах. Какая реализация хука `FileVisitor` удаляет каждый каталог только после удаления его дочерних элементов?
Was this page helpful?