同步事件处理

在多设备同步过程中,需要处理两大类事件:一是本地的文件变化二是云端的文件变化。事件的处理在同步算法中扮演了核心的角色,后续的同步动作,比如上传、下载、删除、重命名、移动、合并等都基于对事件的处理结果。

在Obsidian中,同步涉及到4类事件,分别为

  1. 创建文件或文件夹。
  2. 重命名文件或文件夹。
  3. 删除文件或文件夹。
  4. 修改文件。

以上是同步过程中关心的四类事件,这四类事件都对应了Obsidian用户的相关操作。

监听4类事件

文件的变化事件通过 vault.on() 方法进行监听:

vault.on('create', () => {}) // 新增文件
vault.on('rename', () => {}) // 重命名(移动)
vault.on('delete', () => {}) // 删除文件
vault.on('modify', () => {}) // 修改文件

✏️ 除此之外,还可以通过cross对相关方法进行插桩进行代码层级上的监听。暂未对该方法进行探究,以下内容都是基于 vault.on 触发的事件进行描述的。

知道了怎么监听之后,再来看一下这四类事件的特点,然后再进行合理地处理。正确认识到问题的特殊性,才能做出相应合理的措施。

Obsidian文件事件的特点

  • create事件。
    • 触发:用户新建文件或者文件夹的时候。
    • 特点:由于用户在一个时刻只能create一个文件或者文件夹,因此一个新建操作对应一个create事件。
  • rename事件。
    • 触发:(1)点击重命名文件或文件夹;(2)移动文件或者文件夹到另一个位置。
    • 特点:在针对文件夹重命名或者移动的时候,会触发针对文件夹及其子项的rename事件,事件的顺序为先文件夹自身,再子项,符合广度优先的特征
  • delete事件:
    • 触发:用户删除文件或文件夹。
    • 特点:在删除文件夹的时候,会触发针对文件夹及其子项的delete事件,但是事件的顺序和rename不一样,没有明显的顺序,基本上是批量一起触发的
  • modify事件。
    • 触发:用户修改了文件。
    • 特点:仅在文件内容变动的时候触发,因此文件夹是不会触发这种事件的。

事件收集和预处理

create事件和modify事件由于存在单操作对应单个事件的特点,因此不需要额外的预处理。但是,在重命名文件夹和删除文件夹的时候,情况就不一样了。举个例子,比如删除文件夹A,里面有文件夹A-1,假设先触发了删除A,再触发了删除A-1。假如直接基于这两个事件触发云盘操作:

  • 删除云盘文件夹A。
  • 删除云盘文件夹A-1,此时由于A-1已经不存在,那么这是一次无效的删除操作。

✏️ 在删除或者移动一个大文件夹时,会产生大量的无效操作,因此需要在真正向云端发送请求前进行预处理。

除此之外,事件的收集也变成了一个问题,还是上面的删除事件为例,我们希望同时收集事件删除A和事件删除A-1,然后再过滤冗余操作,最后向云服务发送删除A请求。但是事件触发的时间先后以及时间间隔我们无法假定。

  • 针对delete事件。在一般场景下,删除事件批量发生,事件间隔在几个毫秒左右。因此通过 debounce 方法可以达到延迟触发的效果。
  • 针对rename事件。由于事件有先后顺序,因此可以通过当前事件对应的文件路径来判断时候已经有parent文件在处理,如果存在parent,那么可以忽略当前事件。

综上,我们得到了一个处理本地文件事件的基本流程:

事件触发 ➡ 事件收集 ➡ 事件过滤 ➡ 后续操作

事件收集和过滤操作可以用下面的示例代码表示:

debounce(() => {
    // 事件预处理:fileEventTemp为原始事件数组,当作队列处理
    this.fileEventTemp.sort((a, b) => {
        if (a.timestamp !== b.timestamp) {
            return a.timestamp - b.timestamp;
        } else {
            return a.targetPath.length - b.targetPath.length;
        }
});
    // historyEvents为预处理后的事件
    const historyEvents: FileEvent[] = [];
    let nextEvent = this.fileEventTemp.shift();

    while (nextEvent) {
        if (historyEvents.some(e => nextEvent?.targetPath.startsWith(e.targetPath))) {
            logger.info(`Ignore event: ${nextEvent}`);
        } else {
            historyEvents.push(nextEvent);
        }
        nextEvent = this.fileEventTemp.shift();
    }
}, 1000, true)(); // 延迟1秒,收集最近1秒的事件到fileEventTemp数组中

事件压缩(聚合)

考虑这样的场景,文件A重命名为了文件B,然后又重命名成了文件C,这就产生了两个rename事件:

  1. rename A -> B
  2. rename B -> C
    在预处理中,会收集到这两个事件,事实上这两个事件可以合并成一个事件
  3. rename A -> C

Sync Vault中支持rename事件的链式聚合,可以减少云端请求数量。

本地事件预处理完毕后,就触发真正的 同步流程 了。