5.1 CI(持续集成)
5.1.1 什么是 CI(持续集成)
团队开发中最重要的工作是什么?是编码等开发工作?还是测试工作?当然这些工作都是团队开发中基本且重要的工作。但是为了顺利地推进由多人参与的开发以及测试,最重要的基础是“集成”。
●…… 集成(integration)
将各个成员的工作成果集中到一处进行集成,直到形成可以运行的系统,各个成员的开发工作才有了意义,才能进行测试,最终才能以产品的形式向顾客提供价值。
集成,具体来说就是像下面这样执行 build 和测试的流程。
将所有的代码集中到一处
设置依赖程序库等的路径
必要的情况下进行编译
进行数据库构建和数据加载
必要的情况下对中间件进行配置和启动
实施单元测试、集成测试、用户验收测试等
●…… 持续地进行集成就是 CI
将这些集成处理流程持续地执行,就是 CI(Continuous Integration,持续集成)1 。
1 如果从一开始就能实现之前提到的所有集成处理流程的自动化,这个自然是最理想的。但这将耗费大量的时间和精力,因此没有必要将实现所有集成处理的自动化作为目标。不仅仅是 CI,类似的实践的效果以及对应的开销都需要进行权衡。因此,可以先从自己项目所必要的处理开始实现自动化。
原本 CI 是作为敏捷开发方法之一的极限编程(eXtreme Programming,XP)的实践项目被提出的。其实可以毫不过分地说,在以高品质、快速地提供有价值的软件为目标的敏捷开发中,CI 是最基本、最重要的实践。
在以前的瀑布模型开发 2 中,一般要等到所有的开发工作都结束后,到测试阶段时才开始着手进行集成。
2 将系统的开发流程分为分析、设计、编码、测试、部署等阶段,并按上述顺序依次进行作业的开发模型。
想必读者都有过这样的经验:直到测试阶段才开始实施集成,往往会出现程序库缺失、数据库构建失败、编译无法通过等问题。集成的实施越迟、代码的量越大,就越困难。
要集成多名团队成员的作业内容更是一件相当麻烦的工作,因此往往容易往后拖延。但是只有经过了集成这个步骤,程序才能作为一个整体开始运行。在进行集成之前,确认产品是否满足需求,是否有奇怪的 bug 等检证工作也无法开展。可见拖延并不是一个好办法。
不将复杂的集成处理推后,而是在开发中时常地进行集成处理,由此来排除软件开发中的复杂性,这就是 CI 的思考方式。
5.1.2 使开发敏捷化
●…… 瀑布式开发的开发阶段
过去的瀑布式开发是在需求定义完成后进行设计、开发,之后再依次进行单元测试、集成测试、用户验收测试,因此到开始正式提供服务的开发周期容易拖得很长(图 5.1)。
图 5.1 瀑布式开发的开发阶段
开发全部完成后才开始单元测试,这就意味着如果开发周期为 3 个月,那么多名开发人员为期 3 个月所编写的代码直到单元测试阶段才第一次集中到一处进行 build。在此之后还有集成测试和用户验收测试。
从图 5.1 就能看出,从需求定义确定下来,到用可以实际运行的程序进行验证,这期间的时间间隔非常大。甚至会有在项目临近结束时还需要重新返工的风险。
瀑布式开发是确保各阶段的正确性之后再开始下一阶段,因此理论上在进入下一个阶段后就不应该返回上一阶段。但实际上,很多事情只有在目睹运行的系统后才会知道。正如读者所知,很多采用瀑布式开发的项目直到临近结束还在修改需求、重新编码并重新测试。
●…… 敏捷开发的开发阶段
与之相对,以 Scrum 为代表的敏捷开发采取了相反的方式,即以 sprint 这样较短的周期来循环实施设计开发、单元测试、集成测试、用户验收测试(图 5.2)。sprint 的周期因项目而异,通常多设定为两周到 1 个月。
图 5.2 敏捷开发的开发阶段
Scrum 以 sprint 为周期提交工作成果,并以 sprint review 这样的流程对工作成果进行审查,给出反馈,并反映到下一个 sprint 的作业中。以正式提供服务为目标 3 ,重复上述过程。上述流程可以理解成将瀑布式开发中从需求定义到用户验收测试的过程浓缩到为期两周的 sprint 内进行 4 。这样的方式能够及早地检查出产品和需求之间的差异,调整的机会也会相应增加。
3 进一步来说,上线运营与其说是终点,倒不如说是开始。如果能在上线后持续进行 sprint,反复地改善和审查,就可以向顾客提供更多的价值。
4 当然要在两周内实现所有的需求是不可能的。因此要排出需求的优先顺序并估计工数、制定计划。从优先度高的项目开始,按顺序投入到 sprint 开发中。在每次的 sprint review 中检查开发状况,并根据市场的变化,一边调整优先度一边进入下一个开发周期,这就是敏捷开发。这里的估计工数和制定计划是敏捷开发中最困难、同时也是最有趣的部分。本书就不详述了,有兴趣的读者可以参考下面的文献资料。
《敏捷估计与规划》,(美)Mike Cohn 著,宋锐译,清华大学出版社,2007
CI 是敏捷开发的基础中最重要的实践。sprint 中的工作成果通常是指“可以确认满足某种需求的可运行的程序”,进一步说就是“可以判断是否能够正式投入运营的可运行程序”。要在短短两周的时间内制作出可以运行的产品,如果还要在集成上花费时间是肯定来不及的。更何况是到了 sprint 的后期才开始集成并测试,无论如何都是来不及的。因此就需要每天进行开发、集成、测试这样的循环,这就是 CI。换言之, build 和测试的自动化途径将是至关重要的。
无论是从自动化集成的角度还是从其他角度来看,CI 都是非常重要的。之前已经提到过,Scrum 以 sprint 周期为粒度反复进行 review 和反馈。实现 CI 的话,编程作业和自动化测试的循环就能粒度更细、运转更迅速。自动测试的结果正是程序是否正确运行的反馈。
也就是说,如图 5.3 所示,项目整体以 sprint 为粒度进行反馈,并这样不断循环。而每个 sprint 中又进一步以 CI 为粒度进行反馈,同样进行着循环。这样就构成了嵌套的结构图。
图 5.3 sprint 和 CI 的循环
以这样的粒度快速进行反馈的循环,就能够在快速开发的同时确保产品的质量,这就是敏捷开发。而在背后支撑着敏捷开发的就是 CI 这样的实践。
5.1.3 为什么要进行 CI 这样的实践
那么为何 CI 和敏捷开发近年来逐渐走红 5 了呢?想知道答案的话就需要从以下两个角度出发来思考。
5 这里虽然是说近年来才逐渐走红,事实上 CI 这样的思考方式很早之前就有了。比如《程序员修炼之道》(Andrew Hunt、David Thomas 著,马维达译,电子工业出版社,2011 年)中就提到了自动化测试的必要性;Joel on Software (Avram Joel Spolsky,APress,2004 年)中也以专栏的形式介绍了自动化 build 和自动化测试对提高工作效率的作用。近年来随着相应工具的出现,CI 也开始逐渐普及了。
成本效益(cost benefit)
市场变化的速度
●…… 成本效益
一般而言,bug 出现的时间越长,修改该 bug 的成本就越高。例如使用刚刚编写的代码当场进行测试,即使发现了 bug,查明原因并进行修改也是比较容易的。
与之相对,如果不进行测试直接提交代码,3 天后才注意到 bug 的话会怎么样呢?两周后、1 个月之后会怎么样呢?那时所写的代码的内容可能已经记不太清了,其他开发人员也可能对该代码进行过提交了,修改 bug 的难度将大大提升。而如果是 3 个月后或半年后才发现,想想就觉得非常恐怖了。
实现 build 和测试的自动化,并且能够实施 CI 的话就能解决这个问题。通过实施 CI,提交之后就能立即察觉是否有 bug 产生,修改 bug 的成本将大大降低。所以说 CI 的实施能大大提高成本效益。
这里所说的成本主要是指修改 bug 相关的经济方面的成本,除此之外还有其他需要注意的成本,例如可维护性相关的成本。
保持代码清晰易读、添加功能方便,这是长期维护产品中非常重要的。不仅是单纯地加快增加新功能的速度所带来的经济方面的优点,在保持负责维护和功能开发的团队成员的精神健康以及提高开发效率方面都会起到很大的作用。
●…… 市场变化的速度
如今的市场瞬息万变。特别是网站和智能手机的 App,1 款产品的开发周期一般不足 1 至 2 年。因为在开发期间市场趋势很可能会发生变化,导致开发出来的产品失去作用。还有 Web 之外的一些类似于财务系统这样的业务系统,还会受到突然的政策修改等外部环境变化的影响。
如果每次都按照瀑布式的开发流程,从头开始全部实施的话,可以 说很难跟上市场的变化。话虽如此,如果只是单纯地提早发布日期、缩短开发日程,而不在 CI 等方面下工夫的话,那只会造成 bug 和退化频发,进而导致发布的产品质量下降。效果恰恰相反。
为了应对市场的变化,在一定程度上不得不牺牲代码的可读性和可维护性。特别是在产品开发初期,这是常有的事情。不同的产品可能有所差异,一般而言投入市场越迟,就越容易失去机会。最差的情况下,辛苦开发的产品可能完全没有被用户接受的机会,产品开发的投入完全打了水漂。
●…… 兼顾开发速度和质量
如何才能既保持能够应对市场变化的开发速度,又保证高质量呢?CI 就能起到重要的作用。
例如,编写能确保产品 API 正常运行的最低限度的测试代码并执行 CI,在保证代码内容正常运行的基础上优先向市场进行发布。这个阶段代码的可读性和可维护性较差,功能添加也不方便,只是姑且能够运行并向用户提供价值而已。
确认产品能够正常使用之后,再用考虑了之前牺牲的可读性和可维护性的代码来替换原来的代码。这是为了应对下一个阶段,即进一步添加功能所必需的。
也就是说,初期优先速度,致力于尽快上市,之后再对代码进行重构以提高可维护性。而支持上述方式的正是 CI,有了 CI 上述方式才成为可能。既应对了市场的快速变化,又控制了确保可维护性和开发人员精神健康所需的成本。
综上,从成本效益和市场的变化速度这两个角度来看,CI 这样的实践是非常重要的。
5.1.4 CI 的必要条件
“CI 的字面意思已经理解了,想开始着手实施,应该怎么做呢?从何处着手呢?”为了回答这样的问题,我们来说一下 CI 的必要条件。
开始实施 CI 的必要条件如下。
版本管理系统
build 工具
测试代码
CI 工具
●…… 版本管理系统
实施 CI 过程中最重要的部分就是版本管理系统。正如第 3 章中所讲解的那样,构建程序所必需的资源应该尽可能地由版本管理系统进行统一的管理,这是非常重要的。例如代码、依赖关系、数据库模式、配置文件等。使用版本管理系统,任何人、任何时候都能够获取最新的资源是非常重要的。
●…… build 工具
同样重要的还有 build 工具。例如 make,写好 build 的定义后,只要执行一条命令,就能够进行代码编译、数据库构建和测试,并最终生成可以运行的程序。主要的 build 工具如表 5.1 所示。
表 5.1 主要的 build 工具
| build 工具 | 说明 |
|---|---|
| make | 经典的 build 工具。根据 Makefile 这样的配置文件进行 build。UNIX 下的软件 build 所必不可少的工具,负责搭建服务器的工程师每天都会接触 |
| SCons | 用 Python 编写的替代 make 的工具。使用由 Python 写的配置文件 SConstruct 进行 build。支持各种语言的 build,在 OSS 界有着一定的占有率。比较有名的是 V8 6 的 build 就使用了该工具 |
| Ant | Java 写的 build 工具,使用名为 build.xml 的 XML 文件来定义 build 顺序。因为要定义 build 顺序,所以使用起来比 Maven 稍显复杂,相反也正因为可以定义 build 顺序,所以非常灵活,在 Java 的世界中被广泛使用 |
| Maven | 由 Java 编写的项目管理工具。用 pom.xml 文件来定义 build。Maven 是根据 CoC(Convention over Configuration,惯例优先原则)所制作的工具,因此只要符合 Maven 的规则,就很少需要对 build 进行额外的定义。反之,和 Ant 相比灵活性要差了些。另外,Maven 作为项目管理工具,除了 build 以外还具有项目网站的生成、测试的执行以及部署等各类功能 |
| Gradle | Gradle 是近年来 Java 界比较受关注的新 build 工具。它并非使用 Java,而是用名为 Groovy 的 JVM(Java Virtual Machine)上运行的脚本语言编写的。配置也可以使用 Groovy 编写,比 XML 可读性强、自由度高。还可以只使用 Gradle 进行依赖关系的管理 7 。如果觉得 Ant 过于复杂,Maven 又缺乏灵活性,可以考虑使用 Gradle 作为解决这两个问题的 build 工具 |
| Rake | Ruby 编写的 build 工具。使用由 Ruby 编写的配置文件 Rakefile。随着 Ruby on Rails 的普及,以及以 Chef 和 Capistrano 为代表的服务器构建自动化工具 8 的普及,Rake 开始被广泛使用 |
6 Google 的 JavaScript 发动机。
7 实际上其内部包含了 Ivy,由 Ivy 进行依赖关系的管理。
8 Chef 和 Capistrano 都是由 Ruby 编写的工具,和同样可以用 Ruby 来定义 build 的 Rake 配合度较好。因此多数的 Chef 和 Capistrano 的任务都是由 Rake 来编写的。
当然,如果无论如何现有的工具都不可用的话,也可以用 shell 脚本或其他熟悉的语言自行编写 build 脚本,但这里并不推荐这样做。多数情况下使用表 5.1 中的 build 工具都应该能够满足需求。并且如果使用的是全栈式(full stack)Web 应用程序框架的话,有的框架也会自带 build 工具。
无论使用哪一款工具,重要的都是要具备无论谁在什么时候进行了提交,build 所需的处理都能自动进行这样的机制。
●…… 测试代码
CI 中的 build 工作并不是仅仅将代码编译一下就结束了。执行测试,持续地确认应用程序的正确性也是非常重要的。通过测试来确保程序的正确性就不必担心退化的发生,能够大胆地添加新功能和进行代码重构,还能够加快开发速度、提高产品质量。用于编写测试代码的框架的相关内容将稍后讲解。
●…… CI 工具
当然,执行 CI 需要相应的工具。CI 工具是将版本管理系统和代码、build 工具组合起来,持续地进行集成作业的工具。如果仅仅是执行自动 build、自动测试的话,不使用 CI 工具也可以进行。但通过利用 CI 工具的各类功能,能够解决很多问题,比如自动化测试何时进行,执行的结果如何显示和通知,build 结果和版本管理系统、缺陷管理系统之间的可追溯性如何确保等。
5.1.5 编写测试代码所需的框架
编写测试代码所需的框架有以下两种。
测试驱动开发(TDD)的框架
行为驱动开发(BDD)的框架
●…… 测试驱动开发(TDD)的框架
即实现测试驱动开发(Test Driven Development,TDD)所需的框架。
在编写应用程序的代码之前,为了确认需求先编写测试代码(test first),然后再编写符合测试代码的应用程序代码,这样的手法就是 TDD。这也是极限编程(XP)实践的一种。具有代表性的测试驱动开发框架有 Java 的 JUnit9 和 TestNG 10 等,还有为数众多的各类语言的 xUnit 系列框架。
例如 JUnit 的情况下,对于 Sample 类的 getName() 函数,可以像下面这样编写测试代码。
public class SampleTest { @Test public final void testSamplegetNameIsTakafumi() { Sample sample = new Sample(); assertThat(sample.getName(), is("Takafumi")); } }这些 xUnit 系列框架的特征是:通过编写测试用例,确认测试对象类以及方法的动作的正确性。换言之,也可以理解为根据测试代码来设计类的 API。这个特征和接下来的行为驱动开发工具形成了很好的对照。
●…… 行为驱动开发(BDD)的框架
从测试驱动开发发展而来,近年来逐渐流行起来的便是行为驱动开发(Behavior Driven Development,BDD)。BDD 和 TDD 一样都是最先编写测试代码。
和 TDD 的不同之处在于,TDD 是针对程序的 API 编写测试,而 BDD 则是接近于需求说明的编写方法。如同其名字一样,BDD 著眼于程序的行为。其方式是在需求确定后编写应用的代码,所以与 TDD 的测试优先相对,BDD 可以说是需求优先(spec first)。
BDD 的代表性框架有 Ruby 的 RSpec11 和 Cucumber 12 、JavaScript 的 Jasmine13 、Scala 的 Specs214 等。其他还有各个语言所对应的 xSpec 系列的框架。另外,Cucumber 方面,对 Ruby 之外的各语言的支持也在正式发布中。
例如 Cucumber 面向 Java 实现的 Cucumber-jvm 中,在名为 sample.feature 的文件中用自然语言如下这样描述需求。
#language: ja 特征(feature): Sample功能 场景(scenario):确认Sample类中的Name属性 前提:new Sample类 结果:getName的返回值应该为“Takafumi” 接着如下定义空的测试类。 import org.junit.runner.RunWith; import cucumber.junit.Cucumber; @RunWith(Cucumber.class) @Cucumber.Options(format={"pretty"}) public class SampleTest { }定义好之后,像通常的 JUnit 一样执行上述文件,标准输出会显示如下代码。
You can implement missing steps with the snippets below: @前提("^: new Sample类$") public void new _Sample类() throws Throwable { // Express the Regexp above with the code you wish you had throw new PendingException(); } @结果("^: getName的返回值应该为\”([^\”]*)\”$") public void _getName应该为(String arg_name) throws Throwable { // Express the Regexp above with the code you wish you had throw new PendingException(); }这就是用自然语言描述的 sample.feature 所对应的测试代码的雏形。将这段雏形代码复制粘贴到刚才空的测试类中并加以实现。本例的具体实现如下。
之后只需要向 sample.feature 用自然语言添加需求并执行测试即可。向 sample.feature 中添加的需求如果没有在测试类中进行实现的话,就会在标准输出中显示代码,提示要添加新的测试函数。
像这样根据自然语言描述的需求(行为的定义)来编写测试代码,这就是 Cucumber 的方式。
这些处理在技术上和 JUnit 并没有太大的变化,区别仅在于测试的编写方式和执行方式。虽说如此,也正是因为这些不同之处,Cucumber 才使验收人员能够用自然语言编写需求规格。
这次用 Cucumber 试着写了 Sample 类的单元测试。本来 Cucumber 是用来定义行为并进行测试的工具,所以一般多用于结合多个类的测试。也就是说,Cucumber 原本是用于确保集成测试和用户验收测试的。这次是为了对比 TDD 框架和 BDD 框架而特意编写了相同的测试。
大多数的需求都能用接近自然语言的 DSL(Domain Specific Language)15 来编写,这是 BDD 的特点。使用近似于自然语言的 DSL 来描述需求,然后直接将其用于测试。BDD 是以让负责需求定义的人或客户等立场上接近于产品利益相关者的人也能够编写需求为目的而设计的,这也是其特征。与之相对,TDD 框架的机制是促进 API 的设计。
15 和 Java、Ruby 这样通用的程序不同,DSL 是为了解决特定领域的问题而设计的语言,因此也称为领域特定语言。
5.1.6 主要的 CI 工具
CI 工具数量众多,具有代表性的有以下两个。
Jenkins 16
TravisCI 17
●…… Jenkins
原名为 Hudson18 的 Jenkins 如今可以毫不为过地说是 CI 工具事实上的标准。全世界的项目都在使用 Jenkins(图 5.4)。
18 原本是 Sun Microsystems 旗下的作为 OSS 开发的名为 Hudson 的工具。由于 Oracle 收购 Sun Microsystems 而产生的商标问题,开源社区在 2010 年对 Hudson 进行了 fork(复制代码并作为另外的项目重新开始),并将其改名为 Jenkins。之后几乎所有 Hudson 的提交者都转移到了 Jenkins 项目,Oracle 也因为无法继续维护 Hudson 项目,最终于 2012 年末将其转交给了 Eclipse 基金会。后来就几乎没有向 Hudson 项目的代码提交了,而 Jenkins 则作为名副其实的活跃着的项目留了下来。
图 5.4 Jenkins
在 Jenkins 之前还有过 Continuum 和 Cruise Control 等,几乎都已经没有存在感了。从 Jenkins 被称为事实上的标准可以看出,Jenkins 具备丰富的功能并且安装简便。Jenkins 的相关内容将在 5.4 节进行讲解。
●…… TravisCI
作为通用的 CI 工具,Jenkins 可以说是一个非常不错的选择,但最近使用 TravisCI 的情况也开始逐渐增加(图 5.5)。TravisCI 是 GitHub 提供的配套的 CI 工具。虽然非 GitHub 的项目无法使用,但它设置简单,只需要 5 分钟左右就能够开始 CI。
图 5.5 TravisCI
TravisCI 的功能虽然并不多,但就进行单元测试和集成测试来说已经足够了。并且还能够和 GitHub 密切关联,使 GitHub 项目最近的 build 状态一目了然(图 5.6)。
图 5.6 用图标表示 build 状态
在合并 Pull Request 之前能够自动执行测试,这是 TravisCI 的特长 19 。借助上述功能,就能够避免在合并 Pull Request 之后才发现 build 出错的事态,使得高效的团队开发成为可能。
19 顺便提一下,Jenkins 通过使用插件也能够实现同样的功能。这部分内容将在 5.4 节进行讲解。TravisCI 默认支持 Pull Request 的 CI,这点非常方便。
以前 TravisCI 只能用于公开的代码库,从 2013 年开始私有的代码库也能使用了。因此如果在使用 GitHub 的话,TravisCI 可以说是值得考虑的工具。
