日拱一卒,元编程不是元宇宙,麻省理工教你makefile、依赖管理和CI

2022-09-21 11:03:33 浏览数 (1)

作者 | 梁唐

出品 | 公众号:Coder梁(ID:Coder_LT)

大家好,日拱一卒,我是梁唐。

今天我们继续麻省理工missing smester,消失的学期的学习。这一节课的内容关于元编程。

B站视频链接:https://www.bilibili.com/video/BV1x7411H7wa?p=7

元编程(metaprogramming)并不是一个新鲜的概念或者是什么技术,这节课的老师用它来泛指一些和编程的流程相关的内容。比如构建系统、代码测试和依赖管理。

这些东西和git有点相似,在我们单打独斗的时候,看起来无关紧要。但是当我们进入公司,参与到一些大型的项目当中的时候,这些东西随处可见。等到那个时候才发现一无所知就不好了,所以这节课会带领大家稍稍了解一下这个概念,为以后做准备。

有一点需要注意,在一些编程语言当中,元编程拥有另外的含义,用来指代操作程序的程序,比如Python中的元类等。这和这节课的内容是完全不同的。

日拱一卒,欢迎大家打卡一起学习。

构建系统

如果你用LaTeX写一篇论文,你会需要运行什么命令呢?是不是需要一个命令生成benchmark,一个命令生成图表,一个命令将图插入论文?

对于大多数项目而言,不论是否包含代码,一般都会有一个创建过程。你需要执行一系列操作来得到想要的结果。通常,这个过程包含许多步骤或者是很多分支。跑这个命令生成这个,跑那个命令生成那个,最终可能还要把多个结果进行合并。整个过程可能非常麻烦,好在有很多工具可以帮助我们解决这个问题。

这些工具通常叫做创建系统,种类很多,你可以根据你的任务、喜好的语言、项目大小进行选择。但根本上,这些工具大同小异,你需要定义依赖、创建目标以及创建规则。你告诉系统你需要得到的结果,工具会找到构建这些目标需要的依赖,并且根据规则进行创建。理想情况下,如果依赖没有变化,系统不会重新创建目标结果。

make是最常见的构建系统之一,它几乎内置在所有Unix系统当中。虽然也有短板,但对于小型项目来说,它非常完美。当你执行make的时候,它在当前目录寻找一个叫做Makefile的文件,其中包含所有的目标、依赖以及创建规则。让我们来看一个例子:

代码语言:javascript复制
paper.pdf: paper.tex plot-data.png
    pdflatex paper.tex

plot-%.png: %.dat plot.py
 ./plot.py -i $*.dat -o $@

文件中的内容都是规则:如何使用冒号右侧的文件创建冒号左侧的文件。换句话说,冒号左侧的是目标,右侧的是依赖。缩进的部分是一个用来从依赖创建目标的程序。在make当中,第一条指令同样表明了最终目标。如果你运行make的时候不带任何参数,它就是我们最终创建的结果。或者,你可以加上参数,比如make plot-data.png,它将会创建你给定的目标。

规则当中的%是一个模式,它将会匹配左右两侧相同的字符串。比如,如果目标是plot-foo.png,那么make将会查找foo.datplot.py这两个依赖。让我们在不提供任何依赖的情况下,运行一下查看一下结果:

代码语言:javascript复制
$ make
make: *** No rule to make target 'paper.tex', needed by 'paper.pdf'.  Stop.

make告诉我们为了创建paper.pdf,它需要paper.tex,但没有任何一条规则关于如何创建它,所以停止了。让我们试着修改一下:

代码语言:javascript复制
$ touch paper.tex
$ make
make: *** No rule to make target 'plot-data.png', needed by 'paper.pdf'.  Stop.

有意思的是,我们有创建plot-data.png的规则,但这是一条模式规则。因为创建plot-data.png的依赖文件data.dat不存在,所以make告诉我们,它无法创建。让我们再试着改一下:

代码语言:javascript复制
$ cat paper.tex
documentclass{article}
usepackage{graphicx}
begin{document}
includegraphics[scale=0.65]{plot-data.png}
end{document}
$ cat plot.py
#!/usr/bin/env python
import matplotlib
import matplotlib.pyplot as plt
import numpy as np
import argparse

parser = argparse.ArgumentParser()
parser.add_argument('-i', type=argparse.FileType('r'))
parser.add_argument('-o')
args = parser.parse_args()

data = np.loadtxt(args.i)
plt.plot(data[:, 0], data[:, 1])
plt.savefig(args.o)
$ cat data.dat
1 1
2 2
3 3
4 4
5 8

当我们执行make的时候会发生什么?

代码语言:javascript复制
$ make
./plot.py -i data.dat -o plot-data.png
pdflatex paper.tex
... lots of output ...

创建成功了!

如果再执行一次会怎么样?

代码语言:javascript复制
$ make
make: 'paper.pdf' is up to date.

答案是什么也没有发生,因为make检查了它所有的依赖,发现都没有更新,那么它也就不会重新创建paper.pdf。让我们试着修改一下paper.tex,重新make:

代码语言:javascript复制
$ vim paper.tex
$ make
pdflatex paper.tex
...

注意make没有重新执行plot.py,因为plot-data.png的依赖没有变化。

依赖管理

从宏观的角度来说,你的项目依赖的可能是其他人的项目。你可能依赖一些需要安装的程序(比如Python),系统包(比如openssl),或者是一些编程语言的库(比如matplotlib)。

大多数依赖可以通过某些仓库获得,这些仓库当中存储了大量的依赖,并且提供非常方便的安装机制。比如Ubuntu系统下的安装包仓库,你可以使用apt工具进行安装。Ruby的仓库RubyGems,以及python库PyPi。

由于这些库的安装方法往往大相径庭,所以我们不会过多地深入细节,或者是其中任何一个工具。我们只会讲述一些通用的术语,比如版本。大多数项目每次发布的时候会提供一个用数字表达的版本号。比如8.1.3或者是64.1.20192004,通常都是数字,有时也有例外。

版本号有很多用途,最重要的功能是确保程序可以正确运行。设想一下,比如我发布了我这个库的新版本,我改了其中一些函数的签名。其他人更新之后可能就会遇到问题,他们的编译和构建可能会失败,因为之前的函数名不存在了。

版本控制可以通过指定版本来解决这个问题。即使最新的库发生了变化,依赖它的项目仍然可以使用它过去的版本。

但这看起来不够理想,比如当我想要修改一个安全问题的时候,它不会影响任何接口(API),但所有使用这个旧版本的都需要升级,怎么样能确保这点呢?

这也是版本号包含多个部分的原因。每个部分的数字代表的含义往往各不相同,但也有一个常用的规范:https://semver.org/。在这个版号当中,通常写成:major.minor.patch。规则如下:

  • 如果新的版本没有改变 API,请将补丁号递增
  • 如果您添加了 API 并且该改动是向后兼容的,请将次版本号递增
  • 如果您修改了 API 但是它并不向后兼容,请将主版本号递增

这会带来很多好处,如果我的项目依赖你的项目,只要使用的主版本号是相同的就没有问题。次版本号不低于之前使用的版本即可。也就是说,如果我依赖你的1.3.7版本,我使用1.3.8,1.6.1或者是1.3.0都是可以的。2.2.4可能不行,因为主版本号增加了。

我们可以把Python当做是一个很好的例子,你可能已经注意到了Python2和Python3的代码并不完全兼容,这是因为它们的主版本号已经变了。同样,使用Python3.5编写的代码在Python3.7上是OK的,但在Python3.4上可能不行。

在使用依赖管理系统的时候,你可能会遇到锁文件(lock file)的概念。锁文件的定义很简单,它会列出你所依赖的每一个项目的具体的版本。通常,你需要显式执行升级程序才能升级你依赖的版本。

这样设计有很多原因,比如避免无意义的重编译、创建可复制的编译,或禁止自动升级到最新版本。还有一种极端依赖锁定叫做vendoring,它会把你依赖当中的所有代码和程序都复制到你的项目当中。这样,你就可以完全掌控所有依赖的变化,并且允许你引入你自己的修改,这同样意味着你需要显式地去拉取上游维护者的更新。

持续集成系统

当你工作在越来越膨胀的项目中时,你会发现当你创建变更的时候总有许多额外的工作。你需要上传一份新版本的文档,上传编译好的软件版本,发布代码到pypi,运行你的单元测试,以及等等这类的事情。

也许每次当有人向的GitHub仓库发送pull request时,你都希望它们的代码会被检查代码风格,以及运行一些基准测试?当这样的需求多了,那么是时候学习一下持续集成了。

持续集成也被缩写成CI(continuous intergration),是一种雨伞术语,意思是当代码变更时需要做的事。有很多公司提供了各种CI的工具,大部分都是开源和免费的。比较大的有Travis CI,Azure Pipelines以及GitHub Actions。它们工作起来都差不多:你在你的仓库当中创建一个文件,描述当仓库发生变更的时候需要做的事情。

最常见的是,当有人上传了代码之后,运行单元测试。当事件被触发了之后,CI提供方会启动一个或更多虚拟机,执行你指定的命令。它们通常会记录下来运行的结果。你可以进行一些设置让你注意到单元测试失败或者是通过的时候,或者是当测试通过的时候,你的仓库会获得一个徽标。

本节课的课程网页基于GitHub pages搭建,这就是一个很好的CI系统的例子。每次当master分支有了代码提交的时候,它都会运行Jekyll博客软件并且在GitHub的特定域名重新构建网页。这就让我们可以很方便地更新网站了,我们只需要在本地进行修改,然后使用git进行提交和push,CI就会做完剩下的事情。

测试简介

多数的大型软件都有“测试组件(test suite)”。您可能已经对测试的相关概念有所了解,但是我们觉得有些测试方法和测试术语还是应该再次提醒一下:

  • 测试组件:所有测试的统称
  • 单元测试:一种“微型测试”,用于对某个封装的特性进行测试
  • 集成测试:一种“宏观测试”,针对系统的某一大部分进行,测试其不同的特性或组件是否能协同工作
  • 回归测试:一种实现特定模式的测试,用于保证之前引起问题的 bug 不会再次出现
  • 模拟(Mocking): 使用一个假的实现来替换函数、模块或类型,屏蔽那些和测试不相关的内容。例如,您可能会“模拟网络连接” 或 “模拟硬盘”

练习

  1. 大多数的 makefiles 都提供了 一个名为 clean 的构建目标,这并不是说我们会生成一个名为clean的文件,而是我们可以使用它清理可以被make重新创建的文件。您可以理解为它的作用是“撤销”所有构建步骤。在上面的 makefile 中为paper.pdf实现一个clean 目标。您需要将构建目标设置为phony。可以使用 git ls-files 的子命令。其他一些有用的 make 构建目标可以访问这个网站:https://www.gnu.org/software/make/manual/html_node/Standard-Targets.html#Standard-Targets
  2. 指定版本要求的方法很多,让我们学习一下 Rust的构建系统的依赖管理:https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html。大多数的包管理仓库都支持类似的语法。对于每种语法(尖号、波浪号、通配符、比较、乘积),构建一种场景使其具有实际意义
  3. Git 可以作为一个简单的 CI 系统来使用,在任何 git 仓库中的 .git/hooks 目录中,您可以找到一些文件(当前处于未激活状态),它们的作用和脚本一样,当某些事件发生时便可以自动执行。请编写一个pre-commit (https://git-scm.com/docs/githooks#_pre_commit)钩子,它会在提交前执行 make paper.pdf并在出现构建失败的情况拒绝您的提交。这样做可以避免产生包含不可构建版本的提交信息
  4. 基于 GitHub Pages 创建任意一个可以自动发布的页面。添加一个GitHub Action 到该仓库,对仓库中的所有 shell 文件执行 shellcheck(方法之一);
  5. 构建属于您的 GitHub action,对仓库中所有的.md文件执行proselint 或 write-good,在您的仓库中开启这一功能,提交一个包含错误的文件看看该功能是否生效。

喜欢本文的话不要忘记三连~

0 人点赞