程序员构建总是出问题,怎么办?
程序员文章站
2022-07-12 11:01:09
...
构建这一问题,到底是哪个环节出了 Bug?
我总是听到程序员谈论构建的问题:“构建出错了”,“我把构建搞坏了”等等。然而,真正的问题在于构建这个概念本身就有问题。每次修改应用程序都需要从头重新构建的想法从根本上就有缺陷。
这个概念的实际问题在于,构建会导致开发过程中的反馈循环漫长而痛苦。有些系统的应对手段是通过极快的速度,他们的观点是,哪怕你每次进行修改都编译整个系统,也不是问题,因为在你反应过来之前构建就会结束。
问题在于,系统的规模会一天天扩大,终有一天这个法子再也行不通。
更深层次的问题是,你会发现每次修改代码后,即使立即重新构建,还是需要重新启动应用程序,而且每当你发现一个问题、修改代码、经过重新编译后,问题还是没有消失。换句话说,构建与实时编程是对立的。构建的反馈循环永远都那么漫长。
从根本上讲,每次修改代码的时候,没必要重新编译整个系统。就好像你想换个灯泡也没必要把整座摩天大楼都拆了重建吧。
无论怎么优化,构建都无法真正成为实时。
因此,诸如make之类的工具永远也无法解决问题。而且这些工具本身还有许多其他问题。例如,我们必须复制代码中嵌入的依赖项信息:import、include、use或 extern 声明之类的语句为我们提供的文件和模块信息与我们手动输入并无二样。这些复制工作乏味且容易出错。而且这种复制太粗糙,粒度仅限于文件。编译器可以更精确地管理这些依赖关系,例如跟踪何处使用了哪些函数。
注意:有些工具(如GN)可以由编译器提供依赖关系。虽然仍然很粗糙。
另外,这些工具提供的语言所具备的抽象机制和工具支持都很差。一般大家对make的不满是其引入了其他类似性质的工具层,例如Cmake。
一个更好的解决方案是,为构建生成更好的DSL。基于某种真正的编程语言的内部DSL是改善问题的一种方法。例如 rake 和 scons,分别使用了 Ruby 和 Python。这些工具简化了构建工作,但它们仍与构建有着千丝万缕的联系,这才是我最关心的根本问题。
话虽如此,如果我们不打算使用传统的构建系统来管理我们的依赖项,那么应该怎么办?
首先,我们需要认识到我们的许多依赖关系不是基础的东西,例如可执行文件、共享库、目标文件和任意类型的二进制文件等。我们真正需要“构建”的只有源代码。毕竟,如果使用解释器,那么你只需创建最基本的源代码,然后逐步编辑和发展源代码。
使用解释器可以避免构建二进制文件的问题。
成本是性能。编译是一种优化,尽管是很重要的优化,通常是必不可少的。编译需要比解释器更全面的分析,而且还会执行预先计算,以避免我们在执行过程中重复工作。从某种意义上说,编译器的作用是记住解释器的部分工作。
许多动态JIT正是实现了这一点,但从根本上讲,静态编译也是如此——你只需提前记住即可。
从这个角度看,构建是分阶段执行的一种形式,而我们不断构建的二进制文件只是缓存。
我们可以通过结合解释与编译,解决解释器的性能难题。许多使用JIT编译器的系统正是这样做的。其优点之一在于,我们不必在启动应用程序之前等待优化。还有我们可以修改代码,并通过重新解释和重新优化立即反应修改的结果。
然而,并非所有的JIT都会这样做,但是这种做法已经延续了数十年,例如Smalltalk VM等。Smalltalk 有很多优点,其中之一便是你很少会遇到构建的麻烦。
然而,即使假设你的JIT引擎在发展的过程中对代码进行了增量优化,你与实时开发之间仍有障碍,这个障碍就是你仍然需要构建。
类型。如果你的代码由于类型错误而出现问题,该怎么办?再次重申,我们不需要通过构建来检测到这一点。增量式类型检查器能够在保存代码时发现问题。当然,以往我们很少使用增量式类型检查器。实时系统的开发历来采用动态类型语言并非巧合。但是,没有根本性的原因能够说明为什么我们不能使用静态类语言支持增量开发。这些技术可以追溯到Cecil。有一篇有关Scala.js的文章https://2016.splashcon.org/details/splash-2016-oopsla/36/Parallel-Incremental-Whole-Program-Optimizations-for-Scala-js详细讨论了使用静态类语言进行增量编译。
测试。很多时候,构建过程包含测试,而构建失败也是由于测试发现了应用程序中的逻辑错误。但是,测试本身并不是构建的一部分,也不必依赖于构建,毕竟构建只是更新应用程序的一种方法。在实时系统中,源代码会立即反映到更新后的应用程序中。在这种环境中,测试可以针对每次更新展开,但是开发人员无需等待测试完成。
资源。应用程序可以结合各种资源:媒体、文档、各种数据(源文件或二进制文件、表或机器学习模型等)。其中一些资源可能需要自己计算,例如利用TeX或markdown之类的文档生成PDF或HTML,而且很少包含实时或增量的阶段。
即便我们的资源可供随时使用,过度依赖文件系统结构也会引发问题。资源通常会以文件的形式呈现。部署的结构可能与源代码库不同。在源代码库中编辑组件不会改变构建结构。
纠正这些问题并非易事,通常软件工程师甚至都不愿意尝试。相反,他们会越来越依赖构建过程。这真的没有必要。
我们可以将资源视为缓存的对象,并根据需要生成它们。部署应用程序时,我们需要确保所有资源都经过了预先计算,而且缓存在应用程序的固定位置上,如果在开发过程中这个缓存丢失,那么应用程序还能将它们放回同一个位置。软件知道这些位置,因此它能够找出缓存在应用程序固定位置上的资源。
当通过应用程序逻辑访问资源时,上述推理过程就很合理。那么那些没有被应用程序使用,而是提供给用户使用的资源呢?在有些情况下,文档、示例代码以及附带的资源都属于这种情况。这类资源的处理不是应用程序本身的一部分,因此,它不是构建问题,而是部署问题。也就是说,部署只是将合适的对象放到既定的位置上。
处理多种语言。在处理多种语言时,由于某些语言不支持增量开发,所以我们可能会*使用构建系统。假设应用程序的核心是用实时语言编写的,那么我们应该将其他语言视为资源;它们的二进制文件是开发过程中动态计算并缓存的资源。
总结
构建和实时是针锋相对的。
编译结果是缓存资源的一种形式,它是分阶段执行的结果。
为了在工业环境中实现实时,我们需要通过构建开发环境,确保每个阶段都经过了严格的优化。
当底层的缓存值过期时,应自动缓存分段结果并无效化旧的缓存值。
无论分阶段保存的值是资源、共享库/二进制文件还是其他内容,这种方法统统适用。
对于计算缓存值和确定缓存有效性来说,必要的数据必须保存在应用程序的固定位置。
让我们建设一个全新、勇敢、没有构建的世界。
推荐阅读