浏览、上传、下载都有了之后,一个云盘管理器最该有的下一个能力是同步:把本地一个目录和云端一个前缀对起来,一键备份、还原,或双向同步。这功能听起来简单,却最容易做歪——判断「哪些文件变了」的边界条件极多,做错了要么漏传(备份不完整),要么每次全量重传(慢且费钱)。
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 回退
}
}
三个层次:
- 大小不同,直接判定改动——最便宜也最可靠的信号;
- 大小相同、且云端 ETag 是整对象 MD5,才去读本地文件算 MD5 精确比对。注意「云端 ETag 是不是 MD5」这件事本身有讲究:分片上传的 ETag 不是整对象 MD5,不能直接比——这个判断我们早在做完整性校验时就沉淀成了
etag_as_md5; - 大小相同但哈希取不到(分片 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_Store、node_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 排除把不该同步的挡在门外;
- 预览先行,改数据前先看清楚。
一个「同步」按钮,背后是一条从判断语义、成本取舍到基建复用的完整链路。