浏览、上传、下载都有了之后,一个云盘管理器最该有的下一个能力是同步:把本地一个目录和云端一个前缀对起来,一键备份、还原,或双向同步。这功能听起来简单,却最容易做歪——判断「哪些文件变了」的边界条件极多,做错了要么漏传(备份不完整),要么每次全量重传(慢且费钱)。

Nebula 的做法是把它拆成两层:一个纯函数的 diff 引擎决定「做什么」,一个执行层复用已有传输基建去「做」。

diff 引擎:一个纯函数决定每个文件的命运

同步的核心不是传文件,而是先算出该对每个文件做什么。我们把它写成一个不碰网络、不碰磁盘的纯函数,只吃两侧的文件清单:

pub enum SyncMode { MirrorUp, MirrorDown, TwoWay }   // 备份 / 还原 / 双向
pub enum SyncAction { Upload, Download, DeleteRemote, DeleteLocal, Skip }

pub fn diff(
    local: &BTreeMap<String, LocalFile>,   // 相对路径 → (大小, 可选 MD5)
    remote: &BTreeMap<String, RemoteFile>, // 相对路径 → (大小, 可选 ETag)
    mode: SyncMode,
    delete_extra: bool,
) -> Vec<DiffItem>;

以镜像模式(备份 / 还原)为例,逻辑就是对两侧相对路径的并集逐个判断:

  • 两侧都有 → 相同则 Skip,不同则按方向 Upload(备份)/ Download(还原);
  • 只在本地 → 备份 Upload;还原模式下若开了「删多余」则 DeleteLocal;
  • 只在云端 → 还原 Download;备份模式下若开了「删多余」则 DeleteRemote

双向模式的判断要复杂些,它需要「上次同步的快照」,下面单独讲。纯函数的好处是可以彻底单测:各模式、删除多余、双向的各种增删改冲突、汇总统计,全部用内存里的假清单跑,不用起 GUI、不用连云。这也是 Nebula 一贯的做法——把「难的判断」从「脏的 IO」里剥出来。

同 / 异判定:复用秒传语义,留一个 size-only 回退

diff 里最微妙的是「两侧的同名文件到底一样不一样」。天真做法是比大小,但大小相同内容不同的情况完全存在;比完整内容又太贵。我们复用了 Nebula 秒传(skip-unchanged upload)已经打磨过的 ETag 语义:

fn same(local: &LocalFile, remote: &RemoteFile) -> bool {
    if local.size != remote.size { return false; }        // 大小不同 → 一定改了
    match (local.md5, etag_as_md5(remote.etag)) {
        (Some(l), Some(r)) => l == r,                     // 两边哈希都在 → 按哈希
        _ => true,                                        // 取不到哈希 → size-only 回退
    }
}

三个层次:

  1. 大小不同,直接判定改动——最便宜也最可靠的信号;
  2. 大小相同、且云端 ETag 是整对象 MD5,才去读本地文件算 MD5 精确比对。注意「云端 ETag 是不是 MD5」这件事本身有讲究:分片上传的 ETag 不是整对象 MD5,不能直接比——这个判断我们早在做完整性校验时就沉淀成了 etag_as_md5;
  3. 大小相同但哈希取不到(分片 ETag、或云端根本没给 MD5),退回 size-only:视为相同。

第三条是个有意的取舍。如果这里保守地「取不到哈希就当改了」,那么每次同步都会把所有分片上传过的大文件重新传一遍——又慢又费钱。size-only 回退避免了这种「每次全量重传」,代价是极少数「大小恰好相同、内容却变了」的文件可能被跳过。对备份 / 同步场景这是合理的默认(和 rsync 的 size-only 模式同思路),真要极致可靠可以叠加 mtime 或强制全量。

关键是:只有当「云端 ETag 是 MD5 且大小一致」时才读盘算本地 MD5,和秒传完全一致——不在没必要的地方付出读盘代价。

执行层:站在已有基建上

diff 出结果后,执行就很薄了,因为传输的重活 Nebula 早就有了:

  • 上传走续传上传(upload_resumable),自带分片、断点续传;
  • 下载流式写到 .part 临时文件,完成后原子重命名,中途取消不会留下半个文件;
  • 删除直接调 provider 的 delete;
  • 全程支持取消(取消标志逐文件检查)与进度(按动作数上报),单个文件出错记 failed 并继续,不让一个坏文件中断整场备份。

扫描两侧也复用了现成能力:云端用递归 list_all_files,本地用一个显式栈的异步遍历。执行前先跑一遍 diff(而不是用预览时的旧结果),保证「所见即将做」。

双向同步:靠一份快照区分「删除」与「新增」

备份 / 还原是单向的,好判断。双向难在:某个文件这次不见了,到底是「用户在这边删了、应该把删除传播到另一边」,还是「另一边新增了、还没同步过来」?只看两侧当前状态分不清——必须知道上次同步时的样子

所以双向模式维护一份 manifest(上次成功同步的快照:每个相对路径的大小 + 哈希),存在本地 SQLite 的 sync_manifest 表里,按同步任务分组。有了它,就能算出每一侧「相对上次」的变化——未变 / 新增 / 修改 / 删除:

enum Change { Unchanged, Added, Modified, Deleted }

// 本地当前 vs manifest
match (cur, manifest) {
    (None,    None)    => Unchanged,
    (Some(_), None)    => Added,      // 上次没有,现在有
    (None,    Some(_)) => Deleted,    // 上次有,现在没了
    (Some(l), Some(m)) => if l 与 m 内容不同 { Modified } else { Unchanged },
}

两侧的变化一组合,动作就清楚了:一侧变、另一侧没变 → 把变化传过去(改动传播、删除也传播);两侧自上次后都改了(或一边删一边改)→ 判为冲突,同步时不动它,绝不自动丢数据。

每次同步成功后重建 manifest:记录已达成一致的状态,冲突和失败项保留旧记录,这样下次仍能正确识别。

冲突不自动决定,交给你

标记冲突只是第一步——总得有人决定保留哪边。所以预览列表里,每个冲突文件都带三个按钮:留本地(上传覆盖云端)、留云端(下载覆盖本地)、跳过(这次先不处理)。你的选择作为一份「决议」随执行一起下发,后端在跑之前把已决议的冲突转成对应的上传 / 下载:

for it in &mut items {
    if it.action == Conflict {
        match resolutions.get(&it.rel_path) {
            Some(KeepLocal)  => it.action = Upload,
            Some(KeepRemote) => it.action = Download,
            _ => {} // 没选就保持冲突,不动
        }
    }
}

解决后的文件正常走上传 / 下载,manifest 也随之记下新的约定状态,冲突就此消解。定时任务不带决议,所以自动同步遇到冲突只会留着不动——把「覆盖谁」这种有损决定永远留给人,是这个功能的底线。

glob 排除:别把 .DS_Store 和 node_modules 传上云

真实目录里总有不该同步的东西:.DS_Storenode_modules/*.tmp。所以每个同步任务带一组 glob 排除规则,命中的相对路径在 diff 之前就从两侧剔除。

排除匹配是自己写的一个小匹配器(不引第三方 glob 依赖),支持三种通配:

  • * —— 段内任意(不跨 /);
  • ** —— 跨目录任意;
  • ? —— 单字符;

外加一条实用约定:不含 / 的规则匹配任意路径段。于是 .DS_Store 命中任何目录下的该文件、*.tmp 命中任何位置的临时文件,不必写成 **/.DS_Store。这个匹配器也用一组样例做了单测,锁死「* 不跨斜杠、** 跨目录」这类容易写错的边界。

界面

命令面板搜「备份」或在文件夹 / Bucket 上右键都能打开同步对话框:选账号、本地目录、云端前缀、模式(备份 / 还原 / 双向)、是否删多余、排除规则。先预览——出一份逐文件的动作清单和汇总(↑上传 N ↓下载 M ✕删除 K,共需传多少字节),确认无误再开始同步,带实时进度条、可随时取消,完成后给一份报告并自动重新预览反映最新状态。

预览与执行分离是刻意的:同步会改数据(尤其开了「删多余」),让你在动手前先看清楚要发生什么,是这个功能该有的分寸。

从一次性操作到「配置一次、自动维护」

单次同步已经有用,但备份的真实形态是「配置一次、以后自动维护」。所以我们让整套参数(账号、本地目录、云端前缀、模式、删多余、排除规则)可以保存成一个命名任务,存进本地 SQLite 的 sync_jobs 表。下次打开对话框,点一下就把任务载回表单,或直接删掉。

在此之上加一个定时间隔(手动 / 每 15 分钟 / 每小时 / 每 6 小时 / 每天),应用里就跑一个每分钟一跳的调度器:

// 每分钟检查一次:到点的任务静默执行,并更新 last_run
for (const j of await syncJobs()) {
  if (j.interval_mins > 0 && now - j.last_run >= j.interval_mins * 60) {
    await syncRun(`job-run-${j.id}`, j.spec)
    await saveSyncJob({ ...j, last_run: now })
  }
}

用一个「正在跑」的集合避免同一个任务重叠触发,last_run 时间戳持久化,所以关掉再打开也不会因为「刚过点」而立刻重跑一遍。

这里有个诚实的边界:调度是前端定时器驱动的,应用得开着才会跑——这是桌面应用的常规做法,对「日常开着的工作机上定时备份」完全够用。真正「关了应用也能跑」需要一个系统级后台服务(launchd / 计划任务 / systemd),那是另一个量级的工程,当前不做,也不假装做到。

小结

  • 纯函数 diff 引擎决定每个文件的动作,可彻底单测;
  • 同异判定复用秒传的 ETag 语义,并用 size-only 回退避免无谓重传;
  • 执行层复用续传 / 流式下载 / 取消 / 限速,薄而可靠;
  • glob 排除把不该同步的挡在门外;
  • 预览先行,改数据前先看清楚。

一个「同步」按钮,背后是一条从判断语义、成本取舍到基建复用的完整链路。