JAVA文件监听技术记录

文件监听技术网上讲的很多,散碎而全面,但关联性弱,看的越多就感觉疑问反复出现,比如有些人说 WatchService 监听原理是循环,有些人说是信号通知 Jnotify 监听文件,apache 的 common-io-2.6 包的监听文件最好,为什么会这样,所有我要做个笔记

简介

文件监控是指非JAVA服务监控一个/一些文件(夹)内部的新建、修改、删除等操作,这些操作都不是JAVA服务自身操作的。实现监听的方式一般两种,一是循环比对最后修改时间,二是信号反馈机制,可以直观的看出方法二在时效性上就比方法一要强,因为这是操作系统的文件系统发现文件有变更的时候主动通知JAVA服务的,具体通知的能力还是要看对应的文件系统。但方法一对比方法二的好处是兼容性强,不管什么文件系统都是一样的逻辑。
现在比较常用出名的是 jdk1.7 版本引入的 WatchService 和 Apache 下的 common-io 包的 FileAlterationObserver .
对此我整理总结下本次要记录的功能目录

  1. WatchService (jdk1.7提供)
    1. PollingWatchService (循环比较,jdk11 默认提供,macOS系统运行时使用)
    2. WindowsWatchService(信号反馈,windows系统下运行时使用)
    3. LinuxWatchService(信号反馈,Linux 系统下运行时使用)
  2. FileAlterationObserver(apache 的 common-io 包)

WatchService 介绍

下图是多个维度的整合,因为Windows 的 JDK 内就有 WindowsWatchService 类,但没有 LinuxWatchService 类,JDK 11 才有兜底策略类:PollingWatchService。

整体结构图如下

image-20230223182139552

WatchService

它是主体,也是我们主要操作的,需要通过 JDK FileSystem 文件系统类获取一个对应操作系统的 WatchService 实现类,Windows 获取 WindowsWatchService,MacOS系统获取 PollingWatchService (JDK11提供) ,Linux 获取 LinuxWatchService 。接口如下

void close(); // 关闭这个监控
WatchKey poll(); //获取一个 WatchKey ,如果这个 WatchKey 已经销毁那就删除掉返回Null
WatchKey poll(long timeout, TimeUnit unit);// 与poll()功能一致,只是阻塞借用队列的阻塞超时
WatchKey take();//与poll()只有一点区别在于不是返回null,而是继续等待下个。使用时一般用这个

虽然初步看该类接口后三个大同小异,但实际实现是 LinkedBlockingDeque 类,它是基于链接节点的可选边界阻塞双端队列,poll,阻塞poll,take 都是该类的方法实现

WatchKey

这个类主要是干了一个桥接的功能,它桥接了主线程(用户直接操作的 WatchService )和子线程(此线程将去指定目录下找触发事件文件)。该类的实现类都是 WatchService 的内部类。

先看这个类的方法

boolean isValid(); //判断这个桥接器(监视器)是否还能用
List<WatchEvent<?>> pollEvents();//检索并删除此监视器的所有未处理事件,返回检索到的事件List,没有不等待为空
boolean reset(); //重新去排队监听,对于取消的没用
void cancel(); //取消这个监听器
Watchable watchable(); //返回当前监视键关联的可监视对象,比如被监视的目录

子线程的具体逻辑其实也是在这个类处理的,因为 WatchService 的具体实现都是比较定制化的

PollingWatchService

该类虽然在 JDK 11 才搜到,但不排除有其它更低版本也有,先讲这个类是因为这个的实现比较特别

整体逻辑图如下

image-20230227114154288

从这个逻辑图可以清楚的看到内部是怎么监听到文件的流程,以及使用方是怎么获取到文件变更记录的。而普遍代码都会在 5,6,7环节包裹死循环,循环获取监听到变更的 WatchKey。

简述下这标着的几个点信息

  1. 这是通过 FileSystem 获取系统支持的监听 WatchService ,会初始化一个单线程池默认10秒定时执行,和一个 LinkedBlockingDeque 队列。
  2. 第二是我们常用的注册一个目录的监听,内部会生成一个 WatchKey ,WatchKey 会初始化一个 Runner 放入单线程池中定时执行
  3. WatchKey 点会初始的时候查下当前目录下的所有文件的最后更新时间并缓存成一个map,用于后期更新校验逻辑判断。(需要注意的是这些文件不会被当作成新增或更新状态记录通知出来)
  4. 这点是当定时执行的 Runner 扫描目录与缓存的最新更新时间对比后,将变更记录缓存到当前 WatchKey 下的一个 list 中,并将自身追加到 WatchService 的 LinkedBlockingDeque 队列里面
  5. 代码上我们一般套个死循环调用 take 方法获取发现文件变更的 WatchService ,这个方法实际上用的也是 LinkedBlockingDeque 队列提供的阻塞方法,如果有往这个队列里面放,就能取出来
  6. 获取 WatchKey 缓存的事件 List 取出来,会清空原来的
  7. 重新调用 WatchKey 的 reset 方法将 WatchKey 状态重置,这样下次就会继续调用它的线程,去扫描对比变更的文件了。

总的来说 PollingWatchService 使用下的原理是比较清晰简单的,也是便于理解的,但还是有些需要注意的点

  • Files.getLastModifiedTime().toMillis() 方法在获取文件最后修改时间,精确到毫秒,但有些 JDK 的 BUG 会导致获取的时间戳,毫秒位全部为 000 导致精度丢失。
  • 因为不能监听子目录下的文件变更记录,一般在监听的 WatchKey 事件中有目录创建事件的时候会同时创建这个目录的监听器 WatchKey 以便于监听该目录,但若监听的目录量大且变更逻辑处理耗时过长,会导致新创建的目录没能及时创建对应的 WatchKey ,导致部分文件发生变更记录丢失的情况,可以参考上面说的第三点。
    解决方案就是发现新增的文件创建完 WatchKey 后,主动扫描内部文件信息并处理

WindowsWatchService

该类在 JDK 7版本就已经支持

WindowsWatchService 代码上就复杂了很多,主要突出在打通与 Windows 的文件系统的 ”通道“,以及”注册新的目录“,与获取文件信号变更上,以及如何将信号划分到对应的 WatchKey ,如果将这几点看作黑盒,整体逻辑是与 PollingWatchService 差不多的,但性能,时效,空间占用上肯定会优越很多,因为内部是只用了一个线程在跑,在获取信号然后发送给 WatchKey ,它在将自身放到 LinkedBlockingDeque 队列中。然后后面就是一样的逻辑了。

但还是需要注意 WindowsWatchService 内部虽然不是通过时间戳进行比较的,但还是存在一些问题

  • 如只能监听当前目录的问题不能监听子目录,所以还是会有 PollingWatchService 第二点的问题,需要妥善处理,以免文件丢失。
  • 文件一次修改可能会触发多次监听

注意点:因为 WindowsWatchService 本事依赖于 Windows 的文件系统,定制性强,用了这么旧比稳定,但一些注意点还行需要记录,只是我现在还没发现

待续:

LinuxWatchService

该类在 JDK 7版本就已经支持,以下讲的是在内核2.6.13 版本下,Linux支持 inotify 的情况下

LinuxWatchService 具体逻辑上与前面一样,但有一点非常好是因为使用的是 inotify 系统,支持目录与子目录监听,不需要对文件创建事件做额外的处理,不会出现监听不到的问题,但如果处理不及时缓存不够会丢弃事件这时就出现事件丢失问题,建议搭配mq使用。

但还是存在以下问题

  • 因为监听所以实时性非常高,文件可能并未上传或覆盖完毕就能发送大量修改时间

但也要注意调整以下文件数据,因为这种监控是和文件系统息息相关的

注意点:

  • 通过/proc接口中的如下参数设定inotify能够使用的内存大小:/proc/sys/fs/inotify/max_queue_events 应用程序调用inotify时需要初始化inotify实例,并时会为其设定一个事件队列,这文件中值则是用于设定此队列长度的上限;超出此上限的事件将会被丢弃;
  • /proc/sys/fs/inotify/max_user_instances 此文件中的数值用于设定每个用户ID(以ID标识的用户)可以创建的inotify实例数目的上限;
  • /proc/sys/fs/inotify/max_user_watches 此文件中的数值用于设定每个用户ID可以监控的文件或目录数目上限,默认最大目录数8192吧;

Apache 的文件监控

先说版本:commons-io-2.11.0.jar

先看 apache 实现监控的手绘图,如下

image-20230302152442481

可以看出一个 FileAlterationMonitor 能配置多个监听根目录的 FileAlterationObserver,一个 FileAlterationObserver 里面能包含多个 FileAlterationListener 事件处理器。因为 FileAlterationObserver 是监听一个根文件,所以只有一个 FileEntry ,但 FileEntry 参数性质能看出这是一个能包裹 "自身" 层级的对象,它能在内存里维护一套根目录的结构体。

说下上面的序号,认为要注意的点

  1. 每一个 FileAlterationMonitor 都是实现于 Runner 是一个线程,所以调用 start 是不向下阻塞的
  2. 所有 FileAlterationObserver 是在 FileAlterationMonitor 开启的一个线程中按的先进先出顺序监听文件变化的,外部是一个死循环,参数间隔事件是这一批 FileAlterationObserver 执行完下一批的间隔
    image-20230302160414398
  3. FileAlterationListener 也是在一个线程的当前线程中先进先出顺序执行各种事件逻辑的
  4. 调用 FileAlterationMonitor 的 start 方法启动监听,启动后无法动态新增 FileAlterationObserver 需要先停止,再添加,再启动。启动时在当前线程先有序初始化所有的 FileAlterationObserver 内的 FileEntry 对象,生成一个目录包含子目录的结构体,再开一个新线程监听文件变化

怎么判断文件变更

文件变更具体如下,我认为它比JDK逻辑写的好的就是这块

文件变更检测逻辑
这部分代码思路是:传入当前 FileEntry 对象,以及 FileEntry 对象上次监听的文件列表,在加上这次监听的文件列表三个参数,时间On就能对比完成,对比逻辑可以看下图
uTools_1677905932878
previous 是旧文件列表,files 是新文件列表,这些文件列表是根据文件名排过序的,具体可看后面,首先在331-335行这块主要是根据文件名排序比较是否有之前不存在,将不存在的置为新增,如上图 File2,File4 文件,第二次的 File5 文件,第336-341行就是一路比较到了一个之前有的,如图中 第一次匹配的 File1 ,第二次 File5,这些就走修改,内部会开始校验最后更新时间,是不是目录等严格比较是否为文件修改,确认后才真通知删除,第341-344行就是比较到了一个之前有的 File3 ,现在没有了,这种置为删除,第346-349行将循环遗漏的全部算新增,如 File9 文件
这就是Apache 文件监听的事件来源的对比逻辑原理。

还有一些补充,比较器是比名字的FileAlterationObserver 属性 comparator,它的属性是初始 FileAlterationObserver 是指定,虽然只有一个实现类,但 IOCase 才是真用于比较的,它有三种类型。

/** 这个属性的三种类型对应的是字符串比较,第一种是大小写比较,第二种是忽略大小写比较,第三种是根据操作系统来 */
private final IOCase caseSensitivity;

/**
* 对比两个文件
*/
@Override
public int compare(final File file1, final File file2) {
   return caseSensitivity.checkCompareTo(file1.getName(), file2.getName());
}

FileEntry 内的子文件或目录用的是如下进行排序
1677906938575

并不是所有的文件走到修改逻辑,会触发修改事件
uTools_1677907322653
uTools_1677907392867
从这里看到,网上有部分人说的比较最后更新来源就是这里了,但主要还是文件名。
好处:

  • 不会漏监听文件问题,但 FileAlterationObserver 的新增是需要停止 FileAlterationMonitor ,加完后再启动

注意点:

  • 因为是主动扫描比较的,但如果扫描的文件数据居多的情况下还是会有性能问题,但我没有测试,据网上所说大约60万文件数时。但仅供参考
  • 因为是单线程循环比较的,所有时效性需要注意下
  • 文件一次修改可能会触发多次监听这问题还是有的

总结

以后大部分使用用还是用 Apache 的吧,感觉这个数据量小的时候还是很稳的,也便于调试。