原创

WatchService 不是文件操作日志:用快照补齐丢失和合并的事件

同一个文件保存一次,可能收到多次修改事件;连续保存几次,也可能只看到合并后的通知。如果程序把每条 ENTRY_MODIFY 当成一笔必须执行的业务命令,文件系统稍有变化就会产生重复处理和漏处理。

WatchService 更适合作为“目录可能变了”的提示。真正需要维护的是当前目录状态,事件负责缩短发现变化的时间,补扫负责在通知不完整时重新对齐。两者合在一起,才能把文件观察器的承诺说清楚。

下面用 Java 17 写一个可运行的单目录观察器。它只记录目录直接包含的普通文件,忽略符号链接和子目录;用文件名、长度、修改时间识别观察到的变化,不复制内容,也不执行上传或删除业务。这个范围够小,能够看清事件与状态之间的关系。

先承认无法重建完整的操作历史

一个文件在两次扫描之间创建又删除,最后状态没有变化,快照也不会显示这段短暂历史。某些通知实现本身就可能看不到这类短命文件。即使最终收到了事件,也不能把它当成绝对有序、绝不丢失的审计日志。

这不是给事件队列增加一个去重集合就能修好的问题。如果业务要求每一份输入都必须被处理,输入生产者需要明确的交付协议:写到临时文件,写完后移动到待处理目录;消费者成功后记录确认状态。观察器负责发现候选,持久任务记录负责业务可靠性。

本文程序承诺的是另一件事:在目录状态保持稳定、扫描持续成功的前提下,最终报告和上一份快照不同的文件。这个承诺允许合并事件,也允许一次失败之后补扫;它不声称恢复中间发生过的每一次操作。

可以直接运行的观察器

保存为 WatchSnapshot.java。输入目录必须已经存在,而且本身不能是符号链接。程序默认运行十五秒,也可以在第二个参数指定一到三千六百秒,结束时会关闭监听服务。

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.*;
import java.nio.file.attribute.*;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;

public class WatchSnapshot {
    record Stamp(long size, FileTime modified) { }

    static Map<String, Stamp> scan(Path root) throws IOException {
        Map<String, Stamp> result = new TreeMap<>();
        List<Path> paths;
        try (Stream<Path> stream = Files.list(root)) {
            paths = stream.toList();
        } catch (UncheckedIOException ex) {
            throw ex.getCause();
        }
        for (Path path : paths) {
            try {
                BasicFileAttributes a = Files.readAttributes(path,
                        BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
                if (a.isRegularFile()) {
                    result.put(path.getFileName().toString(),
                            new Stamp(a.size(), a.lastModifiedTime()));
                }
            } catch (NoSuchFileException ignored) {
                // A concurrent deletion is normal for this directory observer.
            }
        }
        return result;
    }

    static void printChanges(Map<String, Stamp> old, Map<String, Stamp> now) {
        now.forEach((name, stamp) -> {
            if (!old.containsKey(name)) System.out.println("ADDED " + name);
            else if (!old.get(name).equals(stamp)) System.out.println("CHANGED " + name);
        });
        old.keySet().stream().filter(name -> !now.containsKey(name))
                .forEach(name -> System.out.println("REMOVED " + name));
    }

    public static void main(String[] args) throws Exception {
        if (args.length < 1 || args.length > 2) {
            throw new IllegalArgumentException("usage: WatchSnapshot directory [seconds]");
        }
        Path root = Path.of(args[0]).toAbsolutePath().normalize();
        if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
            throw new IllegalArgumentException("a real directory is required");
        }
        long seconds = args.length == 2 ? Long.parseLong(args[1]) : 15;
        if (seconds < 1 || seconds > 3600) throw new IllegalArgumentException("seconds: 1..3600");
        long end = System.nanoTime() + Duration.ofSeconds(seconds).toNanos();
        try (WatchService watch = root.getFileSystem().newWatchService()) {
            root.register(watch, StandardWatchEventKinds.ENTRY_CREATE,
                    StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE);
            Map<String, Stamp> previous = scan(root);
            long nextScan = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
            System.out.println("READY files=" + previous.size());
            while (System.nanoTime() < end) {
                WatchKey key = watch.poll(250, TimeUnit.MILLISECONDS);
                boolean dirty = false;
                if (key != null) {
                    for (WatchEvent<?> event : key.pollEvents()) {
                        dirty = true;
                        if (event.kind() == StandardWatchEventKinds.OVERFLOW) {
                            System.out.println("OVERFLOW -> rescan");
                        }
                    }
                    if (!key.reset()) throw new IOException("watch registration is no longer valid");
                }
                if (dirty || System.nanoTime() >= nextScan) {
                    try {
                        Map<String, Stamp> now = scan(root);
                        printChanges(previous, now);
                        previous = now;
                    } catch (IOException ex) {
                        System.err.println("SCAN_FAILED " + ex.getClass().getSimpleName());
                    }
                    nextScan = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
                }
            }
        }
        System.out.println("STOPPED");
    }
}

在第一个终端中执行:

mkdir watch-input
java WatchSnapshot.java watch-input 20

看到 READY files=0 后,在另一个终端中使用同一工作目录:

printf 'one' > watch-input/a.txt
sleep 3
printf 'a longer value' > watch-input/a.txt
sleep 3
rm watch-input/a.txt

两次等待只是给手工实验留出观察窗口,不是程序可靠性的组成部分。修改后的内容刻意改变长度,让这个简单指纹必然能够区分它与先前版本。在本次 Java 17 的本地验证中得到:

READY files=0
ADDED a.txt
CHANGED a.txt
REMOVED a.txt
STOPPED

不同系统的原始事件条数可以不同,因此程序没有断言底层一定发出几条 ENTRY_MODIFY。它核对的是扫描结果。改成连续快速写入时,也可能直接从初始值观察到最终值,中间版本不会承诺逐个打印。

OVERFLOW 之后为什么要重扫

事件可能产生得比消费更快。实现知道自己丢掉通知时,会通过 OVERFLOW 告诉使用者。此时继续按剩余事件逐条修补状态,没有依据判断遗漏了哪些名字,应该重新检查受观察目录。

程序把任何事件都当成 dirty 提示,出现 OVERFLOW 时明确打印补扫原因。它没有尝试人为制造一次必定溢出的样本:缓冲容量、系统实现和事件合并都不相同,某台机器上大量创建文件也不保证复现相同通知。溢出分支根据 Java 17 的接口契约实现,真实执行已验证建立、修改、删除和正常关闭路径。

每两秒还有一次周期补扫。这并非保证所有存储设备每两秒都能发现变化,而是本例在监听轮询层给扫描安排的周期。若扫描很慢,或文件系统本身不可用,观察延迟仍会增加。生产程序应给扫描时间、失败次数和最后一次成功时间留下观测入口。

程序先注册监听,再取得初始快照。如果把顺序反过来,首次扫描与注册之间发生的变化可能完全没有通知可读。注册在前也不能让整个目录变成事务快照,但至少事件可以覆盖初始扫描过程中的变化,后面的补扫还能再次对齐。

reset 与关闭决定观察器能不能继续工作

WatchKey 取出后,需要读取事件并调用 reset(),它才重新具备后续排队资格。忘记 reset 的表现常常是“第一轮有输出,后来怎么改都没动静”。不能为了让它继续运行就忽略 reset 返回值;返回 false 说明这份注册已经失效,根目录删除、文件系统变化等都可能导致这种情况。

本例直接结束并报错,让使用者决定重新挂载、重新创建目录还是停止任务。自动重新注册也可以,但应单独设计身份核对:同一个路径名再次出现,未必还是原来那份目录。观察器对目标身份没有判断,就可能跟踪到被替换的新位置。

try-with-resources 确保服务关闭。若扩展成长寿命后台任务,可由停止入口关闭 WatchService 来唤醒等待线程,并把 ClosedWatchServiceException 解释为有意停机;不要把它与目录故障混在一个无穷重试循环里。

快照指纹也有自己的精度上限

长度加修改时间不是内容摘要。相同长度的重写,若时间戳精度不足或被显式恢复,可能与旧指纹相同。需要按内容识别变化时,应读取摘要或使用业务版本,但这会增加 I/O,也要处理读取期间文件继续变化的情况。本文不用廉价指纹宣称做到内容级去重。

扫描期间文件消失是正常竞态,所以单文件的 NoSuchFileException 被跳过;其他扫描失败保留旧快照,不把“读不到”改写成“目录已经清空”。这个区别很重要,否则一次权限故障就会产生一批假的删除通知。

对子目录递归观察还需要维护多份注册,发现新目录后再登记,并处理目录移动和旧 key 失效。不要只在启动时递归注册一次,就以为后续新建的子目录也会自动加入。若目录数量很大,还要核对操作系统监听资源上限。在这些需求出现之前,先把单目录的状态协议做准确,比把递归代码匆忙接到事件回调上更稳妥。

正文到此结束
评论插件初始化中...
Loading...