1. Wprowadzenie
Przypomnijmy przykład przejścia z pomocą Files.walk():
Path start = Paths.get("my-folder");
try (Stream<Path> stream = Files.walk(start)) {
stream.forEach(System.out::println);
}
To działa świetnie, jeśli trzeba po prostu przejść po plikach i katalogach i coś z nimi zrobić. Ale co, jeśli zadanie jest bardziej złożone?
- Trzeba rekurencyjnie usunąć katalog razem ze wszystkimi plikami i podkatalogami (a nie tylko, gdy jest pusty).
- Trzeba rekurencyjnie skopiować lub przenieść katalog.
- Trzeba zebrać statystyki (na przykład policzyć łączny rozmiar wszystkich plików, pogrupować pliki według rozszerzeń).
- Trzeba obsłużyć błędy (na przykład jeśli do jakiegoś katalogu nie ma dostępu, nie chcemy, żeby wszystko się wywracało).
W takich przypadkach Stream API nie jest już tak wygodne: trzeba wstawiać try-catch w lambdach, pilnować kolejności przechodzenia (na przykład najpierw usuwać pliki, a potem katalogi) i kod staje się nieczytelny.
Do takich zadań powstał mechanizm przechodzenia drzewa systemu plików za pomocą FileVisitor.
2. Interfejs FileVisitor: jak jest zbudowany
Interfejs FileVisitor<T> — to swoisty „handler zdarzeń”, który otrzymuje powiadomienia o każdym odwiedzonym pliku i katalogu podczas przechodzenia systemu plików.
Gdy wywołujesz Files.walkFileTree(start, visitor), Java zaczyna przechodzić drzewo, począwszy od wskazanej ścieżki, i na każdym etapie wywołuje odpowiednią metodę twojego FileVisitor.
Podstawowe metody interfejsu FileVisitor
Tak wygląda interfejs:
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 — wywoływana przed wejściem do katalogu.
- visitFile — wywoływana dla każdego pliku.
- visitFileFailed — wywoływana, jeśli nie udało się uzyskać dostępu do pliku.
- postVisitDirectory — wywoływana po wyjściu z katalogu (czyli po przetworzeniu wszystkich jego plików i podkatalogów).
Każda z tych metod zwraca wartość typu FileVisitResult, która określa, jak kontynuować przechodzenie:
- FileVisitResult.CONTINUE — kontynuować przechodzenie.
- FileVisitResult.SKIP_SUBTREE — pominąć bieżący katalog i wszystko, co w nim jest.
- FileVisitResult.SKIP_SIBLINGS — pominąć „rodzeństwo” (pliki i katalogi na tym poziomie).
- FileVisitResult.TERMINATE — całkowicie przerwać przechodzenie.
Klasa SimpleFileVisitor
Implementowanie wszystkich czterech metod za każdym razem jest uciążliwe, zwłaszcza gdy potrzebujesz tylko jednej lub dwóch z nich. Dlatego w Javie jest wygodna klasa–adapter SimpleFileVisitor: implementuje ona wszystkie metody z zachowaniem „domyślnym” (po prostu CONTINUE), a ty możesz nadpisać tylko te potrzebne.
Przykład:
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("Plik: " + file);
return FileVisitResult.CONTINUE;
}
}
3. Użycie Files.walkFileTree: podstawowy przykład
Przykład 1: wypisujemy wszystkie pliki i katalogi
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("Katalog: " + dir);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
System.out.println(" Plik: " + file);
return FileVisitResult.CONTINUE;
}
});
}
}
Wynik:
Katalog: my-folder
Plik: my-folder/file1.txt
Plik: my-folder/file2.txt
Katalog: my-folder/subdir
Plik: my-folder/subdir/nested.txt
Jak widać, przechodzenie odbywa się „w głąb”: najpierw wchodzimy do katalogu, potem przetwarzamy jego pliki, a następnie przechodzimy do podkatalogów.
4. Przykład: rekurencyjne usuwanie katalogu
Jedno z najczęstszych zadań: usunąć katalog wraz z całą jego zawartością. Jeśli spróbujesz usunąć katalog przy pomocy Files.delete(path), a nie jest on pusty — dostaniesz wyjątek. Trzeba najpierw usunąć wszystkie pliki i podkatalogi, a dopiero potem sam katalog.
Tak można to zrobić przy użyciu 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); // usuwamy plik
System.out.println("Usunięto plik: " + file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
Files.delete(dir); // usuwamy katalog po tym, jak usunęliśmy wszystko w środku
System.out.println("Usunięto katalog: " + dir);
return FileVisitResult.CONTINUE;
}
});
}
}
Ważna uwaga:
Usuwanie katalogów odbywa się w postVisitDirectory, czyli po usunięciu całej zawartości. Gdybyśmy spróbowali usunąć katalog przed usunięciem plików w środku — dostalibyśmy błąd.
5. Przykład: zliczanie łącznego rozmiaru wszystkich plików w katalogu
Napiszmy teraz FileVisitor, który policzy sumaryczny rozmiar wszystkich plików w katalogu i jego podkatalogach.
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("Łączny rozmiar: " + totalSize + " bajtów");
}
}
Zwróć uwagę:
Używamy pola totalSize do akumulowania rozmiaru. W prawdziwych aplikacjach lepiej unikać pól statycznych i przekazywać zmienne przez obiekty, ale dla prostoty przykładu — tak.
6. Przykład: wyszukiwanie plików po masce (rozszerzeniu) przy użyciu FileVisitor
Załóżmy, że chcemy znaleźć wszystkie pliki .txt w katalogu i podkatalogach i wypisać ich listę.
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("Znaleziono plik .txt: " + file);
}
return FileVisitResult.CONTINUE;
}
});
}
}
Jeśli chcesz zebrać listę znalezionych plików, możesz utworzyć listę:
import java.util.ArrayList;
import java.util.List;
// wewnątrz 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("Łącznie znaleziono: " + txtFiles.size());
7. Obsługa błędów i specyfika przechodzenia
Co zrobić, jeśli do pliku lub katalogu nie ma dostępu?
Czasem podczas przechodzenia trafiają się pliki lub katalogi, do których nie ma dostępu (na przykład brak uprawnień lub plik jest zajęty przez inny proces). W takim wypadku zostanie wywołana metoda visitFileFailed.
Przykład:
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) {
System.err.println("Błąd dostępu do pliku: " + file + " (" + exc + ")");
return FileVisitResult.CONTINUE; // kontynuujemy przejście mimo błędu
}
Jeśli chcesz przerwać przechodzenie przy błędzie, zwróć TERMINATE.
Jak pominąć katalog w całości?
Jeśli nie chcesz wchodzić do określonego katalogu (na przykład katalog .git lub node_modules), możesz zwrócić SKIP_SUBTREE w metodzie preVisitDirectory:
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
if (dir.getFileName().toString().equals("node_modules")) {
return FileVisitResult.SKIP_SUBTREE; // nie wchodzić do tego katalogu ani jego podkatalogów
}
return FileVisitResult.CONTINUE;
}
8. Praktyka: implementujemy własny FileVisitor
Zaimplementujmy FileVisitor, który:
- Znajduje wszystkie pliki z rozszerzeniem .java w katalogu i podkatalogach,
- Zlicza ich liczbę,
- Sumuje łączny rozmiar tych plików.
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"); // na przykład kod źródłowy projektu
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("Znaleziono plik .java: " + file);
}
return FileVisitResult.CONTINUE;
}
}
JavaFileVisitor visitor = new JavaFileVisitor();
Files.walkFileTree(start, visitor);
System.out.println("Łącznie plików .java: " + visitor.count);
System.out.println("Łączny rozmiar: " + visitor.totalSize + " bajtów");
}
}
9. Typowe błędy przy użyciu FileVisitor
Błąd nr 1: próba usunięcia katalogu przed usunięciem plików w środku. Jeśli wywołasz Files.delete(dir) w preVisitDirectory, dostaniesz wyjątek — najpierw trzeba usunąć wszystkie pliki i podkatalogi, a dopiero potem sam katalog (rób to w postVisitDirectory).
Błąd nr 2: brak obsługi błędów dostępu. Jeśli nie nadpiszesz visitFileFailed, program może niespodziewanie się zakończyć przy napotkaniu chronionego pliku. Lepiej jawnie wypisać błąd i kontynuować przechodzenie.
Błąd nr 3: oczekiwanie, że operacja „ukrycia pliku” zadziała tak samo na wszystkich systemach operacyjnych. W Linuxie i macOS plik jest uznawany za ukryty, jeśli zaczyna się od kropki (.gitignore), a w Windows — jeśli ustawiony jest specjalny atrybut. Nie myl tych podejść.
Błąd nr 4: używanie pól statycznych do akumulowania wyniku w aplikacjach wielowątkowych. Jeśli uruchamiasz kilka przejść równolegle, pola statyczne doprowadzą do zamieszania. Lepiej używać pól instancji (albo klasy lokalnej, jak w przykładzie wyżej).
Błąd nr 5: zapomniano zamknąć zasoby przy pracy ze strumieniami wewnątrz FileVisitor. Jeśli twój FileVisitor czyta/zapisuje pliki, używaj try-with-resources, żeby uniknąć wycieków.
GO TO FULL VERSION