本节目标:能安全地遍历目录、按属性过滤文件、用 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:日期、时刻与时区 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。