低维护成本的软件开发

目标

  • 没有技术债务。
  • 软件的设计是容易理解的,需要修改软件时,将很容易找到相应的代码。
  • 软件以可维护为最优先目标,开发者可以在忘记它的数年后回来继续改进它。
  • 软件无限接近完成状态,直到因环境变更而落后或新的需求出现为止都令人满意。
  • 可以自信地升级主版本相同的依赖项。

原则

实现低维护成本的软件开发的关键不在于少犯几个错误,而在于尽量把事情做对。诚然,这是相当有难度的,因为大部分软件项目是生长出来的,不可能一开始就做到完美。
此处总结了一些有助于实现低维护成本的软件开发的一般原则:
  • 摒弃所有非必要功能。
  • 停止使用生态环境不健康的语言和工具链。
  • 使用具有强表达力的语言,避免在编码过程中扭曲和偏离语言的常见模式。
  • 停止将宏用于任何可能产生代码坏味道的目的。
  • 只使用具有类型定义或类型标注的语言。
  • 尽可能使用经过时间检验的技术,因为这些技术有希望存在更长时间。
  • 全局性地思考问题,避免战术编程。
  • 积极重构代码,允许大量的代码行修改以降低软件熵。
  • 通过将复杂性转移给使用者来砍掉不那么必要的设计,因为人性化的设计往往是最难以维护的。现实中类似的例子已经有很多了。让我们考虑一下Markdown编辑器和所见即所得编辑器:前者需要用户付出(极少量的)学习成本,而后者几乎没有学习成本,然而,后者在可维护性上要比前者差得多:
    • Markdown编辑器更容易编写出结构清晰,人类可读的纯文本文件。所见即所得编辑器编写出的要么是难以迁移的内部文档模型,要么是完全陷入混乱的HTML。
    • Markdown文档在2050年还可以使用,Markdown编辑器往往是极简和可替代的。所见即所得编辑器更容易过时,维持向下兼容性更困难,对开发者来说也更难维护,供应商在2050年很可能已经不复存在。
  • 停止使用可能导致供应商锁定的大型软件框架。当一个软件框架正在适配一切环境,或是企图提供一站式解决方案时,它不再是软件框架,而是一颗定时炸弹。
  • 尽可能减少依赖低质量的软件包。这意味着对依赖进行代码质量审查,幸运的是,坏代码风格很容易识别,往往不需要真正理解软件包的工作原理就可以下判断。
  • 不为没有向下兼容性的平台编写软件。
  • 除非软件功能单一,否则应该尽力避免让软件跨平台。
  • 禁止使用云计算平台提供的专有服务。
  • 禁止使用不能锁定子依赖项版本的包管理器,禁止使用Git仓库作为软件包的来源。
  • 如非必要,不应迎合现存的标准和协议:
    • 大多数标准和协议是垃圾,其中大部分甚至没有参考价值,迎合它们只会拖累软件的发展。例如,你可能会不幸地遇到无法用这些标准和协议实现的需求,然后陷入进退两难的窘境。
    • 那些非常流行以至于无法被忽视的标准和协议,其流行度大多来自于它们的先发优势,以及向后兼容的保证。这使得它们在生产中的实现充斥着各种奇怪的边缘案例,收集和应付这些边缘案例会让人精疲力尽。
  • 关注工具链的抽象水平,停止使用难以迁移或升级的工具链。
  • 编写大量自动化集成测试,以提升依赖项升级时的安全感。
  • 软件运行所需的环境应当是专用的,隔离的,而不是与其他项目共享的。
  • 重视技术的自有化,尽可能剥离会受外部因素影响的依赖。

重新发明轮子

重新发明轮子是一种反模式,因为总是已经有一个或多个已经发明好的轮子等着你复用。然而,对已有轮子的复用经常引发依赖管理问题。
这是因为最简单的道路往往是技术债务最重的道路。甄别轮子够不够好是一项能力,背后需要经验的支持。糟糕的第三方软件是普遍性的,就连大多数有一定规模的热门开源软件也是一座座屎山:它们往往具有错误的抽象,糟糕的架构,可怕的API表面积。缺乏令人信服的测试,没有得到积极维护,代码质量差,文档和实际代码不一致或掩盖了重要的细节。太多的战术编码,太少的战略编码,为不存在的需求投入过多时间成本最终导致软件膨胀和腐烂。除非社区里有能够以战略视角控制架构,并且严格执行代码审查的积极维护者或终身仁慈独裁者,否则开源软件的参与模式天然会制造这种屎山,无论贡献者的水平有多高。闭源软件也会有同样的问题,只是闭源软件的不透明性掩盖了这些问题,被扫到地毯下面不代表糟糕的部分消失了。
为了从技术债务中解脱,有时你只能戴上NIH帽子。通过重新发明轮子,你可能会注意到,除了能让我们摆脱糟糕的第三方依赖以外,重新发明轮子还有一个鲜为人知的好处——有可能会发明出更好的轮子。
当然,重新发明轮子应该评估其经济性,有一些轮子你显然是不想,也不应该重新发明的,比如游戏引擎,编译器,集成开发环境,文本编辑器等。这些软件具有非常强的专业性,从外部侵入这些领域的顽固份子将耗费大量时间并且大多会以失败收场。

构建电池式依赖

一些编程语言和框架会声称自己包括电池(batteries included),这指的是它们内置了可以直接使用的高级功能。这些“电池”的本质只是相对稳定的,极力避免暴露底层细节的高级API,正确的抽象和测试使得它们很少出现破坏性变更。
“电池”的概念完全可以套用到依赖管理上,可以建立这样一种“电池式依赖”:这种依赖是实际使用依赖的项目与依赖项之间的中间层,它基于依赖项提供一套有序整洁的API,从而允许避免让业务代码直接使用那些野蛮人发明出来的带有巨型或巨量参数的函数或类。
由于你只关注这些依赖项中使用到部分,你可以在中间层编写自己的测试代码,以确保在依赖项版本更新时,其行为仍然符合预期。当未来依赖项失效时,你可以选择用旧版本的依赖项,更换依赖项,或是自己实现,之前编写的测试被用来保证重构时的有效性。这便是自己构建的电池和他人构建的电池之间的关键区别,你总是可以给自己的电池充电或扩容,而对于他人的电池,通常你只能看着它慢慢没电。
由于形式上的相似性,可能会混淆电池式依赖和经过封装的业务逻辑,两者的主要区别在于它们与依赖项之间的距离。电池式依赖通常距离依赖项很近,而业务逻辑通常距离依赖项很远。只有在少数场合,例如业务逻辑直接建立在相关依赖上的情况下,两者才趋于相同。
电池式依赖似乎是解决依赖管理问题的圣杯,但仍然有几个问题:
  1. 1.
    它毕竟是额外的中间层,当你需要新功能,或是进行破坏性变更时,都要比直接使用依赖项要麻烦些。
  2. 2.
    建立正确的抽象很难。在摸索正确抽象的道路上,有时不可避免会需要推倒重来。人们对未来的需求也经常缺乏预见性,因此一时正确的抽象也可能在未来显得不正确。人们害怕项目由于错误的抽象陷入反复修改的困境,所以宁可把自己卷进一个更大的漩涡里,至少在出错时怪别人总是更容易,很难反驳这种想法。

使用小型软件包

小型软件包是Unix哲学和语义化版本控制的共同体现。软件包的粒度越小,单个包的功能就越少,功能越少就越稳定,改变代码会打破的东西也就越少。
小型软件包有时会带来一些新的问题,例如软件包数量激增带来的管理问题。这类问题会很棘手,因为它们会随着日积月累变得越来越难以靠蛮力解决。这种趋势最后会逼出一些专门的解决方案,例如Facebook的codemod之类的东西,但使用这些东西仍然很麻烦。