关于长期软件的开发
原文信息 | |
---|---|
标题 | On Long Term Software Development |
作者 | Bert Hubert |
发表日期 | 2024.12.22 |
其他译文 | 科技爱好者周刊(第 331 期):你可能是一个 NPC(文摘部分)(摘译) |
最近,荷兰选举委员会(我是其兼职顾问)邀请我做一个演讲,总结他们开源的Abacus投票统计软件。
现在许多软件都以服务的形式进行交付,通常利用持续部署(continuous deployment),提供足够的自动化测试(continous integration),让我们有理由相信新版本(至少在某些情况下)可以正常工作。
与此同时,仍然有很多地方不适合这种持续进行的、仅仅是大概率可以正常工作的更新,比如控制核电站、选举、心脏起搏器、飞机、桥梁、重型机械的软件。简单来说,故障或失效足以引发致命后果的东西。
这些地方需要软件在几十年间长期稳定运行,以及在更新前提供详细的发布说明,不仅仅是“做了一些错误修复和改进”。软件可能在几年内都没有更新,然后规划发布一个全新的主要版本,而且需要从源代码开始构建。
这是个很极端演讲,但也有人喜欢这种开发模式。
关于长期软件的开发:(足够)关心未来
演讲包括关于长期软件开发的通行内容,以及对Abacus软件开发的具体评论。
对于通行内容部分,我咨询了Mastodon社区朋友们的意见:

信息得到了广泛的传播,这篇文章的大部分内容都来自于社区的友好反馈。
下面并没有什么颠覆性的东西,不过一些建议的重要性值得注意,可能会引发读者思考。
依赖关系

从非常高层的层次来看,我们可以这样理解软件:有一个我们无法控制的外部世界,例如客户端软件(如浏览器),这是我们必须面对的现实。在我们自己开发的软件中,存在一些非常重要的决策:比如使用哪种编程语言。更换编程语言需要重写整个技术栈,不是说换就换的。
在这一层之上,我们发现框架往往和代码库存在紧耦合。想想web/JavaScript框架、对象关系映射(ORM)、像Spring Framework、Ruby on Rails、React、Rust Axum等等。尽管可以替换这些依赖,但这是非常繁重的工作,因为影响到了太多的代码。
再上一层,几乎所有东西都依赖于某种数据库。市场上有很多数据库,工作原理大同小异。如果你最初选择了MySQL,后来发现需要更换,这会产生一些工作量,涉及数据库细节或规范。
最后就是可更换的“辅助工具”。这些库在不同的方执行特定功能,但都是单点解决方案。你可以轻易更换这些依赖,而不会影响用户。
从上文可以清楚的看到,如果你在设计长期软件,依赖关系的选择至关重要。系统依靠它们。既然我们无法同样重视每一件事,我们应当更加关注软件金字塔的底部。在长期的时间尺度下,依赖项和外部世界可能不再适配。

在我发布Mastodon帖子之后,得到的一个突出反馈是要严格限制依赖关系。因为随着时间推移,依赖项可能:
- 偏离预期,导致你需要调整代码,或者更糟糕的,悄悄改变软件行为。
- 更新主要版本,语义发生变化,需要重写适配代码。
- 不再维护、完全消失或者质量退化。
- 遭到恶意劫持(比如npm、pypi等)。
- 被新投资者拿来进行商业运营。
- 不同依赖项之间出现依赖冲突。
(我个人就曾在Python项目中遇到过最后一点,一个依赖项要求3.14或更低的版本,而另一个却需要3.15或更高版本)
当然这不是在呼吁完全不使用任何依赖项!为了完成工作,依赖项是不可缺少的。但当我们把时间尺度扩展到10年时,我们必须严格审查依赖项代码:
- 你能否查看它的源代码?从源代码上看,它的技术可靠吗?
- 有哪些人在使用它?
- 它是由谁开发的?目的是什么?
- 开发者的目标是什么?
- 项目有资金支持吗?谁提供的?
- 有人维护项目吗?有安全更新吗?
- 是否有一个社区可以在需要时承担维护工作?
- 如果有需要,我能否承担维护工作?
- 我是否应该提供资金或支持,以确保项目团队正常运作?
- 项目的依赖项情况如何?
- 这些依赖项的安全记录如何?
看起来这是个大任务,没错,评估每个依赖项可能花费数小时甚至数天的时间。因此,这也是我认为引入新依赖项在技术上具有一定难度的原因。评估过程会让你不时停下来权衡利弊。
如今拥有太多的依赖项不是一个好主意,依赖项的更新速度如此之快,以至于代码库实际上成了不断变化的东西。老实说,如果代码有上千个依赖项,大部分你都不了解,那你根本不知道自己在发布什么。
运行时依赖
到目前为止,我们讨论的都是构建编译时依赖项。如今许多项目还具有运行时依赖项,比如S3或Google Firebase。一些服务在实际上已经成为标准(如 S3),其他服务则产生了依赖锁定。简言之,如果计划以10年为周期,需要有一个简短或空白的第三方服务依赖清单。到了2034年,找到你目前所依赖服务的替代品将变得极其昂贵。
这一点对于使用了许多复杂或高级第三方服务的“云原生”软件开发尤为重要。
另外特别需要注意的是构建时服务依赖。如果出于某些原因"npm install"(或等效命令)无法工作,你还能构建软件吗?
测试、测试、测试
我提到过测试吗?在这一点上大家的观点是一致的:尽量多的编写测试。一些观点认为并非所有测试都有相同的价值,这当然是正确的。但你永远不会后悔写了一个测试。测试总是好的,特别是当你有很多不断更新和修改的依赖项时。测试不能阻止依赖项更新,但它们能帮助你更早的适应变化。

测试也可以在重构或移除依赖项时提供(心智)支持。此外,在开发工作中断了三年之后,测试是重新确认系统在使用了新编译器、运行时和操作系统等之后,仍然可以正常工作的好方法。要多写测试。
复杂性
这不是一个新观点,但确实值得重申:复杂性是软件开发的最终挑战。复杂性可以击败最好的程序员和最好的团队。它是最根本的敌人。由于系统存在无序倾向,叠加人类行为的影响,如果没有特别处理,复杂性总会不断增加。
虽然这是一个常见的图表,我认为里面还是有一些新意。但说实话,很可能我只是在复述以往自己读过的内容:

如果需要处理的代码量有限,代码可以复杂一点。随着代码量的增加,如果你仍然想掌控代码,就需要将代码变得简单些。只要代码处于绿色三角形内部,团队就能胜任,你会感觉良好。但你无法随意塑造绿色区域形状。

哪怕聘请更多、更聪明的人,能够处理的复杂性仍然存在硬性上限。即使在最好的情况下也如此。一旦代码超出了绿色区域,麻烦大了。但如同箭头所示,代码的自然演化趋势是向上和向右。用户要求更多特性;程序员喜欢优化代码,哪怕没有实际需要;而即便是必要的缺陷修复,往往也会增加大量新代码(而非通过艰苦工作,消除可能引发缺陷的内部复杂性)。
随着时间推移,复杂性只会不断累积。如果一个函数叫做CreateFile,但在大多数情况下并没有创建文件,处理这样的函数增加了认知负担。减少命名错误的函数或违背直觉的API至关重要。行为异常的调用和方法,让你每天都有可能掉进陷阱。
从实践中得到的信息非常明确:早重构、多重构,删除无效的或重复的代码,花时间简化代码。否则在长达10多年的软件项目中,你将不可避免的陷入无法维护的泥沼。注意,如果你编写了大量测试,这将很容易做到。
编写简单直接的代码。更简单、更直接
人人都知道调试程序比编写程序难一倍。所以如果你在编写程序时已经竭尽所能,那么你要如何调试程序呢?
Brian Kernighan
编写特别直接的代码,编写简单、显而易见的代码。“过早优化是万恶之源。”只要足够简单,你总有机会把它变得复杂。也许这个时刻永远不会到来。不要编写“聪明”的代码,除非别无选择。你永远不会因为编写简单代码而后悔。
特别要注意那些只有在“恰到好处”时才能正常工作的高性能代码和功能。例如我非常喜欢LMDB,但是在稳定获益于它出色的速度和能力之前,PowerDNS经历了不少困难。同样地,我使用过RapidJSON,一个使用SIMD加速的JSON库,但最终发现使用条件过于苛刻,就像拿电锯做杂耍。
然而可怕的是这些技术会吸引你——“我能应付这些难题,得到很好的性能提升”。也许现在运气好可以实现。但在五年后,你或者你的接班人,可能遇到巨大困难。这一点对于复杂的编程语言也同样适用。
认真地说:保持简单,甚至更简单,真的。
基于领英的软件开发
那么,我们要使用什么技术呢?理论上我们应该根据前文提到的依赖清单做决定。现实中这往往是一个近乎下意识的决策:有人尝试了一些看起来很有吸引力的技术,很有成效,我们就采用它。
这种吸引力可能来自于领英上受到尊敬的思想领袖和活跃用户的帖文,也可能来自Hacker News用户对宣称终结一切的新兴框架的推崇。
这些备受瞩目的技术也许确实值得称赞。但最好认识到新技术尚未证明其长期价值。最好在实验性场景中使用新技术,暂时将其排除在十年周期软件项目之外。根据Lindy效应,大部分事物的长期价值与其当前寿命成正比。
日志、遥测、性能
对于无法持续更新或部署给数百万用户的软件,你无法在发生故障之后立刻收到反馈。虽然有点奇怪,但即使在稳定性要求极高的场景下,信息也往往需要一点时间才能传达给能够修复故障的人。
Mastodon社区的反馈是要确保软件充分记录性能、故障和活动情况,从最初的版本就要这么做。多年后在解决那些几个月才运行一次软件的环境中发生的罕见故障时,这些数据是无价之宝。
有一次我不小心向客户发布了一个界面,展示一份简短列表。用户反馈系统无法正常工作,但他们没有告诉我,那里有3000个文件夹。如果有全面的(性能)日志和遥测数据,我就不用遭受好几个月的痛苦了。
文档
人人都知道要多写文档,这不是什么新鲜事。不过我收到的反馈明确指出,一些特定内容需要记录下来。要生成外观吸引人的API文档非常容易,但这些文档并不会告诉你为什么系统设计成这个样子。系统工作方式背后的思想是什么?遵循哪些理念?特殊设计是否事出有因?方案为何按照这种方式分解?
这些内容远远超过架构文档的范围。除了架构文档之外,可以考虑建立(内部)开发者博客,或进行团队访问。聊一聊设计演化背后的驱动因素。
七年之后,当团队成员大多是新人时,这些文档中的知识是无价之宝。“是的,系统是单线程的,我来告诉你为什么”。
具体来说,我得到的反馈是,尽管有人觉得风格良好的代码无需注释,但代码肯定是需要注释的。尤其是说明设计理由的注释。其他反馈还包括改进提交信息,这一点我非常支持,但同时也要确保人人都能轻松查看提交信息。
就个人而言,有时我会感到精神状态不适合编写大量代码,但我完全可以利用这些时间编写有用的注释和笔记。为团队成员安排这样的时间是很好的做法。
团队
一些反馈来自那些为长达80年(!)的任务提供支持的软件团队。
如今团队成员快速更替已经成为普遍现象。在许多地方,待满3年就算老员工了。虽然可以通过完善的文档和全面的测试应对人员流动,这是项艰苦工作。要让软件长期持续成功,最简单的方法就是让团队成员长期留任。聘请程序员作为正式员工,好好照顾他们。听起很反常,对吧?
有些地方,软件开发工作由顾问执行,他们在系统里留下代码然后离开。如果你追求的是在长达十数年间软件质量保持稳定,那么在大多数情况下,这是一个非常糟糕的做法。
开放源代码
这未必是你能够时刻或经常做到的。将代码暴露在外人的目光下,是保持实事求是的好办法。一个有趣的事情:公司或政府常常宣称需要数月或数年时间为开放源代码进行准备(清理)。同那些愿意与外界分享的代码相比,在内部人们往往对糟糕的代码更宽容。开放的源代码通常具有更高的标准,这是让你保持高标准的优秀机制。
当然前提是你能做到这一点。
依赖项(健康)检查
我在前面提到了,依赖项可能发生细微或突然的变化,偏离我们的预期。就算什么都不做,你也会通过缺陷、构建失败或其他非预期行为发现问题。Mastodon社区提出的建议是定期对依赖项进行健康检查。检查可能带来意外惊喜。比如某个依赖项添加了新功能,让你可以简化代码,或者移除另一个不再需要的依赖项。
花时间维护依赖项,可以在选定的时间内主动发现问题。否则就会像维修人员说的那样:要么主动安排维护,要么让设备替你安排。
参考资料
如前文提到的,这些经验都是前人早已深入探讨过的。下面是一些这方面的参考书籍:
- 《程序设计实践》 Brian W. Kernighan,Rob Pike。书中提到很多应当做到的,但在实践中往往被忽视的地方。尽管内容主要围绕C语言展开,蕴含的道理是通用的。
- 《人月神话》 Fred Brooks。被誉为“软件工程的圣经”,因为“所有人引用它,一些人阅读它,极少人践行它”。
- 《软件设计的哲学》 John Ousterhout。资深专业人士的现代解读。
- 《大规模C++软件开发》 John Lakos。这本书的内容大部分局限于传统C++的特定细节,因此我不会强烈推荐购买。然而,如果你偶然读到这本书,其中关于超大规模软件开发的故事生动地展现了实践技巧。书中对于循环依赖的根源和危害提供了宝贵见解。
Hacker News用户cpeterso、rramadass和cratermoon也推荐了以下内容(感谢他们的建议!):
- 《Kill It with Fire: Manage Aging Computer Systems (and Future Proof Modern Ones)》 Marianne Bellotti。作者任职于美国数字服务部门(USDS),负责大型遗留系统维护和现代化改造工作。
- 《软件演化规律(修订版)》 M M Lehman。
- 《对大型程序生命周期中的规律、演化与守恒性的理解》 M M Lehman。
- 《程序、生命周期和软件演化规则》 M M Lehman。
- 《软件演化的度量和规则:九十年代观点》 M M Lehman。
- 《对一个长期存续自由开源软件项目中软件演化规律的研究》 Jesus M Gonzalez-Barahona, Gregorio Robles, Israel Herraiz, Felipe Ortega。
总结
以下是对长期软件开发的强烈建议:
- 保持简单。甚至简单。是的,要更简单。如果需要,你可以随时增加复杂性!
- 保持简单需要定期重构和删除失效代码。
- 再三再三思考依赖项。越少越好。仔细审查和审计。如果你发现无法深入了解数千个依赖项,重新思考方案。不要被网络上的流行趋势迷惑。
- 定期检查依赖项,看看它们的状态如何。
- 测试、测试、测试!这将帮助你及时发现依赖项变化,给你信心进行重构,保持代码简洁。
- 解释一切,不仅仅是代码,还包括设计哲学、理念和“为什么”。
- 致力建设稳定团队。招募对项目长期投入的员工。
- 如果可能,考虑开源。
- 日志和(性能)遥测将在多年以后拯救你。
这些建议并不新鲜,但多位资深开发者热切的叮嘱要认真考虑这些建议,值得我们重视。
最后,我要感谢众多Mastodon用户分享了他们多年的经验。你们的帮助非常宝贵!此外,荷兰选举委员会的同事们提出了精彩的问题和反思,大大地丰富本文的内容。
如果你有进一步的反馈或不同意见,请发邮件到bert@hubertnet.nl告诉我!