Skip to main content

附1 FSEvents 高级 API(异步的事件驱动 API)

这一节的内容主要是在汇总苹果的官方文档.

如下仍然将高级 API 记为 FSEvents 便于识别.

概述​

文件系统事件(FSEvents) API 可以向应用程序提供针对目录中任何修改的通知, 比如应用可以通过它快速检测用户对某个文件的修改. 并且它还提供了一个轻量化的途径来使应用下次启动时, 可以获取到这之间目录中发生的任何变化, 比如备份软件就可以通过这样的方式来确定某个文件是否在给定的时间戳或事件 ID 的前提下是否有修改.

本节主要有如下四个部分的内容:

  • 总览: 文件系统事件 API 总览, 以及 API 的工作原理
  • 如何使用文件系统事件 API: 讲如何使用这个 API, 包含如何创建 event stream, 编写 handler 等.
  • 文件系统事件 API 的安全: 讲 API 相关的安全功能
  • Kernel Queues: 它是另外一种机制, 可以实现类似文件系统事件 API 的功能. 会讲它的工作原理以及在什么情况下用它会更合适.

API 总览​

FSEvents API 在 10.5 及之后的系统都可用, 应用可以使用它监听目录中任何改变并作出响应.

FSEvents 从机制上讲, 有如下三个部分组成(详见FSEvents 底层 API 的内容):

  • 内核代码部分: 将"原始事件通知"通过一个特殊设备(/dev/fsevents)从内核空间传递到用户空间
  • 守护进程 fseventsd: 过滤并发送这些事件
  • 数据库: 记录所有的改变事件.

当应用注册了 FSEvents 通知后, 守护进程会在对应目录中(或目录本身)的任何改变发生后发送一个通知给应用.

需要注意: 守护进程发送的通知粒度是在目录层级的. 它只会告诉应用目录中有什么改变, 但不会详细告诉应用改变的具体内容是什么(否则通知就太臃肿了).

除了将多个文件的改变情况都压缩到一个通知外, 如果多个改变在很短的时间内发生, 守护进程还会将这些改变通知聚合到一起. 意味着在某段时间内目录中的最后一个改变发生后, 守护进程可以保证应用总是能够收到至少一个通知. 注册通知时应用可以指定自己想要的最小时间粒度(时间粒度内的改变都会放到一个通知).

高层的 FSEvents API 的限制主要有:

  1. 它并不是"实时"的, 文件系统的改变不能在发生后就被立即送达, 因此杀毒软件才会直接编写内核扩展并在 VFS 的层级上监听改变.
  2. 它不能反映某个特定文件改变的精确时间点(因为通知是延迟发送的), 如果有改变时间上的需要, 可以使用 kqueue(后面会讲).

高层 FSEvents API 主要用于被动监听一个大型文件树中的改变, 它非常适合备份软件, 实际上苹果自己的备份就是基于高层 FSEvents API 的. 另外它也非常适合一些将自己的数据文件分散存储到磁盘中的应用, 当其他应用改变了某个关联的数据文件时, 该应用就可以收到通知并对应处理.

虽然这些也可以通过 kqueue 实现, 但 FSEvents API 提供了数据连续性的保证(应用下次启动后仍然可以获取退出期间的所有改变).

高层 FSEvents API 的用法​

FSEvents API 由若干组函数构成, 比如可以使用 FSEvents 开头的一些函数获取卷/事件的总体信息, 也可以使用 FSEventStream 开头的函数来创建事件流/操作事件流.

FSEvents 事件流是 API 的核心概念, 下面先来看它的生命期.

FSEvents 事件流的生命期:

  1. 应用通过 FSEventStreamCreate 或 FSEventStreamCreateRelativeToDevice 创建事件流
  2. 调用 FSEventStreamScheduleWithRunLoop 将事件流调度到指定的运行循环上执行.
  3. 调用 FSEventStreamStart, 告诉 fseventsd 守护进程可以开始发送通知过来了
  4. 应用自己处理接收到的事件(FSEvents 想应用提供事件的方式是调用在第 1 步中设置的回调)
  5. 应用在合适的时机调用 FSEventStreamStop, 告诉守护进程停止发送通知
  6. 如果应用想重新接收事件, 则可以再进行第 3 步, 否则,
  7. 调用 FSEventStreamUnscheduleFromRunLoop 将之前调度到运行循环上的事件流取消
  8. 调用 FSEventStreamInvalidate 使事件流失效
  9. 调用 FSEventStreamRelease 释放事件流内存

下面来看和上述步骤对应的具体实现细节.

头文件和依赖​

  • 头文件: #include <CoreServices/CoreServices.h>
  • 依赖: CoreServices.framework

创建事件流​

FSEvents API 支持两种类型的事件流创建:

  • 磁盘事件流(FSEventStreamCreateRelativeToDevice):

    事件 ID 基于磁盘上之前的事件 ID 继续增加. 如果仅观察一个磁盘, 可以使用它. 否则多个磁盘需要创建多个磁盘事件流, 或者使用主机事件流.

  • 主机事件流(FSEventStreamCreate):

    事件 ID 会结合主机上其他事件具体情况增加, 并且 ID 可用保证唯一. (除了一种情况: 将另外一台 10.5 以上版本机器的磁盘挂载到这台主机后, 多个磁盘卷上历史 ID 可能冲突, 任意新事件会按所有卷上的历史最高 ID 继续增加)

总地来说, 应使用磁盘事件流来尽量避免事件 ID 冲突. 相反, 主机事件流在应用运行时观察目录或目录树更合适. 如果观察根文件系统, 则二者基本类似.

下面的代码演示如何创建事件流:

/* Define variables and create a CFArray object containing
CFString objects containing paths to watch.
*/

CFStringRef mypath = CFSTR("/path/to/scan");

CFArrayRef pathsToWatch = CFArrayCreate(NULL, (const void **)&mypath, 1, NULL);

void *callbackInfo = NULL; // could put stream-specific data here.

FSEventStreamRef stream; // 创建的事件流

CFAbsoluteTime latency = 3.0; // 接收间隔

// 创建事件流: 传入的 myCallbackFunction 即事件回调
stream = FSEventStreamCreate(NULL,
&myCallbackFunction,
callbackInfo,
pathsToWatch,
kFSEventStreamEventIdSinceNow, /* Or a previous event ID */
latency,
kFSEventStreamCreateFlagNone /* Flags explained in reference */
);

创建事件流后, 下一步就是将它调度到应用的运行循环上.

将事件流调度到运行循环执行​

调用 FSEventStreamScheduleWithRunLoop 将事件流调度到应用的运行循环上执行. 如果应用没有运行循环, 首先应创建一个(在主线程或新线程), 调用 CFRunLoopGetCurrent 即可对线程分配并初始化运行循环, 后续的 CFRunLoopGetCurrent 调用都会返回相同的运行循环.

下面的代码演示如何将事件流调度到当前线程的运行循环(还未启动事件流):

FSEventStreamRef stream;    // 之前创建的事件流
// 传入事件流引用, 运行循环, 运行循环模式
FSEventStreamScheduleWithRunLoop(stream, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode);

启动​

最后一步是调用 FSEventStreamStart 启动事件流, 即告知事件流可以开始发送事件了, 它只有一个 stream 参数.

事件流调度到运行循环并启动后, 如果此时线程的运行循环还未启动, 则应调用 CFRunLoopRun.

事件处理回调​

事件处理回调需要实现 FSEventStreamCallback 原型, 该原型定义如下:

typealias FSEventStreamCallback = (ConstFSEventStreamRef, UnsafeMutableRawPointer?, Int, UnsafeMutableRawPointer, UnsafePointer<FSEventStreamEventFlags>, UnsafePointer<FSEventStreamEventId>) -> Void

在回调中会接收到三个列表, 分别是:

  1. path 的列表
  2. id 的列表
  3. flag 的列表

三个列表等长, 结合在一起, 就是一个事件列表(每个事件分别由三个列表中对应下标位置的元素组合构成).

为了解对应路径的文件有什么修改, 程序中需要对该路径进行扫描, 正常情况下只需要扫描对应路径, 但也有如下例外情况:

  • 如果对应目录发生事件的同时, 该目录的子目录中也有其他事件发生, 则可能这些事件会被压缩为一个事件, 并且该事件的 flag 是 kFSEventStreamEventFlagMustScanSubDirs. 当接收到带这个 flag 的事件时, 程序必须递归地扫描事件中给出的 path.

  • 当内核和 fseventsd 守护进程之间的通信有错误发生时, 可能收到一个带 kFSEventStreamEventFlagKernelDropped 或 kFSEventStreamEventFlagUserDropped flag 的事件. 此时程序必须完整扫描一次当前正在观察的目录以便获知更改.

    当事件 dropped, kFSEventStreamEventFlagMustScanSubDirs flag 也会被设置, 因此程序中无需分别处理 dropped flag, 而只需要处理这个 flag 即可. dropped flag 仅作为一个额外信息提供.

  • 如果被观察的目录被删除, 移动或重命名时(或它的父目录有这些改变时), 被观察的目录就 "不复存在" 了. 如果程序关心这个事件, 则需要在创建事件流时指定 kFSEventStreamCreateFlagWatchRoot flag, 这样当被观察目录不存在后, 会收到一个带 kFSEventStreamEventFlagRootChanged flag 的事件, 并且 event ID 会是 0. 这样程序可以对应进行处理.

    如果需要知道被观察目录被移动到了哪里, 可以调用 open 打开根目录, 然后传入 F_GETPATH 给 fcntl 来找到当前路径.

  • 如果事件 ID 达到 2^64, 则后面一个事件的 ID 会被归零, 此时会收到一个 kFSEventStreamEventFlagEventIdsWrapped. 虽然这种情况在有生之年根本不会发生, 但仍然有必要处理.

当程序想获取当前事件流观察的路径列表时, 可以调用 FSEventStreamCopyPathsBeingWatched 拿到路径列表.

下面是回调的简单例子:

void mycallback(
ConstFSEventStreamRef streamRef,
void *clientCallBackInfo,
size_t numEvents,
void *eventPaths,
const FSEventStreamEventFlags eventFlags[],
const FSEventStreamEventId eventIds[])
{
int i;
char **paths = eventPaths;

// printf("Callback called\n");
for (i=0; i<numEvents; i++) {
int count;
/* flags are unsigned long, IDs are uint64_t */
printf("Change %llu in %s, flags %lu\n", eventIds[i], paths[i], eventFlags[i]);
}
}

如果在创建事件时, 传入了 kFSEventStreamCreateFlagUseCFTypes flag, 则需要将 eventPaths 转换为 CFArrayRef 对象.

应用再次启动后的事件处理​

高级 file system events API 提供了将发生事件持久化的能力, 当应用再次启动后, 仍然可以从已被持久化的事件中获知被观察目录的对应变化, 从而对应处理.

不过这需要应用自己在处理事件时经常性地保持自己处理的最新事件 ID.

应用再次启动后, 可以在创建事件流时通过 sinceWhen 参数传入自己最后处理的一个事件 ID,

另外还可以根据指定时刻获取该时刻对应的最后一个事件 ID, 即调用 FSEventsGetLastEventIdForDeviceBeforeTime.

由于应用再次启动后, 之前的一些状态会丢失, 比较有效的办法是持久化存储最后一个事件发生后的目录元数据树快照, 具体可以参考这个链接.

结束事件流时的清理工作​

当处理完成后, 程序应结束事件流并释放它, 步骤是:

  1. 调用 FSEventStreamStop 停止事件流
  2. 调用 FSEventStreamInvalidate 无效化这个事件流
  3. 调用 FSEventStreamRelease 释放事件流

在进行清理工作前, 如果需要保证此时文件系统是一个稳定状态, 可以调用 FSEventStreamFlushAsync 或 FSEventStreamFlushSync 将事件流中的数据清空, 并且会返回最后事件 ID. 回调中可以根据这个 ID 确认是否最后一个事件已经处理了, 并且是否正确持久化了(如果需要的话).

示例​

如下是一个 OC 实现的示例程序:

#import "SGFSEventMonitor.h"
#import <CoreServices/CoreServices.h>

void mycallback(ConstFSEventStreamRef streamRef,
void *clientCallBackInfo,
size_t numEvents,
void *eventPaths,
const FSEventStreamEventFlags eventFlags[],
const FSEventStreamEventId eventIds[])
{
int i;
char **paths = eventPaths;

printf("Callback called\n");
for (i=0; i<numEvents; i++) {
printf("Change %llu in %s, flags %u\n", eventIds[i], paths[i], (unsigned int)eventFlags[i]);
}
// TODO: 需要将事件送给 swift 侧进行处理
}

@implementation SGFSEventMonitor {
FSEventStreamRef _stream;
}

-(void)startStreamWith:(NSString*)pathToWatch {
// NS to CF: https://stackoverflow.com/questions/17227348/nsstring-to-cfstringref-and-cfstringref-to-nsstring-in-arc
CFStringRef path_ref = (__bridge CFStringRef)pathToWatch;
CFArrayRef paths_to_watch = CFArrayCreate(NULL, (const void**)&path_ref, 1, NULL);
FSEventStreamRef stream;
CFAbsoluteTime latency = 1.0;
stream = FSEventStreamCreate(NULL,
mycallback,
NULL,
paths_to_watch,
kFSEventStreamEventIdSinceNow,
latency,
kFSEventStreamCreateFlagNone);
_stream = stream;
FSEventStreamScheduleWithRunLoop(stream, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode);
FSEventStreamStart(stream);
CFRunLoopRun();
}

-(void)stopStream {
if (_stream) {
FSEventStreamStop(_stream);
FSEventStreamInvalidate(_stream);
FSEventStreamRelease(_stream);
}
}

@end

当传入被观察的目录路径并开始后, 在被观察目录中的文件改变, 会触发回调, 打印类似如下的信息:

Change 909834631 in <被观察目录的子目录路径>, flags 0

可以发现, FSEvents 并不会给出具体是哪个文件改变了, 而是给出的改变目录的路径.

如果对子目录重命名, 则打印的是被观察目录的路径:

Change 909851983 in <被观察目录路径>, flags 0

同时可以传入文件系统根目录进行观察, 因为 FSEvents 的安全性限制, 所以只会给出当前用户有全选的目录改变情况.

如果要观察文件层级的改变, 则可以在创建流的时候传入 kFSEventStreamCreateFlagFileEvents flag, 表示需要文件级别的观察.

附录​

  1. 需要实现一个使用底层 API 的观察器: rust 版本, 参考这个库进行: https://github.com/dpatriarche/filemon
  2. filemon 也是类似的工具: http://newosxbook.com/tools/filemon.html, 下载后里面有源码可以参考.
  3. 可以使用 fs_usage 传入去观察具体进程的操作