meta programming
meta programming: programs that operate on programs.
Build systems
对于大多数项目,无论是否包含代码,通常都会有一个build process。也就是说,你需要执行一系列操作,才能从输入得到输出。这个过程往往包含很多步骤,甚至有多个分支。比如,运行这个程序生成一张图,运行另一个程序生成某些结果,再运行别的东西来生成最终的论文。
当然,你并不是第一个遇到这种麻烦的人,我们已经有很多工具可以解决这个问题。这些工具通常被称为build systems,而且有很多种。你选择使用哪一种,取决于你当前的任务、偏好的编程语言,以及项目的规模。不过,它们在本质上都非常相似:你需要定义一系列依赖项(dependencies)、若干个目标(targets),以及从依赖项生成目标的规则(rules)。
你告诉build systems你想要生成某个目标,它的工作就是找到该目标所有的传递依赖,然后依次应用规则,生成中间目标,直到最终目标被build出来。理想情况下,build systems在处理过程中不会重复执行那些依赖没有发生变化、且已有build结果的目标对应的规则。
make
是最常见的build systems,几乎在任何基于 UNIX 的计算机上都可以找到它。虽然它有一些缺点,但对于简单到中等规模的项目来说,它运行得相当不错。当你运行 make
时,它会查找当前目录下一个名为 Makefile
的文件。所有的目标、它们的依赖项以及生成规则都定义在这个文件中。下面我们来看一个例子:
1
2
3
4
5
paper.pdf: paper.tex plot-data.png
pdflatex paper.tex
plot-%.png: %.dat plot.py
./plot.py -i $*.dat -o $@
这个文件中的每一条指令都是一条规则,说明如何使用右侧的内容来生成左侧的目标。换句话说,右边列出的内容是依赖项(dependencies),左边是目标(target)。缩进的代码块是一系列程序命令,用于根据这些依赖项生成目标。
在 make
中,第一条规则还定义了默认目标。如果你在命令行中直接运行 make
而不带任何参数,它会尝试build这个默认目标。当然,你也可以运行类似 make plot-data.png
的命令来build特定的目标。
规则中的 %
是一个模式符(wildcard),它会在左边和右边匹配相同的字符串。例如,如果请求build目标 plot-foo.png
,make
就会去查找依赖项 foo.dat
和 plot.py
。
只要我们确保目录下存在所有的依赖项(paper.tex, data.dat, plot.py),运行make
命令之后,我们就能得到想要的输出paper.pdf
。
一次build完成之后,如果我们不做任何修改,在此运行make
命令,会发现该命令什么也没有执行,因为make
检测到没有任何依赖项发生了更改,所以没有必要再重新build一次。
类似的,如果我们修改了paper.tex文件,运行make
时,它只会build改动的部分,而不会重新生成plot-data.png。这种方式使得make
在管理大型项目时能够大大提高build速度。
Dependency management
从更宏观的层面来看,你的软件项目很可能还依赖于其他项目本身。你可能依赖于已经安装的程序(比如 Python)、系统软件包(比如 OpenSSL),或者某种编程语言的库(比如 matplotlib)。如今,大多数依赖项都可以通过一个集中托管大量依赖的仓库获取,而且这些仓库还提供了方便的安装机制。例如:
- Ubuntu 的系统软件包可以通过 Ubuntu 的软件仓库获取,使用的工具是
apt
; - Ruby 的库通过 RubyGems 分发;
- Python 的库通过 PyPI 分发;
大多数项目在每次发布时都会发布一个版本号,通常是类似 8.1.3
,版本号的作用有很多,其中最重要的一个作用是:确保软件能够持续正常运行。
举个例子,假设我发布了一个新版本的库,在这个版本中我重命名了一个函数。如果有其他软件依赖我的库,在我发布更新后,它们尝试build时可能会失败,因为它们调用了一个已经不存在的函数!版本控制就是为了解决这个问题:项目可以声明它依赖于某个特定版本,或者某个版本范围的另一个项目。这样即使底层库更新了,依赖它的软件仍然可以使用旧版本来保持正常运行。
不过这也不是最理想的情况!如果我发布了一个安全更新,但并没有改动库的对外接口(API),那么所有原本依赖旧版本的项目其实都应该立刻更新使用这个新版本。这时候,版本号中的不同部分就派上了用场。
semantic versioning
虽然不同项目对版本号的定义有所不同,但有一个相对常见的标准叫做语义化版本控制(semantic versioning)。根据语义化版本控制,一个版本号通常由三部分组成:major.minor.patch
。其基本规则如下:
- 如果一个新版本的发布没有改变 API,那就只需要增加补丁号(patch version)。
- 如果以向后兼容的方式新增了 API,就增加次版本号(minor version)。
- 如果以不向后兼容的方式更改了 API,就需要增加主版本号(major version)。
我们可以在 Python 的版本号中看到语义化版本控制的例子。很多人都知道,Python 2 和 Python 3 的代码不太兼容,这就是主版本号升级的典型原因。而为 Python 3.5 编写的代码通常可以在 Python 3.7 上运行良好,但可能无法在 3.4 上运行。
lock files
在使用依赖管理系统时,你可能还会遇到 锁定文件(lock files) 的概念。锁定文件其实就是一个列出当前每个依赖项的精确版本号的文件。通常,你需要显式地运行某个更新程序,才能升级依赖项的版本。这样做的原因有很多,例如:
- 避免不必要的重新编译;
- 实现可复现的build(每次build都产生相同的结果);
- 防止自动升级到可能有问题的最新版。
极端的依赖锁定方式叫做 vendoring(捆绑依赖),也就是你把所有依赖项的代码直接复制到自己的项目中。这样你就对依赖拥有了完全的控制权限,可以自行修改它们的代码,但缺点是:你以后需要手动从上游维护者那里拉取更新。
持续集成 CI
CI is basically a cloud build system.
当你开始参与越来越大的项目时,你会发现每次修改代码时,往往还需要完成很多额外的任务。你可能需要上传新版文档,把编译好的版本发布到某个地方,把代码发布到 PyPI,运行测试套件,或者完成其他各种事情。也许你希望每当有人在 GitHub 上提交 Pull Request 时,他们的代码能被自动检查格式、运行性能测试?这就是持续集成(Continuous Integration)可以发挥作用的地方。
持续集成是一个总称,指的是“每当代码发生变化时就自动运行的一些操作”。目前有很多公司提供不同形式的 CI 服务,比较有名的有 Travis CI、Azure Pipelines 和 GitHub Actions。
这些 CI 工具的工作方式大致相同:你在代码仓库里添加一个配置文件,描述在特定事件发生时应该执行哪些操作。其中最常见的规则就是:当有人推送(push)代码时,自动运行测试套件(test suite)。当事件发生后,CI 平台会启动一个或多个虚拟机,执行你在配置中指定的命令,然后记录执行结果。你可以设置为:如果测试失败就给你发通知,或者在代码仓库上显示一个小徽章,表示测试是否通过。
举个例子:missing-semester课程网站使用的是 GitHub Pages,它其实就是一种 CI 行为。每次向 master 分支推送代码时,它会自动运行 Jekyll 博客生成器,并将生成的网站托管在 GitHub 的域名下。这大大简化了我们更新网站的流程:我们只需要在本地修改内容、用 Git 提交并推送,CI 就会自动完成部署。
测试套件(test suite)
大多数大型软件项目都会配有一个测试套件(test suite)。你可能已经对测试的概念有所了解,这里我们简单介绍几种常见的测试方式和术语:
- Test suite(测试套件):所有测试的集合。
- Unit test(单元测试):对某个特定功能进行的微观测试,通常是孤立地测试某个函数或模块。
- Integration test(集成测试):对系统中多个部分协同工作的测试,检查不同功能是否能一起正常运行。
- Regression test(回归测试):专门针对曾经出现过 Bug 的场景写的测试,用来确保这个 Bug 不会再次出现。
- Mocking(模拟):用假的实现替换某个函数、模块或类型,以避免测试过程涉及无关的部分。例如你可以“模拟网络请求”或“模拟磁盘操作”,从而专注于当前测试的逻辑本身。