持续集成(原始版本)

任何软件开发过程的一个重要部分是获得可靠的软件构建。尽管它很重要,但我们经常对这种情况没有发生感到惊讶。在这里,我们讨论了 Matt 在 Thoughtworks 的一个主要项目中实施的过程,这个过程在整个公司中越来越普遍。它强调了一个完全自动化且可重复的构建,包括测试,每天运行多次。这允许每个开发人员每天集成,从而减少集成问题。

2000 年 9 月 10 日



本文已被更新版本取代

软件开发充满了最佳实践,这些最佳实践经常被谈论,但似乎很少被执行。其中最基本、最有价值的一种是完全自动化的构建和测试过程,它允许团队每天多次构建和测试他们的软件。每天构建的想法已经被讨论了很多。 McConnnell 建议将其作为最佳实践,并且它长期以来一直被认为是微软开发方法的一个特征。然而,我们同意 XP 社区的说法,即每天构建只是最低限度。一个完全自动化的过程,允许您每天构建多次,既可实现,也值得付出努力。

我们使用的是“持续集成”这个术语,它是 XP(极限编程)实践之一。但是我们认识到,这种实践已经存在很长时间了,并且被许多从未考虑过将 XP 用于他们工作的人使用。我们一直在软件开发过程中使用 XP 作为基准,这影响了我们的许多术语和实践。但是,您可以在不使用 XP 的任何其他部分的情况下使用持续集成——事实上,我们认为它是任何称职的软件开发活动的重要组成部分。

使自动化的每日构建工作起来,需要几个部分。

所有这些都需要一定程度的纪律。我们发现,将它引入项目需要花费很多精力。我们还发现,一旦安装,保持它并不需要太多努力。

持续集成的优势

关于持续集成最难表达的事情之一是,它对整个开发模式进行了根本性的转变,如果您从未在实践它的环境中工作过,就很难看到这种转变。事实上,大多数人如果独自工作,就会看到这种氛围——因为那时他们只与自己集成。对于许多人来说,团队开发只是伴随着某些作为领土一部分的问题。持续集成减少了这些问题,以换取一定程度的纪律。

持续集成的根本好处是,它消除了人们花费时间寻找错误的环节,在这种环节中,一个人的工作踩到了另一个人的工作,而两个人都没有意识到发生了什么。这些错误很难找到,因为问题不在于一个人的领域,而在于两段工作之间的交互。这个问题随着时间的推移而加剧。通常,集成错误可能在它们第一次显现出来之前几周或几个月就被插入。因此,它们需要花费大量时间才能找到。

通过持续集成,绝大多数此类错误会在它们被引入的同一天显现出来。此外,至少一半的交互位置会立即变得很明显。这大大减少了寻找错误的范围。如果您找不到错误,您可以避免将有问题的代码放入产品中,因此最糟糕的情况是您没有添加也添加了错误的功能。(当然,您可能比讨厌错误更想要这个功能,但至少这样,这是一个明智的选择。)

现在,并不能保证您能找到所有集成错误。该技术依赖于测试,众所周知,测试并不能证明没有错误。关键是持续集成能够捕获足够多的错误,以至于值得付出代价。

所有这一切的最终结果是,通过减少追查集成错误所花费的时间来提高生产力。虽然我们不知道有人对此进行了任何接近科学研究的东西,但轶事证据非常有力。持续集成可以大幅减少在集成地狱中花费的时间,事实上,它可以将地狱变成一个非事件。

越频繁越好

持续集成的核心存在一个基本的反直觉效应。那就是,频繁集成比偶尔集成更好。对于那些这样做的人来说,这是显而易见的;但对于那些没有这样做的人来说,这似乎与直接经验相矛盾。

如果您只偶尔集成,例如少于每天一次,那么集成将是一项痛苦的练习,需要花费大量时间和精力。事实上,它足够笨拙,以至于您最不想做的事情就是更频繁地做它。我们经常听到的评论是“在一个如此庞大的项目中,您无法进行每日构建。”

然而,确实有一些项目做到了。我们在一个由 50 人团队开发的、大约 20 万行代码的代码库上,每天构建几十次。微软在数千万行代码的项目上进行每日构建。

之所以能够做到这一点,是因为集成工作量与两次集成之间的时间量成指数比例。虽然我们不知道对此有任何测量,但这意味着每周集成一次并不需要花费每天集成五倍的时间,而是更像是 25 倍的时间。因此,如果您的集成很痛苦,您不应该将其视为无法更频繁地集成的信号。如果做得好的话,更频繁的集成应该是无痛的,您最终会花费更少的时间进行集成。

一个关键是自动化。集成的大部分工作可以而且应该自动完成。获取源代码、编译、链接和重要的测试都可以自动完成。最后,您应该得到一个简单的指示,说明构建是否成功:是或否。如果是,您就忽略它;如果不是,您应该能够轻松地撤消对配置的最后更改,并确保这次构建将成功。获取工作构建不应该需要任何思考。

有了这样的自动化过程,您可以根据需要频繁地构建。唯一的限制是构建所需的时间。

什么是成功的构建?

一个重要的决定是,什么构成了成功的构建。这似乎很明显,但令人惊讶的是,这会变得很混乱。Martin 曾经审查过一个项目。他问这个项目是否进行了每日构建,并得到了肯定的回答。幸运的是,Ron Jeffries 在场,他进一步探究了这个问题。他问了一个问题:“您如何处理构建错误?” 回答是“我们会向相关人员发送电子邮件”。事实上,该项目已经好几个月没有成功构建了。这不是每日构建,而是每日构建尝试。

我们对成功的构建的定义非常严格。

  • 从配置管理系统中检出所有最新的源代码
  • 从头开始编译每个文件
  • 将生成的的目标文件(在我们的例子中是 Java 类)链接并部署以执行(放入 jar 文件中)。
  • 启动系统并运行一套测试(在我们的例子中,大约 150 个测试类)来测试系统。
  • 如果所有这些步骤都执行完毕,没有错误或人工干预,并且所有测试都通过,那么我们就有了成功的构建

大多数人认为编译和链接就是构建。至少我们认为构建应该包括启动应用程序并对其运行一些简单的测试(McConnnell 使用了“冒烟测试”这个词:打开它,看看是否冒烟)。运行更全面的测试集可以极大地提高持续集成的价值,因此我们更喜欢这样做。

单一源代码点

为了轻松集成,任何开发人员都应该能够轻松地获取一整套当前的源代码。最糟糕的事情莫过于不得不四处询问不同的开发人员以获取最新的代码,然后不得不将它们复制过来,弄清楚应该把它们放在哪里,所有这些都在您开始构建之前。

标准很简单。任何人都应该能够拿着一台干净的机器,将其连接到网络,并使用单个命令下载构建正在开发的系统所需的所有源文件。

显而易见的(我们希望)解决方案是使用配置管理(源代码控制)系统作为所有代码的来源。配置管理系统通常被设计为在网络上使用,并且具有允许人们轻松获取源代码的工具。此外,它们还包括版本管理,因此您可以轻松地找到各种文件的先前版本。成本不应该成为问题,因为 CVS 是一个优秀的开源配置管理工具。

为了使这能够正常工作,所有源文件都应该保存在配置管理系统中。所有通常比人们想象的要多。它还包括构建脚本、属性文件、数据库模式 DDL、安装脚本以及在干净的机器上构建所需的其他任何内容。我们经常看到代码被控制,但没有其他一些必须找到的重要文件。

尝试确保所有内容都在配置管理系统中的单个源代码树下。有时人们在配置管理系统中为不同的组件使用单独的项目。这样做的问题是,人们必须记住哪些组件的哪些版本与其他组件的哪些版本一起工作。对于某些情况,您必须将源代码分开,但是这些情况比您想象的要少得多。您可以从单个源代码树构建多个组件,这些问题应该由构建脚本处理,而不是由存储结构处理。

自动化构建脚本

如果您正在编写一个包含十几个文件的程序,那么构建应用程序可能只需要对编译器执行一个命令:javac *.java。更大的项目需要更多。在这些情况下,您在许多目录中都有文件。您需要确保生成的的目标代码位于正确的位置。除了编译之外,可能还有链接步骤。您有从其他文件生成的代码,需要在您编译之前生成。测试需要自动运行。

大型构建通常需要时间,如果您只做了一个小的更改,您不希望执行所有这些步骤。因此,一个好的构建工具会分析构建过程中需要更改的内容。常见的做法是检查源文件和目标文件的日期,并且只有在源文件日期较晚时才编译。然后依赖关系就变得很棘手:如果一个目标文件发生了变化,那么依赖它的那些文件也可能需要重新构建。编译器可能会处理这类事情,也可能不会。

根据您的需求,您可能需要构建不同类型的项目。您可以构建包含或不包含测试代码的系统,或者包含不同的测试集。某些组件可以独立构建。构建脚本应允许您为不同的情况构建替代目标。

一旦您超越了简单的命令行,脚本通常会处理负载。这些可能是 shell 脚本,或者使用更复杂的脚本语言,例如 perl 或 python。但很快就有必要使用为此类事情而设计的环境,例如 Unix 中的 make 工具。

在我们的 Java 开发中,我们很快发现我们需要一个更严肃的解决方案。事实上,Matt 花了不少时间开发了一个名为 Jinx 的构建工具,该工具专为企业 Java 工作而设计。然而,最近我们已经切换到开源构建工具 Ant。Ant 的设计与 Jinx 非常相似,允许我们编译 Java 文件并将它们打包成 Jar 文件。它还使我们能够轻松地编写我们自己的 Ant 扩展,以允许我们在构建中执行其他任务。

我们中的许多人使用 IDE,大多数 IDE 在其中包含某种构建管理流程。但是这些文件始终是 IDE 专有的,而且通常很脆弱。此外,它们需要 IDE 才能工作。IDE 用户设置自己的项目文件并将其用于个人开发。但是我们依赖 Ant 进行主要构建,并且主构建在使用 Ant 的服务器上运行。

自测试代码

仅仅让程序编译还不够。虽然强类型语言中的编译器可以发现许多问题,但仍然存在太多错误,即使成功编译也会让这些错误通过。为了帮助追踪这些错误,我们非常重视自动测试纪律——这是 XP 提倡的另一种实践。

XP 将测试分为两类:单元测试和验收(也称为功能)测试。单元测试由开发人员编写,通常测试单个类或一小部分类。验收测试通常由客户或外部测试组(在开发人员的帮助下)编写,并对整个系统进行端到端测试。我们使用两种类型的测试,并尽可能地自动化这两种类型的测试。

作为构建的一部分,我们运行了一套我们称之为“BVT”(构建验证测试)的测试。为了使构建成功,BVT 中的所有测试都必须通过。所有 XP 风格的单元测试都在 BVT 中。由于本文是关于构建过程的,因此我们将主要讨论这里的 BVT,请记住,除了 BVT 中的内容之外,还有第二条测试线,因此不要仅仅根据 BVT 来判断整个测试和 QA 工作。事实上,我们的 QA 小组只有在代码通过 BVT 后才会看到代码,因为他们只处理工作构建。

基本原则是,当开发人员编写代码时,他们也会为该代码编写测试。当他们完成一项任务时,他们不仅会签入生产代码,还会签入该代码的测试。那些密切遵循 XP 的人使用 测试优先 的编程风格:在您拥有失败的测试之前,您不应该编写任何代码。因此,如果您想向系统添加新功能,您首先要编写一个只有在该功能存在时才会有效的测试,然后您使该测试生效。

我们用 Java 编写测试,与我们开发的语言相同。这使得编写测试与编写代码一样。我们使用 JUnit 作为组织和编写测试的框架。JUnit 是一个简单的框架,允许您快速编写测试,将它们组织成套件,并以交互方式或批处理模式运行套件。(JUnit 是 xUnit 家族的 Java 成员——几乎每种语言都有它的版本。)

开发人员通常在编写软件时,每次编译都会运行单元测试的一部分。这实际上加快了开发工作,因为测试有助于发现您正在处理的代码中的任何逻辑错误。然后,您不必调试,而是可以查看自上次运行测试以来的更改。这些更改应该很小,因此更容易找到错误。

并非所有人都严格按照 XP 测试优先的风格工作,但关键的好处来自同时编写测试。除了使单个任务更快完成之外,它还构建了 BVT,使其更有可能捕获错误。由于 BVT 每天运行多次,这意味着 BVT 检测到的任何问题都更容易找到,原因相同:我们可以查看少量更改的代码以找到错误。这种通过查看更改的代码进行调试通常比通过逐步调试运行代码更有效。

当然,您不能指望测试能发现所有问题。正如人们常说的:测试不能证明没有错误。然而,完美并不是您获得良好 BVT 回报的唯一点。不完美的测试,如果经常运行,比从未编写过的完美测试要好得多。

一个相关的问题是开发人员编写他们自己代码的测试问题。人们常说,人们不应该测试他们自己的代码,因为很容易忽略自己工作中的错误。虽然这是真的,但自我测试过程需要将测试快速转入代码库。这种快速周转的价值大于单独测试人员的价值。因此,对于 BVT,我们依赖开发人员编写的测试,但有一些单独的验收测试是独立编写的。

自我测试的另一个重要部分是通过反馈来提高测试质量——这是 XP 的一个关键价值。这里的反馈以逃脱 BVT 的错误的形式出现。这里的规则是,在您拥有 BVT 中的失败单元测试之前,您不允许修复错误。这样,每次您修复错误时,您也会添加一个测试以确保它不会再次溜过您。此外,此测试应该让您想到需要编写的其他测试以加强 BVT。

主构建

构建自动化对于个人开发人员来说意义重大,但它真正闪耀的地方是为整个团队生成主构建。我们发现,拥有一个主构建过程可以将团队团结起来,并使尽早发现集成问题变得更容易。

第一步是选择一台机器来运行主构建。我们使用 Trebuchet(我们玩了很多 Age of Empires)一台四处理器服务器,它几乎专门用于构建过程。(在构建需要很长时间的早期,这种马力是必不可少的。)

构建过程在一个始终运行的 Java 类中。如果没有构建正在进行,构建过程将停留在一个 while 循环中,每隔几分钟检查一次存储库。如果自上次构建以来没有人签入任何代码,它将继续等待。如果存储库中有新代码,它将开始构建。

构建的第一阶段是从存储库中进行完整签出。Starteam 为 Java 提供了一个非常好的 API,因此将其连接到存储库非常简单。构建守护进程在当前时间前五分钟查看存储库,并查看过去五分钟内是否有人签入。如果是,它认为在五分钟前签出代码是安全的(这可以防止在有人签入时签出代码,而不会锁定存储库。)

守护进程签出到 Trebuchet 上的一个目录。一旦所有内容都签出,守护进程就会调用目录中的 ant 脚本。然后 Ant 接管以执行完整构建。我们从所有源代码进行完整构建。ant 脚本将一直进行到编译并将生成的类文件分成六个 jar 文件,以部署到 EJB 服务器中。

一旦 Ant 完成编译和部署,构建守护进程就会使用新的 jar 文件启动 EJB 服务器并执行 BVT 测试套件。测试运行,如果全部通过,那么我们有一个成功的构建。构建守护进程然后返回到 Starteam 并使用构建号标记签出的源代码。然后它查看在构建过程中是否有人签入,如果是,它将启动另一个构建。如果不是,守护进程将返回到其 while 循环并等待下一次签入。

在构建结束时,构建守护进程会向所有签入该构建的新代码的开发人员发送电子邮件。电子邮件总结了该构建的状态。在签入代码后离开大楼之前,被认为是不礼貌的,直到您收到该电子邮件。

守护进程将其所有步骤的日志写入一个 XML 日志文件。一个 servlet 在 Trebuchet 上运行,允许任何人通过检查日志来查看构建的状态。

图 1:构建 servlet

屏幕显示当前是否正在运行构建,如果是,则显示其开始时间。在左侧,有一个所有构建的历史记录,无论成功与否。单击构建将显示该构建的详细信息:是否编译、测试状态、进行了哪些更改等。

我们发现许多开发人员会定期关注此网页。它让他们了解项目中正在发生的事情以及人们签入时正在更改的内容。在某个时候,我们可能会在该页面上发布其他项目新闻,但我们不希望它失去相关性。

重要的是,任何开发人员都可以在自己的机器上模拟主构建。这样,如果发生集成错误,开发人员可以在自己的机器上调查和调试问题,而不会占用主构建过程。此外,开发人员可以在签入之前在本地运行构建,以减少主构建失败的可能性。

这里有一个合理的问题,即主构建应该是干净构建,即仅从源代码构建,还是增量构建。增量构建可能快得多,但它们也会带来问题潜入的风险,因为某些东西没有被编译。它还存在我们无法重新创建构建的风险。我们的构建速度很快(大约 15 分钟,约 200KLOC),因此我们很乐意每次都进行干净构建。但是,一些商店喜欢大多数时候进行增量构建,但定期进行干净构建(至少每天一次),以防出现奇怪的错误。

签入

使用自动构建意味着开发人员在开发软件时遵循某种节奏。这种节奏中最重要的部分是他们定期集成。我们遇到过一些组织,他们每天进行构建,但人们并不经常签入。如果开发人员每隔几周才签入一次,那么每天构建对您来说意义不大。我们遵循一个一般原则,即每个开发人员每天大约签入一次代码。

在开始一项新任务之前,开发人员应该首先与配置管理系统同步。这意味着他们本地的源代码副本是最新的。在过时的源代码之上编写代码只会导致麻烦和混乱。

然后,开发人员处理该任务,更新需要更改的任何文件。开发人员可以在任务完成时或任务进行到一半时进行集成,但所有测试都需要在集成之前正常运行。

集成的第一部分是将开发人员的工作副本与存储库重新同步。存储库中更改的任何文件都将复制到工作目录中,并且配置管理系统会警告开发人员任何冲突。然后,开发人员需要构建到同步的工作集并在这些文件上成功运行 BVT。

现在,开发人员可以将新文件提交到存储库。完成此操作后,开发人员需要等待主构建。如果主构建成功,则签入成功。如果不是,开发人员可以修复问题,如果问题很简单,则可以提交修复。如果问题更复杂,则开发人员需要撤消更改,重新同步其工作目录并在本地使事情正常运行,然后再重新提交。

一些签入流程强制执行签入过程的序列化。在这种情况下,会存在一个构建令牌,只有一个开发者可以获取。开发者会获取构建令牌,重新同步工作副本,提交更改,然后释放令牌。这可以防止多个开发者在构建之间更新存储库。我们发现,没有构建令牌,我们很少遇到问题,因此我们不使用它。通常,不止一个人会提交到同一个主构建中,但这种情况很少会导致构建失败:即使失败,通常也很容易修复。

我们还将签入前应谨慎的程度留给开发者自行判断。开发者需要自行判断集成错误发生的可能性。如果她认为可能性很高,那么她会在签入之前先进行本地构建;如果她认为集成错误的可能性很低,那么她就会直接签入。如果她错了,那么她会在主构建运行后发现问题,然后她必须撤回她的更改并找出问题所在。只要错误很容易找到并修复,就可以容忍错误。

总结

开发一个纪律严明且自动化的构建流程对于一个受控的项目至关重要。许多软件大师都这么说,但我们发现,这在业界仍然很少见。

关键是将所有操作都自动化,并经常运行该流程,以便快速发现集成错误。因此,每个人在需要更改时都更有准备,因为他们知道,如果他们确实造成了集成错误,那么很容易找到并修复。一旦你拥有了这些好处,你会发现它们如此重要,以至于你宁愿大喊大叫也不愿放弃它们。


重大修订

2000年9月10日:首次发布