聊一聊构建的抽象

开发 后端
不同编程语言编写的应用,在它运行的状态下,会有不同的运行机制,有的是以二进制的方式运行的,有运行在编程语言的虚拟机之上。而构建所做的事情呢,就是将那些我们写给人类看的代码,转换为机器/程序能看懂的代码。

[[341120]]

 最近,在研究 Gradle 和 Java 相关构建的实现,让我对不同编程语言的应用构建燃起了一点点的兴趣。

不同编程语言编写的应用,在它运行的状态下,会有不同的运行机制,有的是以二进制的方式运行的,有运行在编程语言的虚拟机之上。而构建所做的事情呢,就是将那些我们写给人类看的代码,转换为机器/程序能看懂的代码。所以,构建的本质就是翻译(~~复读机~~)。

PS:本文旨在尝试性的整理我所了解的构建知识。部分内容限于对某一些编程语言的理解有限,并非非常准确。如有偏颇之此,希望大家指正。

引子 1:从 Java 的编译说起

绝大多数程序员都是从 hello, world! 开始自己复制、粘贴的人生生涯。对于那些刚上手 Java 的程序员也是类似的:

  1. javac HelloWorld.java 

而当我们依赖于其它的软件包时,就需要在编译时和运行时加入 classpath 来加入依赖项。于是,对应的运行命令就如下所示:

  1. java -classpath .:libs/joda-time-2.10.6.jar HelloWorld 

这样,我们就能得到预期的结果了:

  1. Hello, World 
  2. Millisecond timein.getMillis(): 1599284014762 

而如果我们需要打成 jar 包就需要一个复杂一点的过程:

  1. jar cvfm hello.jar manifest.txt HelloWorld.class libs/* 

这个过程中,涉及到几个关键的要素:

工具链。即 java 和 javac,以及对应的 Runtime 等。

构建过程。即我要先执行 javac 进行编译,再通过 java 命令来启动应用。

依赖管理。即我们的 joda-time-2.10.6.jar 的位置获取等问题,以及在打包时加入的过程。

源码配置。即转换过程中的 class 和 java

过程中的输入和输出。

引子 2:任务及任务的输入和输出

对于一个制品的构建来说,我们往往会把它拆分为一系列的任务,每个任务有自己的输入和输出。当输入发生变化的时候,需要变化对应的输出。紧接着,我们只需要对任务进行编排即可:

  1. exports.build = series( 
  2. clean, 
  3. parallel( 
  4. cssTranspile, 
  5. series(jsTranspile, jsBundle) 
  6. ), 
  7. parallel(cssMinify, jsMinify), 
  8. publish 
  9. ); 

如上展示的是:哪些任务可以并行,哪些任务需要按顺序执行——也可以认为是任务的依赖。

当然了,还有一种任务是 watch 任务,只用于开发时,而非构建时。如下是 Node.js 中的 Gulp 构建工具的文件监控示例:

  1. function javascript(cb) { 
  2. // body omitted 
  3. cb(); 
  4.  
  5. function scss(cb) { 
  6. // body omitted 
  7. cb(); 
  8.  
  9. watch('src/*.scss', scss); 
  10. watch('src/*.js', series(javascript)); 

两间结合之下,我们就会看到增量任务的概念:只针对修改的部分进行编译,以提升构建效率。在这方面做得比较好的就是 Gradle ,看个官方的示例InputChanges:

  1. abstract class IncrementalReverseTask extends DefaultTask { 
  2. @Incremental 
  3. @InputDirectory 
  4. abstract DirectoryProperty getInputDir() 
  5.  
  6. @OutputDirectory 
  7. abstract DirectoryProperty getOutputDir() 
  8.  
  9. @TaskAction 
  10. void execute(InputChanges inputChanges) { 
  11. inputChanges.getFileChanges(inputDir).each { change -> 
  12. if (change.fileType == FileType.DIRECTORY) return 
  13.  
  14. def targetFile = outputDir.file(change.normalizedPath).get().asFile 
  15. if (change.changeType == ChangeType.REMOVED) { 
  16. targetFile.delete() 
  17. else { 
  18. targetFile.text = change.file.text.reverse() 

同样的,它也需要我们监控对应的输入和输出。稍有不同的是,Gradle 会对文件进行索引,每次只提供变化的部分,让我们根据自己的实际需要进行处理。

增量构建相关资源:

  • tup 是用于 Linux、OSX 和 Windows 的基于文件的构建系统。它输入文件的更改列表和有向无环图(DAG),然后处理DAG 以执行更新依赖文件所需的适当命令。
  • ninja 是一个专注于速度的小型构建系统,类似于GNU Make。
  • SCons 是一套由Python 语言编写的开源构建系统,类似于GNU Make。

引子 3:可选的依赖管理(地狱)

关于依赖的管理槽点,我已经写过一系列的文章,诸如于:管理依赖的 11 个策略、依赖孪生:低成本的依赖安全方案。

单纯从构建这件事情上,对于依赖的管理是可有可无的。出现这个状况的主要原因是:历史上的编程语言都不考虑这个问题。所以,在古老的 C/C++ 语言中,构建系统就是一个头疼的问题。当然了,新晋的 Golang 也缺少良好的设计。

好在,对于依赖管理来说,这个过程并不复杂:

  1. 包命名和版本机制
  2. 包管理服务器
  3. 构建和运行时的依赖管理
  4. 包冲突处理
  5. ……

构建的抽象

好了,有了上面的这一系列基础知识之后,接下来我们就可以看看不同的构建系统里,对于同一概念的抽象,整合了 Bazel、Gradle、Cargo、NPM 等之后有了一个基础的抽象层次:

  • 工作空间(workspace)。工作空间是一个或者多个软件包的集成,它们可以共享依赖、输出目录配置等等。典型的有 Java 中的 Gradle settings.gradle、Rust 中的 Cargo 的 Cargo.toml 等。
  • 仓库。仓库可以映射到 Git 的 repository 中,代表一个可独立构建的软件。
  • 包。最小的可执行单位的项目结构。
  • 包布局。对应于不同的语言、构建系统来说,它用于定义代码的存放位置和结构。
  • 制品。即构建产生的产物,可能是可复用的软件包,也可能是可运行的应用。
  • 任务。定义构建的规则,并执行。

FAQ

为什么是没有项目?在业务领域和技术领域,我们对于项目的定义存在着一定的歧义性。为了减少二义性,我们使用工作空间 + 仓库来解决这个问题。工作空间可以视为一个完整的业务项目。而仓库呢,则是单一个的代码库,可能是一个库,也可能是包含库的完整工程。

现有的最佳方案是 Bazel。

工作区

工作空间是一个或者多个软件包的集成,它们可以共享依赖、输出目录配置等等。典型的有 Java 中的 Gradle settings.gradle、Rust 中的 Cargo 的 Cargo.toml 等。

我们可以将其视为最终的产物,如 Android 生成的 APK,Rust 最后生成的可执行文件。过程中,生成的共享的包都是为了支持这个工程的一部分。

先看 CMakeLists.txt 的目录,我们在工作区的根节点,定义了这个工程,并添加了 projectA 和 projectB。

  1. cmake_minimum_required(VERSION 3.2.2) 
  2. project(globalProject) 
  3.  
  4. add_subdirectory(projectA) 
  5. add_subdirectory(projectB) 

以用于生成最后的构建产物。相似的还有 Rust 中的 workspace:

  1. [workspace] 
  2.  
  3. members = [ 
  4. "adder"

又或者是前端的 Yarn 中的工作区:

  1. "private"true
  2. "workspaces": ["workspace-a""workspace-b"

它们做的都是相同的事情。

仓库

这个概念的再提取是来源于 Bazel。仓库是一系列包的合集,我们可以将其视为团队的边界,从某种意义上可以看作是代码仓库。对于一个庞大的工程来说,它的代码来源是多种多样的,来自组织内的其它团队,来自组织外的其它团队。每个独立的部分,即是一个仓库。

值得注意的是,从最终产物来看,每个团队的产出都是仓库,但是呢,在团队内部,他们就是工作区。

让我们看个 Gradle 的多项目构建示例(Android 工程):

  1. ├── README.md 
  2. ├── library_a 
  3. ├── app 
  4. │   ├── build.gradle 
  5. │   └── src 
  6. ├── build.gradle 
  7. ├── local.properties 
  8. ├── settings.gradle 
  9. └── third-partys 
  10. ├── ... 
  11. ├── build.gradle 
  12. └── settings.gradle 

从目录结构来看,这个是一个工作区,而在工作区呢,它包含了一些三方的代码仓库(third-partys),以及自身的库 library_a 和应用 app。

因此,在这里的 library_a 和 third-partys 的各个项目都算是仓库。

包是一系列代码的合集,它可大可小。最主要的原因在于,因为构建时,我们可能会把一个仓库(哪怕是最小的 Gradle 项目)产出多个包,如 Java 项目中的 src/main 和 src/test。

于是在诸如 bazel 这样的构建工具中,支持自定义的包:

  1. src/my/app/BUILD 
  2. src/my/app/app.cc 
  3. src/my/app/data/input.txt 
  4. src/my/app/tests/BUILD 
  5. src/my/app/tests/test.cc 

对于一个包来说,往往我们还需要定义一系列的相关信息,如包名、依赖信息、入口等等。如 Bazel 中对于 Java 构建的示例:

  1. java_binary( 
  2. name = "ProjectRunner"
  3. srcs = ["src/main/java/com/phodal/ProjectRunner.java"], 
  4. main_class = "com.phodal.ProjectRunner"
  5. deps = [":greeter"], 

这已经实现了对于不同包的信息抽象。顺带的再看个 Java 包中的 MANIFEST 的示例:

  1. Main-Class: HelloWorld 
  2. Class-Path: libs/joda-time-2.10.6.jar 

我们就可以知道之间的联系。

包定义

在打包阶段,我们以简单的形式定义了这个包——因为它并非那么重要,我们也不关心。而当我们决定发布这个包到互联网时,我们就需要好好定义这个包。对应的一些必要信息有:

  • name
  • version
  • authors
  • license
  • description
  • ……

这些信息用于在包管理中心展示,并向使用者提供包相关的信息等。不同的语言中使用的是不同的形式,Rust 使用了自定义的 toml,而诸如 Maven 仓库中则使用了 XML:

  1. <groupId>...</groupId> 
  2. <artifactId>...</artifactId> 
  3. <version>...</version> 
  4. <packaging>...</packaging> 
  5. <dependencies>...</dependencies> 
  6. <name>...</name
  7. <description>...</description> 

类似的在 NPM 的 package.json 中也使用了类似的字段: name、 verison 等信息。

而在这些编程语言中,这个东西就设计得过于简单了,如 Python 的 pip 中使用的 requirements.txt 来管理依赖,当你要发布包的时候使用 setup.py 进行配置。于是,你的应用如果不发布,那就没有包名了……。

包布局

构建工具在设计的时候,会设计默认的软件包分层结构,这个分层架构就是包布局(package layout)。构建工具通过这个布局,来获取所需的输入源和配置等信息。它也包含了一些默认的配置,如 src/main 指向了源码的目录, src/test 指向的是测试代码(不会加入到制品中)

  1. ├── build.gradle 
  2. └── src 
  3. ├── main 
  4. └── test 

对于使用者来说,它们也可以针对于它们的需要扩展这个布局,如 Gradle 里的 SourceSets:

  1. sourceSets { 
  2. main { 
  3. output.resourcesDir = file('out/bin'
  4. java.outputDir = file('out/bin'

对于其它语言也是类似的。但是呢,对于某些语言来说,并非有这么强的关联,如在 Golang 中,就没有这么强的约束。只是呢,原先是默认值,现在需要开发人员来手动配置。

制品

制品是最终的构建产物。同样的,在不同的语言中有不同的命名方式。在 Gradle 中称为 artifacts,在 Rust 中称为 targets……。制品,主要涉及到的是各种文件的流转及其流转规则。

举个简单的例子,一个 jar 文件中必须包含一个 MANIFEST.MF,以用于配置应用程序、扩展和类装载器等相关信息。而相关的文件又会以 META-INF 的方式组织起来。

因此在整个制品的创建过程中,就是复制对应的文件,进行相应的转换,如 java -> .class,再复制到对应的目录,最后再打包在一起的过程。

任务:规则引擎 + DSL

在上述我们看到的例子中,很多就是创建了自身的 DSL,而后用于构建。只有这样才能让使用者得到最大的方便。这是一个相当复杂的过程,它相当于我们要设计一个和平台、语言无关的 DSL。而这种演变方式有多种:

使用 API 抽象的内部 DSL。诸如于 Webpack、Gulp 等实现。

自制的外部 DSL 语言。如 Gradle 所使用的 Groovy、多语言的 Bazel。

规则引擎本身是一组关于任务的 DSL,看个 Gradle 的例子:

  1. task copyReportsDirForArchiving2(type: Copy) { 
  2. from("$buildDir") { 
  3. include "reports/**" 
  4. into "$buildDir/toArchive" 

它所做的事情就是复制。对应的 Gradle 打包示例也是蛮简单的 DSL 抽象:

  1. task packageDistribution(type: Zip) { 
  2. archiveFileName = "my-distribution.zip" 
  3. destinationDirectory = file("$buildDir/dist"
  4.  
  5. from "$buildDir/toArchive" 

Gradle 使用的就是外部 DSL。再看看 Webpack 的打包示例:

  1. module.exports = { 
  2. entry: './path/to/my/entry/file.js'
  3. output: { 
  4. filename: 'my-first-webpack.bundle.js'
  5. path: path.resolve(__dirname, 'dist'
  6. }, 
  7. module: { 
  8. rules: [ 
  9. test: /\.(js|jsx)$/, 
  10. use: 'babel-loader' 
  11. }, 
  12. plugins: [ 
  13. new webpack.ProgressPlugin(), 
  14. new HtmlWebpackPlugin({template: './src/index.html'}) 
  15. }; 

这里的 rules 就是一个简单的规则引擎(使用正则表达式来匹配)

两种模式各自有自己的优缺点,复杂场景下,使用 DSL + 自定义的脚本更容易完成。

PS:看来有空,我也应该写一个的规则引擎

构建的扩展

对于主流的构建系统来说,他们都支持不同形式的扩展支持:

  1. 外部 DSL 扩展
  2. 插件化的接口编程
  3. 项目内编程语言扩展
  4. 项目外编程语言扩展

大部分的东西,我们已经在文中的先前部分提到了,这里就不重复描述了。

结论

应用的构建是一个相当有意思的过程。

设计一个构建系统也变得颇为有趣的。

参考资料:

  • Gradle vs Bazel for JVM Projects
  • Bazel: Concepts and terminology
  • Yarn: Workspaces
  • Gradle: Authoring Multi-Project Builds
  • Cargo: Workspaces
  • Gulp: Tasks

相关目的开源库:

  • lerna A tool for managing JavaScript projects with multiple packages.
  • bazel
  • Blueprint is a meta-build system that reads in Blueprints files that describe modules that need to be built, and produces a Ninja manifest describing the commands that need to be run and their dependencies.

本文转载自微信公众号「phodal」,可以通过以下二维码关注。转载本文请联系phodal公众号。

 

责任编辑:武晓燕 来源: Phodal
相关推荐

2021-11-24 22:47:07

Docker开发容器

2023-07-06 13:56:14

微软Skype

2023-09-22 17:36:37

2021-01-28 22:31:33

分组密码算法

2020-05-22 08:16:07

PONGPONXG-PON

2021-08-01 09:55:57

Netty时间轮中间件

2023-09-27 16:39:38

2018-06-07 13:17:12

契约测试单元测试API测试

2021-03-01 18:37:15

MySQL存储数据

2021-12-06 09:43:01

链表节点函数

2023-09-20 23:01:03

Twitter算法

2021-07-16 11:48:26

模型 .NET微软

2022-08-08 08:25:21

Javajar 文件

2021-01-29 08:32:21

数据结构数组

2021-08-04 09:32:05

Typescript 技巧Partial

2023-05-15 08:38:58

模板方法模式

2021-02-06 08:34:49

函数memoize文档

2018-11-29 09:13:47

CPU中断控制器

2019-02-13 14:15:59

Linux版本Fedora

2020-10-15 06:56:51

MySQL排序
点赞
收藏

51CTO技术栈公众号