课程结构
01.课程概览与 shell
02.Shell 工具和脚本
03.编辑器 (Vim)
04.数据整理
05.命令行环境
06.版本控制(Git)
07.调试及性能分析
08.元编程
09.安全和密码学
10.大杂烩
11.提问&回答
本文档修改自这里,补充了一些视频中展示但配套文档中未提供的代码,以及一些注释,另外,本节中涉及的相关文件可在百度云链接中获取。
元编程
我们这里说的 “元编程(metaprogramming)” 是什么意思呢?好吧,对于本文要介绍的这些内容,这是我们能够想到的最能概括它们的词。因为我们今天要讲的东西,更多是关于 流程 ,而不是写代码或更高效的工作。
本节课我们会学习构建系统、代码测试以及依赖管理。在您还是学生的时候,这些东西看上去似乎对您来说没那么重要,不过当您开始实习或走进社会的时候,您将会接触到大型的代码库,本节课讲授的这些东西也会变得随处可见。
必须要指出的是,“元编程” 也有用于操作程序的程序之含义,这和我们今天讲座所介绍的概念是完全不同的。
构建系统
如果您使用 LaTeX 来编写论文,您需要执行哪些命令才能编译出您想要的论文呢?执行基准测试、绘制图表然后将其插入论文的命令又有哪些?或者,如何编译本课程提供的代码并执行测试呢?
有很多工具可以帮助我们完成这些操作,这些工具通常被称为 “构建系统”。您需要定义依赖、目标和规则。您必须告诉构建系统您具体的构建目标,系统的任务则是找到构建这些目标所需要的依赖,并根据规则构建所需的中间产物,直到最终目标被构建出来。
make
是最常用的构建系统之一,您会发现它通常被安装到了几乎所有基于UNIX的系统中。make
并不完美,但是对于中小型项目来说,它已经足够好了。当您执行 make
时,它会去参考当前目录下名为 Makefile
的文件。所有构建目标、相关依赖和规则都需要在该文件中定义,它看上去是这样的:
~ $ vim makefile
~ $ cat makefile
paper.pdf: paper.tex plot-data.png
pdflatex paper.tex
plot-%.png: %.dat plot.py
./plot.py -i $*.dat -o $@
- 这个文件中的指令,即如何使用右侧文件构建左侧文件的规则
- 冒号左侧的是构建目标,冒号右侧的是构建它所需的依赖
- 缩进的部分是从依赖构建目标时需要用到的一段程序
- 规则中的
%
是一种模式,它会匹配其左右两侧相同的字符串- 如果目标是
plot-foo.png
,make
会去寻找foo.dat
和plot.py
作为依赖
- 如果目标是
在 make
中,第一条指令还指明了构建的目的,如果您使用不带参数的 make
,这便是我们最终的构建结果。或者,您可以使用这样的命令来构建其他目标:make plot-data.png
。
现在,让我们看看如果在一个空的源码目录中执行make
会发生什么?
$ make
make: *** No rule to make target 'paper.tex', needed by 'paper.pdf'. Stop.
make
会告诉我们,为了构建出paper.pdf
,它需要 paper.tex
,但是并没有一条规则能够告诉它如何构建该文件。让我们构建它吧!
$ touch paper.tex
$ make
make: *** No rule to make target 'plot-data.png', needed by 'paper.pdf'. Stop.
哟,有意思,我们是有构建 plot-data.png
的规则的,但是这是一条模式规则。因为源文件data.dat
并不存在,因此 make
就会告诉您它不能构建 plot-data.png
,让我们创建这些文件:
$ cat paper.tex
\documentclass{article}
\usepackage{graphicx}
\begin{document}
\includegraphics[scale=0.65]{plot-data.png}
\end{document}
$ cat plot.py
#!/usr/bin/env python3
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
时会发生什么?
~ $ sudo apt install texlive
# 安装后才能执行pdflatex指令
$ make
./plot.py -i data.dat -o plot-data.png
pdflatex paper.tex
... lots of output ...
看!PDF !
如果再次执行 make
会怎样?
$ make
make: 'paper.pdf' is up to date.
可以看到:目标的依赖没有发生改动,并且我们可以从之前的构建中复用这些依赖,那么与其相关的构建规则并不会被执行。
让我们试试修改 paper.tex
再重新执行 make
:
$ vim paper.tex
# 例如,在begin行下面插入一行:Hello
$ make
pdflatex paper.tex
...
注意 make
并没有重新构建 plot.py
,因为没必要;plot-data.png
的所有依赖都没有发生改变。
依赖管理
就您的项目来说,它的依赖可能本身也是其他的项目。您也许会依赖某些程序(例如 python
)、系统包 (例如 openssl
)或相关编程语言的库(例如 matplotlib
)。 现在,大多数的依赖可以通过某些软件仓库来获取,这些仓库会在一个地方托管大量的依赖,我们则可以通过一套非常简单的机制来安装依赖。
例如 Ubuntu 系统下面有Ubuntu软件包仓库,您可以通过apt
这个工具来访问, RubyGems 则包含了 Ruby 的相关库,PyPi 包含了 Python 库, Arch Linux 用户贡献的库则可以在 Arch User Repository 中找到。
由于每个仓库、每种工具的运行机制都不太一样,因此我们并不会在本节课深入讲解具体的细节。我们会介绍一些通用的术语,例如版本控制。大多数被其他项目所依赖的项目都会在每次发布新版本时创建一个版本号(如8.1.3 或 64.1.20192004;一般是数字构成的,但也并不绝对)。
版本号有很多用途,其中最重要的作用是保证软件能够运行。我们可以指定当前项目需要基于某个版本,甚至某个范围内的版本,或是某些项目来构建。这么做的话,即使某个被依赖的库发生了变化,依赖它的软件可以基于其之前的版本进行构建。
这样还并不理想!如果我们发布了一项和安全相关的升级,它并 没有 影响到任何公开接口(API),但是处于安全的考虑,依赖它的项目都应该立即升级,那应该怎么做呢?一个相对比较常用的标准是语义版本号:主版本号.次版本号.补丁号。相关规则有:
- 如果新的版本没有改变 API,请将补丁号递增;
- 如果您添加了 API 并且该改动是向后兼容的,请将次版本号递增;
- 如果您修改了 API 但是它并不向后兼容,请将主版本号递增。
这么做有很多好处。现在如果我们的项目是基于您的项目构建的,那么只要最新版本的主版本号只要没变就是安全的 ,次版本号不低于之前我们使用的版本即可。我们可以将 Python 的版本号作为语义版本号的一个实例。您应该知道,Python 2 和 Python 3 的代码是不兼容的,这也是为什么 Python 的主版本号改变的原因。类似的,使用 Python 3.5 编写的代码在 3.7 上可以运行,但是在 3.4 上可能会不行。
使用依赖管理系统的时候,您可能会遇到锁文件(lock files) 这一概念。锁文件列出了您当前每个依赖所对应的具体版本号。通常,您需要执行升级程序才能更新依赖的版本。这么做的原因有很多,例如避免不必要的重新编译、创建可复现的软件版本或禁止自动升级到最新版本(可能会包含 bug)。还有一种极端的依赖锁定叫做 vendoring ,它会把您的依赖中的所有代码直接拷贝到您的项目中,这样您就能够完全掌控代码的任何修改,同时您也可以将自己的修改添加进去,不过这也意味着如果该依赖的维护者更新了某些代码,您也必须要自己去拉取这些更新。
持续集成系统
随着您接触到的项目规模越来越大,您会发现修改代码之后还有很多额外的工作要做。您可能需要上传一份新版本的文档、上传编译后的文件到某处、发布代码到 pypi,执行测试套件等等。或许您希望每次有人提交代码到 GitHub 的时候,他们的代码风格被检查过并执行过某些基准测试?如果您有这方面的需求,那么请花些时间了解一下持续集成。
持续集成,或者叫做 CI 是一种雨伞术语(umbrella term,涵盖了一组术语的术语),它指的是那些“当您的代码变动时,自动运行的东西”,市场上有很多提供各式各样 CI 工具的公司,这些工具大部分都是免费或开源的。比较大的有 Travis CI、Azure Pipelines 和 GitHub Actions。它们的工作原理都是类似的:您需要在代码仓库中添加一个文件,描述当前仓库发生任何修改时,应该如何应对。目前为止,最常见的规则是:如果有人提交代码,执行测试套件。当这个事件被触发时,CI 提供方会启动一个(或多个)虚拟机,执行您制定的规则,并且通常会记录下相关的执行结果。您可以进行某些设置,这样当测试套件失败时您能够收到通知或者当测试全部通过时,您的仓库主页会显示一个徽标。
本课程的网站基于 GitHub Pages 构建,这就是一个很好的例子。Pages 在每次master
有代码更新时,会执行 Jekyll 博客软件,然后使您的站点可以通过某个 GitHub 域名(html页面)来访问。对于我们来说这些事情太琐碎了,我现在我们只需要在本地进行修改,然后使用 git 提交代码,发布到远端。CI 会自动帮我们处理后续的事情。
测试简介
多数的大型软件都有“测试套件”。您可能已经对测试的相关概念有所了解,但是我们觉得有些测试方法和测试术语还是应该再次提醒一下:
- 测试套件:所有测试的统称。
- 单元测试:一种“微型测试”,用于对某个封装的特性进行测试。
- 集成测试:一种“宏观测试”,针对系统的某一大部分进行,测试其不同的特性或组件是否能 协同 工作。
- 回归测试:一种实现特定模式的测试,用于保证之前引起问题的 bug 不会再次出现。
- 模拟(Mocking): 使用一个假的实现来替换函数、模块或类型,屏蔽那些和测试不相关的内容。例如,您可能会“模拟网络连接” 或 “模拟硬盘”。
课后练习
-
大多数的 makefiles 都提供了 一个名为
clean
的构建目标,这并不是说我们会生成一个名为clean
的文件,而是我们可以使用它清理文件,让 make 重新构建。您可以理解为它的作用是“撤销”所有构建步骤。在上面的 makefile 中为paper.pdf
实现一个clean
目标。您需要将构建目标设置为make_phony。您也许会发现 git ls-files 子命令很有用。其他一些有用的 make 构建目标可以在make_standard_targets找到; -
指定版本要求的方法很多,让我们学习一下 Rust的构建系统的依赖管理。大多数的包管理仓库都支持类似的语法。对于每种语法(尖号、波浪号、通配符、比较、乘积),构建一种场景使其具有实际意义;
-
Git 可以作为一个简单的 CI 系统来使用,在任何 git 仓库中的
.git/hooks
目录中,您可以找到一些文件(当前处于未激活状态),它们的作用和脚本一样,当某些事件发生时便可以自动执行。请编写一个pre-commit 钩子,它会在提交前执行make paper.pdf
并在出现构建失败的情况拒绝您的提交。这样做可以避免产生包含不可构建版本的提交信息; -
基于 GitHub Pages 创建任意一个可以自动发布的页面。添加一个GitHub Action 到该仓库,对仓库中的所有 shell 文件执行
shellcheck
(方法之一); -
构建属于您的 GitHub action,对仓库中所有的
.md
文件执行proselint 或 write-good,在您的仓库中开启这一功能,提交一个包含错误的文件看看该功能是否生效。
习题解答
-
大多数的 makefiles 都提供了 一个名为
clean
的构建目标,这并不是说我们会生成一个名为clean
的文件,而是我们可以使用它清理文件,让 make 重新构建。您可以理解为它的作用是“撤销”所有构建步骤。在上面的 makefile 中为paper.pdf
实现一个clean
目标。您需要将构建目标设置为make_phony。您也许会发现 git ls-files 子命令很有用。其他一些有用的 make 构建目标可以在make_standard_targets找到;~ $ mkdir tmp; cd tmp # 将上文的几个文件复制进来 ~/tmp $ ls data.dat makefile paper.tex plot.py ~/tmp $ make ~/tmp $ ls data.dat paper.aux paper.pdf plot-data.png makefile paper.log paper.tex plot.py
- 除了想要生成的paper.pdf与plot-data.png,还产生了文件:paper.aux和paper.log。根据题目要求,生成的
clean
文件,将生成的这些文件都清理掉。~/tmp $ vim makefile ~/tmp $ cat makefile paper.pdf: paper.tex plot-data.png pdflatex paper.tex plot-%.png: %.dat plot.py ./plot.py -i $*.dat -o $@ .phony: clean clean: rm *.aux *.pdf *.png *.log ~/tmp $ make ~/tmp $ make clean ~/tmp $ ls data.dat makefile paper.tex plot.py
- 下面修改makefile的clean部分,实现对git仓库的整理
~/tmp $ vim makefile ~/tmp $ cat makefile paper.pdf: paper.tex plot-data.png pdflatex paper.tex plot-%.png: %.dat plot.py ./plot.py -i $*.dat -o $@ .phony: clean clean: mkdir -p Untrack # 创建Untrack目录,若已存在,忽略本命令 rm -f *~ .*~ # -f选项,忽略不存在的文件 git ls-files -o | grep -v Untrack | xargs -r mv -u -t Untrack # xargs的-r选项:忽略空结果,否则传递给mv一个空结果会报错 # -u选项:当待移动文件比目标文件新或目标文件不存在时,才执行移动操作 # -t选项,交换mv的目标路径和待移动文件的位置 # 将本仓库中所有Untracked文件移动到Untrack目录中 ~/tmp $ git init Initialized empty Git repository in /home/laihj/tmp/.git/ ~/tmp (main?) $ vim .gitignore ~/tmp (main?) $ cat .gitignore Untrack # 设置git忽略该目录,用来放置Untracked文件 ~/tmp (main?) $ git add .gitignore data.dat makefile paper.tex plot.py ~/tmp (main+) $ make ~/tmp (main+) $ ls data.dat makefile~ paper.log paper.tex plot.py makefile paper.aux paper.pdf plot-data.png ~/tmp (main+) $ make clean ~/tmp (main+) $ ls # makefile~文件被删除了 data.dat makefile paper.tex plot.py Untrack ~/tmp (main+) $ ls Untrack # Untracked文件都移动到了Untrack目录下 paper.aux paper.log paper.pdf plot-data.png
-
指定版本要求的方法很多,让我们学习一下 Rust的构建系统的依赖管理。大多数的包管理仓库都支持类似的语法。对于每种语法(尖号、波浪号、通配符、比较、乘积),构建一种场景使其具有实际意义;
-
Git 可以作为一个简单的 CI 系统来使用,在任何 git 仓库中的
.git/hooks
目录中,您可以找到一些文件(当前处于未激活状态),它们的作用和脚本一样,当某些事件发生时便可以自动执行。请编写一个pre-commit 钩子,它会在提交前执行make paper.pdf
并在出现构建失败的情况拒绝您的提交。这样做可以避免产生包含不可构建版本的提交信息;
- 查看
.git/hooks
目录下面的pre-commit.sample
文件,重新创建一份在使用git commit
前自动执行的脚本pre-commit
~/tmp (main+) $ vim .git/hooks/pre-commit.sample ~/tmp (main+) $ vim .git/hooks/pre-commit ~/tmp (main) $ cat .git/hooks/pre-commit if ! make; then echo "make failed, commit rejected" exit 1 fi # 移动data.dat到Untrack文件夹 ~/tmp (main+*) $ git rm data.dat rm 'data.dat' ~/tmp (main) $ chmod +x .git/hooks/pre-commit ~/tmp (main) $ git commit make: *** No rule to make target 'plot-data.png', needed by 'paper.pdf'. Stop. make failed, commit rejected
- 基于 GitHub Pages 创建任意一个可以自动发布的页面。添加一个GitHub Action 到该仓库,对仓库中的所有 shell 文件执行 shellcheck(方法之一);
- 进入github的shellcheck-action页面(https://github.com/marketplace/actions/shellcheck),单击“Use latest version”,复制Installation信息,即:
- name: ShellCheck uses: ludeeus/action-shellcheck@2.0.0 # 注意:版本号会实时更新
- 进入你自己的github帐号,并进入其中一个仓库(或新建一个空仓库)。在仓库首页的“Actions”选项下,点击“Simple Workflow”下面的“Configure”按钮,会打开一个blank.yml文件。在文件模板最下方的“steps”之下,按照模板格式,添加刚才复制的shellcheck-action的信息:
... name: CI ... steps: ... - name: ShellCheck uses: ludeeus/action-shellcheck@2.0.0
- 完成后,点击网页右上角的“Commit Changes”(Commit Message可以填写:Add Shellcheck-action)。提交后,右上角会出现“View Runs”按钮,点击进入可以看到仓库的所有Workflow,点击运行刚才提交的blank.yml
- 构建属于您的 GitHub action,对仓库中所有的.md文件执行 proselint 或 write-good,在您的仓库中开启这一功能,提交一个包含错误的文件看看该功能是否生效。
-
Github提供了一个Github Market,可以查找支持的actions
-
与上一题的操作类似,在write-good-action复制action的信息,进入刚才的仓库首页,在.github/workflows目录,可以找到blank.yml,点击进入后,开启编辑,在文件末尾添加write-good-action的信息
... name: CI ... steps: ... - name: Writegood Check uses: mnahinkhan/writegood-action@v1.0.0
-
编辑完成,“Commit Changes”,“Commit Message”可以填写:Add Write-good-action。执行此次更新。
-
编辑仓库首页的README.md文件(观察write-good-action的作用),在最下方新增一行:So the cat was stolen.
-
编辑完成,再次提交。在仓库首页可以看到最新一次提交的信息,在“XX commits”按钮处,可以查看所有的提交。
-
最新的提交出现了一个叉号标志,可以点击该标志,看到“All checks have failed”的信息,点击“Details”可以看到详细的错误信息。
-
根据提示,修改后重新提交。