CodeGym /Các khóa học /JAVA 25 SELF /FileVisitor — duyệt hệ thống tệp và các thao tác đệ quy

FileVisitor — duyệt hệ thống tệp và các thao tác đệ quy

JAVA 25 SELF
Mức độ , Bài học
Có sẵn

1. Giới thiệu

Hãy nhớ lại ví dụ duyệt bằng Files.walk():

Path start = Paths.get("my-folder");
try (Stream<Path> stream = Files.walk(start)) {
    stream.forEach(System.out::println);
}

Cách này hoạt động rất tốt nếu bạn chỉ cần đi qua các tệp và thư mục rồi làm gì đó với chúng. Nhưng nếu bài toán phức tạp hơn thì sao?

  • Cần xóa đệ quy một thư mục cùng toàn bộ tệp và thư mục con (không chỉ khi nó rỗng).
  • Cần sao chép hoặc di chuyển đệ quy một thư mục.
  • Cần thu thập thống kê (ví dụ, tính tổng kích thước tất cả tệp, nhóm tệp theo phần mở rộng).
  • Cần xử lý lỗi (ví dụ, nếu không có quyền vào một thư mục nào đó thì không muốn toàn bộ chương trình bị lỗi).

Trong các trường hợp như vậy, Stream API không còn thuận tiện: bạn phải nhồi try-catch vào lambda, phải kiểm soát thứ tự duyệt (chẳng hạn để xóa tệp trước rồi mới xóa thư mục), và mã trở nên khó đọc.

Cho các bài toán này, cơ chế duyệt cây hệ thống tệp bằng FileVisitor đã được thiết kế.

2. Giao diện FileVisitor: cấu trúc hoạt động

Giao diện FileVisitor<T> là một “trình xử lý sự kiện” nhận thông báo về mỗi tệp và thư mục được ghé qua trong quá trình duyệt hệ thống tệp.

Khi bạn gọi Files.walkFileTree(start, visitor), Java bắt đầu duyệt cây từ đường dẫn được chỉ định và ở mỗi bước sẽ gọi phương thức tương ứng của FileVisitor của bạn.

Các phương thức chính của giao diện FileVisitor

Giao diện trông như sau:

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 — được gọi trước khi đi vào thư mục.
  • visitFile — được gọi cho mỗi tệp.
  • visitFileFailed — được gọi nếu không truy cập được tệp.
  • postVisitDirectory — được gọi sau khi rời khỏi thư mục (tức là sau khi xử lý tất cả tệp và thư mục con của nó).

Mỗi phương thức trả về một giá trị kiểu FileVisitResult để quyết định cách tiếp tục duyệt:

  • FileVisitResult.CONTINUE — tiếp tục duyệt.
  • FileVisitResult.SKIP_SUBTREE — bỏ qua thư mục hiện tại và tất cả nội dung bên trong.
  • FileVisitResult.SKIP_SIBLINGS — bỏ qua các “anh em” còn lại (các tệp và thư mục ở cùng cấp này).
  • FileVisitResult.TERMINATE — dừng duyệt hoàn toàn.

Lớp SimpleFileVisitor

Việc hiện thực cả bốn phương thức mỗi lần khá mệt mỏi, đặc biệt khi bạn chỉ cần một hoặc hai phương thức. Vì vậy, Java có lớp bộ chuyển đổi tiện lợi SimpleFileVisitor: nó đã hiện thực sẵn tất cả phương thức với hành vi “mặc định” (đơn giản là CONTINUE), và bạn có thể override chỉ những phương thức cần thiết.

Ví dụ:

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("Tệp: " + file);
        return FileVisitResult.CONTINUE;
    }
}

3. Sử dụng Files.walkFileTree: ví dụ cơ bản

Ví dụ 1: In ra tất cả tệp và thư mục

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("Thư mục: " + dir);
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                System.out.println("  Tệp: " + file);
                return FileVisitResult.CONTINUE;
            }
        });
    }
}

Kết quả:

Thư mục: my-folder
  Tệp: my-folder/file1.txt
  Tệp: my-folder/file2.txt
Thư mục: my-folder/subdir
  Tệp: my-folder/subdir/nested.txt

Như bạn thấy, việc duyệt diễn ra “theo chiều sâu”: trước tiên vào thư mục, sau đó xử lý các tệp của nó, rồi chuyển sang các thư mục con.

4. Ví dụ: xóa đệ quy thư mục

Một trong những tác vụ phổ biến nhất: xóa một thư mục cùng toàn bộ nội dung bên trong. Nếu thử xóa thư mục bằng Files.delete(path) trong khi nó không rỗng — bạn sẽ nhận ngoại lệ. Cần xóa tất cả tệp và thư mục con trước, rồi mới xóa chính thư mục đó.

Đây là cách thực hiện với 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); // xóa tệp
                System.out.println("Đã xóa tệp: " + file);
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
                Files.delete(dir); // xóa thư mục sau khi đã xóa mọi thứ bên trong
                System.out.println("Đã xóa thư mục: " + dir);
                return FileVisitResult.CONTINUE;
            }
        });
    }
}

Điểm quan trọng:
Việc xóa thư mục diễn ra trong postVisitDirectory, tức là sau khi đã xóa toàn bộ nội dung bên trong. Nếu cố xóa thư mục trước khi xóa các tệp bên trong — bạn sẽ gặp lỗi.

5. Ví dụ: tính tổng kích thước tất cả tệp trong thư mục

Giờ hãy viết một FileVisitor để tính tổng kích thước tất cả tệp trong một thư mục và các thư mục con.

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("Tổng kích thước: " + totalSize + " byte");
    }
}

Lưu ý:
Chúng ta dùng trường totalSize để cộng dồn kích thước. Trong ứng dụng thực tế, tốt hơn nên tránh dùng trường static và truyền biến qua đối tượng, nhưng để đơn giản ví dụ — ta làm vậy.

6. Ví dụ: tìm tệp theo mẫu (phần mở rộng) với FileVisitor

Giả sử chúng ta cần tìm tất cả các tệp .txt trong thư mục và các thư mục con, rồi in danh sách của chúng.

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("Tìm thấy tệp .txt: " + file);
                }
                return FileVisitResult.CONTINUE;
            }
        });
    }
}

Nếu muốn tập hợp danh sách các tệp tìm được, bạn có thể tạo một danh sách:

import java.util.ArrayList;
import java.util.List;

// bên trong 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("Tổng số tìm được: " + txtFiles.size());

7. Xử lý lỗi và các đặc điểm khi duyệt

Làm gì khi không có quyền truy cập vào tệp hoặc thư mục?

Đôi khi khi duyệt, bạn sẽ gặp tệp hoặc thư mục không thể truy cập (ví dụ, bạn không có quyền, hoặc tệp đang bị tiến trình khác sử dụng). Trong trường hợp này, phương thức visitFileFailed sẽ được gọi.

Ví dụ:

@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) {
    System.err.println("Lỗi truy cập tệp: " + file + " (" + exc + ")");
    return FileVisitResult.CONTINUE; // tiếp tục duyệt mặc dù có lỗi
}

Nếu bạn muốn dừng duyệt khi gặp lỗi, hãy trả về TERMINATE.

Làm thế nào để bỏ qua cả một thư mục?

Nếu bạn không muốn đi vào một thư mục nhất định (ví dụ, thư mục .git hoặc node_modules), bạn có thể trả về SKIP_SUBTREE trong phương thức preVisitDirectory:

@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
    if (dir.getFileName().toString().equals("node_modules")) {
        return FileVisitResult.SKIP_SUBTREE; // không đi vào thư mục này và các thư mục con của nó
    }
    return FileVisitResult.CONTINUE;
}

8. Thực hành: tự triển khai FileVisitor

Hãy triển khai một FileVisitor có thể:

  • Tìm tất cả tệp có phần mở rộng .java trong thư mục và các thư mục con,
  • Đếm số lượng của chúng,
  • Tính tổng kích thước các tệp đó.
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"); // ví dụ: mã nguồn của dự án

        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("Tìm thấy tệp .java: " + file);
                }
                return FileVisitResult.CONTINUE;
            }
        }

        JavaFileVisitor visitor = new JavaFileVisitor();
        Files.walkFileTree(start, visitor);

        System.out.println("Tổng số tệp .java: " + visitor.count);
        System.out.println("Tổng kích thước: " + visitor.totalSize + " byte");
    }
}

9. Các lỗi thường gặp khi sử dụng FileVisitor

Lỗi số 1: cố gắng xóa thư mục trước khi xóa các tệp bên trong. Nếu bạn gọi Files.delete(dir) trong preVisitDirectory, bạn sẽ nhận ngoại lệ — trước hết cần xóa tất cả tệp và thư mục con, sau đó mới xóa chính thư mục (hãy làm điều này trong postVisitDirectory).

Lỗi số 2: quên xử lý lỗi truy cập. Nếu không override visitFileFailed, chương trình có thể kết thúc đột ngột khi gặp tệp được bảo vệ. Tốt hơn là hiển thị lỗi một cách rõ ràng và tiếp tục duyệt.

Lỗi số 3: kỳ vọng thao tác “ẩn tệp” hoạt động giống nhau trên mọi hệ điều hành. Trên Linux và macOS, tệp được coi là ẩn nếu tên bắt đầu bằng dấu chấm (.gitignore), còn trên Windows — khi một thuộc tính đặc biệt được bật. Đừng nhầm lẫn các cách tiếp cận này.

Lỗi số 4: dùng trường static để cộng dồn kết quả trong ứng dụng đa luồng. Nếu bạn chạy nhiều lượt duyệt song song, các trường static sẽ dẫn đến rối loạn. Tốt hơn dùng trường của instance (hoặc lớp cục bộ, như trong ví dụ trên).

Lỗi số 5: quên đóng tài nguyên khi làm việc với luồng bên trong FileVisitor. Nếu FileVisitor của bạn đọc/ghi tệp, hãy dùng try-with-resources để tránh rò rỉ tài nguyên.

Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION