1. Введение
Давайте вспомним пример обхода с помощью Files.walk():
Path start = Paths.get("my-folder");
try (Stream<Path> stream = Files.walk(start)) {
stream.forEach(System.out::println);
}
Это работает отлично, если нужно просто пройтись по файлам и папкам и что-то с ними сделать. Но что, если задача сложнее?
- Нужно рекурсивно удалить папку вместе со всеми файлами и подкаталогами (а не только если она пуста).
- Нужно рекурсивно скопировать или переместить каталог.
- Нужно собрать статистику (например, посчитать общий размер всех файлов, сгруппировать файлы по расширениям).
- Нужно обработать ошибки (например, если к какой-то папке нет доступа, не хочется, чтобы всё падало).
В таких случаях Stream API уже не так удобен: приходится городить try-catch в лямбдах, следить за порядком обхода (например, чтобы сначала удалять файлы, а потом папки), и код становится нечитабельным.
Для этих задач и был придуман механизм обхода дерева файловой системы с помощью FileVisitor.
2. Интерфейс FileVisitor: как он устроен
Интерфейс FileVisitor<T> — это такой «обработчик событий», который получает уведомления о каждом посещённом файле и папке при обходе файловой системы.
Когда вы вызываете Files.walkFileTree(start, visitor), Java начинает обход дерева, начиная с указанного пути, и на каждом этапе вызывает соответствующий метод вашего FileVisitor.
Основные методы интерфейса FileVisitor
Вот как выглядит интерфейс:
public interface FileVisitor<T> {
FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs) throws IOException;
FileVisitResult visitFile(T file, BasicFileAttributes attrs) throws IOException;
FileVisitResult visitFileFailed(T file, IOException exc) throws IOException;
FileVisitResult postVisitDirectory(T dir, IOException exc) throws IOException;
}
- preVisitDirectory — вызывается перед входом в директорию.
- visitFile — вызывается для каждого файла.
- visitFileFailed — вызывается, если не удалось получить доступ к файлу.
- postVisitDirectory — вызывается после выхода из директории (то есть после обработки всех её файлов и поддиректорий).
Каждый из этих методов возвращает значение типа FileVisitResult, которое определяет, как продолжать обход:
- FileVisitResult.CONTINUE — продолжать обход.
- FileVisitResult.SKIP_SUBTREE — пропустить текущую директорию и всё, что в ней.
- FileVisitResult.SKIP_SIBLINGS — пропустить остальных «братьев» (файлы и папки на этом уровне).
- FileVisitResult.TERMINATE — полностью прекратить обход.
Класс SimpleFileVisitor
Реализовывать все четыре метода каждый раз — утомительно, особенно если вам нужно только один или два из них. Поэтому в Java есть удобный класс-адаптер SimpleFileVisitor: он уже реализует все методы с поведением «по умолчанию» (просто CONTINUE), а вы можете переопределить только нужные.
Пример:
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
public class MyVisitor extends SimpleFileVisitor<Path> {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
System.out.println("Файл: " + file);
return FileVisitResult.CONTINUE;
}
}
3. Использование Files.walkFileTree: базовый пример
Пример 1: Выводим все файлы и папки
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
public class TreePrinter {
public static void main(String[] args) throws IOException {
Path start = Paths.get("my-folder");
Files.walkFileTree(start, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
System.out.println("Папка: " + dir);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
System.out.println(" Файл: " + file);
return FileVisitResult.CONTINUE;
}
});
}
}
Вывод:
Папка: my-folder
Файл: my-folder/file1.txt
Файл: my-folder/file2.txt
Папка: my-folder/subdir
Файл: my-folder/subdir/nested.txt
Как видите, обход происходит «в глубину»: сначала заходим в папку, потом обрабатываем её файлы, потом переходим к подпапкам.
4. Пример: рекурсивное удаление директории
Одна из самых частых задач: удалить папку вместе со всем её содержимым. Если пробовать удалить папку с помощью Files.delete(path), а она не пуста — получите исключение. Нужно сначала удалить все файлы и подпапки, а потом саму папку.
Вот как это делается с помощью FileVisitor:
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
public class RecursiveDelete {
public static void main(String[] args) throws IOException {
Path dirToDelete = Paths.get("test-folder");
Files.walkFileTree(dirToDelete, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.delete(file); // удаляем файл
System.out.println("Удалён файл: " + file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
Files.delete(dir); // удаляем папку после того, как удалили всё внутри
System.out.println("Удалена папка: " + dir);
return FileVisitResult.CONTINUE;
}
});
}
}
Важный момент:
Удаление папок происходит в postVisitDirectory, то есть после того, как удалили всё содержимое. Если бы мы попытались удалить папку до удаления файлов внутри — получили бы ошибку.
5. Пример: подсчёт общего размера всех файлов в каталоге
Давайте теперь напишем FileVisitor, который посчитает суммарный размер всех файлов в папке и её подкаталогах.
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
public class DirectorySizeCalculator {
private static long totalSize = 0;
public static void main(String[] args) throws IOException {
Path start = Paths.get("my-folder");
Files.walkFileTree(start, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
totalSize += attrs.size();
return FileVisitResult.CONTINUE;
}
});
System.out.println("Общий размер: " + totalSize + " байт");
}
}
Обратите внимание:
Мы используем поле totalSize для накопления размера. В реальных приложениях лучше избегать статических полей и передавать переменные через объекты, но для простоты примера — так.
6. Пример: поиск файлов по маске (расширению) с помощью FileVisitor
Допустим, нам нужно найти все .txt файлы в каталоге и подкаталогах, и вывести их список.
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
public class TxtFileFinder {
public static void main(String[] args) throws IOException {
Path start = Paths.get("my-folder");
Files.walkFileTree(start, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (file.getFileName().toString().endsWith(".txt")) {
System.out.println("Найден .txt файл: " + file);
}
return FileVisitResult.CONTINUE;
}
});
}
}
Если хочется собрать список найденных файлов, можно завести список:
import java.util.ArrayList;
import java.util.List;
// внутри main:
List<Path> txtFiles = new ArrayList<>();
Files.walkFileTree(start, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (file.getFileName().toString().endsWith(".txt")) {
txtFiles.add(file);
}
return FileVisitResult.CONTINUE;
}
});
System.out.println("Всего найдено: " + txtFiles.size());
7. Обработка ошибок и особенности обхода
Что делать, если к файлу или папке нет доступа?
Иногда при обходе встречаются файлы или папки, к которым нет доступа (например, если у вас нет прав, или файл занят другим процессом). В этом случае будет вызван метод visitFileFailed.
Пример:
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) {
System.err.println("Ошибка доступа к файлу: " + file + " (" + exc + ")");
return FileVisitResult.CONTINUE; // продолжаем обход несмотря на ошибку
}
Если вы хотите прервать обход при ошибке, верните TERMINATE.
Как пропустить папку целиком?
Если вы не хотите заходить в определённую папку (например, папка .git или node_modules), можно вернуть SKIP_SUBTREE в методе preVisitDirectory:
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
if (dir.getFileName().toString().equals("node_modules")) {
return FileVisitResult.SKIP_SUBTREE; // не заходить в эту папку и её поддиректории
}
return FileVisitResult.CONTINUE;
}
8. Практика: реализуем собственный FileVisitor
Давайте реализуем FileVisitor, который:
- Находит все файлы с расширением .java в каталоге и подкаталогах,
- Считает их количество,
- Считает общий размер этих файлов.
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
public class JavaFilesStats {
public static void main(String[] args) throws IOException {
Path start = Paths.get("src"); // например, исходный код проекта
class JavaFileVisitor extends SimpleFileVisitor<Path> {
int count = 0;
long totalSize = 0;
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (file.getFileName().toString().endsWith(".java")) {
count++;
totalSize += attrs.size();
System.out.println("Найден .java файл: " + file);
}
return FileVisitResult.CONTINUE;
}
}
JavaFileVisitor visitor = new JavaFileVisitor();
Files.walkFileTree(start, visitor);
System.out.println("Всего .java файлов: " + visitor.count);
System.out.println("Общий размер: " + visitor.totalSize + " байт");
}
}
9. Типичные ошибки при использовании FileVisitor
Ошибка № 1: попытка удалить директорию до удаления файлов внутри. Если вы вызываете Files.delete(dir) в preVisitDirectory, получите исключение — сначала нужно удалить все файлы и подпапки, только потом саму директорию (делайте это в postVisitDirectory).
Ошибка № 2: забыли обработать ошибки доступа. Если не переопределить visitFileFailed, программа может неожиданно завершиться при встрече с защищённым файлом. Лучше явно выводить ошибку и продолжать обход.
Ошибка № 3: ожидание, что операция «скрыть файл» сработает одинаково на всех ОС. На Linux и macOS скрытым считается файл, начинающийся с точки (.gitignore), а на Windows — если выставлен специальный атрибут. Не путайте эти подходы.
Ошибка № 4: использовать статические поля для накопления результата в многопоточных приложениях. Если вы запускаете несколько обходов параллельно, статические поля приведут к путанице. Лучше использовать поля экземпляра (или локального класса, как в примере выше).
Ошибка № 5: забыли закрыть ресурсы при работе с потоками внутри FileVisitor. Если ваш FileVisitor читает/записывает файлы, используйте try-with-resources, чтобы не возникло утечек.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ