Python 持续继承和交付教程(一)
一、自动化测试
在深入研究如何测试 Python 代码的例子之前,必须更详细地讨论测试的本质。为什么我们要进行测试?我们从他们那里得到了什么?缺点是什么?什么是好的测试;什么是糟糕的测试?我们如何对测试进行分类?我们应该写多少种类型的测试?
1.1 我们想从测试中得到什么?
为什么要费心编写测试呢?我们想写或者至少有测试的原因有很多。
一个测试套件中有几个测试是为了响应不同的需求而编写的,这种情况并不少见。
快速反馈
对代码的每一次修改都伴随着引入错误的风险。研究表明,7%到 20%的 bug 修复会引入新的 bug。 1
如果我们能在这些错误找到客户之前找到它们,那不是很好吗?或者甚至在你的同事看到他们之前?这不仅仅是虚荣心的问题。如果您收到快速反馈,说您引入了一个错误,您更有可能记住您刚刚工作的代码库部分的所有细节,因此当您收到快速反馈时,修复错误往往会快得多。
许多测试用例被编写来给出这种快速反馈循环。您通常可以在将您的更改提交到源代码控制系统之前运行它们,它们使您的工作更加高效,并保持您的源代码控制历史清晰。
信心
与前一点相关,但值得单独提及的是,知道测试套件将为您捕捉简单的错误,您可以获得信心的提升。在大多数基于软件的企业中,有一些关键领域存在严重的错误,可能会危及整个企业。想象一下,作为一名开发人员,你不小心弄乱了一个医疗保健数据管理产品的登录系统,现在人们看到了别人的诊断。或者想象一下,自动计费向客户的信用卡收取了错误的金额。
甚至非软件企业也曾因软件错误而遭遇灾难性的失败。由于软件问题,火星气候轨道器 2 和阿丽亚娜 5 号火箭 3 的首次发射都遭受了各自运载工具的损失。
他们工作的重要性给软件开发人员带来了情绪压力。自动化测试和良好的开发方法可以帮助减轻这种压力。
即使人们正在开发的软件不是任务关键型的,风险逆境也可能导致开发人员或维护人员尽可能地进行最小的更改,并推迟必要的重构以保持代码的可维护性。一个好的测试套件所提供的信心可以让开发人员做必要的事情来防止代码库变成众所周知的大泥巴球。 4
调试工具
当开发人员更改代码,从而导致测试失败时,他们希望测试有助于找到 bug。如果测试只是简单地说“有问题”,这比不知道错误要好。如果测试能够提供开始调试的提示,那就更有帮助了。
例如,如果一个测试失败表明函数find_shortest_path引发了一个异常,而不是像预期的那样返回一个路径,那么我们知道要么这个函数(或者它调用的一个函数)中断了,要么它接收到了错误的输入。这是一个更好的调试工具。
设计帮助
极限编程(XP) 5 运动主张你要实践测试驱动开发 (TDD)。也就是说,在您编写任何解决问题的代码之前,您首先要编写一个失败的测试。然后你写足够的代码通过测试。要么你做完,要么你写下一个测试。冲洗并重复。
这有明显的好处:你确保你写的所有代码都有测试覆盖,并且你不会写不必要的或不可及的代码。然而,TDD 实践者也报告说测试优先的方法帮助他们写出了更好的代码。一个方面是,编写测试迫使您考虑实现将具有的应用编程接口(API ),因此您开始在头脑中实现一个更好的计划。另一个原因是纯函数(其返回值仅取决于输入,并且不会产生副作用或从数据库读取数据的函数,等等。)很容易测试。因此,测试优先的方法引导开发人员将算法或业务逻辑从支持逻辑中更好地分离出来。这种关注点的分离是好的软件设计的一个方面。
应该注意的是,并不是每个人都同意这些观点,从经验或论据来看,有些代码比编写代码更难测试,这导致了工作的浪费,因为要求对所有事情都进行测试。尽管如此,测试可以提供的设计帮助是开发人员编写代码的原因之一,因此不应该在这里遗漏。
产品规格
软件项目的大的、统一的规范文档的日子已经过去了。大多数项目都遵循某种迭代开发模型,即使有详细的规范文档,也往往是过时的。
当没有详细的和最新的散文规范时,测试套件可以扮演规范的角色。当人们不确定一个程序在某种情况下应该如何表现时,一个测试可能会提供答案。对于编程语言、数据格式、协议和其他东西,提供一个可以用来验证多个实现的测试套件可能更有意义。
1.2 测试的缺点
对考试可能带来的负面影响保持沉默是不诚实的。这些缺点不应该分散你写测试的注意力,但是意识到它们将帮助你决定测试什么,如何写测试,以及,也许,写多少测试。
努力
编写测试需要时间和精力。因此,当你被赋予实现一个特性的任务时,你不仅要实现这个特性,还要为它编写测试,结果是更多的工作和更少的时间去做其他可能给业务带来直接好处的事情。当然,除非测试提供了足够的时间节约(例如,通过不必修复生产环境中的错误和清理被错误破坏的数据)来分摊编写测试所花费的时间。
要维护的额外代码
测试本身就是代码,必须维护,就像被测试的代码一样。一般来说,您希望使用尽可能少的代码来解决您的问题,因为您拥有的代码越少,需要维护的代码就越少。将代码(包括测试代码)视为负债而非资产。
如果您编写测试以及您的特性和错误修复,当需求改变时,您必须改变这些测试。当重构时,一些测试也需要改变,使得代码库更难改变。
脆性
有些测试可能很脆弱,也就是说,它们偶尔会给出错误的结果。即使有问题的代码是正确的,测试仍然失败,这被称为假阳性。这样的测试失败需要花费时间来调试,而不会提供任何价值。一个假阴性是当测试中的代码被破坏时不会失败的测试。假阴性测试也不提供任何价值,但往往比假阳性更难发现,因为大多数工具会引起对失败测试的注意。
脆弱的测试破坏了对测试套件的信任。如果失败测试的产品部署成为常态,因为每个人都认为那些失败的测试是假阳性,那么测试套件的信号价值已经下降到零。您仍然可以使用它来跟踪与上一次运行相比哪些测试失败了,但是这往往会退化成许多没有人愿意做的手工工作。
不幸的是,有些测试很难稳健地进行。图形用户界面(GUI)测试往往对布局或技术变化非常敏感。依赖于您控制之外的组件的测试也可能是脆性的来源。
虚假的安全感
测试套件的完美运行会给你一种错误的安全感。这可能是由于假阴性(应该失败但没有失败的测试)或者遗漏了测试场景。即使一个测试套件实现了测试代码的 100%语句覆盖率,它也可能会错过一些代码路径或场景。因此,您看到一个通过的测试运行,并把它作为您的软件工作正常的指示,一旦真正的客户接触到产品,就会被错误报告淹没。
测试套件无法直接解决过度自信的问题。只有通过对代码库及其测试的体验,您才能感受到绿色(即通过)测试运行所提供的真实置信度。
1.3 优秀测试的特征
一个好的测试是结合了编写测试的几个原因,同时尽可能避免缺点。这意味着测试应该快速运行,易于理解和维护,在失败时给出良好和具体的反馈,并且是健壮的。
可能有点令人惊讶的是,它偶尔也会失败,尽管人们预计测试会失败。从不失败的测试也不会给你反馈,不能帮助你调试。这并不意味着您应该删除一个您从未记录失败的测试。也许它在开发人员的机器上失败了,他或她在检查更改之前修复了 bug。
不是所有的测试都符合好测试的所有标准,所以让我们来看看一些不同种类的测试和它们固有的权衡。
1.4 各种测试
基于测试的范围(覆盖多少代码)和目的,有一个如何对测试进行分类的传统模型。该模型将测试正确性的代码分为单元测试、集成测试和系统测试。它还增加了冒烟测试、性能测试和其他不同目的的测试。
单元测试
单元测试独立地测试一个程序中最小的单元。在过程式或函数式编程语言中,它往往是一个子程序或函数。在 Python 这样的面向对象语言中,它可以是一个方法。根据您对定义的理解,它也可以是一个类或一个模块。
单元测试应该避免在被测试单元之外运行代码。因此,如果您正在测试一个数据库密集型业务应用,那么您的单元测试仍然不应该执行对数据库(访问网络进行 API 调用)或文件系统的调用。有一些方法可以替代这种外部依赖来进行测试,我将在后面讨论,但是如果您可以构建代码来避免这种调用,至少在大多数单元中,那就更好了。
因为对外部依赖的访问是导致大多数代码变慢的原因,所以单元测试通常非常快。这使得它们非常适合测试算法或核心业务逻辑。
例如,如果您的应用是一个导航助手,其中至少有一段算法上具有挑战性的代码:路由器,在给定地图、起点和目标的情况下,它会生成一条路线,或者可能是一列可能的路线,并附带长度和预期到达时间等指标。这个路由器,或者甚至是它的一部分,是你想要尽可能彻底地用单元测试来覆盖的东西,包括可能导致无限循环的奇怪的边缘情况,或者检查从柏林到慕尼黑的旅程没有经过罗马。
对于这样一个单元,您想要的测试用例的绝对数量使得其他类型的测试不切实际。此外,您不希望由于不相关的组件而导致这样的测试失败,因此将它们集中在一个单元上可以提高它们的特异性。
集成测试
如果你用单个部件组装了一个复杂的系统,比如一辆汽车或一艘宇宙飞船,并且每个部件都可以独立工作,那么整体工作的可能性有多大?出问题的方式有很多:一些线路可能有故障,组件想要通过不兼容的协议交谈,或者可能关节无法承受操作期间的振动。
在软件中没有什么不同,所以人们编写集成测试。综合测试一次练习几个单元。这使得单元之间的边界处的不匹配变得明显(通过测试失败),使得这样的错误能够被及早纠正。
系统测试
系统测试将一个软件放到一个环境中,并在那里进行测试。对于经典的三层体系结构,系统测试从用户界面的输入开始,测试所有层直到数据库。
单元测试和集成测试是白盒测试(需要并利用软件如何实现的知识的测试),而系统测试往往是黑盒测试。他们站在用户的角度,不关心系统的内部。
就如何测试软件而言,这使得系统测试最现实,但是它们也有一些缺点。
首先,管理系统测试的依赖关系可能非常困难。例如,如果您正在测试一个 web 应用,您通常首先需要一个可以用来登录的帐户,然后每个测试用例需要一组它可以使用的固定数据。
第二,系统测试经常一次测试如此多的组件,以至于测试失败不能给出关于实际错误的好线索,并且需要开发人员查看每个测试失败,经常发现变化与测试失败无关。
第三,系统测试暴露了您不打算测试的组件中的故障。由于软件使用的 API 中的传输层安全性(TLS)证书配置错误,系统测试可能会失败,这可能完全在您的控制之外。
最后,系统测试通常比单元和集成测试慢得多。白盒测试允许您只测试您想要的组件,因此您可以避免运行不感兴趣的代码。在 web 应用的系统测试中,您可能必须执行登录,导航到您想要测试的页面,输入一些数据,然后最终执行您实际想要执行的测试。系统测试通常比单元测试或集成测试需要更多的设置,增加了它们的运行时间,延长了人们收到代码反馈的时间。
烟雾测试
冒烟测试类似于系统测试,因为它测试技术堆栈中的每一层,尽管它不是对每一层的彻底测试。它通常不是用来测试应用某个部分的正确性,而是用来测试应用在当前上下文中是否能正常工作。
web 应用的冒烟测试可以像登录一样简单,然后调用用户的个人资料页面,验证用户名是否出现在该页面的某个位置。这不会验证任何逻辑,但会检测到诸如配置错误的 web 服务器或数据库服务器,或者无效的配置文件或凭证。
为了从冒烟测试中获得更多好处,您可以向应用添加一个状态页或 API 端点来执行额外的检查,例如检查数据库中是否存在所有必需的表、相关服务的可用性等等。只有满足了所有这些运行时依赖关系,状态才会是“OK”,冒烟测试可以很容易地确定这一点。通常,您只需为每个可部署的组件编写一到两个冒烟测试,但要为您部署的每个实例运行它们。
性能测试
到目前为止,讨论的测试集中在正确性上,但是非功能性的质量,比如性能和安全性,可能同样重要。原则上,运行性能测试相当容易:记录当前时间,运行某个动作,再次记录当前时间。两个时间记录之间的差异是该动作的运行时间。如有必要,根据这些值重复并计算一些统计数据(如中值、平均值、标准差)。
一如既往,细节决定成败。主要的挑战是创建真实可靠的测试环境、真实的测试数据和真实的测试场景。
许多商业应用严重依赖数据库。因此,您的性能测试环境也需要一个数据库。从硬件和许可成本的角度来看,为测试环境复制一个大型生产数据库实例可能非常昂贵。因此,使用缩小的测试数据库是一种诱惑,这带来了结果无效的风险。如果在性能测试中有些东西很慢,开发人员倾向于说“那只是较弱的数据库;prod 可以轻松解决这个问题”——他们可能是对的。或者不是。没有办法知道。
环境设置的另一个潜在的方面是当涉及到性能时有许多活动部件。在虚拟机(VM)上,您通常不知道 VM 从虚拟机管理程序获得了多少 CPU 周期,或者虚拟化环境是否对 VM 内存玩了有趣的把戏(例如将 VM 的部分内存换出到磁盘),从而导致不可预测的性能。
在物理机器(也是每个虚拟机的基础)上,你会遇到现代的电源管理系统,这些系统根据散热因素来控制时钟速度,在某些情况下,甚至根据 CPU中使用的特定指令来控制时钟速度。66
所有这些因素导致性能测量比你可能从像计算机这样的确定性系统中天真地期望的更不确定。
1.5 摘要
作为软件开发人员,我们希望自动化测试为我们提供快速的变更反馈,在回归到达客户之前捕捉它们,并为我们提供足够的信心来重构代码。好的测试是快速、可靠的,并且当它失败时具有高诊断价值。
单元测试往往很快,具有很高的诊断价值,但只覆盖了一小部分代码。一个测试覆盖的代码越多,它就越慢,越脆弱,它的诊断价值就越低。
在下一章,我们将看看如何用 Python 编写和运行单元测试。然后我们将研究如何为每次提交自动运行它们。
Footnotes 1吉姆·伯德,“bug 和数字:你的代码中有多少 bug?”构建真实软件:在真实世界中开发和维护安全可靠的软件, http://swreflections.blogspot.de/2011/08/bugs-and-numbers-how-many-bugs-do-you.html ,2011 年 8 月 23 日。
2
维基百科,《火星气候轨道器》, https://en.wikipedia.org/wiki/Mars_Climate_Orbiter ,2018 年。
3
J.L. Lions,“阿丽亚娜 5 号:501 航班故障。调查委员会的报告,“ http://sunnyday.mit.edu/accidents/Ariane5accidentreport.html ,1996 年 7 月。
4
维基百科,《大泥球》, https://en.wikipedia.org/wiki/Big_ball_of_mud ,2018 年。
5
维基百科,《极限编程》, https://en.wikipedia.org/wiki/Extreme_programming ,2018。
6
弗拉德·克拉斯诺夫(Vlad Krasnov),《论英特尔频率缩放的危险》,Cloudflare, https://blog.cloudflare.com/on-the-dangers-of-intels-frequency-scaling/ ,2017 年 11 月 10 日。
二、Python 中的单元测试
许多程序员手动测试他们正在编写的代码,方法是调用他们正在开发的代码,将结果打印到控制台,并直观地扫描输出的正确性。这适用于简单的任务,但存在一些问题:
-
当输出变大时,发现错误变得更加困难。
-
当程序员疲劳时,很容易错过微妙的错误输出。
-
当实现的特性变得更大时,人们往往会错过早期“测试”部分的回归。
-
因为非正式的测试脚本通常只对编写它们的程序员有用,其他开发人员就看不到它们的效用了。
因此,单元测试被发明出来了,在单元测试中,人们编写对代码片段的样本调用,并将返回值与期望值进行比较。
这种比较通常在测试通过时产生很少或没有输出,否则产生非常明显的输出。一个测试工具可以用来运行来自几个测试脚本的测试,并且只报告错误和通过测试的统计概要。
2.1 消化:Virtualenvs
为了运行我们将要编写的单元测试,我们需要一些额外的工具,这些工具以 Python 包的形式提供。要安装它们,你应该使用一种叫做的工具。这是一个 Python 目录,其中包含 Python 解释器、包管理程序(如pip)以及到基本 Python 包的符号链接,从而为您提供了一个原始的 Python 环境,您可以在这个环境上构建一个定制的、隔离的虚拟环境,其中包含您所需要的库。virtualenv 允许你安装任何你想要的 Python 包;为应用安装依赖项不需要 root 权限。您可以在给定的 shell 会话中激活一个 virtualenv,当您不再需要它时,只需删除该目录。
Virtualenvs 用于将独立的开发环境相互隔离,并与系统 Python 安装隔离。要创建一个,您需要virtualenv工具,它通常随 Python 安装一起提供,或者在 Linux 发行版上,可以通过包管理器安装。在基于 Debian 的系统上,你可以这样安装它:
$ sudo apt-get install virtualenv
要创建名为venv的 virtualenv,运行
$ virtualenv -p python3 venv
这将准备一个名为venv的目录,其中包含必要的文件。下一步应该是激活它,如下所示:
$ source venv/bin/activate
一旦您激活了它,您就可以使用pip将软件包安装到其中,例如:
$ pip install pytest
完成后,使用命令deactivate将其禁用。
2.2 单元测试入门
为了说明单元测试,让我们从单个函数以及如何测试它开始。这里我想实现的功能是一个二分搜索法。给定一个排序的数字列表(我们称之为干草堆),在其中搜索另一个数字(针)。如果它存在,返回找到它的索引。如果没有,引发类型为ValueError的异常。你可以在 https://github.com/python-ci-cd/binary-search 找到这个例子的代码和测试。
我们从草堆的中间部分开始。如果恰好等于针,我们就完了。如果它比针还小,我们可以在干草堆的左半部分重复搜索。如果它比较大,我们可以在草堆的右半部分继续搜索。
为了跟踪我们需要搜索的干草堆内部的区域,我们保留了两个索引,left和right,并且在每次迭代中,将其中一个移动到更靠近另一个,在每一步中将要搜索的空间减半。
这是第一次尝试实现这个函数时的样子:
def search(needle, haystack):
left = 0
right = len(haystack) - 1
while left <= right:
middle = left + (right - left) // 2
middle_element = haystack[middle]
if middle_element == needle:
return middle
elif middle_element < needle:
left = middle
else:
right = middle
raise ValueError("Value not in haystack")
第一次测试
有用吗?谁知道呢?让我们通过写一个测试来找出答案。
def test_search():
assert search(2, [1, 2, 3, 4]) == 1, \
'found needle somewhere in the haystack'
这是一个简单的函数,它使用样本输入执行search函数,如果没有达到预期,则使用assert来引发异常。我们没有直接调用这个测试函数,而是使用了pytest,一个由同名 Python 包提供的命令行工具。如果您的开发环境中没有它,您可以使用下面的命令安装它(记住在 virtualenv 中运行它):
pip install pytest
当pytest可用时,您可以在包含search函数和测试函数的文件上运行它,如下所示:
$ pytest binary-search.py
==================== test session starts =====================
platform linux -- Python 3.5.2, pytest-3.3.2, py-1.5.2
rootdir: /home/moritz/examples, inifile:
collected 1 item
binary-search.py . [100%]
================== 1 passed in 0.01 seconds ==================
测试运行打印出各种信息:这些信息包括有关平台和所涉及软件版本的细节、工作目录,以及使用了什么pytest配置文件(本例中没有)。
第collected 1 item行显示pytest找到了一个测试函数。下一行中文件名后面的点显示了进度,一个点代表一个已经执行的测试。
在终端中,最后一行显示为绿色,表示测试运行通过。如果我们犯了一个错误,比如说,使用 0 而不是 1 作为预期结果,我们会得到一些诊断输出,如下所示:
========================== FAILURES ==========================
_________________________test_search__________________________
def test_search():
> assert search(2, [1, 2, 3, 4]) == 0, \
'found needle somewhere in the haystack'
E AssertionError: found needle somewhere in the haystack
E assert 1 == 0
E + where 1 = search(2, [1, 2, 3, 4])
binary-search.py:17: AssertionError
================== 1 failed in 0.03 seconds ==================
这显示了失败的测试函数,既有源代码,也有在assert调用中的==操作符两边替换的值,显示了到底哪里出错了。在支持彩色的终端中,失败的测试和底部的状态行以红色显示,以使失败的测试更加明显。
编写更多测试
代码中的许多错误在边缘情况下表现出来,以空列表或字符串作为输入,数字为零,访问列表的第一个和最后一个元素,等等。在编写测试时考虑这些情况并覆盖它们是一个好主意。让我们从搜索第一个和最后一个元素开始。
def test_search_first_element():
assert search(1, [1, 2, 3, 4]) == 0, \
'search first element'
def test_search_last_element():
assert search(4, [1, 2, 3, 4]) == 3, \
'search last element'
查找第一个元素的测试通过了,但是最后一个元素的测试挂起了,也就是说,它无限期地运行而没有终止。您可以通过同时按下 Ctrl 和 C 键来中止 Python 进程。
如果函数search能找到第一个元素却找不到最后一个元素,这里面一定有某种不对称。确实有:确定中间元素使用整数除法运算符//,它将正数四舍五入为零。例如,1 // 2 == 0。这解释了为什么循环会被卡住:当right等于left + 1时,代码将middle设置为left的值。如果执行分支left = middle,函数搜索的干草堆面积没有减少,循环就卡住了。
有一个简单的解决方法。因为代码已经确定索引middle处的元素不是针,所以可以将其从搜索中排除。
def search(needle, haystack):
left = 0
right = len(haystack) - 1
while left <= right:
middle = left + (right - left) // 2
middle_element = haystack[middle]
if middle_element == needle:
return middle
elif middle_element < needle:
left = middle + 1
else:
right = middle - 1
raise ValueError("Value not in haystack")
有了这个修正,所有三个测试都通过了。
测试不愉快的路径
到目前为止,测试主要集中在“快乐路径”上,即找到一个元素并且没有遇到错误的路径。因为异常不是正常控制流中的异常(请原谅双关语),所以它们也应该被测试。
有一些工具可以帮助你验证一个异常是由一段代码引起的,并且是正确的类型。
def test_exception_not_found():
from pytest import raises
with raises(ValueError):
search(-1, [1, 2, 3, 4])
with raises(ValueError):
search(5, [1, 2, 3, 4])
with raises(ValueError):
search(2, [1, 3, 4])
在这里,我们测试三种场景:一个值小于草堆中的第一个元素,大于最后一个元素,最后,它的大小介于第一个和最后一个元素之间,但不在草堆中。
pytest.raises例程返回一个上下文管理器。上下文管理器是一种将代码(在with ...块中)包装到其他代码中的好方法。在这种情况下,上下文管理器从with块捕获异常,如果类型正确,测试通过。相反,如果没有出现异常或者出现了一个错误的类型,比如KeyError,测试就会失败。
和前面的assert语句一样,您可以给测试加上标签。这对于调试测试失败和记录测试都很有用。使用raises函数,您可以将测试标签作为名为message的命名参数进行传递。
def test_exception_not_found():
from pytest import raises
with raises(ValueError, message="left out of bounds"):
search(-1, [1, 2, 3, 4])
with raises(ValueError, message="right out of bounds"):
search(5, [1, 2, 3, 4])
with raises(ValueError, message="not found in middle"):
search(2, [1, 3, 4])
2.3 处理依赖性
并非所有代码都像前面章节中的search函数一样易于测试。一些函数调用外部库或与数据库、API 或互联网交互。
在单元测试中,出于几个原因,您应该避免执行那些外部操作。
-
这些行为可能会产生不必要的副作用,比如向客户或同事发送电子邮件,让他们感到困惑,甚至造成伤害。
-
您通常无法控制外部服务,这意味着您无法控制一致的响应,这使得编写可靠的测试更加困难。
-
执行外部操作,如写入或删除文件,会使环境处于不同的状态,这可能会导致无法重现测试结果。
-
性能受损,对开发反馈周期产生负面影响。
-
通常,外部服务,如数据库或 API,需要凭证,这是一个管理上的麻烦,并对设置开发环境和运行测试构成了严重的障碍。
那么,如何在单元测试中避免这些外部依赖呢?让我们探索一些选择。
将逻辑与外部依赖分离
许多应用从某个地方(通常是不同的来源)获取数据,然后对其进行一些逻辑处理,最后可能会打印出结果。
让我们考虑一下计算网站中关键词的应用的例子。这方面的代码可能如下所示(它使用了requests库;可以用pip install requests在你的 virtualenv)里安装:
import requests
def most_common_word_in_web_page(words, url):
"""
finds the most common word from a list of words
in a web page, identified by its URL
"""
response = requests.get(url)
text = response.text
word_frequency = {w: text.count(w) for w in words}
return sorted(words, key=word_frequency.get)[-1]
if __name__ == '__main__':
most_common = most_common_word_in_web_page(
['python', 'Python', 'programming'],
'https://python.org/',
)
print(most_common)
在撰写本文时,这段代码将Python打印为答案,尽管这在将来可能会改变,由 python.org 维护者决定。
您可以在 https://github.com/python-ci-cd/python-webcount 找到示例代码和测试。
这段代码使用requests库获取网页内容并访问结果文本(实际上是 HTML)。然后,该函数遍历搜索词,计算每个词在文本中出现的频率(使用string.count方法),并用这些计数构建一个字典。然后,它根据单词的出现频率对单词列表进行排序,并返回最常出现的单词,这是排序列表的最后一个元素。
测试most_common_word_in_web_page变得单调乏味,因为它使用了 HTTP 客户端requests。我们可以做的第一件事是将计数和排序的逻辑从获取网站的机制中分离出来。这不仅使逻辑部分更容易测试,还通过分离不真正属于一起的东西,提高了代码的质量,从而增加了内聚性。
import requests
def most_common_word_in_web_page(words, url):
"""
finds the most common word from a list of words
in a web page, identified by its URL
"""
response = requests.get(url)
return most_common_word(words, response.text)
def most_common_word(words, text):
"""
finds the most common word from a list of words
in a piece of text
"""
word_frequency = {w: text.count(w) for w in words}
return sorted(words, key=word_frequency.get)[-1]
if __name__ == '__main__':
most_common = most_common_word_in_web_page(
['python', 'Python', 'programming'],
'https://python.org/',
)
print(most_common)
做逻辑的函数most_common_word,现在是一个纯函数,也就是返回值只取决于传递给它的自变量,它和外界没有任何交互。这样一个纯函数很容易测试(同样,测试进入test/functions.py)。
def test_most_common_word():
assert most_common_word(['a', 'b', 'c'], 'abbbcc') \
== 'b', 'most_common_word with unique answer'
def test_most_common_word_empty_candidate():
from pytest import raises
with raises(Exception, message="empty word raises"):
most_common_word([], 'abc')
def test_most_common_ambiguous_result():
assert most_common_word(['a', 'b', 'c'], 'ab') \
in ('a', 'b'), "there might be a tie"
这些测试更多的是单元测试的例子,它们也提出了一些仅仅阅读函数源代码可能不明显的问题。
-
most_common_word实际上并不寻找单词边界,所以它会愉快地在字符串abbbcc中三次计数“单词”b。 -
当使用空的关键字列表调用该函数时,它会引发一个异常,但是我们并没有费心去指定哪种类型的错误。 1
-
我们还没有指定如果两个或更多单词有相同的出现次数,返回哪个值,因此最后一个测试使用了带有两个有效答案列表的
in。
根据您的情况,您可能希望将这样的测试作为已知边缘情况的文档,或者细化规范和实现。
回到测试外部依赖函数的话题,我们已经取得了部分成功。有趣的逻辑现在是一个独立的纯函数,可以很容易地测试。最初的函数most_common_word_in_web_page,现在更简单了,但是仍然没有经过测试。
我们已经隐含地建立了这样一个原则,即为了使测试更容易而修改代码是可以接受的,但这值得明确地提出来。我们将来会更多地使用它。
测试的依赖注入
如果我们多想想是什么使得函数most_common_word_in_web_page难以测试,我们可以得出结论,不仅仅是通过 HTTP 用户代理requests与外界的交互,还有全局符号requests的使用。如果我们可以用另一个类来代替它,那么测试就更容易了。我们可以通过对测试中的函数进行简单的修改来实现这一点。(为简洁起见,注释已从示例中删除。)
def most_common_word_in_web_page(words, url,
user_agent=requests):
response = user_agent.get(url)
return most_common_word(words, response.text)
该函数现在接受一个可选参数user_agent,默认为requests,而不是直接使用requests。在功能里面,单独使用的requests已经被user_agent取代。
对于只用两个参数调用函数的调用者来说,什么都没有改变。但是编写测试的开发人员现在可以提供他/她自己的测试 double ,一个以确定性方式运行的用户代理的替代实现。
def test_with_test_double():
class TestResponse():
text = 'aa bbb c'
class TestUserAgent():
def get(self, url):
return TestResponse()
result = most_common_word_in_web_page(
['a', 'b', 'c'],
'https://python.org/',
user_agent=TestUserAgent()
)
assert result == 'b', \
'most_common_word_in_web_page tested with test double'
这个测试仅仅模拟了被测试函数使用的部分requests API。它忽略了get方法的url参数,所以纯粹从这个测试中,我们不能确定被测试的函数正确地使用了用户代理类。我们可以扩展 test double 来记录传入的参数值,并在以后检查它。
def test_with_test_double():
class TestResponse():
text = 'aa bbb c'
class TestUserAgent():
def get(self, url):
self.url = url
return TestResponse()
test_ua = TestUserAgent()
result = most_common_word_in_web_page(
['a', 'b', 'c'],
'https://python.org/',
user_agent=test_ua
)
assert result == 'b', \
'most_common_word_in_web_page tested with test double'
assert test_ua.url == 'https://python.org/'
本节演示的技术是 依赖注入 的简单形式。 2 调用者可以选择注入一个函数所依赖的对象或类。
依赖注入不仅对测试有用,而且使软件更具可插拔性。例如,您可能希望您的软件能够在不同的上下文中使用不同的存储引擎,或者不同的 XML 解析器,或者存在多种实现的任何数量的其他软件基础结构。
模拟对象
编写 test double 类很快就会变得单调乏味,因为您通常需要为测试中调用的每个方法编写一个类,并且所有这些类都必须设置为正确地链接它们的响应。如果您编写多个测试场景,您要么必须使测试加倍足够通用以覆盖多个场景,要么再次重复几乎相同的代码。
模拟对象提供了一个更方便的解决方案。您可以轻松地将这些对象配置为以预定义的方式做出响应。
def test_with_test_mock():
from unittest.mock import Mock
mock_requests = Mock()
mock_requests.get.return_value.text = 'aa bbb c'
result = most_common_word_in_web_page(
['a', 'b', 'c'],
'https://python.org/',
user_agent=mock_requests
)
assert result == 'b', \
'most_common_word_in_web_page tested with test double'
assert mock_requests.get.call_count == 1
assert mock_requests.get.call_args[0][0] \
== 'https://python.org/', 'called with right URL'
这个测试函数的前两行导入了类Mock并从中创建了一个实例。然后真正的奇迹发生了。
mock_requests.get.return_value.text = 'aa bbb c'
这将在对象mock_requests中安装一个属性get,当它被调用时,将返回另一个模拟对象。第二个模拟对象上的属性text有一个属性text,它保存了字符串'aa bb c'。
让我们从一些简单的例子开始。如果你有一个Mock对象m,那么m.a = 1安装一个值为1的属性a。另一方面,m.b.return_value = 2配置m,使得m.b()返回2。
可以继续链式,所以m.c.return_value.d.e.return_value = 3使m.c().d.e()返回 3。本质上,赋值中的每个return_value对应于调用链中的一对括号。
除了设置这些准备好的返回值,模拟对象还记录调用。前面的例子检查了一个模拟对象的call_count,它只是记录了这个模拟作为一个函数被调用的频率。
属性包含一组传递给它的最后一次调用的参数。这个元组的第一个元素是位置参数的列表,第二个元素是命名参数的字典。
如果您想检查一个模拟对象的多次调用,call_args_list包含一个这样的元组列表。
Mock类有更多有用的方法。完整列表请参考官方文档 3 。
修补
有时候,依赖注入是不实际的,或者你不想冒险改变现有的代码来测试它。然后,您可以利用 Python 的动态特性来临时覆盖测试代码中的符号,并用测试 doubles 替换它们——通常是模拟对象。
from unittest.mock import Mock, patch
def test_with_patch():
mock_requests = Mock()
mock_requests.get.return_value.text = 'aa bbb c'
with patch('webcount.functions.requests', mock_requests):
result = most_common_word_in_web_page(
['a', 'b', 'c'],
'https://python.org/',
)
assert result == 'b', \
'most_common_word_in_web_page tested with test double'
assert mock_requests.get.call_count == 1
assert mock_requests.get.call_args[0][0] \
== 'https://python.org/', 'called with right URL'
对patch函数的调用(从unittest.mock导入,这是 Python 附带的一个标准库)指定了要修补(临时替换)的符号和替换它的测试 double。patch函数返回一个上下文管理器。因此,在执行离开发生调用的with块后,临时替换会自动撤销。
修补导入的符号时,重要的是在导入该符号的命名空间中修补该符号,而不是在源库中修补该符号。在我们的例子中,我们修补了webcount.functions.requests,而不是requests.get。
打补丁消除了与其他代码(通常是库)的交互。这有利于单独测试代码,但也意味着打了补丁的测试无法检测对已打补丁的库的误用。因此,编写更大范围的测试,如集成测试或验收测试,以涵盖这些库的正确使用是很重要的。
2.4 分离代码和测试
到目前为止,为了方便起见,我们已经将代码和测试放在同一个文件中。然而,代码和测试服务于不同的目的,所以当它们变大时,通常将它们分成不同的文件,通常甚至是不同的目录。我们的测试代码现在也可以自己加载一个模块(pytest),这是一个你不想加在产品代码上的负担。最后,一些测试工具为测试和代码假设了不同的文件,所以我们将遵循这个惯例。
在开发 Python 应用时,通常有一个项目包名和一个同名的顶级目录。测试进入名为tests的第二个顶级目录。例如,Django web 框架有目录django和test,还有一个README.rst作为初学者的入口点,还有一个setup.py用于安装项目。
每个作为 Python 模块的目录必须包含一个名为__init__.py的文件,这个文件可以是空的,也可以包含一些代码。通常,这段代码只导入其他符号,这样模块的用户就可以从顶级模块名导入它们。
让我们考虑一个小应用,给定一个 URL 和一列关键字,打印出 URL 指向的网页上最常出现的关键字。我们可以称之为webcount,并将逻辑放入文件webcount/functions.py。然后,文件webcount/ __init__ .py会是这样的:
from .functions import most_common_word_in_web_page
在每个测试文件中,我们明确地导入我们测试的函数,例如:
from webcount import most_common_word_in_web_page
我们可以将测试函数放入test/目录下的任何文件中。在本例中,我们将它们放入文件test/test_functions.py,以镜像实现的位置。test_ prefix告诉 pytest 这是一个包含测试的文件。
调整 Python 路径
当您用pytest test/test_functions.py运行这个测试时,您可能会得到这样一个错误:
test/functions.py:3: in <module>
from webcount import most_common_word_in_web_page
E ImportError: No module named 'webcount'
Python 找不到测试中的模块webcount,因为它不在 Python 的默认模块加载路径中。
您可以通过在 virtualenv 的site-packages目录中添加项目根目录的绝对路径到一个扩展名为.pth的文件来解决这个问题。例如,如果您使用 Python 3.5,并且您的 virtualenv 位于目录venv/中,您可以将绝对路径放入文件venv/lib/python3.5/site-packages/webcount.pth。在官方 Python 文档中讨论了操纵“Python 路径”的其他方法。 4
一种特定于 pytest 的方法是向项目的根目录添加一个空文件conftest.py。Pytest 查找该名称的文件,并在检测到它们存在时,将包含它们的目录标记为要测试的项目,并在测试运行期间将该目录添加到 Python 路径中。
在调用 pytest 时,您不必指定测试文件。如果您忽略它,pytest 会搜索所有测试文件并运行它们。关于集成 实践 5 的 pytest 文档中有关于这个搜索如何工作的更多信息。
2.5 关于单元测试和 Pytest 的更多内容
在尝试为代码编写测试时,您可能会遇到更多的主题。例如,您可能必须管理fixture,作为您的测试基线的数据片段。或者,您可能需要从运行时加载的代码中修补函数,或者做一些其他没有人让您做好准备的事情。
对于这种情况, pytest 文档 6 是一个很好的起点。如果你想要更全面的介绍,Brian Okken 的书 Python Testing with pytest (务实书架,2017)值得一读。
2.6 在新环境中运行单元测试
开发人员通常有一个开发环境,在这个环境中,他们实现他们的变更,运行自动的,有时是手动的测试,提交他们的变更,并将它们推送到一个中央存储库。这样的开发环境倾向于积累 Python 包,这些包并不明确依赖于正在开发的软件,而且它们倾向于只使用一个 Python 版本。这两个因素也倾向于使测试套件不太具有可重复性,这会导致“在我的机器上工作”的心态。
为了避免这种情况,您需要一种机制来以可重复的方式在几个 Python 版本上轻松执行测试套件。tox 自动化项目 7 提供了一个解决方案:您向它提供一个简短的配置文件tox.ini,它列出了 Python 版本和一个用于安装模块的标准setup.py文件。然后,你可以运行tox命令。
tox命令为每个 Python 版本创建一个新的 virtualenv,在每个环境中运行测试,并报告测试状态。首先,我们需要一个文件setup.py。
# file setup.py
from setuptools import setup
setup(
name = "webcount",
version = "0.1",
license = "BSD",
packages=['webcount', 'test'],
install_requires=['requests'],
)
这使用 Python 的库setuptools使开发中的代码可安装。通常,您会包括更多的元数据,如作者、电子邮件地址、更详细的描述等。
然后,文件tox.ini告诉 tox 如何运行测试,以及在什么环境中运行。
[tox]
envlist = py35
[testenv]
deps = pytest
requests
commands = pytest
本例中的envlist只包含 Python 3.5 的py35。如果你也想在 Python 3.6 上运行测试,你可以写envlist = py35,py36。关键字pypy35指的是 3.5 版本中 Python 的替代 pypy 实现。
现在,调用tox在所有环境中运行测试(这里只有一个),最后报告状态。
py35 runtests: PYTHONHASHSEED="3580365323"
py35 runtests: commands[0] | pytest
================== test session starts ==================
platform linux -- Python 3.5.2, pytest-3.6.3, py-1.5.4,
pluggy-0.6.0
rootdir: /home/[...]/02-webcount-patched, inifile:
collected 1 item
test/test_functions.py . [100%]
=============== 1 passed in 0.08 seconds ================
_________________________summary_________________________
py35: commands succeeded
congratulations :)
2.7 另一个示例项目:matheval
如今,许多项目都是作为 web 服务实现的,因此可以通过 HTTP 使用它们——或者作为 API,或者通过实际的网站。让我们考虑一个很小的 web 服务,它评估被编码为 JSON 数据结构中的树的数学表达式。(你可以在 https://github.com/python-ci-cd/python-matheval/ 找到这个项目的完整源代码。)作为一个例子,表达式 5 * (4 - 2)将被编码为 JSON 树["*", 5, ["+", 4, 2]]并计算为 10。
应用逻辑
实际的评估逻辑相当紧凑(参见清单 2-1 )。
from functools import reduce
import operator
ops = {
'+': operator.add,
'-': operator.add,
'*': operator.mul,
'/': operator.truediv,
}
def math_eval(tree):
if not isinstance(tree, list):
return tree
op = ops[tree.pop(0)]
return reduce(op, map(math_eval, tree))
Listing 2-1File matheval/evaluator.py: Evaluation Logic
使用 Flask 框架,将它公开到 Web 上也不需要太多努力(参见清单 2-2 )。
#!/usr/bin/python3
from flask import Flask, request
from matheval.evaluator import math_eval
app = Flask(__name__)
@app.route('/', methods=['GET', 'POST'])
def index():
tree = request.get_json(force=True)
result = math_eval(tree);
return str(result) + "\n"
if __name__ == '__main__':
app.run(debug=True)
Listing 2-2File matheval/frontend.py: Web Service Binding
一旦您将项目的根目录添加到当前 virtualenv 的一个.pth文件中,并安装了flask先决条件,您就可以启动一个开发服务器,如下所示:
$ python matheval/frontend.py
* Serving Flask app "frontend" (lazy loading)
* Environment: production
WARNING: Do not use the development server in a production environment.
Use a production WSGI server instead.
* Debug mode: on
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
对于生产应用,最好安装gunicorn,然后启动应用
$ gunicorn matheval.frontend:app
对应用逻辑进行单元测试非常简单,因为它是一个纯函数(参见清单 2-3 )。
from matheval.evaluator import math_eval
def test_identity():
assert math_eval(5) == 5, 'identity'
def test_single_element():
assert math_eval(['+', 5]) == 5, 'single element'
def test_addition():
assert math_eval(['+', 5, 7]) == 12, 'adding two numbers'
def test_nested():
assert math_eval(['*', ['+', 5, 4], 2]) == 18
Listing 2-3File test/test_evaluator.py: Unit Tests for Evaluating Expression Trees
index路线并不复杂,不足以保证单独的单元测试,但是在后面的章节中,我们将编写一个冒烟测试,在应用安装后对其进行测试。
我们需要一个小的setup.py文件来通过pytest运行测试(参见清单 2-4 )。
#!/usr/bin/env python
from setuptools import setup
setup(name='matheval',
version='0.1',
description='Evaluation of expression trees',
author='Moritz Lenz',
author_email='moritz.lenz@gmail.com',
url='https://deploybook.com/',
requires=['flask', 'pytest', 'gunicorn'],
setup_requires=['pytest-runner'],
packages=['matheval']
)
Listing 2-4File setup.py for matheval
最后,我们再次需要一个空文件conftest.py,现在可以运行测试了。
$ pytest
==================== test session starts =====================
platform linux -- Python 3.6.5, pytest-3.8.0, py-1.6.0
rootdir: /home/moritz/src/matheval, inifile:
collected 4 items
test/test_evaluator.py .... [100%]
================== 4 passed in 0.02 seconds ==================
2.8 摘要
单元测试通过用样本输入调用一段代码,并验证它返回预期的结果还是抛出预期的异常,来孤立地测试这段代码。使用 pytest,测试是一个函数,其名称以test_开头,包含验证返回值的assert语句。您用pytest path/to/file.py运行这些测试文件,它会为您找到并运行测试。它使测试失败变得非常明显,并试图提供尽可能多的上下文来调试它们。
模拟对象提供了一种快速创建测试副本的方法,修补机制提供了一种将它们注入测试代码的便捷方法。
tox命令和项目创建了隔离的测试环境,使得测试套件可重复,并且更方便在多个 Python 版本和实现上进行测试。
它实际上引发了一个IndexError,试图访问排序列表的最后一个元素,这个元素是空的。
2
维基百科,《依赖注入》, https://en.wikipedia.org/wiki/Dependency_injection ,2018 年。
3
Python 软件基础,“unittest.mock—模拟对象库”, https://docs.python.org/3/library/unittest.mock.html ,2018。
4
https://docs.python.org/3/install/index.html#inst-search-path 。
5
https://docs.pytest.org/en/latest/goodpractices.html 。
6
Pytest,《pytest:帮助你写出更好的程序》, https://docs.pytest.org/en/latest/ ,2018。
7
tox,“欢迎来到 tox 自动化项目”, https://tox.readthedocs.io/en/latest/ ,2018。
三、Jenkins 持续集成
一旦你对你的软件进行了自动化测试,你必须小心保持那些测试的通过。随着代码或基础设施的改变,或者新的库版本,测试可能开始失败。
如果你让它们失败,并且对这种蠕动熵不采取任何措施,测试的诊断价值开始下降,新的回归往往会被一般的噪音所掩盖。保持您的测试通过,并不断地检入新的特性和错误修复,是软件开发团队工程文化的一部分。
有工具可以帮助团队。持续集成 (CI) 服务器监控版本控制库,并在每次新提交时自动运行测试套件,可能在各种平台上运行。当他们导致一些测试失败时,他们可以通知开发人员,给出测试工作的历史概况,并可视化趋势数据,例如测试覆盖率。
当您使用这样的工具时,它通过提供独立的评估,帮助您发现测试何时开始失败,将失败分类到某些提交或平台,并使“它在我的机器上工作”的咒语过时。然而,从事软件工作的工程师仍然需要规程来修复 CI 工具发现的测试失败。
3.1 持续集成服务器
根据其部署模型,有两种 CI 服务器。您在自己的基础设施上安装和运行本地软件,而基于云的软件或软件即服务 (SaaS)软件通常由创建 CI 服务器的供应商运行。
在企业设置中,内部软件往往是首选的解决方案,因为这意味着被测试的源代码不必离开组织的网络。
最流行的开源本地 CI 服务器软件是 Jenkins 、1 一个基于 Java 的项目,在麻省理工学院的许可下,我们将在本章的后面使用它。这一类的其他例子还有build bot2(用 Python 写的)和 CruiseControl 。 3 流行的封闭源码,内部 CI 软件有 JetBrains 的team city4和 Atlassian 的 Bamboo 。 5
在托管 CI 服务领域,Travis CI6由于其与 GitHub 的出色集成而非常受欢迎。Travis 也是开源的,可以自托管。AppVeyor7常用于基于 Windows 的 CI。Travis 和 AppVeyor 都为开源项目提供免费计划。
大多数 CI 软件都有一个中央服务器组件,可以轮询源代码库的变更,也可以由钩子触发。如果检测到源代码库中有变化,就会触发一个作业。该作业可以在服务器中集中配置,也可以在源代码存储库中配置。例如,Travis 希望在存储库的根目录中有一个名为.travis.yml的文件,该文件指导 Travis 如何准备环境以及执行哪些命令来触发构建和测试。
一旦 CI 服务器知道要执行哪些测试,以及在哪些环境中执行,它通常会将实际的测试运行委派给工作节点。然后,工作节点将它们的结果报告给服务器,服务器负责发送通知,并通过 web 界面提供输出和结果以供检查。
3.2 Jenkins 入门
首先,您需要一个正常工作的 Jenkins 安装。官方网站 8 包含如何在所有常见操作系统上安装和设置 Jenkins 的说明。接下来,你也可以找到快速指导,让一个 Docker 为基础的詹金斯操场跑步。
在 Docker 中运行 Jenkins
通常,在生产环境中,您会在一台机器上运行 Jenkins 服务器,并让几个构建人员在不同的(虚拟)机器上工作。为了便于设置,我们将放弃这种明智的区分,在同一个 docker 容器中运行服务器和所有构建作业,只是为了管理更少的 Docker 容器。
为此,我们使用来自 Jenkins 的官方 Docker 映像,但是添加了tox Python 模块(我们将使用它来创建可再现的构建环境),以及我们想要测试的 Python 版本。
这个定制是通过一个定制Dockerfile来完成的,看起来像这样:
FROM jenkins/jenkins:lts
USER root
RUN apt-get update \
&& apt-get install -y python-pip python3.5 \
&& rm -rf /var/lib/apt/lists/*
RUN pip install tox
要构建定制映像,您必须安装 Docker,并且您的用户必须能够访问 Docker 守护进程,在基于 UNIX 的系统上,它通过将用户添加到docker组并重新登录来工作。建筑看起来像这样:
$ docker build -t jenkins-python .
这首先从 Dockerhub 下载图像jenkins/jenkins:lts,这可能需要几分钟。然后它运行 Dockerfile 的RUN行中的命令,docker file 安装pip,然后安装tox。由此产生的图像被命名为jenkins-python。
接下来,通过运行
$ docker run --rm -p 8080:8080 -p 50000:50000 \
-v jenkins_home:/var/jenkins_home jenkins-python
-v ...参数附加了一个卷,这使得 Jenkins 服务器在容器被终止并重启时不会丢失状态。
在启动过程中,容器在控制台上产生如下输出:
Please use the following password to proceed to installation:
b1792b6c4c324f358a2173bd698c35cd
复制密码,然后将浏览器指向http://127.0.0.1:8080/,并按照设置说明进行操作(第一步需要输入密码)。说到插件,将 Python 插件添加到要安装的插件列表中。
插件安装过程可能需要几分钟时间。之后,您就有了一个正常工作的 Jenkins 服务器。
配置源代码库
Jenkins 基于源代码控制库中的源代码运行作业。对于一个合适的软件开发项目,您可能已经有了一个存储代码的地方。如果没有,可以使用众多云托管服务中的一个,比如 GitHub 、9git lab、 10 或者 Atlassian 的 Bitbucket 。 11 你也可以在自己的基础设施上安装 GitLab、 Gitea 、12Gogs、 13 或者其他 Git 管理项目。
无论哪种情况,您最终都会得到一个可以通过网络访问的 Git 存储库,这正是 Jenkins 所需要的。为了便于演示,我在 https://github.com/python-ci-cd/python-webcount 创建了一个公共的 GitHub 库。
对于私有存储库,您还需要一个 SSH 密钥对或用户名和密码的组合来访问存储库。
创建第一个詹金斯工作
我们希望 Jenkins 定期运行我们项目的测试。为此,我们需要创建一个作业,它配置 Jenkins 从哪里以及如何获得源代码并运行测试的所有细节。
要创建作业,请单击 Jenkins 起始页左栏中的 New Item 链接。然后,您必须输入一个名称,例如存储库的名称python-webcount,以及一个作业类型,这里是多配置项目。然后单击“确定”继续。
下一个屏幕提供了过多的配置选项。以下是让我们的示例作业运行的基本要素:
-
在源代码管理部分选择 Git,并输入存储库 URL(例如,
https://github.com/python-ci-cd/python-webcount.git)。对于私有存储库,您还必须在 URL 下方输入有效凭证(图 3-1 )。 -
在 Build Trigger 部分,选择 Poll SCM 并输入字符串
H/5 * * * *作为调度,这意味着每五分钟轮询一次。 -
在配置矩阵下,添加一个名为
TOXENV和值为py35的用户定义轴。如果你在 Jenkins 中安装了更多的 Python 版本并在项目的tox.ini文件中定义,你可以在这里添加它们,用空格分开(图 3-2 )。 -
在构建部分,选择执行 Python 脚本,并将以下简短的 Python 脚本粘贴到脚本区域(图 3-3 )。
图 3-3
Jenkins 配置:构建配置
图 3-2
Jenkins 配置:构建触发器和配置矩阵
图 3-1
Jenkins 配置:源代码管理
import
os, tox
os.chdir(os.getenv("WORKSPACE"))
tox.cmdline()
添加这些信息后,您可以保存页面并拥有第一个工作配置项作业。
每五分钟,Jenkins 将检查 Git 存储库中的新提交,如果有,它将获取它们,通过 tox 运行测试,并在前端提供状态。
当您定义更多的 tox 环境时,Jenkins 会显示每个环境的测试是通过还是失败,并为您提供每个环境的历史记录。
3.3 将更多测试细节导出到 Jenkins
在当前状态下,Jenkins 完全基于运行的脚本的退出代码来检测测试状态,这并没有提供良好的粒度。我们可以通过指示 tox 编写一份机器可读的摘要并让 Jenkins 读取这些数据来提高粒度。
为此,将项目 Git 存储库中的tox.ini文件中的commands = pytest行改为
commands = pytest --junitxml=junit-{envname}.xml
对于环境py35,pytest 然后创建一个文件junit-py35.xml,它更详细地描述了测试运行。
在 Jenkins 的作业配置中,单击 Post-build actions 并添加一个 Publish JUnit test result report 类型。在现场测试报告 XMLs 中,输入模式**/junit-*.xml。(见图 3-4 。)
图 3-4
构建后操作:发布 JUnit 测试结果报告
当作业再次运行时,Jenkins 会获得单个测试函数的状态,甚至会报告每个函数的运行时间。这允许直接从 Jenkins web 界面进行更好的诊断。
3.4 与 Jenkins 合作的模式
现在,用 Jenkins 进行测试的基础已经就绪,是时候考虑如何在日常工作中使用它了。大部分都集中在保持测试绿色上,也就是说,所有的测试都通过。经验表明,如果你不关注保持你的工作绿色,开发人员习惯了失败的测试,然后从仅仅 1%的失败测试下滑到测试运行成为纯粹的噪音,因此失去了他们的价值。
此外,您应该对开发工作流程中的测试部分进行审查,以确保测试准确地反映了需求,即使新特性改变了需求。
责任
如果几个开发人员在同一个代码基础上工作,为通过测试套件定义清晰的职责是很重要的。通常,破坏测试套件的人(根据 Jenkins 中从绿色到红色的变化来衡量)负责再次修复它。
在一个像润滑良好的机器一样工作的团队中,一条规则可能就足够了。如果不是这样,任命一个主要负责绿色测试套件的构建大师是有意义的。
这并不意味着构建大师必须清理所有失败的测试。这更像是一个管理角色,与那些破坏了测试套件的人交谈,并确保他们清理干净。如果事实证明这不可行,那么恢复导致问题的提交,并在通过所有测试后重新包含它。
如果没有人觉得有必要一直做这个工作,构建大师的角色也可以在不同的开发人员之间轮换。
通知
通知可以帮助开发团队保持测试绿色,简单地通过通知成员关于被破坏的测试,从而让他们知道需要一个动作。通知可以通过电子邮件发送到开发人员使用的聊天系统,甚至发送到开发人员办公室中的监视器。Jenkins 丰富的插件生态系统几乎涵盖了所有常用的通知技术。
如果您将 Jenkins 配置为在测试套件中断时发送通知,那么也要将其配置为在测试套件再次通过时发送通知。否则,每个相关人员都会讨厌 Jenkins 只给出负面反馈的通知,这对成功的 CI 流程来说不是一个好位置。
功能分支和拉请求
如果您的开发工作流是基于特性分支的,并且可能是基于合并请求或拉请求的(其中第二个人评审并合并变更),那么在您的 CI 系统中包含这些分支也是有意义的。负责合并分支的开发人员可以在知道所有测试仍然通过特性分支的情况下这样做。
对于正式的合并请求或拉请求,GitHub 和 GitLab 等 Git 托管解决方案甚至支持这样一种模式,即只有在所有测试都通过的情况下,请求才能被合并。在这样的场景中,不仅要测试功能分支,还要测试功能分支和开发分支合并的结果,这是有意义的。这避免了所有测试在开发分支和特性分支中都通过,但是合并中断了一些测试的情况。
詹金斯可以将这些集成作为插件使用。 14
3.5 Jenkins 中的其他指标
一旦一个团队顺利地使用 CI 系统工作,您就可以使用它来收集关于软件的其他度量,并将其引导到一个理想的方向。请注意,仅在有限的实验中引入这样的度量,并且仅当您发现它们为开发过程提供了切实的价值时,才将其扩展到更大的项目。它们都伴随着维护成本和减少开发人员自主权的成本。
代码覆盖率
代码覆盖率衡量测试运行期间执行的一段源代码中的语句或表达式占表达式总数的百分比。代码覆盖率是一个简单的代理,代表了测试套件对代码的测试有多彻底,尽管应该有所保留,因为一段代码中路径号的组合爆炸会导致未检测到的错误,即使是在测试过的代码中。
pytest-cov15项目收集这样的数据,如果测试覆盖率低于某个阈值,您甚至可以使用它来使您的 CI 工作失败。
复杂性
衡量一个代码库的复杂度有各种各样的尝试,比如圈 复杂度和可维护性指数,一个叫做radon16的工具可以为 Python 代码计算出来。虽然这些数字不太可靠,但观察它们的趋势可以让你对代码库的健康状况有所了解。
编程风格
当一个项目定义了一种编码风格时,它可以使用类似于 pylint 17 或flake 818的工具来检查存储库中的代码是否真正遵守了指导方针,如果检测到违反,甚至会导致构建失败。这两个工具带有一组默认规则,但是可以定制成您自己的规则。
架构约束检查
如果一个项目遵循一个定义良好的架构,那么可能会有可以通过编程方式检查的代码规则。例如,由用户界面(UI)、业务逻辑和存储后端组成的封闭三层系统可能具有如下规则:
-
UI 可能不直接使用存储后端,只使用业务逻辑。
-
存储后端可能不会直接使用 UI。
如果这些层作为 Python 代码中的模块来处理,您可以编写一个小脚本来分析所有源文件中的导入语句,并检查它们是否违反了这些规则。静态导入分析器,如snakefood19可以使这变得更容易。
当检测到违规时,这样的工具应该使 CI 步骤失败。这允许您跟踪架构的思想是否在代码中实际实现,并防止代码慢慢削弱底层架构原则。
3.6 摘要
Jenkins 是一个 CI 服务器,它会自动为您运行测试套件,通常是针对源存储库中的每个新提交。这为您提供了测试套件状态的客观视图,可能在多个 Python 版本或平台上。
一旦你有了这个观点,你就可以有一个测试套件总是通过的过程,并且你可以从测试套件中获得价值。
当一个成熟的团队能够很好地处理 CI 过程时,您可以在 CI 过程中引入其他度量,比如代码覆盖率或者对架构规则的遵守。
Footnotes 12
3
http://cruisecontrol.sourceforge.net/ 。
4
5
www.atlassian.com/software/bamboo 。
6
7
8
https://jenkins.io/download/ 。
9
10
11
12
13
14
https://github.com/jenkinsci/gitlab-plugin/wiki/Setup-Example 。
15
https://pytest-cov.readthedocs.io/en/latest/ 。
16
https://radon.readthedocs.io/en/latest/intro.html 。
17
https://pypi.org/project/pylint/ 。
18
http://flake8.pycqa.org/en/latest/ 。
19
四、持续交付
持续集成(CI)是健壮的现代软件开发的基石,但它不是软件开发方法的顶峰。相反,它是更先进技术的推动者。
当 CI 作业显示所有测试都通过时,您可以合理地确定软件可以独立工作。但是和其他软件配合的好吗?我们如何将它呈现在最终用户面前?这就是连续交付(CD)的用武之地。
当您实践 CD 时,您将软件的部署过程自动化,并在几个环境中重复它。您可以使用其中的一些环境进行自动化测试,例如全系统集成测试、自动化验收测试,甚至性能和渗透测试。当然,这并不排除手工问答,手工问答仍然可以发现一类自动化测试不容易发现的缺陷。最后,您使用相同的自动化在您的生产环境中部署软件,在那里它到达它的最终用户。
建立一个 CD 系统听起来的确是一项令人生畏的任务,事实也确实如此。然而,好处是多方面的,但也许并不是所有的好处都是一目了然的。
本章的其余部分将讨论 CD 的好处,并提供一个粗略的路线图来实现它。本书的其余部分致力于展示 CD 的简单方法和实现它的例子。
4.1 光盘和自动化部署的原因
因为实现 CD 可能需要大量的工作,所以清楚这样做的原因和潜在好处是有好处的。你也可以使用本节中的论点来说服你的管理层投资这种方法。
节约时间
在大中型组织中,应用及其基础设施通常由独立的团队开发和操作。每个部署都必须在这些团队之间进行协调。必须提交变更请求,必须找到适合两个团队的日期,必须传播关于新版本的信息(例如什么新配置是可用的或需要的),开发团队必须使二进制文件可供安装,等等。无论是在开发团队还是在运营团队,所有这些都很容易耗费每个版本几个小时或几天的时间。
然后,实际的部署过程也需要时间,通常伴随着停机。由于通常要在工作时间避免停机,部署必须在晚上或周末进行,这使得运营团队不太愿意执行任务。宝贵的善意也被人工调配消耗殆尽。
自动化部署可以节省大量时间和信誉。例如, Etsy 1 引入了连续(因此是自动化的)交付,将部署时间成本从“部署大军”的6-14 小时减少到单人15 分钟。 2
更短的发布周期
不言而喻,需要大量努力的任务比那些几乎不需要努力的任务完成得少得多。冒险的努力也是如此:我们倾向于避免经常这样做。
手动发布和部署的公司通常每周发布一次,甚至更少。有些公司每月甚至每季度发布一次。在更保守的行业,甚至每 6 个月或 12 个月发布一次也不是闻所未闻。
不频繁的发布总是导致冗长的开发过程和缓慢的上市时间。如果软件每季度部署一次,从规范到部署的时间很容易被缓慢的发布周期所支配,至少对于小的特性是如此。
这可能意味着,例如,在结账过程中用户体验不佳的在线企业必须等待大约三个月才能改善用户体验,这可能会花费大量金钱。自动化部署使得更频繁的发布更加容易,减轻了这种痛苦。
更短的反馈周期
获得软件反馈的最佳方式是将其部署到生产环境中。在那里,人们将实际使用它,然后你可以听听他们要说什么,甚至连续测量他们与系统不同部分的参与度。
如果您正在开发供公司内部使用的工具,您可能会让一些人在一个试运行环境中试用它们,但这并不容易。这需要他们从实际工作中抽出时间;必须用所有必要的数据(客户数据、库存等)来设置登台环境,甚至使其可用;然后那里的所有更改最终都会丢失。根据我的经验,让用户在非生产环境中进行测试是一项艰苦的工作,只有在重大变更时才值得。
对于手动的,因此也是不频繁的发布,反馈周期很慢,这违背了“敏捷”或“精益”开发过程的整体思想。
精益软件开发是一种受丰田精益制造流程启发的开发模式,其重点是减少不必要的工作、快速交付软件、学习和相关原则。
因为人类的交流容易产生误解,一个特性的第一次实现很少能满足最初的期望。反馈周期是不可避免的。因此,缓慢的发布周期导致缓慢的开发,使涉众和开发人员都感到沮丧。
但是也有副作用。当改进周期花费很长时间时,许多用户甚至都懒得请求小的改进。这是一个真正的遗憾,因为一个好的用户界面是由数以百计的小便利和锋利的边缘组成的,它们必须是圆形的。所以,从长远来看,缓慢的发布周期会导致更差的可用性和质量。
发布的可靠性
手动释放有一个恶性循环。它们往往是不频繁的,这意味着许多变化都集中在一个版本中。这增加了出错的风险。当一个大版本带来太多麻烦时,经理和工程师会寻找方法来提高下一个版本的可靠性,通过增加更多的验证步骤和过程。
但是更多的过程意味着更多的努力,更多的努力导致更慢的周期,导致每个版本更多的变化。你可以看到事情的发展。
自动化发布过程的步骤,甚至整个过程,是打破这种恶性循环的一种方式。在不折不扣地遵循指令方面,计算机比人强得多,在漫长的软件部署之夜结束时,它们的注意力不会下降。
一旦发布过程变得更可靠,执行起来更快,就很容易推动更频繁的发布,每一次都引入更少的变化。自动化节省的时间释放了资源来进一步改进自动化发布过程。
随着进行更多的部署,也带来了更多的经验,这使您能够更好地进一步改进流程和工具。
较小的增量使训练更容易
当一个部署引入了一个 bug,并且这个部署只引入了一两个特性或者 bug 修复,通常很容易就能找出是哪个变更导致了这个 bug (triaging)。相比之下,当许多变更是同一个部署的一部分时,就很难对新的 bug 进行分类,这意味着浪费了更多的时间,但是这也导致需要更长的时间才能修复缺陷。
更多的建筑自由
软件业当前的趋势是从巨大的、单一的应用转向更多更小组件的分布式系统。这就是微服务模式的全部内容。较小的应用或服务往往更容易维护,可伸缩性需求要求它们都必须能够在不同的机器上运行,并且通常每个服务都要在几台机器上运行。
但是,如果部署一个应用或服务已经很痛苦,那么部署十个甚至一百个更小的应用肯定会更痛苦,并且将微服务与手动部署混合在一起是完全不负责任的。
因此,自动部署打开了可能的软件架构的空间,您可以利用它来解决业务问题。
先进的质量保证技术
一旦你有了必要的基础设施,你就可以采用令人惊讶的 QA 策略。例如,GitHub 使用新老实现 3 的实时并行执行来避免结果和性能参数的倒退。
假设你开发了一个旅游搜索引擎,你想改进搜索算法。您可以同时部署引擎的旧版本和新版本,并针对两者运行传入的查询(或其中的一部分),并定义一些度量标准来评估它们。例如,快速旅行和低成本使航班衔接很好。你可以用它来发现新引擎比旧引擎表现差的情况,并使用这些数据来改进它。你也可以用这些数据来证明新搜索引擎的优越性,从而证明开发它所付出的努力是正确的。
但是,如果每个新版本都必须手动部署,并且部署每个版本是一项很大的工作,那么这样的实验是不实际的。自动部署不会自动给你带来这些好处,但是它是使用这种高级 QA 技术的先决条件。
4.2 光盘计划
我希望现在你已经相信裁谈会是一个好主意。当我到达那个阶段时,实际实施它的前景似乎相当令人生畏。
CD 的过程可以分解成几个步骤,每个步骤都可以单独管理。更好的是,每个步骤的自动化提供了好处,即使整个过程还没有自动化。
让我们来看看一个典型的光盘系统和所涉及的步骤。
管道架构
CD 系统的结构是一个流水线。版本控制系统中的新提交或分支触发流水线的实例化,并开始执行一系列阶段中的第一个。当一个阶段成功运行时,它会触发下一个阶段。如果失败,整个管道实例将停止。
那么手动干预是必要的,通常是通过添加新的提交来修复代码或测试,或者通过修复环境或管道配置。然后,管道的新实例或失败阶段的重新运行就有机会成功。
偏离严格的管道模型是可能的。例如,潜在地并行执行的分支允许在不同的环境中运行不同的测试,并等待下一步,直到两者都成功完成。分支成多个流水线,从而并行执行,称为扇出;在(图 4-1 )中,将管道连接成一个单独的分支称为风扇。
图 4-1
扇出分支管道;范也加入了他们
典型的阶段是构建、运行单元测试、部署到第一个测试环境、在那里运行集成测试、可能部署到各种测试环境并在其中进行测试,以及最终部署到生产环境(图 4-2 )。
图 4-2
部署管道的典型推荐阶段
有时候,这些阶段有点模糊。例如,Debian 包的典型构建也运行单元测试,这减少了对单独单元测试阶段的需求。同样,如果部署到一个环境中,对其部署到的每个主机运行冒烟测试,则不需要单独的冒烟测试阶段(图 4-3 )。
图 4-3
在实际的流水线中,将多个推荐的阶段合并成一个阶段是很方便的,并且可能有理论掩盖的额外阶段
通常,有一个软件控制整个管道的流量。它为一个阶段准备必要的文件,运行与该阶段相关的代码,收集其输出和工件(即该阶段产生的值得保留的文件,如二进制文件或测试输出),确定该阶段是否成功,然后继续下一个阶段。
从架构的角度来看,这使阶段不必知道下一步是什么阶段,甚至不必知道如何到达运行它的机器。它分离了阶段并保持关注点的分离。
反模式:每个环境独立构建
如果您为您的源代码使用一个分支模型,比如 GitFlow 4 ,那么自动将develop分支部署到测试环境是很有诱惑力的。当发布时间到来时,您将开发分支合并到master分支中(可能通过独立发布分支的间接方式),然后您自动构建master分支并将结果部署到生产环境中。
这很诱人,因为它是现有的、经过验证的工作流的直接扩展。不要这样做。
这种方法的一个大问题是,您实际上没有测试将要部署的内容,另一方面,您将一些未经测试的内容部署到生产环境中。即使您在部署到生产环境之前已经有了一个试运行环境,但是如果您没有实际发布在之前的环境中测试过的二进制文件或包,那么您所做的所有测试都是无效的。
如果您从不同的来源(比如不同的分支)构建“测试”和“发布”包,那么产生的二进制文件将会不同。即使您使用完全相同的源代码,构建两次仍然是一个坏主意,因为许多构建是不可重复的。不确定的编译器行为以及环境和依赖关系的差异都可能导致包在一个版本中运行良好,而在另一个版本中失败。最好通过将您在测试环境中测试过的版本部署到生产环境中,来避免这种潜在的差异和错误。
环境之间的行为差异(如果需要的话)应该由不属于构建的配置来实现。同样不言而喻的是,配置必须在版本控制之下并自动部署。有专门用于部署配置的工具,比如 Puppet、Chef 和 Ansible,后面的章节将讨论如何将它们集成到部署过程中。
一切都取决于包装形式
构建可部署的工件是 CD 管道的早期阶段:构建、存储库管理、安装和操作都依赖于包格式的选择。Python 软件通常被打包成源代码 tarball,格式由setuptools包确定,有时也打包成二进制轮包,由 Python 增强提案(PEP) 427 指定。 5
源 tarballs 和 wheels 都不特别适合部署正在运行的应用。它们在安装时缺少钩子来创建必要的系统资源(如用户帐户),启动或重新启动应用,以及其他特定于操作系统的任务。它们也不支持管理非 Python 依赖项,比如用 c 编写的数据库客户端库。
Python 包由 pip 包管理器安装,默认为系统范围的全局安装,有时与操作系统包管理器安装的 Python 包交互不佳。例如,存在虚拟环境形式的工作区,但是管理这些工作区需要额外的关注和努力。
最后,在开发和操作职责分开的情况下,操作团队通常更熟悉本地操作系统包。尽管如此,源代码 tarballs 作为创建更适合直接部署的格式的包的起点,发挥了非常有用的作用。
在本书中,我们部署到 Debian GNU/Linux 机器上,因此我们使用两步过程构建 Debian 包。首先,我们使用一个由setuptools支持的setup.py文件创建一个源 tarball。然后工具dh-virtualenv创建一个包含 virtualenv 的 Debian 包,该软件及其所有 Python 依赖项都安装在这个包中。
管理 Debian 仓库的技术
部署 Debian(和大多数其他)包的工作方式是将它们上传到一个库中。然后,使用该存储库的 URL 配置目标机器。从目标机器的角度来看,这是一个基于拉的模型,允许它们获取尚未安装的依赖项。这些存储库由特定的目录布局组成,其中预定义名称和格式的文件包含元数据并链接到实际的包文件。
这些文件和目录可以通过传输机制公开,例如本地文件访问(可能通过网络文件系统挂载)、HTTP 和 FTP。HTTP 是很好的选择,因为它设置简单,易于调试,而且通常不会成为性能瓶颈,因为它是标准的系统组件。
有各种各样的软件来管理 Debian 仓库,其中大部分都没有很好的文档记录或者很少维护。一些解决方案,如 *debarchiver、*或 dak ,提供了通过 SSH 的远程上传,但没有给出上传是否成功的即时反馈。Debarchiver 还批量处理上传的文件,这是由 cron 作业触发的,这会导致延迟,从而降低自动化的乐趣。
我选择了**,* 6 ,这是一个用于管理存储库的命令行工具集。当您向存储库中添加一个新的包时,会以退出代码的形式给出及时的反馈。它没有提供一种方便的方式将文件上传到存储库所在的服务器上,但是这是管道管理器可以做的事情。*
*最后,Aptly 可以在一个存储库中保存同一个包的多个版本,这使得回滚到以前的版本更加容易。
安装软件包的工具
一旦您构建了一个 Debian 包,将它上传到一个存储库中,并配置了目标机器来使用这个存储库,交互式包安装看起来就像这样:
$ apt-get update && apt-get install $package
在自动化安装中有一些微妙之处需要注意。您必须关闭所有形式的交互,可能控制输出的详细程度,配置降级是否可接受,等等。
与其试图找出所有这些细节,不如重用一个现有的工具,该工具的作者已经完成了艰苦的工作。配置管理工具如 Ansible 、、 7 、、 Chef 、、 8 、、木偶、、 9 、、盐、、10、雷克斯、、 11 、都有安装包的模块
然而,并不是所有的配置管理系统都适合自动化部署。Puppet 通常在基于拉的模型中使用,在这种模型中,每个 Puppet 管理的机器定期联系服务器,并请求其目标配置。这对可伸缩性来说很好,但是集成到工作流中是一个大问题。在基于推的模型中,管理器联系被管理的机器(例如,通过 SSH)然后执行命令,这种模型更适合于部署任务(通常提供更简单、更愉快的开发和调试体验)。
对于这本书,我选择了 Ansible。这主要是因为我喜欢它的声明式语法,它的简单模型,并且到目前为止,谷歌搜索已经为所有实际问题找到了很好的解决方案。
控制管道
即使您从构建、测试、分发和安装软件的角度考虑部署管道,所做的大部分工作实际上是“粘合”,即使整个事情顺利运行的小任务。这些包括轮询版本控制系统,为构建作业准备目录,收集构建的包(或者在失败时中止当前管道实例),以及将工作分配给最适合该任务的机器。
当然,也有完成这些任务的工具。Jenkins 等一般 CI 和构建服务器通常可以完成这项工作。但也有专门做 CD 流水线的工具,比如 Go 连续交付 (GoCD) 12 和 Concourse 。 13
虽然 Jenkins 是一个很好的 CI 工具,但它以工作为中心的世界观使它不太适合 CD 的管道模型。在这里,我们将探索 GoCD,它是 ThoughtWorks,Inc .的开源软件,主要用 Java 编写,可用于大多数操作系统。为了方便基于 Debian 的开发环境,它提供了预构建的 Debian 包。
在接下来章节的例子中,我们将打包一个也运行单元测试的构建。在生产环境中,如果 Jenkins 中的所有测试都已通过,您可能会在 Jenkins 管道中包含一个使用 GoCD API 来触发 CD 步骤的构建后操作。
4.3 总结
CD 支持以小增量部署软件。这缩短了上市时间,缩短了反馈周期,并使得对新引入的 bug 进行分类变得更加容易。
CD 中涉及的步骤包括单元测试、包构建、包分发、安装和已安装包的测试。它由一个管道系统控制,为此我们将使用 GoCD。
Footnotes 12
Mike Britain,“Etsy 持续部署的原则和实践”,SlideShare, www.slideshare.net/mikebrittain/principles-and-practices-in-continuous-deployment-at-etsy ,2014 年 4 月 2 日。
3
梁赫群·马蒂,“快速移动并修复事物”,GitHub 工程, http://githubengineering.com/move-fast/ ,2015 年 12 月 15 日。
4
文森特·德里森,“一个成功的 Git 分支模型”,nvie.com, http://nvie.com/posts/a-successful-git-branching-model/ ,2010 年 1 月 5 日。
5
Python 软件基础,《PEP 427—车轮二进制包格式 1.0》, www.python.org/dev/peps/pep-0427/ ,2018。
6
7
8
9
10
11
12
13
*
五、构建包
我们将首先探索创建 Python 源代码 tarball 的基础知识,然后从这些 tarball 创建 Debian 包。
5.1 创建 Python 源代码 Tarball
要创建一个 Python 源代码 tarball,您必须编写一个使用distutils或setuptools的setup.py脚本。然后python setup.py sdist以正确的格式创建 tarball。
distutils是 Python 标准库的一部分,但是缺少一些常用的特性。setuptools通过扩展distutils增加了这些特性。你使用这些工具中的哪一个主要是个人喜好和环境的问题。
这是一个非常小的setup.py文件,使用setuptools作为第二章中webcount的例子。
from setuptools import setup
setup(
name = "webcount",
version = "0.1",
packages=['webcount', 'test'],
install_requires=['requests'],
)
这将从setuptools导入setup函数,并使用关于包的元数据调用它——名称、版本、要包含的 Python 包列表和依赖项列表。
setuptools文档 1 列出了可以传递给setup函数的其他参数。最常用的包括
-
Author:用于维护人员的姓名。author_email是联系人的电子邮件地址。 -
这应该是一个项目网站的链接。
-
package_data:用于向 tarball 添加非 Python 文件。 -
description:这是对软件包用途的一段描述。 -
python_requires:用于指定你的包支持哪些 Python 版本。 -
scripts:它可以保存作为可运行脚本安装的 Python 文件列表,而不仅仅是 Python 包。
当setup.py文件就绪后,您可以运行python setup.py sdist,它会在dist目录中创建一个 tarball。该文件的命名类似于setup.py中的name,后跟破折号、版本号,然后是后缀.tar.gz。在我们的例子中,它是dist/webcount-0.1.tar.gz。
5.2 用 dh-virtualenv 进行 Debian 打包
官方的 Debian 库有超过 40,000 个软件包,包括用所有通用编程语言编写的软件。为了支持这种规模和多样性,已经开发了一些工具来简化打包,而且还支持许多定制挂钩。
这个工具主要位于devscripts包中,它从debian目录中读取元数据和构建指令。
虽然 debhelper 工具的完整描述对于单独的一本书来说是一个足够大的主题,但我想在这里提供足够的信息来帮助您开始。
打包入门
dh-make包提供了一个创建框架debian目录的工具,其中已经填充了一些元数据和示例文件,您可以基于这些文件创建自己的版本。工具的其余部分然后利用debian包中的文件,从您的源代码构建二进制档案。
如果您在自己的开发环境中遵循这个示例,请确保在继续之前安装了dh-make包。
Debian 开发者的起点通常是另一个项目发布的带有源代码的 tar 存档,Debian 社区称之为上游的*。对于上一章的示例项目,我们是自己的上游,使用 Git 存储库而不是 tarball,所以我们必须指示dh_make构建自己的“原始”tarball,如下所示:*
$ dh_make --packageclass=s –yes --createorig \
-p python-webcount_0.1
Maintainer Name : Moritz Lenz
Email-Address : moritz@unknown
Date : Tue, 04 Sep 2018 15:04:35 +0200
Package Name : python-webcount
Version : 0.1
License : blank
Package Type : single
Currently there is not top level Makefile. This may require additional tuning Done. Please edit the files in the debian/ subdirectory now.
5.3 控制文件
debian/control有关于源包的元数据,并且可能有多个从这个源包构建的二进制包。对于python-webcount项目,经过一些小的编辑,它看起来像清单 5-1 。
Section: unknown
Priority: optional
Maintainer: Moritz Lenz <moritz@unknown>
Build-Depends: debhelper (>= 10), dh-virtualenv
Standards-Version: 4.1.2
Package: python-webcount
Architecture: any
Depends: python3
Description: Count occurrences of words in a web page
Listing 5-1File debian/control: Metadata for the Debian Package
Source: python-webcount
这声明了构建依赖关系dh-virtualenv,您需要安装它,以便构建 Debian 包。
指导构建过程
Debian 维护人员使用命令dpkg-buildpackage或debuild来构建 Debian 包。除此之外,这些工具以当前动作作为参数来调用debian/rules脚本。动作可以是configure、build、test或install等。
通常,debian/rules是一个 makefile,带有一个调用 deb helperdh的 catchall 目标%。最小的debian/rules脚本如下所示:
#!/usr/bin/make -f
%:
dh $@
我们必须扩展它来调用dh-virtualenv并告诉dh-virtualenv使用 Python 3 作为其安装的基础。
%:
dh $@ --with python-virtualenv
override_dh_virtualenv:
dh_virtualenv --python=/usr/bin/python3
作为 makefile,这里的缩进必须是实际的制表字符,而不是一系列空格。
声明 Python 依赖项
dh-virtualenv需要一个名为requirements.txt的文件,该文件列出了 Python 的依赖关系,每一个都在单独的一行上(列出了 5-2 )。
flask
pytest
gunicorn
Listing 5-2File requirements.txt
这些行将被传递给命令行上的pip,因此指定版本号的工作就像在 pip 中一样,例如pytest==3.8.0。您可以使用这样一行代码
--index-url=https://...
指定一个指向您自己的 pypi 镜像的 URL,然后dh-virtualenv用它来获取包。
构建包
一旦这些文件准备就绪,您就可以使用以下命令来触发构建:
$ dpkg-buildpackage -b -us -uc
-b选项指示dpkg-buildpackage只构建二进制包(这是我们想要的可部署单元),而-us和-uc跳过了 Debian 开发者将他们的包上传到 Debian 镜像的签名过程。
该命令必须在项目的根目录(也就是包含debian目录的目录)中调用,当成功时,它会将生成的.deb文件放入根目录的父目录中。
创建 python-matheval 包
打包matheval为 Debian 包python-matheval的工作方式类似于webcount。主要区别在于matheval是一个应该一直运行的服务。
我们使用 systemd ,2Debian、Ubuntu 和许多其他 Linux 发行版使用的 init 系统来控制服务进程。这是通过写一个单元文件,存储为debian/python-matheval.service来完成的。
[Unit]
Description=Evaluates mathematical expressions
Requires=network.target
After=network.target
[Service]
Type=simple
SyslogIdentifier=python-matheval
User=nobody
ExecStart=/usr/share/python-custom/python-matheval/bin/\
gunicorn --bind 0.0.0.0:8800 matheval.frontend:app
PrivateTmp=yes
InaccessibleDirectories=/home
ReadOnlyDirectories=/bin /sbin /usr /lib /etc
[Install]
WantedBy=multi-user.target
管理 systemd 单元文件是 Debian 软件包的一项标准任务,因此有一个助手工具可以为我们完成这项任务:dh-systemd。我们必须安装它,并在control文件中将它声明为一个构建依赖项(清单 5-3 )。
Source: python-matheval
Section: main
Priority: optional
Maintainer: Moritz Lenz <moritz.lenz@gmail.com>
Build-Depends: debhelper (>=9), dh-virtualenv,
dh-systemd, python-setuptools
Standards-Version: 3.9.6
Package: python-matheval
Architecture: any
Depends: python3 (>= 3.4)
Description: Web service that evaluates math expressions.
Listing 5-3debian/control File for the python-matheval Package
debian/rules文件同样需要一个--with systemd参数。
#!/usr/bin/make -f
export DH_VIRTUALENV_INSTALL_ROOT=/usr/share/python-custom
%:
dh $@ --with python-virtualenv --with systemd
override_dh_virtualenv:
dh_virtualenv --python=/usr/bin/python3 --setuptools-test
大家熟悉的dpkg-buildpackage调用一起创建了一个 Debian 包,它在安装时会自动启动 web 服务,并在安装了新版本的包时重新启动它。
dh-virtualenv 的权衡
dh-virtualenv工具使得创建包含所有 Python 依赖项的 Debian 包变得非常容易。这对开发人员来说非常方便,因为这意味着他/她可以开始使用 Python 包,而不必从它们创建单独的 Debian 包。
这也意味着您可以依赖安装在同一台机器上的多个应用中的几个不同版本的 Python 包——如果您使用系统范围的 Python 包,这是不容易做到的。
另一方面,这种“胖包装”意味着如果一个 Python 包包含一个安全缺陷,或者一个严重的错误,那么您必须重新构建并部署所有包含有缺陷代码副本的 Debian 包。
最后,dh-virtualenv包被绑定到构建服务器上使用的 Python 版本。因此,例如,如果一个包是为 Python 3.5 构建的,它就不能与 Python 3.6 一起工作。如果您要从一个 Python 版本过渡到下一个版本,您必须并行地为两个版本构建包。
5.4 总结
我们分两步构建包:首先,基于 Python setuptools的 Python 源代码 tarball,然后通过dh-virtualenv构建二进制 Debian 包。这两个步骤都使用了一些文件,主要基于声明性语法。最终结果是一个大部分自包含的 Debian 包,只需要在目标机器上安装一个匹配的 Python 版本。
https://setuptools.readthedocs.io/en/latest/setuptools.html#basic-use 。
2
维基百科,《系统》, https://en.wikipedia.org/wiki/Systemd ,2018 年。
*
六、分发 Debian 软件包
一旦 Debian 软件包被构建,它必须被分发到将要安装它的服务器上。Debian,以及基本上所有其他的操作系统,都使用拉模型。包及其元数据存储在服务器上,客户端可以与服务器通信并请求元数据和包。
元数据和包的总和被称为一个库。为了将包分发到需要它们的服务器,我们必须建立和维护这样一个存储库。
6.1 签名
在 Debian 领域,包是加密签名的,以确保它们不会在存储服务器上或传输过程中被篡改。因此,第一步是创建一个密钥对,用于对这个特定的存储库进行签名。(如果您已经有了用于签名包的 PGP 密钥,可以跳过这一步。)
下面假设您正在与一个没有 GnuPG 密匙环的原始系统用户一起工作,这个密匙环将用于维护 Debian 仓库。它还假设您已经在版本 2 或更高版本中安装了gnupg包。
首先,创建一个名为key-control-file-gpg2的文件,内容如下:
%no-protection
Key-Type: RSA
Key-Length: 1024
Subkey-Type: RSA
Name-Real: Aptly Signing Key
Name-Email: nobody@example.com
Expire-Date: 0
%commit
%echo done
用您自己的电子邮件地址或您所从事项目的电子邮件地址替换 nobody@example.com,然后运行以下命令:
$ gpg --gen-key --batch key-control-file-gpg2
该命令的输出包含如下所示的一行:
gpg: key D163C61A6C25A6B7 marked as ultimately trusted
十六进制数字串D163C...是密钥 ID,每次运行都不同。用它来导出公钥,我们稍后会用到它。
$ gpg --export --armor D163C61A6C25A6B7 > pubkey.asc
6.2 准备存储库
我恰当地使用了1来创建和管理存储库。它是一个没有服务器组件的命令行应用。
要初始化一个存储库,我首先必须想出一个名字。在这里,我称之为myrepo。
$ aptly repo create -distribution=stretch \
-architectures=amd64,i386,all -component=main myrepo
Local repo [myrepo] successfully added.
You can run 'aptly repo add myrepo ...' to add packages
to repository.
$ aptly publish repo -architectures=amd64,i386,all myrepo
Warning: publishing from empty source, architectures list
should be complete, it can't be changed after publishing
(use -architectures flag)
Loading packages...
Generating metadata files and linking package files...
Finalizing metadata files...
Signing file 'Release' with gpg, please enter your
passphrase when prompted:
Clearsigning file 'Release' with gpg, please enter your
passphrase when prompted:
Local repo myrepo has been successfully published.
Please set up your webserver to serve directory
'/home/aptly/.aptly/public' with autoindexing.
Now you can add following line to apt sources:
deb http://your-server/ stretch main
Don't forget to add your GPG key to apt with apt-key.
You can also use `aptly serve` to publish your repositories over HTTP quickly.
现在已经创建了存储库,您可以通过运行
$ aptly repo add myrepo python_webcount_0.1-1_all.deb
$ aptly publish update myrepo
这将把.aptly/public中的文件更新为一个有效的 Debian 仓库,其中包含了新添加的包。
6.3 自动创建存储库和添加包
为了在部署管道中使用,使用一个命令创建存储库以及向这些存储库中添加包是很方便的。为不同的环境建立单独的存储库也是有意义的。因此,我们需要为测试、试运行和生产环境各准备一个存储库。第二个维度是为其构建包的发行版。
下面是一个小程序(清单 6-1 ),给定一个环境、一个发行版和一个 Debian 包的文件名列表,在路径$HOME/aptly/$environment/$distribution中创建存储库,添加包,然后更新存储库的公共文件:
#!/usr/bin/env python3
import json
import os
import os.path
import subprocess
import sys
assert len(sys.argv) >= 4, \
'Usage: add-package <env> <distribution> <.deb-file>+'
env, distribution = sys.argv[1:3]
packages = sys.argv[3:]
base_path = os.path.expanduser('~') + '/aptly'
repo_path = '/'.join((base_path, env, distribution))
config_file = '{}/{}-{}.conf'.format(base_path, env,
distribution)
def run_aptly(*args):
aptly_cmd = ['aptly', '-config=' + config_file]
subprocess.call(aptly_cmd + list(args))
def init_config():
os.makedirs(base_path, exist_ok=True)
contents = {
'rootDir': repo_path,
'architectures': ['amd64', 'all'],
}
with open(config_file, 'w') as conf:
json.dump(contents, conf)
def init_repo():
if os.path.exists(repo_path + '/db'):
return
os.makedirs(repo_path, exist_ok=True)
run_aptly('repo', 'create',
'-distribution=' + distribution, 'myrepo')
run_aptly('publish', 'repo', 'myrepo')
def add_packages():
for pkg in packages:
run_aptly('repo', 'add', 'myrepo', pkg)
run_aptly('publish', 'update', distribution)
if __name__ == '__main__':
init_config();
init_repo();
add_packages();
Listing 6-1add-package, a Tool for Creating and Populating Debian Repositories
它可以用作
$ ./add-package testing stretch python-matheval_0.1-1_all.deb
将python-matheval_0.1-1_all.deb文件添加到环境测试的扩展存储库中,如果存储库尚不存在,它会自动创建该存储库。
6.4 为存储库提供服务
事实上,这些存储库只能在一台机器上使用。使它们对更多机器可用的最简单的方法是通过 HTTP 将公共目录作为静态文件提供。
如果您使用 Apache 作为 web 服务器,那么为这些文件提供服务的虚拟主机配置可能如清单 6-2 所示。
ServerName apt.example.com
ServerAdmin moritz@example.com
DocumentRoot /home/aptly/aptly/
Alias /debian/testing/stretch/ \
/home/aptly/aptly/testing/stretch/public/
Alias /debian/production/stretch/ \
/home/aptly/aptly/production/stretch/public/
# more repositories go here
Options +Indexes +FollowSymLinks
Require all granted
LogLevel notice
CustomLog /var/log/apache2/apt/access.log combined
ErrorLog /var/log/apache2/apt/error.log
ServerSignature On
Listing 6-2Apache 2 Configuration for Serving Debian Repositories
在创建日志目录(mkdir -p /var/log/apache2/apt/)、启用虚拟主机(a2ensite apt.conf)并重启 Apache 之后,Debian 仓库就准备好了。
相反,如果您更喜欢使用 lighttpd 、2,您可以使用清单 6-3 中的配置片段。
dir-listing.encoding = "utf-8"
server.dir-listing = "enable"
alias.url = (
"/debian/testing/stretch/" =>
"/home/aptly/aptly/testing/stretch/public/",
"/debian/production/stretch/" =>
"/home/aptly/aptly/production/stretch/public/",
# more repositories go here
)
Listing 6-3lighttpd Configuration for Serving Debian Repositories
配置计算机以使用存储库
当机器使用一个新的存储库时,它首先必须信任用来签署存储库的密钥。
将 PGP 公钥(pubkey.asc)复制到将使用该存储库的机器上,并导入它。
$ apt-key add pubkey.asc
然后添加实际的包源。
$ echo "deb http://apt.example.com/ stretch main" \
> /etc/apt/source.list.d/myrepo.list
在一个apt-get update之后,存储库的内容是可用的,并且一个apt-cache policy python-matheval将存储库显示为这个包的一个可能的源。
$ apt-cache policy python-webcount
python-webcount:
Installed: (none)
Candidate: 0.1-1
Version table:
*** 0.1-1 0
990 http://apt.example.com/ stretch/main amd64 Packages
100 /var/lib/dpkg/status
Debian 仓库管理的旋风之旅到此结束,包分发也结束了。
6.5 总结
来自 APT 软件套件的 Debian 软件包安装程序,如apt-get和aptitude读取元数据并从存储库中下载软件包。像这样的软件可以很好地管理这些存储库。
加密签名对软件包进行认证,并捕捉修改软件包的中间人攻击和传输错误。您必须创建一个 GPG 密钥,并适当地提供它,并将目标机器配置为信任此密钥。
Footnotes 12
七、包部署
在前面的章节中,您已经看到了 Debian 软件包是如何构建的,如何插入到一个存储库中,以及如何将这个存储库配置为目标机器上的软件包源。有了这些准备工作,交互式安装实际的包就变得容易了。
要安装python-matheval示例项目,运行
$ apt-get update
$ apt-get install python-matheval
在目标机器上。
如果需要多台计算机来提供服务,那么协调更新会很有好处,例如,一次只更新一台或两台主机,或者在转移到下一台主机后,在每台主机上进行一次小型集成测试。一个很好的工具是一个开源的 IT 自动化和配置管理系统。
7.1 可扩展:一级
Ansible 是一个非常实用和强大的配置管理系统,很容易上手。如果您已经熟悉 Ansible(或者选择使用不同的配置管理和部署系统),您可以安全地跳过这一节。
连接和库存
Ansible 通常用于通过安全外壳(SSH)连接到一个或多个远程机器,并使它们进入所需的状态。连接方法是可插拔的。其他方法包括local,它只是调用本地机器上的命令,以及docker,它通过 Docker 守护进程连接以配置一个正在运行的容器。Ansible 称这些远程机器为主机。
要告诉 Ansible 在哪里以及如何连接,您需要编写一个清单或主机文件。在清单文件中,您可以定义主机和主机组,还可以设置控制如何连接到它们的变量(清单 7-1 )。
# example inventory file
[all:vars]
# variables set here apply to all hosts
ansible_ssh_user=root
[web]
# a group of webservers
www01.example.com
www02.example.com
[app]
# a group of 5 application servers,
# all following the same naming scheme:
app[01:05].example.com
[frontend:children]
# a group that combines the two previous groups
app
web
[database]
# here we override ansible_ssh_user for just one host
db01.example.com ansible_ssh_user=postgres
Listing 7-1File myinventory: an Ansible Hosts File
详见库存档案介绍 2 。
要测试连接,您可以在命令行上使用ping模块。
$ ansible -i myinventory web -m ping
www01.example.com | success >> {
"changed": false,
"ping": "pong"
}
www02.example.com | success >> {
"changed": false,
"ping": "pong"
}
让我们将命令行分成几个部分。-i myinventory告诉 Ansible 使用myinventory文件作为库存。web告诉 Ansible 在哪个主机上工作。它可以是一个组,如本例所示,也可以是单个主机,或者是几个这样的东西,用冒号隔开。例如,www01.example.com:database将选择一个 web 服务器和所有数据库服务器。
最后,-m ping告诉 Ansible 执行哪个模块。ping大概是最简单的模块。它只是发送响应"pong",而不在远程机器上进行任何更改,它主要用于调试库存文件和凭证。
这些命令在不同的主机上并行运行,因此打印响应的顺序可能会有所不同。如果在连接到主机时出现问题,在命令行中添加选项-vvvv,以获得更多输出,包括来自 SSH 的任何错误消息。
Ansible 隐式地为您提供了组all,您猜对了,它包含清单文件中配置的所有主机。
模块
每当你想通过 Ansible 在主机上做一些事情时,你就调用一个模块来完成它。模块通常接受指定应该发生什么的参数。在命令行上,您可以用ansible -m module –a 'arguments'添加这些参数。例如:
$ ansible -i myinventory database -m shell -a 'echo "hi there"'
db01.example.com | success | rc=0 >>
hi there
Ansible 提供了丰富的内置模块和第三方模块生态系统。大多数模块都是幂等的,这意味着在第一次运行之后,使用相同的参数重复执行不会带来任何变化。例如,不是指示 Ansible 创建一个目录,而是指示它确保该目录存在。第一次运行这样的指令会创建目录,第二次运行它不会做任何事情,但仍然会报告成功。
在这里,我想介绍几个常用的模块。
外壳模块
shell模块 3 在主机上执行一个 shell 命令,并接受一些选项,如chdir,在运行命令之前,切换到另一个工作目录。
$ ansible -i myinventory database -m shell -e 'pwd chdir=/tmp'
db01.example.com | success | rc=0 >>
/tmp
这很普通,但也是最后的选择。如果手头的任务有更具体的模块,您应该更喜欢更具体的模块。例如,您可以使用shell模块来确保系统用户的存在,但是更专业的用户模块 4 更易于使用,并且可能比临时编写的 shell 脚本做得更好。
复制模块
用 copy 、 5 可以将文件从本地一字不差地复制到远程机器上。
$ ansible -i myinventory database -m copy \
-a 'src=README.md dest=/etc/motd mode=644 db01.example.com' | success >> {
"changed": true,
"dest": "/etc/motd",
"gid": 0,
"group": "root",
"md5sum": "d41d8cd98f00b204e9800998ecf8427e",
"mode": "0644",
"owner": "root",
"size": 0,
"state": "file",
"uid": 0
}
模板模块
template 6 的工作方式大多与copy类似,但它在将源文件传输到远程主机之前,会将其解释为 Jinja2 模板、 7 。这通常用于创建配置文件和合并来自变量的信息(稍后将详细介绍)。
模板不能直接从命令行使用,而是在行动手册中使用,因此这里有一个简单的行动手册示例。
# file motd.j2
This machine is managed by {{team}}.
# file template-example.yml
---
- hosts: all
vars:
team: Slackers
tasks:
- template: src=motd.j2 dest=/etc/motd mode=0644
稍后将在行动手册中详细介绍,但是您可以看到,这定义了一个变量team,将其设置为值Slackers,并且模板对该变量进行插值。
运行行动手册
$ ansible-playbook -i myinventory \
--limit database template-example.yml
在数据库服务器上创建一个包含内容的文件/etc/motd
This machine is managed by Slackers.
文件模块
file模块 8 管理着文件名的属性,比如权限,还可以让你创建目录和软硬链接。
$ ansible -i myinventory database -m file \
-a 'path=/etc/apt/sources.list.d
state=directory mode=0755'
db01.example.com | success >> {
"changed": false,
"gid": 0,
"group": "root",
"mode": "0755",
"owner": "root",
"path": "/etc/apt/sources.list.d",
"size": 4096,
"state": "directory",
"uid": 0
}
apt 模块
在 Debian 和衍生发行版(比如 Ubuntu)上,安装和移除软件包通常是由来自apt家族的软件包管理器来完成的,比如apt-get、aptitude,而在更新的版本中,则直接由apt二进制文件来完成。
apt模块 9 从 Ansible 内部对此进行管理。
$ ansible -i myinventory database -m apt \
-a 'name=screen state=present update_cache=yes'
db01.example.com | success >> {
"changed": false
}
这里已经安装了screen包,所以模块没有改变系统的状态。
单独的模块可用于管理 apt-keys 10 ,利用这些模块对存储库进行密码验证,以及管理存储库本身。 11
yum 和 zypper 模块
对于基于 RPM 的 Linux 发行版,可以使用 yum 12 和 zypper模块 13 (在编写本文时,处于预览状态)。他们通过同名的包管理器来管理包的安装。
包装模块
package模块 14 使用它检测到的任何包管理器。因此,它比apt和yum模块更通用,但支持的功能要少得多。例如,在apt的情况下,它不提供在做其他事情之前是否运行apt-get update的任何控制。
特定应用模块
到目前为止,所展示的模块与系统非常接近,但是也有一些模块用于实现常见的特定于应用的任务。例子包括处理 与数据库,15网络相关的东西比如代理, 16 版本控制系统, 17 集群解决方案比如 Kubernetes , 18 等等。
剧本
行动手册可以包含以定义的顺序对模块的多次调用,并将其执行限制在单个主机或一组主机上。它们以 YAML 文件格式 19 编写,这是一种为人类可读性而优化的数据序列化文件格式。
这里有一个样例剧本(清单 7-2 )安装了最新版本的go-agent Debian 包,即 Go 连续交付 (GoCD)的工作器。 20
---
- hosts: go-agent
vars:
go_server: go-server.example.com
tasks:
- apt: package=apt-transport-https state=present
- apt_key:
url: https://download.gocd.org/GOCD-GPG-KEY.asc
state: present
validate_certs: no
- apt_repository:
repo: 'deb https://download.gocd.org /'
state: present
- apt: update_cache=yes package={{item}} state=present
with_items:
- go-agent
- git
- build-essential
- lineinfile:
dest: /etc/default/go-agent
regexp: ^GO_SERVER=
line: GO_SERVER={{ go_server }}
- copy:
src: files/guid.txt
dest: /var/lib/go-agent/config/guid.txt
user: go
group: go
- service: name=go-agent enabled=yes state=started
Listing 7-2An Ansible Playbook
for Installing a GoCD Agent on a Debian-Based System
该文件中的顶级元素是一个单元素列表。单个元素以hosts: go-agent开始,这将执行限制到组go-agent中的主机。这是附带的清单文件的相关部分:
[go-agent]
go-worker01.p6c.org
go-worker02.p6c.org
然后,它将变量go_server设置为一个字符串,这里是运行 GoCD 服务器的主机名。
最后是剧本的核心部分:要执行的任务列表。每个任务都是对一个模块的调用,其中一些已经讨论过了。以下是一个快速概述。
-
首先,
apt安装 Debian 包apt-transport-https,以确保系统可以通过 HTTPS 从 Debian 仓库获取元数据和文件。 -
接下来的两个任务使用
apt_repository21 和apt_key22模块来配置将从其中安装实际go-agent包的存储库。 -
另一个对
apt的调用安装了所需的包。此外,一些更多的软件包安装了一个循环结构。 23 -
lineinfile模块 24 通过 regex(正则表达式)在文本文件中搜索一行,并用预定义的内容替换它找到的行。这里,我们使用它来配置代理连接到的 GoCD 服务器。 -
最后,
service25 模块启动代理,如果它还没有运行(state=started),并确保它在重新引导时自动启动(enabled=yes)。
使用ansible-playbook命令调用剧本,例如ansible-playbook -i inventory go-agent.yml。
一个行动手册中可以有多个任务列表,当它们影响不同的主机组时,这是一个常见的用例。
---
- hosts: go-agent:go-server
tasks:
- apt: package=apt-transport-https state=present
- apt_key:
url: https://download.gocd.org/GOCD-GPG-KEY.asc
state: present
validate_certs: no
- apt_repository:
repo: 'deb https://download.gocd.org /'
state: present
- hosts: go-agent
tasks:
- apt: update_cache=yes package={{item}} state=present
with_items:
- go-agent
- git
- build-essential
- ...
- hosts: go-server
tasks:
- apt: update_cache=yes package={{item}} state=present
- apt: update_cache=yes package=go-server state=present
- ...
变量
变量对于控制剧本中的流程和填充模板中的点以生成配置文件都很有用。有几种方法可以设置变量。一种方法是通过vars: ...直接在剧本中设置它们,如前所述。另一种方法是在命令行中指定它们。
ansible-playbook --extra-vars=variable=value theplaybook.yml
第三种非常灵活的方法是使用group_vars特性。对于主机所在的每个组,Ansible 查找文件group_vars/thegroup.yml和匹配group_vars/thegroup/*.yml的文件。一个主机可以同时属于几个组,这为您提供了额外的灵活性。
例如,您可以将每台主机分为两组,一组用于主机所扮演的角色(如 web 服务器、数据库服务器、DNS 服务器等。),另一个用于其所处的环境(测试、试运行、生产)。下面是一个使用这种布局的小例子。
# environments
[prod]
www[01:02].example.com
db01.example.com
[test]
db01.test.example.com
www01.test.example.com
# functional roles
[web]
www[01:02].example.com
www01.test.example.com
[db]
db01.example.com
db01.test.example.com
要仅配置测试主机,您可以运行
ansible-playbook --limit test theplaybook.yml
并将特定于环境的变量放在group_vars/test.yml和group_vars/prod.yml中,将特定于 web 服务器的变量放在group_vars/web.yml中,等等。
您可以在变量中使用嵌套的数据结构,如果这样做,您可以配置 Ansible 来合并这些数据结构,如果它们在几个源中被指定的话。您可以通过创建一个名为ansible.cfg的文件进行配置,其内容如下:
[defaults]
hash_behavior=merge
这样,您可以拥有一个设置默认值的文件group_vars/all.yml
# file group_vars/all.yml
myapp:
domain: example.com
db:
host: db.example.com
username: myappuser
instance. myapp
然后覆盖该嵌套数据结构的单个元素,例如在group_vars/test.yml中,如下所示:
# file group_vars/test.yml
myapp:
domain: test.example.com
db:
hostname: db.test.example.com
test组变量文件没有触及的键,例如myapp.db.username,是从all.yml文件继承的。
角色
角色是将剧本的各个部分封装成可重用组件的一种方式。让我们考虑一个导致简单角色定义的真实例子。
对于部署软件,您通常希望部署刚刚构建的确切版本,因此行动手册的相关部分是
- apt:
name: thepackage={{package_version}}
state: present
update_cache: yes
force: yes
但是这需要您在运行剧本时提供package_version变量。当您不运行新构建软件的部署,而是配置一台新机器,并且必须安装几个软件包,每个软件包都有自己的剧本时,这将是不实际的。
因此,我们将代码一般化,以处理缺少版本号的情况。
- apt:
name: thepackage={{package_version}}
state: present
update_cache: yes
force: yes
when: package_version is defined
- apt: name=thepackage state=present update_cache=yes
when: package_version is undefined
如果您将几个这样的行动手册包含在一个中,并在同一台主机上运行它们,您可能会注意到,它大部分时间都在为每个包含的行动手册运行apt-get update。
第一次更新apt缓存是必要的,因为在部署之前,您可能刚刚在您的本地 Debian 镜像上上传了一个新的包,但是后续的运行是不必要的。因此,您可以将主机已经为其缓存更新的信息存储在一个 事实 , 26 中,这是 Ansible 中一种基于主机的变量。
- apt: update_cache=yes
when: apt_cache_updated is undefined
- set_fact:
apt_cache_updated: true
正如你所看到的,明智地安装一个包的代码库已经增长了一些,是时候把它分解成一个角色了。
角色是具有预定义名称的 YAML 文件的集合。命令
$ mkdir roles
$ cd roles
$ ansible-galaxy init custom_package_installation
为名为custom_package_installation的角色创建一个空框架。之前放在所有剧本中的任务现在放在角色主目录下的文件tasks/main.yml(清单 7-3 )中。
- apt: update_cache=yes
when: apt_cache_updated is undefined
- set_fact:
apt_cache_updated: true
- apt:
name: {{package}={{package_version}}
state: present
update_cache: yes
force: yes
when: package_version is defined
- apt: name={{package} state=present update_cache=yes
when: package_version is undefined
Listing 7-3File roles/custom_package_installation/tasks/main.yml
要使用该角色,请将其包含在行动手册中,如下所示:
---
- hosts: web
pre_tasks:
- # tasks that are executed before the role(s)
roles:
role: custom_package_installation
package: python-matheval
tasks:
- # tasks that are executed after the role(s)
pre_tasks和tasks是可选的。只包含角色的剧本就可以了。
Ansible 有更多的功能,如处理程序,允许您在任何更改后只重新启动服务一次,更灵活的服务器环境的动态清单,加密变量的保险库, 27 ,以及用于管理常见应用和中间件的现有角色的丰富生态系统。
关于 Ansible 的更多内容,我强烈推荐优秀的书籍 Up and Running, 2nd ed。,作者洛林·霍赫斯坦(奥赖利传媒,2017)。
7.2 使用 Ansible 部署
有了上一节的 Ansible 知识,部署就成了一项简单的任务。我们从环境的单独清单文件开始(清单 7-4 )。
[web]
www01.yourorg.com
www02.yourorg.com
[database]
db01.yourorg.com
[all:vars]
ansible_ssh_user=root
Listing 7-4Ansible Inventory File production
也许测试环境只需要一台 web 服务器(清单 7-5 )。
[web]
www01.testing.yourorg.com
[database]
db01.stagingyourorg.com
[all:vars]
ansible_ssh_user=root
Listing 7-5Ansible Inventory File testing
在测试环境中的 web 服务器上安装包python-matheval现在是一行程序。
$ ansible -i testing web -m apt -a 'name=python-matheval update_cache=yes state=latest'
一旦您开始使用 Ansible 进行部署,您可能还想使用它来执行其他配置管理任务,因此为您想要部署的每个包编写一个行动手册是有意义的。这里有一个(清单 7-6 )使用了本章前面“角色”一节中的包安装角色。
---
- hosts: web
roles:
role: custom_package_installation
package: python-matheval
Listing 7-6File deploy-python-matheval.yml: Deployment Playbook for Package python-matheval
然后,您可以像这样调用它
$ ansible-playbook -i testing deploy-python-matheval.yml
7.3 总结
Ansible 可以为您安装软件包,但它还可以做更多的事情。它可以配置操作系统和应用,甚至在几台机器上协调流程。
通过编写一个清单文件,你告诉 Ansible 它控制哪些机器。行动手册指定要做什么,使用模块来完成单个任务,例如创建用户或安装软件。
Footnotes 12
http://docs.ansible.com/ansible/intro_inventory.html 。
3
http://docs.ansible.com/ansible/shell_module.html 。
4
http://docs.ansible.com/ansible/user_module.html 。
5
http://docs.ansible.com/ansible/copy_module.html 。
6
http://docs.ansible.com/ansible/template_module.html 。
7
http://jinja.pocoo.org/docs/dev/ 。
8
http://docs.ansible.com/ansible/file_module.html 。
9
http://docs.ansible.com/ansible/apt_module.html 。
10
http://docs.ansible.com/ansible/apt_key_module.html 。
11
http://docs.ansible.com/ansible/apt_repository_module.html 。
12
http://docs.ansible.com/ansible/yum_module.html 。
13
http://docs.ansible.com/ansible/zypper_module.html 。
14
http://docs.ansible.com/ansible/package_module.html 。
15
http://docs.ansible.com/ansible/list_of_database_modules.html 。
16
http://docs.ansible.com/ansible/list_of_network_modules.html 。
17
http://docs.ansible.com/ansible/list_of_source_control_modules.html 。
18
http://docs.ansible.com/ansible/kubernetes_module.html 。
19
20
21
http://docs.ansible.com/ansible/apt_repository_module.html 。
22
http://docs.ansible.com/ansible/apt_key_module.html 。
23
http://docs.ansible.com/ansible/playbooks_loops.html 。
24
http://docs.ansible.com/ansible/lineinfile_module.html 。
25
http://docs.ansible.com/ansible/service_module.html 。
26
https://docs.ansible.com/ansible/set_fact_module.html 。
27
http://docs.ansible.com/ansible/playbooks_vault.html 。