我如何学会停止担心并保留过时的依赖项

依赖更新问题

绝大多数依赖更新问题的解决方案是从更新依赖项的间隔入手的,因为时间显然是最大的影响因素。

现代软件项目往往采取以下策略中的一项:

  1. 1.

    尽快更新,小步快跑式地升级依赖项,从一天至一周的范围里选取一个时间间隔更新依赖项。

  2. 2.

    为了避免依赖项升级难度增大,从半个月至半年里挑选一个间隔更新依赖项。

尽快更新策略的问题

“频繁更新,尽快更新”是现代软件对依赖管理问题的看法,然而遵守此策略的收益实在太低。

自动化测试的缺陷

尽快更新策略高度依赖于自动化测试,但自动化测试不能保证软件没有被破坏。

升级,总是有可能破坏东西,你升级的依赖项可能本身就测试不足,或写了足够多的测试但却仍然没有覆盖到某个边缘情况。你可能自认为你写了足够多的测试,实际却仍然出现问题,仅仅是因为新版本改变了某个不易发觉的细节行为,这种情况并不算少见。你的代码测试了功能,但测量过性能变化吗?新版本的依赖为每个操作增加了几十毫秒的延迟,这个问题没有被发现,直到正式上线。

难以或无法自动化测试的项目也很常见,覆盖不到的角落到处都是。视觉测试和端到端测试被普遍认为实施成本高昂,且经常假性失败,功能变更时可能又需要重写一遍,它们真的比手工测试划算吗?

最终,你盲目地发布了通过自动化测试但有缺陷的软件,等到你发现事情被搞砸时,会希望桌子上有一把手枪。

假性尽快更新

一些遵循尽快更新策略的项目并不会很快发布。这样的项目根本没有尽快更新的必要,把更新间隔拉长一点反而有助于依赖项解决自身的bug。

效率低下的假性工作

人们认为小步快跑式的更新能够降低升级时的摩擦,这可能是真的,但任何一点摩擦都会被项目数量以乘法方式放大。

如果被维护的只有寥寥几个软件,摩擦确实不大,尽管这些升级无法自动化,但重复几次显然不算是什么繁重的工作。如果被维护的软件有几十个,几百个,摩擦就会因量变产生质变,这些不能自动化完成的升级绝对是痛苦的。仅升级依赖还不是最糟的,若是项目之间存在着依赖关系,并且依赖项的新版本强迫你修改某个被广泛依赖的底层项目的接口,你就中大奖了。由于这种尽快更新的策略,你和你的团队可能每隔几周就得遭受一遍这种巨大的痛苦,并且潜意识会告诉你这些工作实际上是无意义的,因为升级不仅可能没有带来改善,还引入了潜在的破坏。

依赖合并机器人只是很酷

一些人会在项目里引入依赖合并机器人,因为这很酷,而且似乎减少了依赖更新的工作量。然而,自动化更新机器人并没有真正改善依赖更新问题,因为它们还没有聪明到自动修改软件的源代码以适配新版本。事实上,除了制造假性工作量以外,它们什么也不会。

从Github Copilot令人震惊的代码预测能力来看,也许未来有一天机器人也能在升级依赖方面大显神威,但至少现在还没有。

基于时间的依赖更新策略的问题

如果我们局限于时间这一因素来思考依赖更新问题,最后就只会得出一个答案:项目应该选择一个合理的依赖更新间隔,这个间隔既不太短,不至于让人疲于奔命,也不太长,不至于让升级困难重重。

我们知道尽快更新策略是用紧跟最新版本的方式来降低依赖升级的困难性,因为人们认为从上一个版本升级到最新版本的成本最低。尽管“从上一个版本升级到最新版本的成本最低”是如废话一般的正确认识,但指望通过跟上版本来降低升级困难性的思路从根本上是错误的。时间本身并不制造破坏,制造破坏的是主版本升级,当你需要从2.9版本升级到3.0版本时,你通常不会因为当前依赖版本是上一个版本而得到什么优待,尤其是当依赖项的主版本升级是一次大规模重写或范式变更的时候。

这就是为什么基于时间的依赖更新策略是盲目的,是为了更新而更新,因为它建立在一个错误的基础上,人们为跟上版本所付出的努力大多只是徒劳。这种策略本身还暗藏了一种完美主义倾向,那些注定偶尔出现的例外最终会向维护者增加不必要的精神压力。

依赖更新问题的本质

造成依赖更新十分困难的根本原因有两个,一个源自软件的主导者,一个源自依赖的主导者。

糟糕的软件设计引起的代码耦合

这可以归咎为软件设计者本身的懒惰,功力不足,钱没给够,软件的生命周期比预料得长,或者单纯的积重难返。重构这些软件几乎等同重写,需要占用极高的认知带宽,并且项目越大,牵连的人、事、物越多就越不可能实现。

21世纪还在运行的那些用COBOL语言编写的程序堪称是这方面最知名的“传说级”案例,你几乎不能指望在这些项目上看到回炉重造的希望。

重写式依赖更新

如果软件设计得足够好,你会发现重写式依赖更新会逐渐变成一种可行的选项。当软件的核心代码稳定,分层完善到足以低摩擦地替换掉其中的任一组件时,即使跨多个主版本的依赖项升级也不足为惧,因为升级充其量只是更换一个可拔插组件罢了。

对那些达到“已完成”状态的项目来说,重写式依赖更新是一种很好的更新方式,因为这些软件可能很多年才更新一次。基于时间的依赖更新策略对这些项目来说完全是浪费时间,因为软件在可见的未来没有新需求,不需要升级依赖项,由于缺乏攻击面,甚至连安全漏洞都不会有。

依赖项主导下的不稳定范式

依赖项范式变更造成的困难往往更严重,因为依赖项用户处于被动,而依赖项作者处于主动。用户需要被迫配合新范式来大幅改变现存的代码,因为旧范式最终会失去兼容性。

用户之所以使用这些依赖很大程度是因为别无选择,前端MVVM框架的发展是这方面的绝佳例子:在多年的试错之后,得出的最先进解决方案仍然有很大的改进空间,没人知道接下来朝哪个方向改进是正确的,整个社区的开发人员都像是在走一步看一步。谁知道3年后的主流GUI范式会不会是直接在显式状态图上创建的呢?

真正摆脱这种依赖问题是不可能的,因为你极不可能提出更好的方案。但至少开发人员应该有足够的眼光去识别解决方案的好坏,在一些明显缺乏持续性的依赖项上下注只是在把终将引爆的炸弹的爆破范围变得更大罢了。

这种依赖项的存在不应该成为实施尽快更新策略的借口,因为范式变更的时间点通常是明确的,项目根本不需要定期检查更新。社区的文档和生态环境的跟进会有一定的滞后性,因此过早更新的坏处会远大于好处。

解决方案

按需更新依赖

如果软件的耦合性问题被顺利解决,就可以开始只根据实际需要来更新依赖。这种更新方式不受需要维护的软件数量的限制,因为它只关注于当下需要修改的软件。

有很多种需要更新依赖的情况,有一些是积极的,比如编程语言的新功能取代了依赖,有一些则是消极的,比如依赖项的作者弃坑跑路了。无论如何,以按需更新依赖的策略推进的项目都有足够大的选择权,这本质上是一种通过改善软件架构争取到的自由。自由意味着你不因使用过时的依赖项而产生负罪感,不因盲目跟进最新版本而掉进未知的大坑。

重新发明轮子

重新发明轮子往往被视作是一种反模式,因为总是已经有一个或多个已经发明好的轮子等着你复用。然而,对已有轮子的复用经常引发依赖管理问题,几乎是万恶之源,为什么会这样?

最简单的道路往往是技术债务最重的道路。甄别轮子够不够好是一项能力,背后需要大量的经验和经历的支持,而大多数开发人员并不具备这样的能力。具备能力的人会发现,糟糕的第三方软件是普遍性的,甚至大多数有一定规模的热门开源软件也是一坨屎:错误的抽象,糟糕的架构,可怕的API表面积。缺乏令人信服的测试,没有得到积极维护,代码质量差,文档和实际代码不一致或掩盖了重要的细节。战术编码太多,战略编码太少,为不存在的需求投入过多时间成本最终导致软件膨胀和腐烂。除非社区里有能够以战略视角控制架构,并且严格执行代码审查的积极维护者或终身仁慈独裁者,否则开源软件的参与模式天然会制造狗屎,无论贡献者的水平有多高。闭源软件也会有同样的问题,只是闭源软件的不透明性掩盖了这些问题,而屎被扫到地毯下面不代表屎消失了。

你能忍受软件是由一堆垃圾拼凑起来的吗?为了从这一切混乱中解脱,有时你只能戴上NIH帽子。通过重新发明轮子,你可能会注意到,除了能让我们摆脱糟糕的第三方依赖以外,重新发明轮子还有一个鲜为人知的好处——有可能会发明出更好的轮子。

当然,重新发明轮子应该评估其经济性,有一些轮子你显然是不想,也不应该重新发明的,比如游戏引擎,编译器,集成开发环境,文本编辑器等。这些软件具有非常强的专业性,从外部侵入这些领域的顽固份子将耗费大量时间并且大多会以失败收场。

构建电池式依赖

现代软件几乎不可能脱离社区存在,没有社区,开发效率至少会下降一个数量级。对绝大多数软件项目来说,关键在于如何有效利用社区的资源,同时最大限度避免被社区绊倒。

一些编程语言和框架会声称自己包括电池(batteries included),这通常指的是它们内置了可以直接使用的高级功能。这些“电池”的本质只是相对稳定的,极力避免暴露底层细节的高级API,正确的抽象和测试使得它们很少出现破坏性变更。

“电池”的概念完全可以套用到依赖管理上,你可以建立这样一种“电池式依赖”:这种依赖是实际使用依赖的项目与依赖项之间的中间层,它基于依赖项提供一套有序整洁的API,从而允许你避免让业务代码直接使用那些野蛮人发明出来的带有巨型或巨量参数的函数或类。由于你只关注这些依赖项中使用到部分,你可以在中间层编写自己的测试代码,以确保在依赖项版本更新时,其行为仍然符合预期。当未来依赖项失效时,你可以选择用旧版本的依赖项,更换依赖项,或是自己实现,之前编写的测试被用来保证重构时的有效性。这便是自己构建的电池和他人构建的电池之间的关键区别,你总是可以给自己的电池充电或扩容,而对于他人的电池,通常你只能看着它慢慢没电。

由于形式上的相似性,可能会混淆电池式依赖和经过封装的业务逻辑,两者的主要区别在于它们与依赖项之间的距离。电池式依赖通常距离依赖项很近,而业务逻辑通常距离依赖项很远。只有在少数场合,例如业务逻辑直接建立在相关依赖上的情况下,两者才趋于相同。

电池式依赖似乎是解决依赖管理问题的不二法门,但仍然有几个问题:

  1. 1.

    它毕竟是额外的中间层,当你需要新功能,或是进行破坏性变更时,都要比直接使用依赖项麻烦得多。

  2. 2.

    建立正确的抽象很难。软件的层级越高,需求就越复杂,建立正确抽象的难度就越大。在摸索正确抽象的道路上,不可避免会需要推倒重来。人们对未来的需求也经常缺乏预见性,因此正确的抽象也可能在未来显得不正确,这种现象尽管比较少见,但确实存在。人们害怕项目会因为错误的抽象而陷入反复修改的困境,这正是依赖管理成本产生的源头。电池式依赖也因此成为了以一当十的程序员和普通程序员之间的分水岭:经验丰富的高手更擅长建立正确和有潜力的抽象,出现破坏性变更的次数更少,因此他们倾向于构建电池式依赖,而其他人则更愿意在项目里直接使用依赖。

  3. 3.

    并不适用于所有情况.

使用小型软件包

小型软件包本身是Unix哲学的一种体现。软件包的粒度越小,功能就越少,功能越少就越稳定,改变代码会打破的东西也就越少。

需要注意的是,并不是所有依赖都适合作为小型软件包发布或使用,显然有一些相互依赖的软件就是只适合作为一个大包存在。

避免过度模块化

教条式地遵守DRY原则很可能导致过度模块化,导致小型软件包越来越多。

依赖应该具有正确的抽象,错误的抽象会埋下问题的种子。如果你无法确定一个抽象是否正确,就不要急于让它成为一个独立的软件包,以免搞出更大的连锁反应。甚至有一些经验表明,恰到好处的重复有时比建立抽象更好,因为进行有针对性的修改成本更低。