云存储按量计费,而「同一份文件不小心存了好几份」是悄悄花钱的常见来源:同一批照片上传了两次、备份脚本重复跑、不同目录里放了同一个大文件。Nebula 现在能把一个文件夹下内容相同的对象找出来,让你回收这部分空间。
不下载文件,用 ETag 当内容指纹
找重复最朴素的想法是把每个文件下下来算哈希比对——但那要下载全部内容,对一个大桶来说慢又贵。好在对象存储列举时就返回了 ETag,而 ETag 恰恰是内容的指纹:
- 整对象上传的 ETag 就是内容的 MD5;
- 分片上传的 ETag 虽不是整体 MD5,但在相同分片规格下,同一份内容会算出同一个 ETag。
所以判据很简单:大小一致且 ETag 一致,就是重复。完全不用下载文件,列一遍目录就够了。
// 按 (大小, ETag) 分组;ETag 为空的对象无法判定,直接排除
for f in files {
let etag = match &f.etag {
Some(e) if !e.is_empty() => e.clone(),
_ => continue,
};
map.entry((f.size, etag)).or_default().push(f);
}
为什么把大小也算进 key?ETag 已经很强了,但加上大小是一道零成本的保险——两个对象只有在大小和 ETag 都相同时才归为一组,进一步杜绝误判。这个判据是保守的:它可能因为 ETag 缺失(某些对象没有)或分片规格不同而漏报一些真重复,但绝不会把不同内容误报成重复。对「要删文件」的功能来说,宁可漏报也不能误报——这是底线。
分组是个纯函数
真正的判断——「把一堆 (路径, 大小, ETag) 分成若干重复组」——不碰网络,是纯逻辑。所以它被写成一个纯函数,能脱离云端单测:
pub fn group_dupes(files: Vec<Entry>) -> Vec<DupGroup>
测试锁死了几条关键行为:同签名归一组、单例不算重复、ETag 为空 / 空串一律忽略、组按「可回收字节数」降序排(浪费最多的排最前,你一眼看到最该清理的)。每组的可回收空间是 (副本数 - 1) × 大小——保留一份,其余都是冗余。
App 层只负责「脏活」:递归列举文件夹(带一个 5 万文件的扫描上限防止超大桶失控),把文件清单丢给这个纯函数。判断与 IO 分离,是 Nebula 一贯的写法。
删除:每组默认保留一份
找到重复只是一半,删除才是有风险的一半。所以对话框的默认选择经过刻意设计:每组自动勾选除第一个之外的所有副本——也就是「默认保留一份、删掉多余的」。你可以逐个改勾选(比如想保留某个特定路径下的那份),底部实时显示「删除 N 个 · 省 X」,点删除还要再确认一次,因为这操作不可撤销。
这个默认值很重要:它让最常见的意图(「这些重复的,留一个就行」)一步到位,又不会因为「全选」而把一组文件删得一个不剩。清理存储是为了省钱,不是为了丢数据。
小结
- ETag 当指纹:不下载文件,列举即可找重复;
- 判据保守:大小 + ETag 双一致才算重复,只漏报不误报;
- 纯函数分组:可单测,按可回收空间排序;
- 删除留一份:默认保留每组一个副本,二次确认,不丢数据。
一个「查找重复」按钮,背后是「怎么在不下载的前提下可靠判定相同」和「怎么让删除既顺手又安全」两个问题的答案。
顺带:大文件排行
和「找重复」互补的另一个成本治理问题是「谁最占空间」。所以 Nebula 还有一个大文件排行:递归列举一个文件夹,按大小降序取前几十个对象,每个显示它占该目录总量的百分比和一根比例条,点一下跳到它所在的目录。
这个功能实现上更简单——列举本来就返回了每个对象的大小,排序取 Top N 是个纯函数(降序、并列按路径稳定、top 超过文件数也安全),同样脱离云端单测。它和重复查找共用同一套「递归列举 + 纯逻辑」的骨架:找重复是按内容签名分组,找大文件是按大小排序,都不下载文件、都把判断做成可测的纯函数。清理云存储,先看清「重复的」和「最大的」这两类,往往就能回收掉大头。
还有一个配套的「导出清单 CSV」:递归列举一个文件夹,把每个对象的路径、大小、修改时间、存储类型、ETag 写成一份 CSV 存到本地,用于盘点、审计或离线归档。CSV 的字段转义(逗号 / 引号 / 换行按 RFC 4180 处理)同样是个纯函数,单测锁死。列举 → 纯逻辑 → 落地,是这一组「存储治理」功能共同的形状。