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, щоб не виникло витоків.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ