简介:这是一份面向Java初学者及需要文件监控实践案例的开发者的源码程序,核心功能是按约5秒周期扫描指定目录,动态显示文件数量与大小变化,帮助判断目录内容是否发生变动,可用于跟踪项目产物、监控日志生成,也可延伸到游戏存档或运行日志观察等场景。压缩包整体约7KB,文件总数与类型明细上游暂无有效数据,包体轻量,适合快速解压查看与本地运行。已有156人浏览学习。内容围绕Java文件读写与事件监听展开,涉及File属性读取、nio.file中的WatchService、WatchKey与WatchEvent处理,以及commons-io的FileAlterationObserver等常见实现;同时体现扫描线程与界面更新分离、定时节拍控制资源占用的思路。对想理解目录监控、事件回调与多线程响应性的读者,可用作练手项目,并为日志监控、产物跟踪等小工具开发提供参考。
1. 目录监控这件事:文件写入完成后,程序就要有响应
服务器上有个目录,别人不断往里放文件,你需要盯着它什么时候多了一个、什么时候被改到一半。把它写成定时任务去轮询,扫慢了会漏文件,扫快了又让 CPU 白白消耗。这个 Java 目录监控程序解决的就是这件事:用 JDK 自带的 WatchService 挂到指定目录上,文件一旦创建、修改、删除就回调出来,再按规则写入日志,或交给下游处理。它不需要数据库,也不需要部署到容器,一个 main 方法就能启动。
这类源码包在分类目录里常被归到「游戏娱乐」一栏,原因是真有不少人拿它做截图自动归档、素材热更新、录屏文件整理这类小活。手里有“文件一到就要处理”的需求、又不想为这点事引入一整套中间件的开发者,最适合先从这份 Java 源码开始读。工程本身可编译,控制台和界面两种运行方式都有,改起来不用先花三天熟悉框架。
2. 技术选型与工程结构:一个 main 加三个包,没有多余分层
2.1 目录结构逐层看
源码包解压后是个标准 Java 工程,没有 Maven 也能编译。常见的目录结构是这样:
watchdir/ ├── src/ │ └── com/example/watchdir/ │ ├── Main.java # 入口:解析参数、选控制台还是界面 │ ├── core/ │ │ ├── DirWatcher.java # 真正干活的事件循环 │ │ ├── EventType.java # 枚举:CREATE / MODIFY / DELETE │ │ └── FileEventListener.java # 回调接口 │ ├── filter/ │ │ └── NameFilter.java # 扩展名、正则过滤 │ ├── ui/ │ │ └── MonitorFrame.java # Swing 界面,可不要 │ └── util/ │ └── LogWriter.java # 事件落盘 ├── lib/ # 第三方 jar,可选 └── run.bat / run.sh # 双击就能跑这种分法的好处很直接:core包不依赖 Swing,也不读取配置文件,谁要嵌入自己的项目,把core和filter两个包复制走就行。ui只负责把FileEventListener吐出来的事件追加到表格里,删掉界面照样能运行。我见过不少同类小工具把监听逻辑直接抄进按钮的点击回调里,结果一关窗口线程就断,事件全丢,这是最容易踩的坑。
2.2 三种监控方案的取舍
Java 里监控目录无非三条路,选错了后面全是麻烦:
| 方案 | 原理 | 延迟 | 资源占用 | 适用场景 |
|---|---|---|---|---|
| WatchService | 操作系统 inotify / ReadDirectoryChangesW | 毫秒级 | 一个阻塞线程 | 本机目录,JDK 7+ |
| 单线程轮询 | 定时Files.list比对快照 | 取决于间隔 | 目录大时 IO 高 | 网络盘、跨机器挂载 |
| Commons IO FileAlterationMonitor | 内部仍是轮询 | 秒级 | 同上 | 老项目、要求少依赖 |
这份源码走的是第一条,也就是java.nio.file.WatchService。它是 JDK 原生能力,不用引任何 jar,事件由内核推过来,不用你反复比对目录。代价是它对“网络映射盘”这类文件系统经常不灵——内核收不到远端变更通知,程序就静默不动了。所以工程里通常还留了一个轮询兜底开关,配置里把mode改成poll,就退化成定时扫描。
常见做法是:本地盘用 WatchService,挂载盘和虚拟机共享目录用轮询,两者共用同一套回调和过滤逻辑,切换只改一个枚举值。这样界面层、日志层、业务层都不用动。
2.3 版本与依赖,编译前先确认
WatchService 从 JDK 7 开始有,Files.readAttributes、StandardWatchEventKinds这些也都是 7 之后的 API。所以在pom.xml或build.gradle里,source/target不能低于 1.7;如果源码里用了var或者List.of,那实际要求是 11 以上,编译前先跑一遍java -version确认,不然报的错会让你误以为是代码写错了。
依赖方面,纯 WatchService 版本零第三方依赖;只有两种情况会引 jar:一是要用commons-io做文件拷贝,二是界面用 FlatLaf 换皮。如果只做监听和日志,lib目录可以是空的,这一点对新手很友好——不用先配私服、不用等依赖下载,javac直接就能编。
提示:拿到源码先看
src里有没有module-info.java。有的话是模块化工程,编译命令要加--module-path,别照着老教程直接javac *.java,会报找不到模块。
3. 核心链路拆解:从注册监听、事件队列到回调分发
3.1 注册目录与事件掩码
第一步是把目录挂到 WatchService 上。这一步只做一次,但要选对事件类型:
// DirWatcher.java —— 注册被监控目录 public void watch(Path dir) throws IOException { WatchService ws = FileSystems.getDefault().newWatchService(); // 三种事件全要:很多编辑器保存文件是「写临时文件 -> 改名」,只监听 CREATE 会漏 WatchKey key = dir.register(ws, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); this.watchService = ws; this.rootKey = key; this.watchedDir = dir; }register的返回值WatchKey是后续取事件的凭据,必须存住。掩码里三个常量各管一件事:ENTRY_CREATE管新建和重命名进来,ENTRY_MODIFY管内容被写,ENTRY_DELETE管删除和移走。少注册一个,对应事件就永远收不到,而且不会报错,只能靠现象排查。
注意register只对当前这一层目录生效,子目录要单独注册。工程里递归开关打开时,是对整棵树做一次深度遍历、每个目录各注册一次,并把WatchKey和目录路径放进一个Map里存起来。
3.2 事件循环:take 与 poll 的差别不止是阻塞
事件循环是整个程序的根本,写法基本固定:
// DirWatcher.java —— 事件循环 while (running) { WatchKey key; try { key = watchService.take(); // 阻塞等待,没有事件时线程挂起,不烧 CPU } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 保留中断标记,让上层能优雅退出 break; } catch (ClosedWatchServiceException e) { break; // 服务已关闭,正常退出 } Path parent = (Path) key.watchable(); // 事件发生在哪个目录 for (WatchEvent<?> event : key.pollEvents()) { if (event.kind() == StandardWatchEventKinds.OVERFLOW) { log.warn("事件队列溢出,目录 {} 可能有遗漏", parent); continue; // 溢出时 context 是 null,不能 resolve } Path child = parent.resolve((Path) event.context()); dispatcher.dispatch(event.kind(), child); } if (!key.reset()) { // 必须 reset,否则这个 key 再也不产生事件 watched.remove(key); // 目录被删或不可访问,移出监听集合 if (watched.isEmpty()) break; } }这里每个参数都有讲究。take()会一直阻塞,适合常驻进程;poll()不阻塞、poll(long, TimeUnit)带超时,适合需要定期检查running标志的场景。event.context()返回的是相对路径,必须用key.watchable()补全,否则拿到的是个光秃秃的文件名。reset()是最容易忘的一行:不调用,这个WatchKey就进入无效状态,现象是“程序还在跑但一个事件都不来”,非常隐蔽。
3.3 文件真的写完了吗:去抖与稳定判定
收到ENTRY_CREATE不代表文件写完了。往目录里拷一个 500MB 的包,内核可能在开头就抛出创建事件,这时候去读只能读到半截。这就是为什么很多人的程序在本地测没事,一上生产就出现“读到 0 字节”的问题。
工程里的做法是等文件大小稳定:
// DirWatcher.java —— 两次采样大小一致才认为写完了 private boolean waitUntilStable(Path file, long timeoutMillis) { long deadline = System.currentTimeMillis() + timeoutMillis; long lastSize = -1L; while (System.currentTimeMillis() < deadline) { if (!Files.exists(file)) return false; // 中途被删了,放弃 long size = Files.size(file); if (size == lastSize && size > 0) return true; // 大小没变,认为写完 lastSize = size; LockSupport.parkNanos(Duration.ofMillis(200).toNanos()); } return false; // 超时,交给上层决定丢不丢 }参数上,timeoutMillis别设太大,3000 到 5000 毫秒够用;采样间隔 200 毫秒是经验值,太密没意义,太疏大文件会多等。size > 0这个判断是为了避开“文件刚创建还是空”的那一瞬。有些实现会再加一道Files.isWritable检查,但在 Windows 上这个判断不可靠,还是看大小变化稳。
3.4 回调分发:把事件交给业务,而不是写在循环里
事件循环里只做三件事:取事件、补全路径、丢给分发器。业务逻辑一律走接口:
// FileEventListener.java —— 业务方只实现这个接口 public interface FileEventListener { void onCreate(Path file); void onModify(Path file); void onDelete(Path file); }分发器持有一个List<FileEventListener>,遍历调用,单个监听器抛异常就try/catch掉并记日志,绝不允许多个监听器互相影响。这样不管是控制台打印、写日志文件,还是界面刷新表格,都只是注册一个实现类的事。
还有一层是过滤。目录里天天有一堆.tmp、.swp、~结尾的中间文件,全往下游丢会引发各种异常:
// NameFilter.java —— 先过滤,再分发 private static final Pattern IGNORE = Pattern.compile(".*(\\.tmp|\\.swp|\\.part|~)$"); public boolean accept(Path file) { String name = file.getFileName().toString(); if (IGNORE.matcher(name).matches()) return false; if (name.startsWith(".")) return false; // 隐藏文件,macOS 上特别多 return exts.isEmpty() || exts.contains(ext(name)); // exts 为空表示全放行 }exts从命令行参数读,比如-e jpg,png,mp4,只关心这几种。这一步在分发之前做,能挡掉后面 80% 的不必要麻烦。
4. 跑起来:编译、参数与两种启动方式
4.1 编译:javac 直接编和 Maven 打包
没有构建工具时,最稳的是先用javac跑一次:
# Linux / macOS javac -encoding UTF-8 -d out $(find src -name "*.java") java -cp out com.example.watchdir.Main -d /data/inbox -o ./events.log:: Windows,文件名太长时先把源文件列表存成文件再编译 dir /s /b src\*.java > sources.txt javac -encoding UTF-8 -d out @sources.txt java -cp out com.example.watchdir.Main -d D:\inbox -o .\events.log-encoding UTF-8不能省。源码里只要有中文注释或者中文日志,Windows 默认 GBK 编译就会报“编码 GBK 的不可映射字符”,这是新手最常见的编译失败原因。-d out把 class 输出到独立目录,源码目录保持干净。
有pom.xml的话用 Maven 更省事,mvn -q clean package之后target下会出一个带Main-Class的 jar,直接java -jar就能跑。如果打出来的 jar 说“没有主清单属性”,说明maven-jar-plugin里没配mainClass,补上就行。
4.2 命令行参数怎么传
工程里常见的是四个参数,参数名如果和源码不同,按Main.java里的解析改即可:
| 参数 | 含义 | 默认值 | 示例 |
|---|---|---|---|
-d/--dir | 被监控目录 | 无,必填 | /data/inbox |
-o/--out | 事件日志文件 | 控制台输出 | ./events.log |
-e/--ext | 只关心的扩展名 | 全部 | jpg,png,mp4 |
-r/--recursive | 是否递归子目录 | false | true |
参数解析在Main里手写了一个循环,没用第三方库,所以传参格式很严格:-d /path,中间有空格,写成-d/path会解析失败。日志默认追加模式写,长期跑要注意轮转,不然文件会越来越大。
4.3 界面模式与打包成可执行 jar
带 Swing 界面的版本,Main里判断有没有-ui参数,有就SwingUtilities.invokeLater起窗口。窗口上两个按钮:选目录、开始/停止。开始之后事件往上追加,最多保留 500 行,超了从头删,避免长时间运行把内存撑爆。
给不装 JDK 的机器用,得打成可执行 jar:
mvn clean package # 生成 target/watchdir-1.0.jar,双击或 java -jar 运行 java -jar target/watchdir-1.0.jar -d D:\inbox -e jpg,png如果连java命令都没有,只能靠jpackage(JDK 14+)打成 exe 或者 dmg,那个体积会大不少,一般内部工具没必要。
注意:分发 jar 的时候别把
out目录和src一起打包,加个.gitignore或者.gitattributes,不然压缩包会莫名其妙变大好几倍。
4.4 第一次验证:造几个文件试试
跑起来之后别急着接业务,先手写验证一遍。开两个终端,一个跑程序,一个做操作:
# 终端 A:跑起来 java -cp out com.example.watchdir.Main -d ./test-inbox -e txt # 终端 B:依次造三种情况 echo hello > ./test-inbox/a.txt # 期望:CREATE + MODIFY echo more >> ./test-inbox/a.txt # 期望:MODIFY rm ./test-inbox/a.txt # 期望:DELETE echo temp > ./test-inbox/b.tmp # 期望:无输出,被过滤器挡掉四种现象都对上,说明监听、过滤、回调这条链是通的。如果 CREATE 有、MODIFY 没有,先回头查register的掩码;如果 DELETE 有但路径不对,查resolve有没有少写。这一步花五分钟,比后面在业务里怀疑监听逻辑强得多。
5. 避坑与排查:跑起来之后才会遇到的五个问题
5.1 本层目录有反应,子目录里的文件没动静
现象:监控/data/inbox,往里放文件能收到事件,放进/data/inbox/sub/就完全静默,程序也不报错。 原因:WatchService只对register过的那一层目录生效,不会自动往下递归,这是 API 设计如此,不是 bug。 解决:递归开关打开时,用Files.walk或手写栈遍历一遍,把每个子目录都register一次,WatchKey和路径的映射存进Map<WatchKey, Path>。另外要处理运行期新建子目录的情况——收到子目录的 CREATE 事件后补注册一次,否则新建的目录后续事件还是收不到。
5.2 保存一次文件,触发了三次 MODIFY
现象:在编辑器里改一个文件保存,日志里刷出两三条 MODIFY,下游被重复处理。 原因:很多编辑器的保存策略是“写临时文件 → 删除原文件 → 改名”,内核层面就是三次事件;有些系统对write也会分块上报。 解决:加去抖。用Map<Path, Long>记最后一次处理时间,同一路径 300 毫秒内只处理一次;或者在分发前对(事件类型, 路径)做一次短窗口合并。别用Thread.sleep在循环里硬等,会把整个事件线程卡住,后面的目录事件全积压。
5.3 刚创建就去读,读到 0 字节或报文件被占用
现象:onCreate里直接Files.readAllBytes,偶尔拿到空数组,Windows 上还会抛“另一个程序正在使用此文件”。 原因:创建事件在文件内容写完之前就抛出来了,尤其是拷贝大文件时,事件和写入是并行的。 解决:用 3.3 里的稳定判定,两次采样大小一致再读。判定不通过就丢进重试队列,隔 500 毫秒再试,超过重试上限记一条 warning 然后放弃。放弃比无限重试好——目录里躺着一个永远写不完的临时文件时,无限重试会把线程池占满。
5.4 跑两三天就不动了,但进程还在
现象:程序常驻,前几天正常,某天开始一条事件都不来,jstack看线程卡在take()上。 原因:主要有两种。一是某个WatchKey没有reset(),那个目录永久失效;二是 Linux 上inotify的 watch 数量有上限,目录树特别大时注册到一半就失败了,register抛的异常如果被吞掉,现象就是部分目录永远没有事件。 解决:reset()写在for循环外面、每个 key 处理完必调,返回 false 就把 key 从 Map 里删掉。Linux 上先看cat /proc/sys/fs/inotify/max_user_watches,目录多的话调大,或者干脆对这个场景改用轮询模式。日志里把注册失败的目录名打出来,别让异常黑匣子化。
5.5 中文文件名变问号,路径匹配全失效
现象:文件叫测试.jpg,日志里打印成???.jpg,按扩展名过滤的规则还能用,但按文件名做的业务匹配全废。 原因:JVM 启动时的file.encoding和系统默认编码不一致,Windows 上是重灾区。 解决:启动加-Dfile.encoding=UTF-8,打印路径统一用path.toString()而不是自己拼字符串;日志文件用OutputStreamWriter显式指定 UTF-8,别依赖平台默认。控制台本身编码不对的话,输出还是会花,但落盘的日志是对的——以落盘为准排查。
6. 二次开发:从“能收到事件”改到“能进业务”
拿到这份源码,很多人第一反应是把main里的路径改成自己的,然后开跑。能跑,但接进业务三天就会顶不住——因为它只会通知事件,不会判断这个事件该不该往下走,也不会在失败时保留补偿记录。真正要改的是两处。
第一处是规则层。别在onCreate里写 if-else,抽成一条条规则,配置化:
// Rule.java —— 每条规则:匹配什么、做什么、失败了怎么办 public class Rule { private String ext; // 关心的扩展名 private String targetDir; // 处理后挪到哪 private boolean deleteSource; // 处理完是否删源文件 public boolean matches(Path file) { return ext == null || file.toString().endsWith("." + ext); } }规则从rules.properties读,改行为不用重新编译,这在现场调试时能省下不少来回。第二处是投递层:事件处理成功再确认,失败进重试队列,重试三次还不行就落到failed/目录,人工兜底。清单文件和失败目录是这套东西唯一的补救手段,不要省。
改完怎么验证?我一般做三步:先造 100 个文件用脚本一次性灌进去,看事件数对不对得上;再把某个文件权限改成只读,看失败分支会不会把文件挪进failed/;最后让程序挂着跑一晚上,第二天看日志里有没有 OVERFLOW 告警。三步都过,才敢接到真实目录上。
| 改造点 | 改动位置 | 验证方式 |
|---|---|---|
| 规则配置化 | 新增Rule+rules.properties | 改配置不重启,行为跟着变 |
| 失败重试 | dispatcher里加队列 | 只读文件能进failed/ |
| 事件积压监控 | 循环里统计 OVERFLOW | 灌 1000 个文件看告警 |
血泪经验是别把业务逻辑写进事件循环线程,一个大文件处理十秒,后面所有目录的事件全堵在那十秒里。改成“循环只入队、业务用线程池消费”,问题立刻消失。从那以后我每次接这类监听需求,都强制先跑一遍灌文件的压测,再谈功能——不然上线第一天就会被“文件延迟半小时才处理”教做人。希望帮到你。
本文还有配套的精品资源,点击获取