来自系统的自动消息:“你的代码死亡已超六个月,建议彻底删除哦。”
任何大型项目都一定会积累下“死代码”,也就是那些不再使用的模块,或者在早期开发期间存在但已经多年没跑过的程序。事实上,很多项目在创建完成后都会先运行一段时间,之后再也无人问津。
这些死代码会继续产生成本:自动化测试系统并不知道哪些代码无需再测,负责大规模清理的人们也会把很多不再运行的代码白白移来挪去。所以虽然这些代码的生产成本很高,但它同时也需要耗费大量时间加以维护。这类维护工作不能轻易跳过,否则未来就一定会造成更大的回溯管理成本。
那么,能不能靠削减代码量来降低维护成本?代码仓库里的内容真的都有存在的必要吗?
我们通常不清理代码,清理它们需要大量的时间和精力,证明其到底还有没有用更是一件麻烦事:我们不能只靠“Chesterton's fence”法则,就是“看不出这个有什么用,那就让我们把它清除掉”, 因为有一些灾难警报、闰年触发代码闲置时间更长,如果被清除了就有可能带来大麻烦。
在谷歌里,代码清除更为艰难。
谷歌跟业界其他公司不同,它只有一个代码仓库,全公司的代码都放在这个库里,二十多年来,上万名软件工程师为同一个包含数十亿行的代码仓库提交贡献。这套代码仓库存储在 Piper 系统当中,与编码相关的共享库源代码、生产服务、实验程序、诊断和调试工具等一切都被集中在这里。
这种开放方法极为强大。如果工程师不确定如何使用某个库,可以通过搜索找到示例;好心的贡献者还可以对整个代码仓库做重要更新,包括转向更新的 API、引入 Python 3 或 Go 泛型等语言特性等。
编写代码对应着极高的成本,所以代码往往被企业视为重要资产。然而,不再使用的代码也会在维护和清理等层面持续耗费时间和精力。一旦代码库达到一定规模,投入工程时间来做自动化清理就开始具有现实意义,特别是像谷歌这样拥有数十亿行代码的情况下。
但在这样的单一代码库的条件下,最坏最坏的情况就是不小心删掉了“源代码”,Google SRE 首席软件工程师说,这种情况“意味着谷歌使用的每个数据中心、每个工作站都会突然停止运行——不仅仅是关闭,甚至连存储都无法使用。(虽然只有在世界末日时才会发生)。”
那么,他们是怎么清理这些死代码的?谷歌最近在其博客中介绍了 Sesenmann“自动删除代码”项目,该项目的目标是自动识别出无效代码,再发送代码审查请求(变更列表)以将其删除。
Sesenmann 在德语中代表“死神”的无情收割之义。据谷歌介绍,该项目非常成功,每周可提交超过 1000 个待删除的变更列表,而且截至目前已经删除了谷歌全部 C++代码中的 5%。
谷歌的构建系统 Blaze(即 Bazel 的内部版本)是达成这个目标的关键:它会以一致且可访问的方式表示二进制目标、库、测试和源文件之间的依赖关系,帮助维护者据此建立起依赖关系图。如此一来,大家就能找到未链接至任何二进制文件的库,并将其作为潜在的删除对象。
但这还只是问题的一小部分:那些二进制文件又该如何处理?所有一次性数据迁移程序和已经被弃用的系统诊断工具呢?如果不把它们清理掉,相对应的各个依赖库也将被保留下来。
了解程序是否有用的唯一完美方法,就是检查它们是否正在运行。所以对于内部二进制文件(即运行在谷歌数据中心或员工工作站上的程序),程序在运行时会写入一个日志条目,记录下时间和对应的特定二进制文件。通过汇总,得到谷歌内部所使用的各个二进制文件的活跃度信号。如果一个程序很长时间都没有被用到,该项目就会尝试发送相应的删除变更列表。
当然,其中也有例外:某些程序代码仅仅是 API 的使用示例;有些程序的运行位置根本就没有对应的日志信号。对于凡此种种的各类情况,贸然删除代码肯定会惹出大麻烦。有鉴于此,建立一套阻止屏蔽列表系统就非常重要,可供大家标记异常,避免用虚假的变更列表打扰到已经忙碌不堪的软件工程师。
在谷歌的博客上,谷歌的工程师 Phil Norman 举了一个简单的例子。
假定有两个二进制文件,它们各自依赖于不同的库,另外还同时共享第三个库。忽略源文件和其他依赖项的话,我们将这种关系绘制成以下结构:
假如 main1 正在使用,但 main2 的最后一次使用却是在一年多之前,那就可以构建起树状传播活动信号将 main1 及其依赖的所有内容均标记为活动。余下的部分则可以去掉;由于 main2 依赖于 lib2,所以这次我们希望在一次变更中同时删除这两个目标:
到目前为止一切顺利,但真正的生产代码需要经过单元测试,其构建目标由测试的库决定。这就让整个遍历结构变得更加复杂:
测试基础设施会运行所有测试,包括 lib2_test,可是 lib2 从未被“真正”执行过。也就是说,我们不能单纯将测试运行作为“活跃度”信号:在这种情况下,可以误以为 lib2_test 保持活动,并导致 lib2 永远存在。只能清理未经测试的代码,而这会严重阻碍清理工作的有效进行。
根本目标是让每个测试都能共享所测试库的使用情况,所以我们可以让库和测试相互依赖来达成这个目标,据此在图中创建循环:
这样就将各个库及其测试转化成了强连接组件,可以使用与以往相同的方法标记出“活”节点,之后寻找有待删除的“死”节点集合。区别在于这次使用了 Tarjan 强连通分量算法来处理循环。
这样做很简单,但前提是能轻松看出测试及所测库之间的关系。遗憾的是,情况并不总是这么乐观。在以上示例中,由于遵循简单的命名约定,所以大家能将测试与库快速匹配起来。但这种方法在实际生产系统中往往并不奏效。
比如以下两种情况:
左边的是 LZW 压缩算法实现,分别存在单独的压缩器和解压缩器库。该测试实际上是对两者都进行测试,以确保数据在压缩和解压缩后不致损坏。在右侧,web_test 负责测试 Web 服务器库,它使用 URL 编码器库来提供支持,但实际上并不会测试 URL 编码器本身。这就希望将左侧的 LZW 测试和两个 LZW 库视为同一连接组件,而在右侧则希望排除掉 URL 编码器,只将 web_test 和 web_lib 视为连接组件。尽管需要不同的处理方式,但这两种情况的基本结构是相同的。在实践当中,可以建议工程师将 url_encoder_lib 之类的库标记为“纯供测试”(即仅用于支持单元测试),这样就能解决 web-test 需求。
除此之外,Phil 表示目前谷歌的方法是使用测试和库名称之间的编辑距离来选择最可能与给定测试相匹配的库。至于如何识别 LZW 这类一项测试对应两个库的情况,这可能需要涉及测试覆盖率数据,谷歌并没有讨论这类方法。
自动代码删除对很多工程师来说可能是个陌生的概念,就如同 20 年前单元测试刚刚诞生一样,那时候很多人对此也抱有抵触态度。
虽然删除死代码最终会给软件工程师自己带来助益,大家也肯定希望管理的代码项目能够保持整洁,但“Sesenmann”运行过程中,谷歌也发现很多工程师并不愿意经常收到用于删除代码的自动变更列表。这就是项目当中社会工程的部分了,而且重要程度丝毫不亚于软件工程。
改变人们的想法需要时间和努力,更需要大量细致的沟通。Sensenmann 的沟通策略主要分三个部分。
最重要的就是变更描述,这也是审查人员首先看到的内容。变更描述必须简明扼要,同时又保证能为审查人员提供充分的背景信息以做出正确判断。这样的平衡其实很难达到:内容太短,很多人会找不到自己需要的信息;内容太长,则可能导致满屏文字令人头痛。事实证明,如果能附上标注清晰的支持文档和常见问题解答链接,会大大提高变更描述的易读性和接纳度。
第二部分则是配套文件,这里同样要使用简洁明了的措辞和良好的导航结构。不同的人需要不同的信息:有些人需要保证源代码控制系统中的删除可以回滚,有些人希望了解要如何处理变更造成的负面影响,例如修复对构建系统的误用。通过认真思考和迭代用户反馈,支持文档将成为满足这些需求的宝贵资源。
第三部分是处理用户反馈。有时候,这可能也是最困难的部分:由于负面反馈多于正面反馈,往往就需要冷静的头脑甚至是不少“外交手腕”。总之,务必牢记这些反馈总体上反映出系统改进的最佳方式,尽量让用户更满意、避免未来继续出现类似的负面反馈。
Phil 在谷歌博客中讲道,以谷歌的业务规模出发,估计自动删除代码已经为他们带来了数十倍的投入回报,大大节约了维护成本。
自动删除代码需要解决技术和文化这两大难题。在博客中,他总结道,“虽然我们已经在这两个领域取得了显著进展,但仍不能说彻底解决。不过随着改进的继续,自动删除的接纳度会越来越高,产生的积极影响也将越来越大。这笔投资的价值因人而异,如果您也掌握着一个巨大的单体代码仓库,那不妨认真考虑一下。至少在谷歌,将 C++代码总量的维护负担降低 5%已经标志着一场巨大的胜利。”
如果删除代码也能带来巨大的收益,那是否意味着是时候为删除代码行设置 KPI 了?
参考链接:
https://testing.googleblog.com/2023/04/sensenmann-code-deletion-at-scale.html
领取专属 10元无门槛券
私享最新 技术干货