第七天晚上十一点四十三分,我看着终端里那行ERROR: rules conflict发了好一会儿呆。这个原计划用七天完成的文件归档工具,在第八天的门槛前准时失效了。后来我把这段经历写成了一篇复盘,起名叫《第八天与第九天》——因为真正让它活过来的,不是多熬一个通宵,而是第八天和第九天之间那一次彻底的重构。
这篇文章不是讲某个通用工具怎么用,而是讲一个很普通的本地归档小工具,如何在第八天遭遇连环问题后,于第九天早上从根上重建。如果你也在做自己的小项目,尤其是那种计划一周搞定、结果越做越大的类型,这篇复盘会有用。你会看到我在第八天犯了哪些低级错误、第九天怎么一步步拆掉重来,以及最后沉淀成的一张通用检查清单。我不打算把它包装成什么方法论,就是一次完整的过程记录。
1. 七天计划的第八天:我为什么会卡在最不该卡的位置
1.1 从"五十行的脚本"到"带配置的工具":范围是怎么悄悄膨胀的
项目一开始非常简单。我需要把散落在某个共享目录里的文件自动分类归档:按扩展名判断类型,再根据配置好的规则移动到对应子目录,最后生成一份索引。听起来半小时能写完的脚本,对吧?我当时也是这么想的,所以给它排了七天工期,觉得绰绰有余。
实际情况是,每完成一个功能,我都会在测试的时候发现"还差点意思"。第一天写完文件遍历,我觉得应该支持子目录递归;第二天写完类型判断,我觉得应该处理大小写混用;第三天写归档规则,我觉得应该有"按日期分桶"的选项;第四天我甚至给工具加了一个配置文件解析器,为了以后"给别人用"。到了第五天,我已经在写标签匹配了。你发现没有,这个项目已经不是"一个脚本",而是"一个带配置系统和标签系统的工具"了。范围在每天下午的自我讨论里悄悄膨胀,我完全没有意识到。
第八天的崩溃,本质上不是某个 bug 造成的,而是这个膨胀过程积累到一定程度的必然结果。我后来数了一下,代码里出现了十三个针对特殊情况的 if 分支——有的处理"无扩展名文件",有的处理"隐藏文件",有的处理"重名文件",还有的是为了兼容我之前随手定的几种规则格式。每一条都花了大半个晚上调试,每一条都在增加系统的不可预测性。
1.2 第八天暴露出的三个隐患
第八天上午,我准备跑一次"真实数据演练"。第一批文件大概有三百多个,是从同事们共享目录里拷来的样例。结果一跑,三个问题全弹出来了。
第一个是类型识别不稳定。我用的类型识别库对某些常见格式的判定结果一会儿一个样,同一个文件在迁移前后给出了不同的类型。这就导致一个 PDF 被当成文档,两个小时后又被识别成图片,反复切换目录,场面非常狼狈。
第二个是规则冲突没有提示。配置里我可以同时写"所有 PDF 放到文档目录"和"所有来自某同事的文件放到共享目录",两个规则同时命中时,工具按顺序执行,前面的规则会静默覆盖后面的规则,文件被扔进了一个并非用户预期的位置。更气人的是,它不报错——它安安静静地错着。
第三个问题是我自己的锅:测试数据太"干净"了。我前面六天用来验证的都是手工造的规范文件名,比如 meeting_notes_2024.pdf、invoice_003.docx,真实世界里全是"aaaa.Pdf"、".DS_Store"这种货色。规则一旦碰上这些,各种意想不到的崩溃就来了。
我把这三个问题摆在面前,意识到它们不是三个 bug,而是三块地基裂缝。在第八天这个时间点,我已经不可能再靠"补丁"把它们糊住了。
1.3 为什么项目总在"第八天"出问题
这里说的第八天是个比方,它代表"计划快结束、压力最大、但代码最混乱"的那一天。很多项目不是死于难,是死于"在错误的地基上努力太久"。时间压力会逼人打补丁,补丁又会引入新的特例,特例一多,系统要做任何修改都得先摸一遍雷。我那天夜里改了三轮,每次都是修好 A 又弄坏 B,这就是典型的"打补丁螺旋"。
更重要的一点是,我当时一直在问"怎么修",而不是问"要不要这么修"。类型识别库不稳定,我没想过换掉它,只想着怎么加缓存和容错;规则冲突,我没想过重新设计规则模型,只想着再增加一条优先级字段的特例处理。第七天到第八天的整个状态,就是"我在执行一个已经坏掉的方案,并且坚信只要够努力就能把它执行通"。
2. 第九天凌晨:一次从根上重新拆解项目的重构
2.1 先把"完成"重新定义为"真实可用"
第八天晚上十一点多,我关掉了编辑器。没有继续改,而是重新写了这么几句话:
- 这个工具至少要能稳定处理三种类型:文档、图片、压缩包。
- 规则冲突必须变成显式报错,而不是静默覆盖。
- 任何情况下都不能移动源文件,除非它能被安全归档。
这三句话不是需求说明书,而是我给自己的"保命底线"。当天真正让我解脱的,不是想到了什么高明方案,而是承认:我之前定义的"完成"(支持八种类型、完整标签系统、可配置界面)是不现实的,是一个人在无人监督时给自己加的戏。第九天凌晨,我做出的第一个决定就是删掉预览功能、删掉标签系统、删掉多语言配置,只保留核心闭环:遍历 -> 识别类型 -> 应用规则 -> 移动文件 -> 写索引。
2.2 重写数据模型:从"像产品经理画的"到"能自圆其说"
原来的配置文件长这样:一个 categories 列表,里面每个分类有 patterns 数组和一个 target 字段;还有一个 priorities 数组,单独控制规则优先级。看起来没毛病,但"单独控制优先级"和"顺序即优先级"两种思路混在一起,导致一个规则集里我可以同时用两种方式表达规则顺序,冲突自然就来了。
第九天早上我重写的时候,索性把所有规则统一成一张"规则表"。每条规则只有四个字段:name、type_pattern、target、priority。优先级是数字,数字越大越先执行;执行前先检查所有规则是否两两互斥,如果有两条规则同时命中且优先级相同,启动时直接报错并列出具体规则名,绝不静默执行。
索引结构也做了修改。原来是一张"文件路径 -> 最终目录"的映射表,改成了带日志的格式:每次移动都写一条归档记录,包含原名、新名、命中的规则名、操作时间。这样出了问题至少能追溯,而不是对着一个已经移动完的文件猜它的过去。
2.3 替换类型识别依赖:用文件头魔数加扩展名白名单
这是第九天里最"舒服"的一个决定,因为替换之后整个工具轻了非常多。我原来的方案依赖一个独立的类型识别库,为了几个文件格式识别要引入连串运行时,部署的时候还要处理各种系统级依赖。第九天我评估了一下:这个工具实际要识别的格式不超过十种,完全可以自己写。
我写了一个两级识别逻辑。第一级读文件头部的前若干个字节,和一组"魔数"比对:PDF 以%PDF开头,ZIP 系列以PK开头,PNG 以特定十六进制开头。第二级看扩展名,把常见的别名归一化。两级都识别不出,就归入 unknown,并且默认不移动、只记录。
下面的代码是我后来定型的一个简化版本:
MAGIC_RULES = [ ("pdf", b"%PDF"), ("archive", b"PK\x03\x04"), ("image", b"\x89PNG\r\n\x1a\n"), ] EXTRA_SUFFIX = { ".pdf": "pdf", ".docx": "docx", ".xlsx": "xlsx", ".jpg": "jpg", ".jpeg": "jpg", ".png": "png", ".zip": "archive", ".7z": "archive", } def sniff_type(path): with open(path, "rb") as fh: head = fh.read(8) for file_type, magic in MAGIC_RULES: if head.startswith(magic): return file_type suffix = path.rsplit(".", 1)[-1].lower() if "." in path else "" return EXTRA_SUFFIX.get("." + suffix, "unknown")实际工程里还加了一层:扩展名是 unknown 但魔数命中的,以魔数结果为准;魔数没有命中但扩展名命中白名单的,可以用扩展名兜底,但要写入索引的"置信度"字段,方便后续人工复核。替换完这个依赖,部署体积小了一大半,也不再需要任何联网能力,行为完全可预测。我后来反复劝别人:能用手写的确定性判断,就不要轻易引入一个你不知道它内部怎么决策的库。
2.4 第九天的验证顺序:主路径优先,别拿真实文件当第一批实验品
重构之后我没有立刻拿三百个真实文件去跑,而是先做了三件事。第一,造了一组最小的合成样本:每种类型三个文件,分别放在三个不同的来源目录里,确保规则能跑通。第二,写了一个"规则冲突测试":故意配置两条优先级相同的冲突规则,确认启动阶段会报错。第三,才是把真实文件放进去跑,但一开始只放二十个,确认索引、归档记录都正常后再全量跑。
这个顺序看着不起眼,但它帮我区分了"代码的问题"和"数据的问题"。第八天我是怎么干的?上来直接跑三百个真实文件,出错了根本分不清是规则设计的问题还是某个文件本身的问题。第九天改完,全量回归时间从四十分钟降到了六分钟左右,因为大部分问题在合成样本阶段就被拦截了。
3. 第八天和第九天的本质差异:陷入执行与回到问题本身
3.1 两天的做法对比
我整理了一张对比表,与其说是技术复盘,不如说是思维方式的复盘:
| 维度 | 第八天的我 | 第九天的我 |
|---|---|---|
| 面对错误的反应 | 觉得是自己不够细心,继续修 | 觉得是方案设计有问题,先退一步看 |
| 遇到不稳定的依赖 | 加缓存、加重试、加补偿 | 换成确定性更高的自研方案 |
| 规则互相冲突 | 增加优先级语法来打补丁 | 统一数据模型,启动阶段显式校验 |
| 移动文件的策略 | 就地移动,出错再想办法 | 先确认命中规则,再记录索引,最后移动 |
| 验证方式 | 用少数理想文件名自测 | 合成样本 + 冲突测试 + 小批量真实数据 |
这张表想要说明的其实是一句话:第八天的我,把"项目出问题"当成了"我出问题",于是拼命修复自己;第九天的我,把"项目出问题"当成了"项目的定义需要调整",于是开始调整定义。前者是执行者心态,后者算是设计者心态。尤其当系统里已经出现十三个特例分支的时候,应该意识到——问题不是分支写得不够好,而是分支根本不该存在。
3.2 复盘表:时间到底花在哪里
另一个让我吃惊的数据来自时间统计。我回看了这八天的工作日志,把活动分成了三类:新增功能、修复 bug、打磨体验。真正算下来:
- 新增功能花了约三天半,但最终存活下来的只有大概一天半的工作量。
- 修复 bug 花了约两天,其中一半以上的 bug 是新增功能带来的。
- 打磨体验(调整颜色、给输出加省略号、重构输出格式)花了约一天,这些在第九天几乎全部被删掉了。
如果只算"最终融进交付版本"的产出,那有效工作时间大概是四天半,而不是八天。多出来的三天半,本质上是在为一个不断膨胀的版本买单。我后来听一位前辈说过一句话:项目延期往往不是最后几天的问题,是前面的每一天都在为后面埋雷。深以为然。
3.3 一次"断舍离"决策的成本收益计算
有人可能会问,删掉标签系统、预览功能,这些真的没关系吗?我把当时的成本收益也算了一遍。
标签系统:已经写了大约两天。如果保留,我需要再花至少半天完善标签编辑逻辑,还要考虑标签和规则之间的联动,预计引入三个新边界问题。真实需求是"能按文件类型归档",标签只是我脑补出来的加分项。删除后,核心逻辑少了 200 行左右,测试场景从二十多个降到十二个。
预览功能:是我在第七天临时起意加的,因为觉得演示的时候更好看。可这个工具的使用者最后只需要结果——文件被正确归类,索引可查询。预览功能需要额外做界面,保守估计两天,遥遥无期。删除它没有造成任何实际业务损失,只造成了"我舍不得已经写了一半的代码"的心理损失。
多语言配置:原本计划支持配置文件热加载、多场景切换,但真实使用场景只有一个共享目录、一套规则。保留热加载意味着要处理配置变更时文件还在移动的并发问题。删除后,整个工具的并发模型变成"单次执行、跑完退出",简单到不会出错。
这三个删除动作,耗掉我大概四十分钟,但省下了少则两天、多则说不清的工作量。我后来常跟人讲:删需求的速度,才是成熟度。不是写代码的速度。
4. 从这次"第八天与第九天"里沉淀的通用检查清单
4.1 代码层:重构前必须做的三件事
如果你也决定在项目中途推倒重来,别急着删文件。第九天动大手术之前,我先做了三件事,后来发现每件都救了我一次。
第一,备份当前版本。听上去很废话,但你不知道重构到一半的时候有多想退回旧版。我直接打了一个broken_v8的标签,没有删除任何代码,只是新建了分支。这个心理锚点很重要:你知道自己有退路,就不会在做决定时畏首畏尾。
第二,写行为快照测试。在动手改之前,我用了一小时把旧代码的"当前行为"记录下来,特别是有哪些输入会输出什么结果、哪些场景会抛什么错。这些测试不是用来证明旧代码正确,而是用来锁住行为,告诉我重构之后哪些变化是预期的,哪些变化是意外引入的。
第三,冻结外部接口。无论内部怎么改,先想清楚这个工具对外提供什么边界:配置文件格式、命令行参数、索引文件结构。一旦定下来,重构期间就不再改它们,避免"边重构边改契约"造成的混乱。事实上我在第九天确实动了配置格式,但动手前我先明确告诉自己要改到什么样才算完,而不是边改边想。
这三个动作加起来只用了大约一个半小时,但让后面的重构有了坐标系。没有坐标系的推倒重来,容易变成第二次单向冒险。
4.2 心态层:识别"假性忙碌"的信号
第八天晚上,我坐在屏幕前,光标一直闪,文件一个个打开又关上。外人看来我挺努力,但我心里清楚:有效产出几乎为零。我开始整理一份"假性忙碌"信号清单,现在每次项目卡壳都会拿出来对照:
- 同一段代码在一小时内被打开三次,但改动的行数少于十行;
- 反复运行同一个测试,期待它得到不同的结果;
- 开始调格式、调颜色、调输出对齐,但因为害怕面对真正的 bug;
- 花大量时间给代码加注释,注释内容是自己当时为什么这么写的内心独白;
- 反复刷新需求文档,但不敢在文档里删任何功能。
这些信号的共同特点是:行为上看起来很忙,事实上全部是在回避核心决策。第八天的我,已经明显进入了这种状态。识别出来之后,我学会了强制自己放下电脑散两分钟步,问一句:"我现在是在解决问题,还是在逃避做决定?"这个问题几乎每次都能把注意力拽回正轨。
4.3 协作层:如果这是团队项目,如何在第八天及时求援
这次工具是我个人项目,但我模拟过如果这是团队项目会怎么处理。第八天的错误版本里,我唯一的求助动作是上网搜"类型识别不稳定怎么办",而没有找任何同事。其实在那种全面卡壳的时候,最有效的动作是:把"输入是什么、输出应该是什么、现在卡在哪一层"这三个问题口头讲一遍。
能一句话说清卡点,本身就说明思路还清醒;说不清的时候,说明你对系统的理解还不够,这比 bug 本身更危险。团队协作里,第八天最好的救兵不是某个技术大牛,而是愿意听你把问题讲完、然后问句"你到底想要什么结果"的人。我从那次经历之后,在周会里被问过无数次"你卡在哪了",我现在能很快给出答案:要么是规则校验缺一条链路,要么是某个行为的判定不符合预期——这两类问题,绝大多数都能在"讲清楚"的过程中自己发现答案。
5. 一些你可能也会遇到的后续坑
5.1 重构后配置文件兼容性:老用户升级怎么办
第九天晚上,我把新版本跑通之后,并没有直接替换旧配置。因为新规则模型和旧配置差异很大,老配置直接加载会报错或者静默丢失规则。我写了一个迁移脚本:读取旧配置,尝试转换新格式,无法转换的规则单独落到一个 migration_report 文件里,附带原因说明。这样老用户升级后不会出现"我的规则怎么少了"的困惑,而是能看到明确提示。
这个做法的启发是:重构不能只对自己负责,还要对已有的使用者负责。哪怕使用者只有自己,也应该在升级后能回答"我的旧规则还在吗,为什么不在"这个问题。别让迁移成本隐性转嫁到未来某一天。
5.2 文档与代码不一致:第九天晚上我重写了说明
原来的说明文档写得天花乱坠,列了好多功能和配置项,一部分是我准备做的,一部分是删掉的,还有一部分是我自己都没完全验证过的。第九天收工前,我花了一个小时重新写了说明文档,只保留当前版本真实存在的功能、真实可用的配置项,并且每一项后面都配了一个最小示例。写的时候很痛苦,因为等于承认自己之前写的文档是在画饼。但写完很爽,因为别人——包括未来的我——照着分子操作,每一步都能落地。
我现在有个习惯:文档里写了什么,代码里就必须能跑通;跑不通的功能,文档里就要删掉。这条看起来简单的纪律,能省掉无数"文档说支持但实际不生效"的扯皮。
5.3 项目结束后我保留的"第八天笔记"
项目交付后,我没有把那段崩溃经历从记忆里抹掉,而是把它写成了一份单独的笔记,标题就叫"第八天笔记"。里面记的不只是技术问题,而是当时的心情曲线:第六天还觉得自己很牛,第七天有点怀疑,第八天完全崩盘,第九天重获掌控。我后来做其他项目排期时,会刻意给自己留出一天"缓冲日",专门用来处理那些最容易在最后一两天冒出来的整合问题。
我甚至总结出一个规律:小项目做到第七成进度时,往往是信心巅峰,也是最危险的时候;做到第九成时,才会暴露真正的整合风险。如果第七天就开始做最小可用的端到端验证,第八天就不会被迫推倒。这个教训我每次都写进项目计划的第一页。
最后只分享一个小技巧。我现在给自己定了一条规矩:任何 bug 如果修了三轮还绕不过去,就允许自己把代码全部关掉,去一张白纸上重写一遍输入和输出,再想清楚中间该经过哪几步。第八天那种状态下,重写白纸比继续在编辑器里较劲有效一百倍。因为真正让人崩溃的不是某个错误,而是你发现自己已经无法解释系统为什么会给出现在这个结果。当你还能完整解释一件事时,离修好它往往已经很近了;当你解释不清时,答案一定在更上层——是定义错了、模型错了,或者这个功能本来就不该存在。那天晚上如果我没有逼自己在白纸上写出那三句"保命底线",第九天的重构根本不会发生。所以《第八天与第九天》最后留下来的,不是一段代码,是一张写着"我到底要什么"的纸。