Обход файловых деревьев в 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. Третий — канонический шаблон рекурсивного удаления, удаляющий всё дерево в конце.
Что важно понять из этого примера:
- Хук
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.