JavaRush /Курси /JAVA 25 SELF /FileVisitor — обхід файлової системи та рекурсивні операц...

FileVisitor — обхід файлової системи та рекурсивні операції

JAVA 25 SELF
Рівень 39 , Лекція 3
Відкрита

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, щоб не виникло витоків.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ