前些日子,我读到关于 Linux 内核的一场争议(坦白说,我认为双方的处理方式都不尽妥当),由此引发了我的一个思考:很少有人真正理解长期维护庞大软件项目的艰辛。无论是非技术人员,还是初出茅庐的开发者,都难以体会其中的辛苦;而令人讶异的是,甚至那些能够编写出庞大复杂代码的资深程序员,也往往未必能深刻领会这一点。这种认识上的不足,正是那次 Linux 内核争论背后部分原因之一(尽管其中还涉及其他多重因素)。
这也解释了软件开发领域中常听到的一些评论,例如:
- “我周末就能写完这个!”(通常指对 Dropbox 这样重要产品的轻视)
- “只要集成<某个库>就行了——它能替你实现这个功能”
- “我这儿做了个原型,拿去直接集成到你们的产品里吧!”
- “我做了个很酷的插件,为什么不把它直接并入核心产品?”
- “为什么这个开源项目不接受我那一万行的代码补丁?我可是做了所有艰难的工作!”
现实是,如果你需要在长达数年的时间里维护一个庞大而复杂的软件项目,你会渐渐明白:实现一个功能的最初编码,仅仅是全部工作量的一小部分。因为随之而来的还有测试、排查并修复错误、性能优化、适应其他变化的升级、重构、客户支持、文档编写与不断更新,甚至可能还要彻底重写代码——所有这一切构成了后续长期维护的庞大工程。就我看来,除非你在职业生涯中曾负责维护一个至少 10 万行代码且不断演进的项目长达五年以上,否则很难体会到其中独有的挑战。
我们的经验
以我们为例,基于浏览器的游戏与动画工具 Construct 的代码量现已达到约 75 万行,而最初的代码正是在十年前奠定的基础。(事实上,这已是我们的第三代产品,此前还有 Construct 2 与 Construct Classic —— 我们大约始于 2007 年。)我估计,一个新功能最初代码的编写,大约只占该功能整体工作量的 25%。其余的 75%则是后续维护:包括测试、诊断与修复 Bug、性能调优、使功能适应其他改动、代码重构、客户支持、文档编写及后续修订,甚至有时需要彻底重写代码——而重写后的维护工作依然绵延不断。
放到更大范围来看,Construct 也并不算多么庞大——浏览器、如 Linux 这样的操作系统,甚至其他不少项目,其代码行数均以百万计。对于这些项目,新功能最初编码所占比例可能仅为 10%,甚至更低。
当你屡屡因一处小改动而不得不付出大量额外劳动,或是在计划一个令人激动的新升级时忽然发现某个功能成为了升级的巨大障碍,又或者不得不完全重写一个重大功能(还要处理引用了他人代码而产生的各种复杂问题)——这些情形我们都曾深切体会过——你就会对这种现实有更深的理解。许多人(包括资深开发者)往往把软件开发看成是单纯地编写所需代码,然后任务结束。也许在某些岗位上确实如此!但对于我们这样的项目来说,情况远非如此简单。
维护者的心声
当你真正明白这一切时,视角便会截然不同。外行人看到某人在开源项目中一次性提交了一万行新代码时,往往会觉得这是多么慷慨而又乐于助人的行为,从而对其表示尊重与配合。但那些肩负着维护整个代码库重任的资深开发者深知,这位“慷慨”的贡献者可能会突然消失得无影无踪——而长远来看,他们实际上把自己所写代码工作量的 4 到 10 倍,转嫁给了项目其他开发者。如果对方理所当然地决定不再承担这份后续责任,那么问题就变得棘手:如何礼貌而坚决地拒绝一个看似慷慨的人?在那次 Linux 内核争议中,这似乎正是内核开发者试图表达的一部分观点。(不过,他们使用了我认为极不委婉的语言,这似乎加剧了矛盾。)
建筑类比
软件本质上是一个高度抽象的事物,这使得我们很难形成直观的理解。为帮助大家从维护者的角度理解这种情况,我打了个比方,用建造房屋来类比。虽说任何比喻都有不尽完善之处,而且我本人并无建筑经验,但毕竟人人都对真实世界的物质事物有所感触,希望这能较好地传达我的观点。
志愿的建筑工人
假设你是一位经验丰富的建筑师,决心亲手打造一座坚固耐用、可使用数十年的新房。你精心挑选最优质的材料与工艺,以期建成一座经得起时间考验的房屋。
正当此时,一位刚刚踏入建筑行业的年轻亲戚向你提出建议:他愿意免费为你的房屋建造一处扩建部分,不但能让你出租赚取租金,他自己也能积累实战经验、丰富履历。大家纷纷称赞这是一份多么慷慨的好意,于是你欣然接受,让他动工建造扩建部分。
然而,随着工程进行,你渐渐发现,这位亲戚施工速度虽然很快,但所用的材料廉价、工艺简陋。你深知,要建造一座经久耐用的房屋必须精益求精,而他只做足及格线。虽然两部分之间需要进行复杂的管道、电线、暖气等系统的衔接,可毕竟对方是免费劳务,你只好将就。
工程竣工后,整体看来,两部分无论在水电、隔水、居住条件或符合规范上,都算得上“过得去”。你与亲戚相互击掌致意,对方则轻描淡写地说或许你还欠他点什么,随后便销声匿迹,投向了自己的未来。
维护难题接踵而至
快进十年。你的主屋依然坚固如初,然而那处扩建部分却问题频出:屋顶漏水、保温效果差导致暖气账单高企、电路频频跳闸,种种瑕疵不仅影响扩建区,也波及到主屋。尽管你靠出租赚得一笔租金,但租户们却不断抱怨这些故障。你不得不不断投入维修精力,勉力维持运营,但最终事实摆在眼前:这部分建筑明显难以长久维持。问题日益恶化,迫使你必须进行彻底的整修:屋顶需要重修、电路得重新布线,甚至墙体标准也该追上主屋的水准……但眼下租户的权益不容侵犯,不能轻易将他们迁出。长期以来,租户们对不断出现的维修问题怨声载道,这无疑令你压力重重。
维护者的噩梦
此时,你正处于维护者的噩梦之中。最简单的解决方案似乎是拆除扩建部分,弃之不顾;然而在软件领域,为了保持向后兼容,往往无法简单地舍弃已有功能。因此,在此比喻中,你又不得不顾及租户的居住需求。此时你面临一系列棘手选择:
- 持续不断地做临时修补,但你明白这样只会让问题愈演愈烈,耗费更多时间、金钱与精力,终究无济于事。
- 再次请求那位亲戚无偿出力,然而很可能他会以“我已经付出足够了”为由拒绝,或者早已物归原主,再也难以找到。
- 拆除重建扩建部分,但这需要你在施工期间另寻他处安置租户,成本大增。(在软件中,这可能对应于采用复杂的变通方案或编写专门的过渡代码。)
- 另起新建一处扩建区,将租户搬迁过去后再拆除旧有部分。虽是个不错的方案,但前提是你得有足够的空间,并确保新扩建部分既具备旧有功能又能做到更高质量。与此同时,旧区的种种问题仍旧挥之不去,必然使情况在短期内进一步恶化。(在软件上,这类似于开发全新的功能模块,再将所有用户迁移过去,且整个过程往往异常棘手。)
- 分阶段重建扩建部分,在租户继续居住的同时逐步改造。这虽然能避免搬迁之苦,但工地环境难免影响租户生活,并且由于每个阶段都要确保房屋始终可居住,因此工作复杂度和费用均大幅攀升,这往往成为最慢、最昂贵的方案——但在其他方案皆不可行的情况下,或许不得不如此。(在软件中,这对应于在确保向后兼容的前提下,逐步升级现有代码。)
无论你选择哪一种方案,经过漫长而昂贵的重建工程后,一个令人痛心的事实会浮现:你所投入的时间与金钱早已抵消了多年来的所有租金收入,甚至在未来数年内也难有回报。最终,你只能无奈地承认:如果当初不让那位亲戚帮忙建造扩建部分,或是干脆自己动手,情况或许会好得多——省下的钱与减少的麻烦,远比事后弥补来的轻松。
这就是你对外部贡献心生警觉的时刻。你渐渐明白:虽然别人建造了那部分结构,但最终长久的维护重担却落到了你的肩上,而这份负担远超最初施工的工作量,最终带来的麻烦与花销,足以让你宁愿从未接受过外援。
软件实例
回到软件领域,即便是在我们的 Construct 产品中,也曾多次遇到类似状况,尽管我们的产品并非开源。以下是一些真实案例:
- 社区插件的遗留问题:
Construct 2 曾采用一位社区成员贡献的存储插件。几年后,我们用自主研发的插件取而代之,但出于向后兼容的考虑,客户项目依然可以继续使用旧版插件。尽管这事发生在大约 10 年前,而此后我们又推出了全新的 Construct 3,客户依旧会因旧版社区插件产生兼容性问题。 - 外包开发带来的隐患:
曾有建议认为,通过外包给第三方开发者,可以更快地加入新功能。我们尝试了这种方式,为官方 Sprite Font 插件外包开发。然而,原开发者渐行渐远,当涉及到 Bug 修复与功能需求时,他面对另一位编码风格截然不同的开发者时,困难重重。最终,我们不得不为 Construct 3 重写所有插件,结果反复验证:最好还是使用我们自己完全理解的代码。 - 第三方库的长期问题:
有时,为实现某项功能,我们会引入第三方库。该库的开发者可能会持续维护约 5 年,然后便转身离去。十年后,我们依旧被迫面对该库的 Bug 修复与性能改进问题,不得不考虑是自行重写,还是迁移至另一库(而另一库未来也可能不再维护)。以 Construct 为例,我们至今已更换了四个用于压缩 JavaScript 代码的库,而每一次切换都伴随着痛苦且耗时的项目重构。 - 原型示范的误区:
有时有人仅用一两天时间做了一个原型或概念验证,就急于说服我们在 Construct 中实现该功能。然而,他们所展示的仅仅是冰山一角,而你知道真正的工作量远远超出眼前这点代码。 - 直接侵入代码的代价:
也有人利用开发工具或突破封装限制,直接将某个功能硬塞进代码库,然后质问为何不将其正式支持。殊不知,长期维护这种“临时方案”会引发一系列升级问题,而未来计划中的改动可能与之冲突,最终导致严重的向后兼容性危机。贡献者通常只关注眼前的功能能否顺利运行,而我们则必须为日后潜在的种种后果买单。
在开源项目中,这样的问题可能更为严峻——理论上,任何人都可以直接贡献大量代码,且许多项目还鼓励这种做法。我猜,如果对外来代码毫无筛选地全盘接受,整个项目不久便会陷入混乱,因此项目负责人必然会设定一定的提交要求。不过,关于这方面我并无太多开源经验,因此仅能旁观指出,这正是 Linux 内核争论中部分问题的症结所在。
结语
软件作为一种抽象的产物,往往难以直观把握其内在机理。我相信,在业界中,能连续 5 年以上维护一个庞大而不断演进的代码库的人并不多——正如我所说,有的开发者似乎能轻松写出大量复杂代码,但对后续维护所需的付出却缺乏足够的认识。长期软件维护,其实与维护一栋建筑颇为相似:无论是房屋还是代码,时间总会带来各种磨损,需要不断的维修、更换部分零件,甚至在某个时点可能需要彻底改造。尽管数字与二进制不会像物理材料那样自然衰退,但“软件腐烂”这一说法恰如其分地描述了未经维护的软件随着时间推移而不断恶化的状态,就好似有机体逐渐腐败一般。作为一名软件开发者,你往往得在长时间的磨炼中,亲眼见证代码腐朽、总结教训,并逐渐领悟长期维护的深刻道理。
我不禁想起 Robert C. Martin 关于编程的那句名言:
阅读代码所花时间远超书写代码的时间,比例大概在 10:1 以上。
这在一定程度上揭示了一个事实:在大型、长期的软件项目中,新功能最初的编码仅仅是冰山一角,而随之而来的维护工作才是主要部分,而这份重担,最终都落在了项目维护者的肩上。太多时候,一个提议采用某段代码,实际上是将大部分后续工作无形中转嫁给了别人,即便出发点再如何良好。试问,当你建议在某个软件项目中使用一段代码时——你是否有决心在十年后仍然亲自处理由此引发的所有问题?答案通常是否定的,而维护者却明白,这最终会落到他们头上。正因如此,Linux 内核开发者在面对贡献代码时,往往偏爱那些长期持续贡献的老面孔,而对新来者的代码则持极度谨慎的态度。这种做法虽然可能使社区看起来过于严苛、难以接近,但却体现了长期维护一个项目所需的那份超乎寻常的责任感和承诺,而这正是现实中并非人人都能承担的重任,即便他们初衷再好。
希望通过这一比喻,我们能够以更现实的角度讨论软件改进的问题,更好地平衡在大型项目中提出与采纳改进建议时那错综复杂的人际与技术挑战——无论是在 Construct 还是 Linux 内核中,都是如此。
全文编译自:The reality of long-term software maintenance from the maintainer's perspective