《Java 编程入门》14.3 目录遍历与 WatchService

从按年月归档的 CSV 目录出发,讲清 Files.walk 的 maxDepth 语义、Files.find 的属性过滤、walkFileTree 的访问者模式与 glob 匹配;再用 WatchService 监听 inbox,给出去抖、过滤临时文件、幂等导入和 key.reset 的完整做法。

本节目标:能安全地遍历目录、按属性过滤文件、用 glob 匹配路径,并用 WatchService 监听一个导入目录;同时知道目录监听会漏事件、会重复事件,不能把它当成可靠的消息队列。
适用版本:JDK 25(对照 JDK 21);WatchService 的底层实现因操作系统而异,行为细节不完全一致。

上一节留下的问题

library 的数据开始变多:data/2026/10/books.csv、data/2026/09/books.csv,将来还会有更多月份。启动时要把所有 CSV 都加载进来,不能写死一个路径。另一个需求来自管理员:把新导出的 CSV 丢进 data/inbox/,程序自动发现并导入,不用重启。

这两件事分别对应 java.nio.file 的两组 API:遍历(Files.walk、Files.find、walkFileTree)和监听(WatchService)。遍历看起来简单,但 maxDepth 的计数方式、流的关闭、符号链接的处理都有坑;监听看起来像「文件一变就回调」,实际却会受到平台、编辑器写文件方式、事件合并的影响。

Files.walk 与 maxDepth

Files.walk(root) 返回一个 Stream<Path>,包含根目录本身和它下面所有层级的路径。它默认深度优先,必须关闭,因为它持有目录句柄:

try (Stream<Path> paths = Files.walk(root, 2)) {
    paths.filter(Files::isRegularFile)
            .map(root::relativize)
            .sorted()
            .forEach(System.out::println);
}

本机目录结构是:

tree/
├── notes.txt
└── 2026/
    ├── 09/books.csv
    └── 10/books.csv
    └── 10/loans.jsonl

Files.walk(root, 2) 的输出是:

notes.txt

只出现了一个文件,2026/10/books.csv 没有被遍历到。原因是 maxDepth 从根开始数:根是 0,2026 是 1,2026/10 是 2,2026/10/books.csv 是 3。maxDepth = 2 只到目录,不包含目录里的文件。要包含第三层文件,得传 3,或者干脆不传深度限制。

这是一个非常常见的 off-by-one。写「只扫两层目录」时,先想清楚「两层」指的是路径段还是目录层。更稳妥的做法是先不限制深度,再用 path.getNameCount() - root.getNameCount() 过滤。

Files.walk 的流要放在 try-with-resources 里。不关闭不会立刻报错,但目录句柄会泄漏,长跑进程最终可能 Too many open files。

Files.find 与属性过滤

Files.find 在遍历的同时把 BasicFileAttributes 交给你,省掉每个文件一次 readAttributes 调用:

try (Stream<Path> paths = Files.find(root, 3,
        (path, attrs) -> attrs.isRegularFile() && attrs.size() > 10)) {
    paths.map(root::relativize).sorted().forEach(System.out::println);
}

本机输出:

2026/09/books.csv
2026/10/books.csv
2026/10/loans.jsonl

notes.txt 只有 7 字节,被 size() > 10 过滤掉了。属性过滤比「先 walk 再逐个 Files.size」快,因为属性在遍历时已经拿到,不需要额外系统调用。

Files.find 默认不跟随符号链接。要跟随就传 FileVisitOption.FOLLOW_LINKS,但要小心循环链接:A 指向 B,B 又指向 A,遍历会无限深入,直到抛 FileSystemLoopException。除非明确需要,否则不要开。

walkFileTree:需要控制遍历过程时

Files.walk 和 Files.find 是声明式过滤,适合「找出符合条件的路径」。如果你要在进入目录、离开目录、访问失败时做不同处理,用 Files.walkFileTree 和 FileVisitor:

long[] totals = new long[2];
Files.walkFileTree(root, new FileVisitor<>() {
    @Override
    public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
        return FileVisitResult.CONTINUE;
    }

    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
        totals[0]++;
        totals[1] += attrs.size();
        return FileVisitResult.CONTINUE;
    }

    @Override
    public FileVisitResult visitFileFailed(Path file, IOException exc) {
        System.out.println("failed: " + file + " -> " + exc.getClass().getSimpleName());
        return FileVisitResult.CONTINUE;
    }

    @Override
    public FileVisitResult postVisitDirectory(Path dir, IOException exc) {
        return FileVisitResult.CONTINUE;
    }
});
System.out.println("files = " + totals[0] + ", bytes = " + totals[1]);

本机输出:

files = 4, bytes = 71

访问者模式的价值在于失败可以局部处理。visitFileFailed 收到某个文件打不开的异常,你可以选择跳过继续,而不是让整个遍历中断。postVisitDirectory 在离开目录时调用,适合做「删空目录」这类收尾工作。

删除整棵目录树的标准写法是「先收集、倒序删」,或者用访问者在 postVisitDirectory 里删目录:

try (Stream<Path> stream = Files.walk(root)) {
    List<Path> paths = stream.sorted(Comparator.reverseOrder()).toList();
    for (Path path : paths) {
        Files.deleteIfExists(path);
    }
}

先收集再删,避免「正在遍历的目录被删掉」导致流报错。倒序让文件先删、空目录后删。

glob 与 PathMatcher

PathMatcher 用一套与平台无关的 glob 语法匹配路径:

PathMatcher csv = root.getFileSystem().getPathMatcher("glob:**/*.csv");
try (Stream<Path> paths = Files.walk(root)) {
    paths.filter(csv::matches)
            .map(root::relativize)
            .sorted()
            .forEach(System.out::println);
}

本机输出:

2026/09/books.csv
2026/10/books.csv

几个 glob 规则:

  • * 匹配一个路径段内的任意字符,不跨 /。
  • ** 匹配任意多个路径段,所以 **/*.csv 能匹配嵌套文件。
  • ? 匹配单个字符,[abc] 匹配字符集合,{csv,jsonl} 匹配多个后缀。
  • 匹配的是整个路径,不是文件名。只写 *.csv 通常匹配不到 2026/10/books.csv,因为 * 不跨分隔符。

需要更复杂的规则时用 glob: 之外的 regex:,或者直接用 Pattern 对 path.toString() 做匹配。glob 的好处是跨平台:Windows 的 \ 会被统一处理,不用自己替换分隔符。

WatchService:监听目录变化

WatchService 让操作系统把目录变化通知给 JVM。基本流程是:注册目录和事件类型,轮询或阻塞等待 WatchKey,处理事件,最后 reset()。

try (WatchService watcher = FileSystems.getDefault().newWatchService()) {
    dir.register(watcher,
            StandardWatchEventKinds.ENTRY_CREATE,
            StandardWatchEventKinds.ENTRY_MODIFY,
            StandardWatchEventKinds.ENTRY_DELETE);
    while (true) {
        WatchKey key = watcher.take();
        for (WatchEvent<?> event : key.pollEvents()) {
            if (event.kind() == StandardWatchEventKinds.OVERFLOW) {
                continue;
            }
            Path name = (Path) event.context();
            System.out.println(event.kind().name() + " " + name);
        }
        if (!key.reset()) {
            break;
        }
    }
}

本机 macOS 上监听一个目录,依次创建、追加、重命名、删除文件,6 秒内实际收到:

watching /tmp/java_agent_e/watch
ENTRY_CREATE books.csv
ENTRY_CREATE loans.csv
ENTRY_DELETE books.csv
ENTRY_DELETE loans.csv
done

注意两件事:

  • 没有 ENTRY_MODIFY。 追加写入没有产生修改事件,重命名被拆成了「新名字创建 + 旧名字删除」。不同平台、不同文件系统、不同编辑器保存方式,事件的数量和类型都可能不同。
  • 事件会合并。 连续快速写同一个文件,可能只收到一个创建事件;缓冲区溢出时收到 OVERFLOW,具体丢了哪些事件不知道。

所以 WatchService 的正确用法是把事件当成「目录可能变了」的提示,而不是「发生了什么」的完整日志。收到事件后重新扫描目录、按文件内容或时间戳判断,而不是假设事件序列就是操作序列。

key.reset() 必须调用。它把 key 重新放回就绪队列;不调用,这个目录后续的事件不会再送达。reset() 返回 false 表示目录已不存在或无法访问,循环应该退出。

在 library 里落地

library 的导入流程设计成「监听 + 幂等导入」:

  • 监听 data/inbox 的 ENTRY_CREATE。
  • 只处理 .csv 后缀,忽略以 . 开头的临时文件(编辑器和 Office 经常先写临时文件再改名)。
  • 读取后按 ISBN 去重,已存在的书跳过。
  • 导入成功后把文件移到 data/processed/,失败则留在原地并记录日志。

读取复用 14.2 的严格解码逻辑,抽成 BookCsvReader.readCsv;监听器只负责事件和文件搬运:

public final class ImportWatcher implements AutoCloseable {

    private final Path inbox;
    private final Path processed;
    private final LibraryRepository repository;
    private final WatchService watcher;

    public ImportWatcher(Path inbox, Path processed, LibraryRepository repository)
            throws IOException {
        this.inbox = inbox;
        this.processed = processed;
        this.repository = repository;
        Files.createDirectories(inbox);
        Files.createDirectories(processed);
        this.watcher = FileSystems.getDefault().newWatchService();
        inbox.register(watcher, StandardWatchEventKinds.ENTRY_CREATE);
    }

    public void runOnce(Duration timeout) throws IOException, InterruptedException {
        WatchKey key = watcher.poll(timeout.toMillis(), TimeUnit.MILLISECONDS);
        if (key == null) {
            return;
        }
        try {
            for (WatchEvent<?> event : key.pollEvents()) {
                if (event.kind() != StandardWatchEventKinds.ENTRY_CREATE) {
                    continue;
                }
                Path name = (Path) event.context();
                if (!name.toString().endsWith(".csv") || name.toString().startsWith(".")) {
                    continue;
                }
                importFile(inbox.resolve(name));
            }
        } finally {
            key.reset();
        }
    }

    private void importFile(Path file) throws IOException {
        List<Book> incoming = BookCsvReader.readCsv(file);
        int imported = 0;
        for (Book book : incoming) {
            if (repository.findByIsbn(book.isbn()).isEmpty()) {
                repository.save(book);
                imported++;
            }
        }
        Files.move(file, processed.resolve(file.getFileName()),
                StandardCopyOption.REPLACE_EXISTING);
        System.out.println("imported " + imported + " from " + file.getFileName());
    }

    @Override
    public void close() throws IOException {
        watcher.close();
    }
}

几个设计点:

  • runOnce 而不是无限循环,方便测试和优雅退出;生产代码可以在外层循环里调用,并检查中断标志。
  • poll(timeout) 而不是 take(),让调用方有机会退出。
  • 按 ISBN 去重,即使同一个文件被处理两次,结果也一样。这是幂等性的来源。
  • 移动而不是删除,保留导入痕迹,出问题能回看原文件。
  • 失败留在原地,下次扫描还能重试;配合日志避免无限重试同一个坏文件。

如果文件很大,读取需要时间,期间可能又收到同一个文件的修改事件。可以在 importFile 里先检查文件大小是否稳定,或者用「先移动到临时目录再读取」的方式,避免读到写了一半的文件。

常见错误与边界

忘记关闭 Files.walk 的流。 目录句柄泄漏,短时间看不出问题,长跑进程会耗尽文件描述符。所有返回 Stream<Path> 的方法都要 try-with-resources。

maxDepth 差一层。 根算第 0 层,文件通常比目录多一层。写死深度前先用小目录验证输出。

跟随符号链接导致循环。 FOLLOW_LINKS 遇到环会抛 FileSystemLoopException。不确定目录结构时不要开,或者用 visitFileFailed 处理。

忘记 key.reset()。 目录只会通知一次,之后再也没有事件。这是 WatchService 最常见的「不工作」原因。

监听文件而不是目录。 多数平台只支持监听目录。要监听单个文件,监听它所在目录,再按文件名过滤。

把 ENTRY_MODIFY 当可靠信号。 本机实测就没有收到修改事件。不要用事件计数推断写入次数,收到任何相关事件后重新读文件状态。

不处理 OVERFLOW。 事件缓冲区满了会丢事件。收到 OVERFLOW 后应该做一次全量扫描,而不是继续信任后续事件。

导入临时文件。 Office、编辑器保存时会先写 .~lock、.tmp、随机后缀文件。按后缀和前缀过滤,或者等文件大小稳定后再处理。

小结

  • Files.walk 返回必须关闭的流,maxDepth 从根 0 开始数,文件通常比目录多一层。
  • Files.find 在遍历时拿到属性,适合按大小、类型过滤;默认不跟随符号链接。
  • walkFileTree 用访问者处理「进入、访问、失败、离开」四类事件,适合删除树和局部容错。
  • glob 的 * 不跨路径段,** 才跨;匹配的是整个路径。
  • WatchService 的事件会合并、会缺失、平台相关,只能当「目录变了」的提示,不能当操作日志。
  • key.reset() 必须调用;OVERFLOW 后要全量重扫。
  • library 的导入用「监听 + 按 ISBN 去重 + 移动到 processed」实现幂等和可回看。

文件能读能写、目录能扫能听,接下来进入另一类日常问题:时间。借期 14 天怎么算、逾期几天、跨时区怎么存,下一节讲 java.time。

阅读导航:上一节:14.2 字节流、字符流与编码 · 下一节:15.1 java.time:日期、时刻与时区 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发