2016 年底,我的课程资料目录里堆着几十个名字混乱的视频:1.mp4、01-1.mp4、新建文件夹(2).mp4。手工整理到第十几个时,我决定写一个批量改名工具。这是我第一次写“自己真的会继续用”的程序,也因此第一次意识到,文件操作失败不会像练习题一样自动恢复现场。
第一版逻辑只有三步:遍历目录、拼接新名字、调用 renameTo。测试目录里一切正常,换到真实目录就遇到同名覆盖、文件顺序不稳定和改到一半失败。最危险的是,程序没有记录已经改了哪些文件,我只能凭记忆一点点找回来。
图 1:对不可轻易恢复的操作,先把“准备做什么”变成一份可检查的数据。
把改名设计成两阶段操作
我后来不再边遍历边改名,而是先生成完整计划:
record RenamePlan(Path source, Path target) {}
List<RenamePlan> buildPlan(Path directory) throws IOException {
try (Stream<Path> files = Files.list(directory)) {
List<Path> sorted = files
.filter(Files::isRegularFile)
.sorted(Comparator.comparing(path -> path.getFileName().toString()))
.toList();
List<RenamePlan> plans = new ArrayList<>();
for (int index = 0; index < sorted.size(); index++) {
Path source = sorted.get(index);
String extension = extensionOf(source.getFileName().toString());
Path target = directory.resolve(String.format("lesson-%03d%s", index + 1, extension));
plans.add(new RenamePlan(source, target));
}
return plans;
}
}计划生成后先打印 old → new,默认不执行。只有传入 --apply 并再次确认,才进入文件系统修改阶段。今天看这就是很普通的 dry-run,当时它第一次让我感受到“数据与副作用分开”的价值。
执行前一次性检查所有冲突
如果改到第 17 个文件才发现目标名已存在,前 16 个已经改变。更可靠的做法是在执行前验证整份计划:
- 源文件是否仍然存在;
- 目标路径是否重复;
- 目标文件是否已经存在且不在本次源集合中;
- 源与目标是否位于同一文件系统;
- 当前进程是否拥有写权限;
- 名称在目标系统中是否合法。
void validate(List<RenamePlan> plans) {
Set<Path> sources = plans.stream().map(RenamePlan::source).collect(Collectors.toSet());
Set<Path> targets = new HashSet<>();
for (RenamePlan plan : plans) {
if (!Files.exists(plan.source())) throw new IllegalStateException("missing: " + plan.source());
if (!targets.add(plan.target())) throw new IllegalStateException("duplicate target: " + plan.target());
if (Files.exists(plan.target()) && !sources.contains(plan.target())) {
throw new IllegalStateException("target exists: " + plan.target());
}
}
}交换名称需要中间态
假设 A.mp4 要改成 B.mp4,同时 B.mp4 要改成 A.mp4。直接执行一定冲突。解决方式是先把所有源文件移动到不会碰撞的临时名,再从临时名移动到最终名:
A.mp4 -> .rename-tmp/001
B.mp4 -> .rename-tmp/002
.rename-tmp/001 -> B.mp4
.rename-tmp/002 -> A.mp4这不是数据库意义上的原子事务,进程仍可能在中途退出,但它消除了计划内部的名称覆盖,也让恢复路径更清晰。
每成功一步就写回滚清单
我给每次执行生成一个 JSON 清单,里面记录批次号、源路径、临时路径、目标路径和当前阶段。每完成一次移动,就落盘更新状态。失败时按完成记录逆序恢复:
| 阶段 | 已完成动作 | 恢复方式 |
|---|---|---|
planned |
尚未改动 | 直接退出 |
staged |
源文件已进入临时目录 | 临时路径移回源路径 |
committed |
已改为目标名 | 目标路径按逆序移回源路径 |
rollback_failed |
部分恢复失败 | 保留清单,停止自动操作 |
最后一种状态很重要。程序不能在恢复失败后继续“努力”,否则可能破坏更多文件。它应该停下来,把真实状态和需要人工处理的路径说清楚。
安全默认值比使用说明更可靠
这个工具最后保留了几条默认规则:默认 dry-run;不覆盖已有文件;执行前打印总文件数和目标目录;真实执行要求输入批次摘要;所有改动写清单;任何异常立刻停止。
那时我还不知道“可逆操作”“补偿事务”这些词,但已经被一次半成功的改名教育过。后来做数据库迁移、批量发布和 Agent 工具调用,我都会先问同一个问题:如果它只做完一半,我们靠什么知道现场,又靠什么回来?