那天要处理的是 2 个超过 2000 行的大脚本。它们已经不只是“有点长”,而是同时承担了命令行入口、业务编排、文件处理、依赖调用和测试替身。目标很明确:拆成单文件不超过 800 行、让方法可以复用,同时不改变原有使用方式。
我一开始也差点把它当成一次普通的代码搬家:找出函数,按职责分组,移动到新包,再把旧入口删薄。后来真正读测试时,才发现最危险的部分根本不在函数本身,而在函数周围那些没有写进接口文档的控制点。
测试会在入口模块上替换几个裸名字,让真实的外部调用变成可控的假实现。命令行调用者依赖入口文件的参数和输出,静态工具则依赖顶层字面量注册表和构造器结构。大型脚本重构最容易丢掉的,不是某个函数,而是别人用来控制和识别它的方式。
先问谁在依赖这个入口
拆分前最有价值的动作,不是立刻建目录,而是列出入口的所有消费者。这里的“消费者”不只包括显式导入它的业务代码,也包括测试、命令行、静态检查器,甚至那些只通过模块属性工作的工具。
这次测试原本会对入口模块里的 run_ego_script、preflight、下载函数和视频帧提取函数做模块级替换。业务函数看起来只是调用一个同名函数,但测试实际上把这些名字当成了控制面板。它不需要知道真实依赖怎么运行,只要把入口模块上的名字换掉,副作用就被截住了。
这件事让我重新意识到,代码里的依赖有两种形态。一种是我们主动设计的参数、类和返回值;另一种是使用者从模块命名空间里“顺手”拿到的可替换位置。后者看起来不正式,却一样会形成契约。
如果不先找出这些位置,重构后的代码可能在真实环境里还能跑,测试却失去控制能力。更麻烦的是,失效不一定立刻报错。测试仍然会执行,只是它替换的名字已经不在真正的调用路径上,真实网络、文件或外部程序可能被悄悄触发。一个能被测试替换的名字,就是系统边界的一部分,即使它从未出现在正式接口文档里。
直接搬函数为什么会让测试失效
最容易误判的地方,是以为把函数移动到子模块后,原入口再导入这个函数,行为就自然等价。对生产调用来说,可能确实如此;对 monkeypatch 来说,却不一定。
假设原来的驱动函数在入口模块中直接调用 run_ego_script。测试把入口模块的这个名字替换掉,驱动自然会走替身。现在如果把驱动函数搬到子模块,子模块里绑定的是它自己的名字。测试继续替换旧入口,真正执行的代码却看向另一个命名空间。于是替换静默失效:没有导入错误,没有明显的类型错误,只有测试开始接触真实副作用,或者在更隐蔽的场景下得到不可信的结果。
这里的关键不是 monkeypatch 这个工具,而是名字解析发生在哪里。重构改变了调用方和依赖之间的连接位置,测试原来控制的点也就跟着消失了。重构前后,真正要保持的不是文件布局,而是控制点仍然落在同一条可验证的调用链上。
我选择引入共享的 runtime seam,是因为它能用很小的结构保留这个控制点。把外部副作用函数集中放进一个可变的运行时对象,所有下沉模块都通过这个对象调用;测试改为替换对象上的字段。这样,纯逻辑可以继续拆到更小的模块,副作用也有了稳定的注入位置。
这不是为了增加一层漂亮的抽象。它解决的是一个具体问题:测试需要替换的对象,必须和生产代码实际读取的对象相同。只要这条关系清楚,测试替身就不会因为文件移动而失去作用。
CLI 和导出符号也是契约
测试只是第一层。命令行入口还承担了另一种兼容责任:命令名、子命令、参数、JSON 输出字段和退出码都不能因为内部拆分而改变。使用者不应该因为我们整理了文件,就被迫改调用脚本。
所以旧入口不能简单清空。它应该保留成一个薄 wrapper:负责接住原来的命令行路径,把真实实现转给新模块,同时继续暴露外部已经在使用的符号。这里的“外部”不只指公开函数,也包括测试会导入的辅助函数、常量和私有名字。
这看起来有点反直觉。既然已经拆包,为什么还要在旧入口里重新导出一批符号?因为兼容层的价值不是让新结构看起来更纯粹,而是让迁移可以分阶段完成。下游调用者先保持不动,内部实现再逐步变得清晰。我判断保留这些导出是必要的,是因为它们已经被测试和其他工具实际使用,删除只会把内部整理的成本转嫁给所有调用者。
不处理的后果很明确:CLI 可能仍然能启动,但某个参数不再被识别;输出 JSON 仍然是合法格式,却少了调用方等待的字段;一个看似私有的名字被移走,测试或静态工具就会在导入时失败。兼容入口不是重构的遗留物,而是把内部变化和外部稳定性隔开的缓冲层。
结构消费者不能只看运行结果
这次还有一类依赖很容易被忽略:静态工具。它们不执行完整业务,而是读取源代码的结构,寻找顶层字面量注册表、构造器或特定的入口形状。对这类消费者来说,运行结果正确并不代表输入仍然可识别。
因此,不能只用“所有测试通过”判断拆分完成。测试主要回答行为有没有变化;静态工具还会问,代码是否仍以它认识的方式存在。把注册表计算改成动态拼接,或者把构造器藏到多层工厂里,运行时可能更灵活,结构扫描却可能直接失去目标。
错误捷径就是只按文件大小移动函数,再把失败测试当成测试需要改。这个捷径把测试视为旧结构的阻力,却没有追问测试原本在保护什么。它还会漏掉没有立刻失败的结构消费者。最后得到的可能是一套“新代码能运行、旧工具看不懂、测试假装通过”的半迁移状态。
我更愿意把静态工具看成一种特殊的下游调用者。它没有调用函数,却调用了代码的形状。面对它,我会保留必要的顶层字面量和可识别构造,把真正复杂的实现下沉到内部。这样做牺牲了一点局部的纯粹,却换来了整个工具链的可观察性。
用 3 类基线验收,而不是只跑一遍测试
拆分这种任务,我会先建立 3 类基线。第一类是行为基线:命令、参数、输出字段、退出码和关键业务结果。第二类是结构基线:入口是否仍存在,外部符号是否还能导入,静态工具能否找到它依赖的字面量和构造结构。第三类是测试基线:测试数量、通过数和是否出现跳过,都要记录下来。
这次基线和终态都是 291 个测试通过。这个数字的价值不在于它大,而在于它能和拆分前逐项对照。没有基线时,“测试通过”只能说明现在没有失败;有了基线,才知道是不是少了测试、少了路径,或者不小心改变了覆盖范围。
验收也应该按风险顺序进行。先检查入口帮助和 CLI 参数,再检查导出符号和静态索引,接着确认测试替身确实拦截到了副作用,最后跑完整测试。这样一旦失败,比较容易知道是行为、结构还是控制点出了问题,而不是在一片红色结果里凭感觉修补。
成熟的重构不是把旧文件变短,而是让行为、结构和控制能力都能被分别证明没有悄悄丢失。
下次我会怎么做
下次再遇到超过 2000 行的大脚本,我不会先按函数长度分组。我会先画出一张契约清单:谁调用 CLI,谁导入入口符号,测试 patch 哪些名字,静态工具寻找哪些结构,哪些副作用必须经过可替换 seam。
然后把内容分成 3 类处理。纯逻辑优先下沉,因为它最容易独立验证;副作用集中到共享运行时边界,因为它需要稳定的替换点;入口和导出符号暂时保留,因为它们负责兼容外部世界。每移动一组代码,就用对应的基线验证一次,不把所有不确定性拖到最后。
我也会提醒自己,最容易误判的不是“代码能不能搬”,而是“搬完之后谁还认得它、谁还能控制它”。如果不处理这些隐性契约,重构只是把复杂度从一个文件转移到一群调用者身上。
这件事让我重新理解了可复用。可复用不只是把函数放进新包,更是让调用者知道从哪里进入、测试知道在哪里替换、工具知道如何识别,而使用者不必为内部整理付出额外代价。一个结构真正成熟的标志,不是它看起来多干净,而是下一次变化到来时,仍然能沿着清楚的控制点安全地移动。