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,需要控制遍历顺序(例如先删除文件再删除文件夹),代码会变得难以阅读。

为了解决这些问题,Java 提供了基于 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 以避免资源泄漏。

1
任务
JAVA 25 SELF, 第 39 级, 课程 3
已锁定
研究助理:项目笔记收集
研究助理:项目笔记收集
1
任务
JAVA 25 SELF, 第 39 级, 课程 3
已锁定
数字清理器:永久删除项目
数字清理器:永久删除项目
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION