CodeGym /課程 /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 就不那麼方便了:你得在 lambda 中堆疊 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

如何整個略過某個資料夾?

若不想進入某些資料夾(例如 .gitnode_modules),可以在 preVisitDirectory 回傳 SKIP_SUBTREE

@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:在刪除內部檔案之前就嘗試刪除目錄。 如果在 preVisitDirectory 中呼叫 Files.delete(dir),會拋出例外——必須先刪除所有檔案與子資料夾,再刪除目錄本身(請在 postVisitDirectory 中進行)。

錯誤 2:忽略存取錯誤的處理。 若沒有覆寫 visitFileFailed,當遇到受保護的檔案時,程式可能會意外終止。最好明確輸出錯誤並繼續走訪。

錯誤 3:以為「隱藏檔」在所有作業系統上行為一致。 在 Linux 與 macOS 上,檔名以點開頭(例如 .gitignore)即為隱藏;在 Windows 上則需要設定特殊屬性。不要混淆這兩種機制。

錯誤 4:在多執行緒應用中使用靜態欄位累加結果。 如果同時執行多個走訪,靜態欄位會導致混亂。請改用實例欄位(或像上面的範例一樣使用區域類別)。

錯誤 5:在 FileVisitor 中使用串流時忘了關閉資源。 若你的 FileVisitor 會讀寫檔案,請使用 try-with-resources 以避免資源洩漏。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION