持续集成
持续集成是一种软件开发实践,其中团队中的每个成员至少每天将其更改与同事的更改一起合并到代码库中。每次集成都会通过自动构建(包括测试)进行验证,以便尽快检测到集成错误。团队发现,这种方法可以降低交付延迟的风险,减少集成工作量,并支持采用能够促进代码库健康发展以快速增强新功能的实践。
2024 年 1 月 18 日
我清楚地记得我第一次看到大型软件项目的情景。当时我正在一家大型英国电子公司实习。我的经理是质量保证小组的一员,他带我参观了一个地方,我们走进了一个巨大、压抑、没有窗户的仓库,里面挤满了在隔间里工作的人。我被告知,这些程序员已经为这个软件编写了几年的代码,虽然他们已经完成了编程工作,但他们各自的单元现在正在整合在一起,而且他们已经整合了好几个月了。我的向导告诉我,没有人真正知道完成整合需要多长时间。从那时起,我了解到软件项目的一个常见故事:整合多个开发人员的工作是一个漫长且不可预测的过程。
我已经很多年没有听说过像这样被困在如此漫长的集成中的团队了,但这并不意味着集成是一个轻松的过程。开发人员可能已经为一个新功能工作了几天,定期将公共主分支中的更改拉入到她的功能分支中。就在她准备推送更改之前,主分支上出现了一个重大更改,该更改修改了她正在交互的某些代码。她不得不从完成她的功能转变为弄清楚如何将她的工作与这个更改整合在一起,虽然这对她的同事来说更好,但对她来说效果不佳。希望更改的复杂性在于合并源代码,而不是仅在她运行应用程序时才会出现的潜在故障,这迫使她调试不熟悉的代码。
至少在这种情况下,她可以在提交拉取请求之前发现问题。拉取请求可能充满风险,同时等待某人审查更改。审查可能需要时间,迫使她从下一个功能切换上下文。在此期间进行困难的集成可能会非常令人不安,从而进一步拖延审查过程。而且这可能还不是故事的结局,因为集成测试通常只在拉取请求合并后才运行。
随着时间的推移,这个团队可能会了解到对核心代码进行重大更改会导致这种问题,因此停止这样做。但是,通过阻止定期重构,最终会导致代码库中到处都是垃圾代码。遇到垃圾代码库的人会想知道它是如何变成这样的,答案通常在于集成过程存在太多摩擦,以至于人们不愿意删除这些垃圾代码。
但这并非唯一的方法。我的 Thoughtworks 同事以及世界各地许多其他人所做的大多数项目都将集成视为一件轻而易举的事。任何单个开发人员的工作都只与共享项目状态相差几个小时,并且可以在几分钟内集成回该状态。任何集成错误都会被快速发现并快速修复。
这种对比并不是昂贵且复杂的工具的结果。它的本质在于团队中的每个人都经常(至少每天)针对受控源代码存储库进行集成的简单实践。这种做法被称为“持续集成”(或者在某些圈子里,它被称为“基于主干的开发”)。
在本文中,我将解释什么是持续集成以及如何做好持续集成。我写这篇文章有两个原因。首先,总是有新人进入这个行业,我想向他们展示如何避免那个令人沮丧的仓库。但其次,这个主题需要澄清,因为持续集成是一个被广泛误解的概念。有很多人说他们在做持续集成,但一旦他们描述了他们的工作流程,就会发现他们遗漏了一些重要的部分。清楚地了解持续集成有助于我们进行沟通,以便我们知道在描述我们的工作方式时应该期待什么。它还有助于人们意识到他们可以做些什么来改善他们的体验。
我最初是在 2001 年写这篇文章的,并在 2006 年进行了更新。从那时起,软件开发团队的普遍预期发生了很大变化。我在 20 世纪 80 年代看到的长达数月的集成已经成为遥远的记忆,版本控制和构建脚本等技术已经变得司空见惯。我在 2023 年再次重写了这篇文章,以便更好地解决当时开发团队的问题,并利用 20 年的经验来证实持续集成的价值。
使用持续集成构建功能
对我来说,解释什么是持续集成以及它是如何工作的最简单方法是展示一个关于它如何在小型功能开发中工作的快速示例。我目前正在与一家大型魔法药水制造商合作,我们正在扩展他们的产品质量体系,以计算药水效果的持续时间。我们的系统中已经支持了十几种药水,我们需要扩展飞行药水的逻辑。(我们已经了解到,如果它们失效太早,会严重影响客户保留率。)飞行药水引入了一些需要考虑的新因素,其中之一是二次混合过程中的月相。
我首先将最新产品源代码的副本复制到我的本地开发环境中。我通过使用 git pull
从中央存储库中检出当前主线来完成此操作。
一旦源代码进入我的环境,我就执行一个命令来构建产品。此命令检查我的环境是否设置正确,将源代码编译成可执行产品,启动产品,并对其运行一套全面的测试。这应该只需要几分钟,同时我开始研究代码,以决定如何开始添加新功能。这个构建几乎从未失败过,但我还是这么做了,以防万一,因为如果它确实失败了,我想在开始进行更改之前就知道。如果我在失败的构建之上进行更改,我会感到困惑,以为是我的更改导致了失败。
现在,我获取我的工作副本,并执行处理月相所需的任何操作。这将包括更改产品代码,以及添加或更改一些自动化测试。在此期间,我经常运行自动构建和测试。大约一个小时后,我将月球逻辑合并进来,并更新了测试。
我现在准备将我的更改集成回中央存储库。我的第一步是再次拉取,因为我的同事有可能在我工作的时候将更改推送到主线中。确实有几个这样的更改,我将它们拉入到我的工作副本中。我将我的更改与它们结合在一起,然后再次运行构建。通常情况下,这感觉是多余的,但这次测试失败了。测试给了我一些关于哪里出错的线索,但我发现查看我拉取的提交以查看哪些内容发生了更改更有用。似乎有人对一个函数进行了调整,将其部分逻辑移到了它的调用者中。他们在主线代码中修复了所有调用者,但我在我的更改中添加了一个新的调用,当然,他们还看不到。我进行了同样的调整并重新运行构建,这次通过了。
由于我花了了几分钟时间来解决这个问题,所以我再次拉取,并且又出现了一个新的提交。但是,构建使用这个提交工作正常,所以我能够将我的更改 git push
到中央存储库。
但是,我的推送并不意味着我已经完成了。一旦我推送到主线,持续集成服务就会注意到我的提交,将更改后的代码检出到 CI 代理上,并在那里构建它。由于构建在我的环境中没有问题,所以我预计它在 CI 服务上不会失败,但在程序员圈子里,“在我的机器上可以工作”这句话是有原因的。很少会遗漏导致 CI 服务构建失败的内容,但很少并不等于从不。
集成机器的构建时间并不长,但足以让一个急切的开发人员开始考虑计算飞行时间的下一步。但我是一个老家伙,所以我喜欢花几分钟时间伸展一下腿,读一封电子邮件。我很快收到了来自 CI 服务的通知,说一切正常,所以我再次启动了更改的下一部分的流程。
持续集成实践
上面的故事是持续集成的一个例子,希望它能让你感受到一个普通程序员在使用它时的感受。但是,与任何事情一样,在日常工作中做这件事有很多事情需要解决。所以现在我们将介绍我们需要做的关键实践。
将所有内容都放在版本控制的主线中
如今,几乎每个软件团队都将他们的源代码保存在版本控制系统中,以便每个开发人员都可以轻松地找到产品的当前状态,以及对产品所做的所有更改。版本控制工具允许将系统回滚到其开发过程中的任何时间点,这对于理解系统的历史非常有帮助,可以使用差异调试来查找错误。在我撰写本文时,主流的版本控制系统是git。
但是,尽管版本控制很普遍,但有些团队却没有充分利用版本控制。我对完整版本控制的测试是,我应该能够在一个配置非常简单的环境中工作——比如一台只安装了基本操作系统的笔记本电脑——并且能够在克隆存储库后轻松地构建和运行产品。这意味着存储库应该可靠地返回产品源代码、测试、数据库模式、测试数据、配置文件、IDE 配置、安装脚本、第三方库以及构建软件所需的任何工具。
我应该能够在一台只装有操作系统的笔记本电脑上工作,并通过使用存储库获得构建和运行产品所需的一切。
您可能注意到我说过存储库应该*返回*所有这些元素,这与存储它们并不一样。我们不必将编译器存储在存储库中,但我们需要能够获得正确的编译器。如果我检出去年的产品源代码,我可能需要能够使用我去年使用的编译器来构建它们,而不是我现在使用的版本。存储库可以通过存储指向不可变资产存储的链接来做到这一点——不可变是指一旦资产以 ID 存储,我将始终能够再次获得完全相同的资产。我也可以对库代码执行此操作,前提是我既信任资产存储,并且始终引用特定版本,而不是“最新版本”。
类似的资产存储方案可用于任何过大的内容,例如视频。克隆存储库通常意味着获取所有内容,即使不需要。通过使用对资产存储的引用,构建脚本可以选择仅下载特定构建所需的内容。
通常,我们应该在源代码管理中存储构建任何东西所需的一切,但不存储我们实际构建的任何东西。有些人确实将构建产物保存在源代码管理中,但我认为这是一种坏味道——表明存在更深层次的问题,通常是无法可靠地重新创建构建。缓存构建产物可能很有用,但应始终将它们视为可丢弃的,并且通常最好确保及时删除它们,以便人们在不应该依赖它们时不会依赖它们。
此原则的第二个要素是,应该很容易找到给定工作的代码。其中一部分是存储库内和更广泛的企业内的清晰名称和 URL 方案。这也意味着不必花时间弄清楚要使用版本控制系统中的哪个分支。持续集成依赖于拥有清晰的主线——一个充当产品当前状态的单一、共享的分支。这是将部署到生产环境的下一个版本。
使用 git 的团队大多使用名称“main”作为主线分支,但我们有时也会看到“trunk”或旧的默认值“master”。主线是中央存储库上的那个分支,因此要将提交添加到名为 main
的主线,我需要先提交到我的 main
本地副本,然后将该提交推送到中央服务器。跟踪分支(称为 origin/main
之类的名称)是我本地计算机上的主线副本。但是它可能已过期,因为在持续集成环境中,每天都有许多提交被推送到主线。
我们应该尽可能使用文本文档来定义产品及其环境。我这样说是因为,尽管版本控制系统可以存储和跟踪非文本文档,但它们通常不提供任何工具来轻松查看版本之间的差异。这使得理解所做的更改变得更加困难。未来我们可能会看到更多存储格式具有创建有意义的差异的功能,但目前清晰的差异几乎完全是为文本格式保留的。即使在那里,我们也需要使用能够产生可理解差异的文本格式。
自动化构建
将源代码转换为正在运行的系统通常是一个复杂的过程,涉及编译、移动文件、将模式加载到数据库等等。然而,就像软件开发的这一部分中的大多数任务一样,它可以自动化——因此应该自动化。要求人们输入奇怪的命令或单击对话框窗口是浪费时间,并且是滋生错误的温床。
计算机旨在执行简单、重复的任务。一旦你让人类代表计算机执行重复性任务,所有的计算机都会在深夜聚在一起嘲笑你。
—— 尼尔·福特
大多数现代编程环境都包含用于自动化构建的工具,并且此类工具已经存在很长时间了。我第一次遇到它们是使用make,这是最早的 Unix 工具之一。
构建的任何说明都需要存储在存储库中,实际上这意味着我们必须使用文本表示形式。这样我们就可以轻松地检查它们以了解它们是如何工作的,并且至关重要的是,在它们发生变化时查看差异。因此,使用持续集成的团队会避免使用需要在 UI 中四处点击来执行构建或配置环境的工具。
可以使用常规编程语言来自动化构建,实际上简单的构建通常被捕获为 shell 脚本。但随着构建变得更加复杂,最好使用专为构建自动化而设计的工具。部分原因是此类工具将具有用于常见构建任务的内置函数。但主要原因是构建工具最适合以特定方式组织其逻辑——我将其称为依赖网络的替代计算模型。依赖网络将其逻辑组织成任务,这些任务被构建为依赖关系图。
一个非常简单的依赖网络可能会说“测试”任务依赖于“编译”任务。如果我调用测试任务,它将查看是否需要运行编译任务,如果需要,则先调用它。如果编译任务本身具有依赖项,则网络将查看是否需要先调用它们,依此类推,沿着依赖链向后追溯。像这样的依赖网络对于构建脚本很有用,因为任务通常需要很长时间,如果不需要它们,就会浪费时间。如果自从我上次运行测试以来没有人更改任何源文件,那么我可以节省可能很长的编译时间。
要判断是否需要运行任务,最常见和最直接的方法是查看文件的修改时间。如果编译的任何输入文件在输出之后被修改,那么我们知道如果调用该任务,则需要执行编译。
一个常见的错误是没有将所有内容都包含在自动构建中。构建应该包括从存储库中获取数据库模式并在执行环境中启动它。我将详细说明我之前的经验法则:任何人都应该能够引入一台干净的机器,从存储库中检出源代码,发出单个命令,并在他们自己的环境中运行系统。
虽然一个简单的程序可能只需要一两行脚本文件来构建,但复杂的系统通常具有庞大的依赖关系图,经过微调以最大限度地减少构建事物所需的时间。例如,本网站有超过一千个网页。我的构建系统知道如果我更改此页面的源代码,我只需要构建此页面。但是,如果我更改了发布工具链中的核心文件,那么它需要重建所有文件。无论哪种方式,我都在我的编辑器中调用相同的命令,构建系统会计算出要执行多少操作。
根据我们的需要,我们可能需要构建不同类型的东西。我们可以构建带有或不带有测试代码的系统,或者使用不同的测试集。某些组件可以独立构建。构建脚本应该允许我们为不同的情况构建替代目标。
使构建自测试
传统上,构建意味着编译、链接以及使程序执行所需的所有其他内容。程序可能会运行,但这并不意味着它做的事情是正确的。现代静态类型语言可以捕获许多错误,但还有更多错误会漏网。如果我们想像持续集成要求的那样频繁地集成,这是一个关键问题。如果错误进入产品,那么我们将面临在一组快速变化的代码库上执行错误修复的艰巨任务。手动测试太慢,无法应对频繁的变化。
面对这种情况,我们需要确保错误首先不会进入产品。主要的技术是全面的测试套件,在每次集成之前运行该套件以尽可能多地消除错误。当然,测试并不完美,但它可以捕获很多错误——足以发挥作用。我早期使用的计算机在启动时会进行可见的内存自检,这让我将其称为自测代码。
编写自测代码会影响程序员的工作流程。任何编程任务都结合了修改程序的功能,以及增强测试套件以验证这种更改后的行为。程序员的工作不仅仅是在新功能正常工作时完成,而是在他们拥有自动化测试来证明这一点时完成。
自本文的第一个版本发布以来的二十年里,我看到编程环境越来越接受为程序员提供构建此类测试套件的工具的需求。对此的最大推动是 JUnit,最初由 Kent Beck 和 Erich Gamma 编写,它在 1990 年代后期对 Java 社区产生了显着影响。这激启了其他语言的类似测试框架,通常称为Xunit框架。这些框架强调了一种轻量级、程序员友好的机制,允许程序员轻松地与产品代码一起构建测试。通常,这些工具具有一些图形进度条,如果测试通过则为绿色,但如果任何测试失败则变为红色——导致出现“绿色构建”或“红条”之类的短语。
一个完善的测试套件永远不会允许一个淘气的顽童在测试没有变红的情况下造成任何损害。
对此类测试套件的测试是,我们应该确信,如果测试是绿色的,那么产品中就没有重大错误。我喜欢想象一个淘气的顽童,他能够对产品代码进行简单的修改,例如注释掉行或反转条件语句,但不能更改测试。一个完善的测试套件永远不会允许顽童在测试没有变红的情况下造成任何损害。任何测试失败都足以导致构建失败,99.9% 的绿色仍然是红色。
自测代码对于持续集成非常重要,以至于它是必要的前提条件。通常,实施持续集成的最大障碍是测试技能不足。
自测代码和持续集成如此紧密地联系在一起也就不足为奇了。持续集成最初是作为极限编程的一部分开发的,而测试一直是极限编程的核心实践。这种测试通常以测试驱动开发 (TDD) 的形式进行,这种实践指示我们永远不要编写新代码,除非它修复了我们之前编写的测试。TDD 对于持续集成来说不是必需的,因为测试可以在生产代码之后编写,只要它们在集成之前完成即可。但我确实发现,在大多数情况下,TDD 是编写自测代码的最佳方式。
测试充当代码库健康状况的自动化检查,虽然测试是此类代码自动验证的关键要素,但许多编程环境提供了额外的验证工具。 Linter 可以检测不良的编程实践,并确保代码遵循团队的首选格式样式,漏洞扫描程序可以发现安全弱点。 团队应该评估这些工具,将它们包含在验证过程中。
当然,我们不能指望测试能发现所有问题。 常言道:测试并不能证明没有错误。 然而,完美并不是我们从自测试构建中获得回报的唯一一点。 不完美的测试,如果经常运行,也比从未编写过的完美测试要好得多。
每个人每天都将提交推送到主线
任何代码都不会处于未集成状态超过几个小时。
-- Kent Beck
集成主要与沟通有关。 集成允许开发人员将他们所做的更改告知其他开发人员。 频繁的沟通使人们能够随着变化的发展迅速了解情况。
开发人员提交到主线的唯一先决条件是他们能够正确构建他们的代码。 当然,这包括通过构建测试。 与任何提交周期一样,开发人员首先更新他们的工作副本以匹配主线,解决与主线的任何冲突,然后在他们的本地机器上构建。 如果构建通过,那么他们就可以自由地推送到主线。
如果每个人都频繁地推送到主线,开发人员就会很快发现两个开发人员之间是否存在冲突。 快速解决问题的关键是快速发现问题。 当开发人员每隔几个小时提交一次代码时,就可以在冲突发生后的几个小时内检测到冲突,此时发生的事情不多,很容易解决。 未被发现数周的冲突可能很难解决。
代码库中的冲突有多种形式。 最容易发现和解决的是文本冲突,通常称为“合并冲突”,即两个开发人员以不同的方式编辑同一段代码。 一旦第二个开发人员将更新后的主线拉入他们的工作副本,版本控制工具就会很容易地检测到这些冲突。 更难的问题是 语义冲突。 如果我的同事更改了一个函数的名称,而我在我新添加的代码中调用了该函数,版本控制系统就无法帮助我们。 在静态类型语言中,我们会遇到编译失败,这很容易检测到,但在动态语言中,我们得不到这样的帮助。 而且,当同事更改了我调用的函数体,对其功能进行细微更改时,即使是静态类型的编译也无济于事。 这就是拥有自测试代码如此重要的原因。
测试失败提醒我们更改之间存在冲突,但我们仍然必须找出冲突是什么以及如何解决它。 由于提交之间只有几个小时的更改,因此问题可能隐藏的地方只有那么多。 此外,由于变化不大,我们可以使用 差异调试 来帮助我们找到错误。
我的经验法则是,每个开发人员都应该每天提交到主线。 在实践中,那些有持续集成经验的人会比这更频繁地集成。 我们集成的频率越高,需要查找冲突错误的地方就越少,修复冲突的速度也就越快。
频繁提交鼓励开发人员将他们的工作分解成几个小时的小块。 这有助于跟踪进度并提供进度感。 人们最初通常会觉得他们在几个小时内无法做出有意义的事情,但我们发现指导和练习有助于我们学习。
每次推送到主线都应该触发构建
如果团队中的每个人至少每天都进行集成,这意味着主线应该保持健康状态。 然而,在实践中,事情仍然会出错。 这可能是由于纪律松懈,在推送之前没有更新和构建,也可能是开发人员工作空间之间的环境差异。
因此,我们需要确保在参考环境中验证每次提交。 通常的方法是使用监控主线的持续集成服务(CI 服务)。 (CI 服务的例子有 Jenkins、GitHub Actions、Circle CI 等工具。)每次主线收到提交时,CI 服务都会将主线的头部检出到集成环境中,并执行完整的构建。 只有当此集成构建为绿色时,开发人员才能认为集成已完成。 通过确保每次推送都有一个构建,如果我们遇到失败,我们就会知道故障在于最后一次推送,从而缩小了我们必须查找修复它的范围。
我想在这里强调的是,当我们使用 CI 服务时,我们只在主线上使用它,主线是版本控制系统参考实例上的主分支。 使用 CI 服务来监控和构建多个分支是很常见的,但集成的全部意义在于让所有提交都共存于一个单一分支上。 虽然使用 CI 服务为不同的分支进行自动构建可能很有用,但这与持续集成不同,使用持续集成的团队只需要 CI 服务来监控产品的单个分支。
虽然现在几乎所有团队都在使用 CI 服务,但完全有可能在没有 CI 服务的情况下进行持续集成。 团队成员可以手动将主线上的头部检出到集成机器上,并执行构建以验证集成。 但是,当自动化如此容易获得时,手动流程就毫无意义了。
(这是提及我在 Thoughtworks 的同事为持续集成贡献了许多开源工具的合适时机,特别是第一个 CI 服务 Cruise Control。)
立即修复损坏的构建
只有在主线保持健康状态的情况下,持续集成才能发挥作用。 如果集成构建失败,则需要立即修复。 正如 Kent Beck 所说:“没有什么任务比修复构建更重要了”。 这并不意味着团队中的每个人都必须停止他们正在做的事情来修复构建,通常只需要几个人就能让事情恢复正常。 这确实意味着有意识地将构建修复作为一项紧急的、高优先级的任务。
通常,修复构建的最佳方法是从主线中还原错误的提交,以便团队中的其他人可以继续工作。
通常,修复构建的最佳方法是从主线中还原最新的提交,将系统恢复到最后一个已知的良好构建。 如果问题的原因很明显,那么可以通过新的提交直接修复它,否则还原主线可以让一些人在单独的开发环境中找出问题,从而允许团队中的其他人继续使用主线工作。
一些团队更喜欢使用待处理头(也称为预测试、延迟或门控提交)来消除破坏主线的风险。 为此,CI 服务需要进行设置,以便推送到主线进行集成的提交不会立即进入主线。 而是将它们放在另一个分支上,直到构建完成,并且仅在绿色构建后才迁移到主线。 虽然这种技术避免了任何破坏主线的风险,但一个有效的团队应该很少看到红色的主线,而且在少数情况下,它的可见性会鼓励人们学习如何避免它。
保持构建快速
持续集成的全部意义在于提供快速反馈。 没有什么比耗时的构建更能消耗持续集成的精力了。 在这里,我必须承认,对于什么才算是一个漫长的构建过程,我这个古怪的老家伙感到很有趣。 我的大多数同事都认为一个小时的构建时间是完全不合理的。 我记得团队曾经梦想着他们可以做到这么快——而且偶尔我们还会遇到很难让构建达到这种速度的情况。
然而,对于大多数项目来说,XP 规定的十分钟构建时间是完全合理的。 我们大多数现代项目都实现了这一点。 值得付出努力来实现这一目标,因为从构建时间中节省的每一分钟,对于每个开发人员来说,每次提交代码都能节省一分钟。 由于持续集成需要频繁提交,因此这会占用大量时间。
如果我们盯着一个小时的构建时间,那么实现更快的构建似乎是一项艰巨的任务。 即使是在开始一个新项目并考虑如何保持快速时,也会让人感到畏惧。 至少对于企业应用程序来说,我们发现通常的瓶颈是测试——尤其是涉及外部服务(如数据库)的测试。
也许最关键的一步是开始着手建立部署流水线。 部署流水线(也称为构建流水线或阶段构建)背后的想法是,实际上有多个构建是按顺序完成的。 对主线的提交会触发第一个构建——我称之为提交构建。 提交构建是当有人将提交推送到主线时需要的构建。 提交构建必须快速完成,因此它会采取一些捷径,从而降低检测错误的能力。 诀窍在于平衡错误查找和速度的需求,以便良好的提交构建足够稳定,供其他人继续工作。
一旦提交构建良好,其他人就可以放心地处理代码。 然而,我们可以开始进行进一步的、速度较慢的测试。 其他机器可以在构建上运行需要更长时间才能完成的进一步测试例程。
一个简单的例子是两阶段部署流水线。 第一个阶段将进行编译并运行更局部的单元测试,其中缓慢的服务被测试替身替换,例如假的内存数据库或外部服务的存根。 此类测试可以非常快地运行,保持在十分钟的准则内。 然而,任何涉及更大规模交互的错误,特别是那些涉及真实数据库的错误,都不会被发现。 第二阶段构建运行一组不同的测试,这些测试会访问真实的数据库,并涉及更多的端到端行为。 此套件可能需要几个小时才能运行。
在这种情况下,人们使用第一阶段作为提交构建,并将其作为他们的主要 CI 周期。如果二级构建失败,那么这可能没有相同的“停止一切”质量,但团队的目标是尽快修复此类错误,同时保持提交构建运行。由于二级构建可能慢得多,因此它可能不会在每次提交后都运行。在这种情况下,它会尽可能频繁地运行,从提交阶段选择最后一个良好的构建。
如果二级构建检测到错误,则表明提交构建可以进行另一项测试。我们希望尽可能地确保任何后期故障都会导致提交构建中出现本可以捕获该错误的新测试,因此该错误在提交构建中保持修复状态。这样,每当出现问题时,提交测试就会得到加强。在某些情况下,无法构建能够暴露该错误的快速运行测试,因此我们可能决定仅在二级构建中测试该条件。幸运的是,大多数情况下,我们可以在提交构建中添加合适的测试。
加快速度的另一种方法是使用并行性和多台机器。特别是云环境,允许团队轻松地为构建启动一小批服务器。如果测试可以合理地独立运行(编写良好的测试可以做到这一点),那么使用这样的服务器群可以获得非常快的构建时间。这种并行云构建对于开发人员的集成前构建也可能是有价值的。
在我们考虑更广泛的构建过程时,值得一提的是另一类自动化,即与依赖项的交互。大多数软件使用由不同组织生成的大量依赖软件。这些依赖项的变化会导致产品中断。因此,团队应该自动检查依赖项的新版本并将它们集成到构建中,本质上就像它们是另一个团队成员一样。这应该经常进行,通常至少每天一次,具体取决于依赖项的变化率。对于运行契约测试,应使用类似的方法。如果这些依赖项交互变为红色,则它们不会像常规构建失败那样具有相同的“停止生产线”效果,但确实需要团队迅速采取行动进行调查和修复。
隐藏进行中的工作
持续集成意味着一旦有一点进展并且构建正常,就立即进行集成。通常,这表明在用户可见的功能完全形成并准备好发布之前进行集成。因此,我们需要考虑如何处理潜在代码:代码是未完成功能的一部分,存在于实时版本中。
有些人担心潜在代码,因为它将非生产质量的代码放入已发布的可执行文件中。执行持续集成的团队确保发送到主线的所有代码都是生产质量的,以及验证代码的测试。潜在代码可能永远不会在生产中执行,但这并不能阻止它在测试中得到执行。
我们可以通过使用关键接口来防止代码在生产中执行 - 确保提供新功能路径的接口是我们添加到代码库中的最后一件事。测试仍然可以在最终接口以外的所有级别检查代码。在一个设计良好的系统中,此类接口元素应该很少,因此只需编写一小段程序即可轻松添加。
使用暗启动,我们可以在将某些更改对用户可见之前在生产中对其进行测试。此技术对于评估对性能的影响非常有用,
关键接口涵盖了大多数潜在代码的情况,但对于无法做到这一点的情况,我们使用功能标志。每当我们要执行潜在代码时,都会检查功能标志,它们作为环境的一部分进行设置,可能在特定于环境的配置文件中。这样,潜在代码可以处于活动状态以进行测试,但在生产中被禁用。除了启用持续集成之外,功能标志还使 A/B 测试和金丝雀发布的运行时切换更容易。然后,一旦功能完全发布,我们就确保立即删除此逻辑,以便标志不会使代码库混乱。
抽象分支是另一种管理潜在代码的技术,它对于代码库中大型基础结构更改特别有用。本质上,这为正在更改的模块创建了一个内部接口。然后,该接口可以在旧逻辑和新逻辑之间路由,随着时间的推移逐渐替换执行路径。我们已经看到这样做是为了切换诸如更改持久性平台之类的普遍元素。
在引入新功能时,我们应始终确保我们可以在出现问题时回滚。并行更改(又名扩展-收缩)将更改分解为可逆的步骤。例如,如果我们重命名数据库字段,我们首先使用新名称创建一个新字段,然后写入旧字段和新字段,然后从现有的旧字段复制数据,然后从新字段读取,最后才删除旧字段。我们可以反转这些步骤中的任何一个,如果我们一次性完成这样的更改,这是不可能的。使用持续集成的团队通常希望以这种方式分解更改,使更改保持较小且易于撤消。
在生产环境的克隆中进行测试
测试的目的是在受控条件下,找出系统在生产中可能出现的任何问题。其中很重要的一部分是生产系统将在其中运行的环境。如果我们在不同的环境中进行测试,则每个差异都会导致测试中发生的事情在生产中不会发生的风险。
因此,我们希望将我们的测试环境设置为尽可能精确地模拟我们的生产环境。使用相同的数据库软件,使用相同的版本,使用相同版本的操作系统。将生产环境中所有适当的库放入测试环境中,即使系统实际上并不使用它们。使用相同的 IP 地址和端口,在相同的硬件上运行它。
与过去相比,虚拟环境使做到这一点变得容易得多。我们在容器中运行生产软件,并可靠地构建完全相同的容器用于测试,即使在开发人员的工作区中也是如此。这样做是值得付出努力和成本的,与追踪由环境不匹配造成的漏洞中爬出的单个错误相比,价格通常很小。
某些软件设计为在多个环境中运行,例如不同的操作系统和平台版本。部署管道应安排在所有这些环境中并行进行测试。
需要注意的一点是,当生产环境不如开发环境好时。生产软件是否会在连接了不可靠 WiFi 的机器上运行,例如智能手机?然后确保测试环境模拟较差的网络连接。
每个人都可以看到正在发生的事情
持续集成就是沟通,因此我们希望确保每个人都可以轻松查看系统的状态以及对其所做的更改。
要传达的最重要的事情之一是主线构建的状态。CI 服务具有仪表板,允许每个人查看他们正在运行的任何构建的状态。它们通常与其他工具链接,以将构建信息广播到内部社交媒体工具,例如 Slack。IDE 通常会挂钩到这些机制中,因此开发人员可以在他们用于大部分工作的工具中收到警报。许多团队只发送构建失败的通知,但我认为也值得发送成功的消息。这样,人们就会习惯于定期的信号,并对构建的长度有所了解。更不用说每天收到“做得好”的消息总是件好事,即使它只是来自 CI 服务器。
共享物理空间的团队通常会为构建提供某种始终在线的物理显示器。通常,这采用显示简化仪表板的大屏幕的形式。这对于提醒每个人注意损坏的构建特别有价值,通常使用主线提交构建上的红色/绿色颜色。
我比较喜欢的一种较旧的物理显示器是使用红色和绿色的熔岩灯。熔岩灯的一个特点是,在打开一段时间后,它们会开始冒泡。这个想法是,如果红灯亮了,团队应该在它开始冒泡之前修复构建。构建状态的物理显示器通常很有趣,为团队的工作空间增添了一些古怪的个性。我对一只跳舞的兔子记忆犹新。
除了构建的当前状态之外,这些显示器还可以显示有关近期历史的有用信息,这可以作为项目运行状况的指标。在本世纪之交,我与一个团队合作,该团队一直无法创建稳定的构建。我们在墙上放了一个日历,上面显示了一整年,每天都有一个小方块。如果质量保证小组收到一个通过提交测试的稳定构建,他们每天都会在当天贴上绿色贴纸,否则就会贴上红色方块。随着时间的推移,日历揭示了构建过程的状态,显示出稳步的改进,直到绿色方块变得如此普遍,以至于日历消失了 - 它的目的已经实现。
自动化部署
要进行持续集成,我们需要多个环境,一个环境来运行提交测试,可能还需要更多环境来运行部署管道的其他部分。由于我们每天要在这些环境之间多次移动可执行文件,因此我们需要自动执行此操作。因此,拥有允许我们轻松地将应用程序部署到任何环境中的脚本非常重要。
借助现代化的虚拟化、容器化和无服务器工具,我们可以更进一步。不仅可以使用脚本部署产品,还可以使用脚本从头开始构建所需的环境。这样,我们可以从现成的准系统环境开始,创建产品运行所需的环境,安装产品并运行它 - 所有这些都完全自动化。如果我们使用功能标志来隐藏正在进行的工作,那么可以使用所有功能标志打开来设置这些环境,以便可以使用所有即将发生的交互来测试这些功能。
这样做的自然结果是,这些相同的脚本使我们能够以类似的轻松方式部署到生产环境中。许多团队使用这些自动化每天多次将新代码部署到生产环境中,但即使我们选择不太频繁的节奏,自动部署也有助于加快流程并减少错误。这也是一种便宜的选择,因为它只使用我们用于部署到测试环境中的相同功能。
如果我们自动部署到生产环境,我们会发现一个额外的功能非常方便,那就是自动回滚。糟糕的事情时有发生,如果遇到紧急情况,能够快速回到最后一个已知的良好状态是非常重要的。能够自动回滚还可以减轻部署的压力,鼓励人们更频繁地部署,从而更快地向用户推出新功能。蓝绿部署允许我们快速发布新版本,并在需要时通过在已部署版本之间切换流量来快速回滚。
自动化部署使设置金丝雀发布变得更容易,将新版本的产品部署给一部分用户,以便在向所有用户发布之前发现问题。
移动应用程序是必须自动化部署到测试环境中的典型例子,在这种情况下,需要将应用程序部署到设备上,以便在提交到应用商店之前探索新版本。实际上,任何绑定设备的软件都需要能够轻松地将新版本安装到测试设备上的方法。
在部署此类软件时,请记住确保版本信息可见。“关于”屏幕应包含与版本控制相关联的构建 ID,日志应使查看正在运行的软件版本变得容易,并且应该有一些 API 端点可以提供版本信息。
集成方式
到目前为止,我已经描述了一种处理集成的方法,但如果它不是通用的,那么一定还有其他方法。与任何事物一样,我给出的任何分类都有模糊的界限,但我发现将处理集成的三种风格视为:预发布集成、功能分支和持续集成是很有用的。
最古老的是我在 80 年代那个仓库里看到的那种 - 预发布集成。这将集成视为软件项目的一个阶段,这是瀑布模型的自然组成部分。在这样的项目中,工作被划分为单元,这些单元可以由个人或小团队完成。每个单元都是软件的一部分,与其他单元的交互最少。这些单元是独立构建和测试的(“单元测试”一词的最初用法)。然后,一旦单元准备就绪,我们将它们集成到最终产品中。这种集成只发生一次,然后是集成测试,最后是发布。因此,如果我们考虑这项工作,我们会看到两个阶段,一个阶段是每个人并行开发功能,然后是在集成时进行单一流的努力。
这种风格中集成的频率与发布的频率相关联,通常是软件的主要版本,通常以月或年为单位。这些团队将对紧急错误修复使用不同的流程,因此它们可以独立于常规集成计划发布。
如今,最流行的集成方法之一是使用功能分支。在这种风格中,功能被分配给个人或小团队,就像旧方法中的单元一样。但是,开发人员不会等到所有单元都完成后再进行集成,而是在功能完成后立即将其集成到主线中。有些团队会在每次功能集成后发布到生产环境,而另一些团队则更喜欢将一些功能打包发布。
使用功能分支的团队通常希望每个人都定期从主线拉取代码,但这只是半集成。如果 Rebecca 和我正在开发不同的功能,我们可能会每天从主线拉取代码,但我们不会看到彼此的更改,直到我们中的一个人完成我们的功能并将其集成,将其推送到主线。然后,另一个人将在下次拉取时看到该代码,并将其集成到他们的工作副本中。因此,在每个功能被推送到主线后,所有其他开发人员都将进行集成工作,以将最新的主线推送与他们自己的功能分支合并。
这只是半集成,因为每个开发人员都将主线上的更改合并到他们自己的本地分支中。只有当开发人员推送他们的更改时,才会发生完全集成,从而导致另一轮半集成。即使 Rebecca 和我都从主线拉取相同的更改,我们也只是与这些更改集成,而不是与彼此的分支集成。
使用持续集成,我们每天都将我们的更改推送到主线,并将其他所有人的更改拉取到我们自己的工作中。这会导致更多的集成工作,但每次集成的工作量要小得多。将几个小时的工作合并到代码库中比合并几天的工作要容易得多。
持续集成的优势
在讨论三种集成风格的相对优点时,大多数讨论实际上是关于集成频率的。预发布集成和功能分支都可以以不同的频率运行,并且可以在不改变集成风格的情况下更改集成频率。如果我们使用预发布集成,则每月发布和每年发布之间有很大差异。功能分支通常以更高的频率工作,因为集成发生在每个功能单独推送到主线时,而不是等待将一堆单元一起批处理。如果一个团队正在进行功能分支,并且所有功能的构建时间都不超过一天,那么它们实际上与持续集成相同。但持续集成的不同之处在于,它被*定义*为一种高频风格。持续集成将设置集成频率本身作为目标,而不是将其绑定到功能完成或发布频率。
因此,大多数团队可以通过提高频率而不改变其风格来看到我在下面讨论的因素的有效改进。将功能的大小从两个月减少到两周有很大的好处。持续集成的优势在于将高频集成作为基线,设置使其可持续的习惯和实践。
降低交付延迟的风险
很难估计完成复杂集成需要多长时间。有时,在 git 中合并可能会很困难,但随后一切都会顺利进行。其他时候,合并可能很快,但一个细微的集成错误需要几天的时间才能找到并修复。集成之间的时间越长,要集成的代码越多,花费的时间就越长 - 但更糟糕的是不可预测性的增加。
这一切都使预发布集成成为一种特殊的噩梦。因为集成是发布前的最后步骤之一,所以时间已经很紧迫,压力也很大。在一天的晚些时候有一个难以预测的阶段意味着我们有一个非常难以缓解的重大风险。这就是为什么我在 80 年代的记忆如此深刻,而且这并不是我唯一一次看到项目陷入集成地狱,每次他们修复一个集成错误时,就会出现两个新的错误。
任何增加集成频率的步骤都会降低这种风险。需要完成的集成越少,新版本准备就绪之前未知的时间就越少。功能分支通过将此集成工作推送到各个功能流来提供帮助,因此,如果单独留下,流可以在功能准备就绪后立即推送到主线。
但*单独留下*这一点很重要。如果其他人推送到主线,那么我们在功能完成之前引入了一些集成工作。因为分支是隔离的,所以在一个分支上工作的开发人员对其他功能可能推送的内容以及集成它们需要多少工作没有太多了解。虽然存在高优先级功能可能面临集成延迟的风险,但我们可以通过阻止推送低优先级功能来管理这一点。
持续集成有效地消除了交付风险。集成非常小,通常可以顺利进行。一个尴尬的集成是需要花费几分钟以上才能解决的集成。最坏的情况是冲突导致某人从头开始重新开始工作,但这仍然不到一天的工作量,因此不太可能困扰利益相关者委员会。此外,我们在开发软件时定期进行集成,因此我们可以在有更多时间处理问题并可以练习如何解决问题时面对问题。
即使团队没有定期发布到生产环境,持续集成也很重要,因为它允许每个人都确切地看到产品的状态。没有需要在发布之前完成的隐藏集成工作,任何集成工作都已经完成。
减少集成时间浪费
我还没有看到任何严肃的研究来衡量花在集成上的时间如何与集成的大小相匹配,但我的轶事证据强烈表明这种关系不是线性的。如果有两倍的代码需要集成,那么执行集成的时间很可能是四倍。这就像我们需要三条线来完全连接三个节点,但需要六条线来连接四个节点一样。集成就是连接,因此是非线性增加,这反映在我的同事的经验中。
在使用功能分支的组织中,大部分时间损失都是由个人感受到的。花费数小时试图在对主线的重大更改上进行变基是令人沮丧的。花费几天时间等待已完成的拉取请求的代码审查,在此期间对主线的另一个重大更改更加令人沮丧。为了调试在两周前完成的功能的集成测试中发现的问题而不得不将工作放在新功能上,这会降低生产力。
当我们进行持续集成时,集成通常是一件轻而易举的事。我拉取主线,运行构建,然后推送。如果存在冲突,我编写的一小段代码在我的脑海中仍然很新鲜,因此通常很容易看到。工作流程是规律的,所以我们对此很熟练,并且我们尽可能地将其自动化。
像许多这些非线性效应一样,集成很容易成为人们吸取错误教训的陷阱。一次困难的集成可能会非常痛苦,以至于团队决定应该减少集成的频率,这只会加剧未来的问题。
这里发生的事情是,我们看到团队成员之间更加密切的合作。如果两个开发人员做出的决定发生冲突,我们会在集成时发现。因此,集成之间的时间越短,我们检测到冲突的时间就越短,我们就可以在冲突变得太大之前处理它。通过高频集成,我们的源代码控制系统成为一个沟通渠道,可以沟通那些否则无法说出口的事情。
更少的错误
错误 - 这些讨厌的东西会破坏信心,扰乱时间表和声誉。已部署软件中的错误会让用户对我们感到愤怒。在常规开发过程中出现的错误会阻碍我们,使我们更难使软件的其余部分正常工作。
持续集成并不能消除错误,但它确实使查找和消除错误变得非常容易。这与其说是因为高频集成,不如说是因为引入了自测代码。如果没有自测代码,持续集成就无法工作,因为如果没有像样的测试,我们就无法保持健康的主线。因此,持续集成建立了定期的测试机制。如果测试不充分,团队会很快注意到,并可以采取纠正措施。如果由于语义冲突而出现错误,则很容易检测到,因为只有一小部分代码需要集成。频繁的集成也适用于差异调试,因此即使是几周后才注意到的错误也可以缩小到很小的更改范围。
错误也是累积的。我们拥有的错误越多,消除每个错误就越困难。这在一定程度上是因为我们会遇到错误交互,在这种情况下,故障表现为多个故障的结果 - 使每个故障都更难找到。这也是心理上的 - 当错误很多时,人们就没有精力去寻找和消除错误。因此,由持续集成加强的自测代码在减少缺陷引起的问题方面具有另一种指数效应。
这就会遇到另一个许多人觉得违反直觉的现象。看到引入变更往往意味着引入错误,人们得出结论,为了获得高可靠性的软件,他们需要降低发布速度。这与 Nicole Forsgren 领导的 DORA 研究项目 的发现完全相悖。他们发现,精英团队更快速、更频繁地部署到生产环境,并且在进行这些更改时,故障发生率显著降低。该研究还发现,当团队在应用程序的代码存储库中只有三个或更少的活动分支、每天至少将分支合并到主线一次、并且没有代码冻结或集成阶段时,他们的性能水平更高。
支持重构以保持生产力
大多数团队都观察到,随着时间的推移,代码库会恶化。早期的决策在当时是好的,但在六个月的工作之后就不再是最优的了。但是,更改代码以整合团队所学到的东西意味着要在现有代码的深处引入更改,这会导致合并变得困难,既耗时又充满风险。每个人都记得,有人为未来做出了一个很好的改变,但却导致了数天的努力,破坏了其他人的工作。鉴于这种经历,没有人愿意重新设计现有代码的结构,即使它现在对每个人来说都很难构建,从而减缓了新功能的交付。
重构是减缓甚至逆转这种衰退过程的一项基本技术。定期进行重构的团队拥有一种规范的技术,可以通过对代码进行小的、保持行为的转换来改进代码库的结构。这些转换的特点大大降低了引入错误的可能性,而且可以快速完成,尤其是在自测试代码的基础上。通过在每次有机会的时候进行重构,团队可以改进现有代码库的结构,使其更容易、更快地添加新功能。
但这个美好的故事可能会被集成问题所破坏。为期两周的重构可能会大大改进代码,但会导致长时间的合并,因为其他每个人在过去两周都在使用旧的结构。这使得重构的成本高得令人望而却步。频繁的集成解决了这个难题,因为它确保了进行重构的人和其他人都在定期同步他们的工作。当使用持续集成时,如果有人对我正在使用的核心库进行了侵入性更改,我只需要调整几个小时的编程来适应这些更改。如果他们做了一些与我的更改方向相冲突的事情,我会立即知道,所以有机会与他们交谈,以便我们找到更好的前进方向。
到目前为止,在这篇文章中,我已经提出了几个关于高频集成的优点的反直觉观点:我们集成得越频繁,我们花在集成上的时间就越少,而且频繁的集成会导致更少的错误。以下是软件开发中最重要、最反直觉的观点:那些花费大量精力保持代码库健康的团队 交付功能的速度更快,成本更低。在编写测试和重构上投入的时间在交付速度方面带来了惊人的回报,而持续集成是在团队环境中完成这项工作的核心部分。
发布到生产环境是一个业务决策
想象一下,我们正在向利益相关者演示一些新构建的功能,她的反应是说 - “这真的很酷,会产生巨大的商业影响。我们多久才能上线?” 如果该功能是在一个未集成的分支上展示的,那么答案可能是几周或几个月,特别是如果到生产环境的自动化很差。持续集成使我们能够维护一个 可发布的主线,这意味着将最新版本的产品发布到生产环境的决定纯粹是一个商业决定。如果利益相关者希望最新版本上线,只需几分钟运行一个自动化管道即可。这使得软件的客户能够更好地控制功能的发布时间,并鼓励他们与开发团队更紧密地合作。
持续集成和可发布的主线消除了频繁部署的最大障碍之一。频繁部署是有价值的,因为它允许我们的用户更快地获得新功能,对这些功能提供更快速的反馈,并在开发周期中进行更普遍的协作。这有助于打破客户和开发之间的障碍 - 我认为这些障碍是成功进行软件开发的最大障碍。
我们什么时候不应该使用持续集成
所有这些好处听起来都相当诱人。但像我这样经验丰富(或愤世嫉俗)的人总是对赤裸裸的好处清单持怀疑态度。很少有东西是没有代价的,关于架构和流程的决策通常是权衡利弊的问题。
但我承认,持续集成是少数几个例外情况之一,对于一个 committed 和 skillful 的团队来说,使用它几乎没有负面影响。零星集成的成本是如此之高,以至于几乎任何团队都可以从提高集成频率中获益。收益的积累是有极限的,但这个极限是几个小时而不是几天,这正是持续集成的领域。自测试代码、持续集成和重构之间的相互作用特别强烈。我们在 Thoughtworks 使用这种方法已经二十年了,我们唯一的问题是如何更有效地做到这一点 - 核心方法已经被证明是有效的。
但这并不意味着持续集成适合所有人。你可能注意到我说过“对于一个committed 和 skillful的团队来说,使用它几乎没有负面影响”。这两个形容词表明了持续集成不适合的环境。
我所说的“committed”,是指一个全职从事某个产品的团队。一个很好的反例是经典的开源项目,其中有一两个维护者和许多贡献者。在这种情况下,即使是维护者每周也只花几个小时在这个项目上,他们不太了解贡献者,也不知道贡献者何时贡献或他们贡献时应该遵循的标准。这就是导致功能分支工作流和拉取请求的环境。在这种情况下,持续集成是不可行的,尽管努力提高集成频率仍然是有价值的。
持续集成更适合于全职从事某个产品的团队,商业软件通常就是这种情况。但在经典的开源和全职模式之间有很多中间地带。我们需要根据团队的投入来判断使用什么样的集成策略。
第二个形容词着眼于团队遵循必要实践的技能。如果一个团队在没有强大的测试套件的情况下尝试持续集成,他们会遇到各种各样的麻烦,因为他们没有机制来筛选错误。如果他们不进行自动化,集成将花费太长时间,干扰开发的流程。如果人们在确保他们推送到主线的代码是绿色构建方面不遵守纪律,那么主线最终会一直处于损坏状态,阻碍每个人的工作。
任何考虑引入持续集成的人都必须牢记这些技能。在没有自测试代码的情况下实施持续集成是行不通的,而且还会给人留下一个错误的印象,即持续集成在做得好的情况下是什么样子的。
也就是说,我认为技能要求并不是特别高。我们不需要明星开发者就能在一个团队中完成这个过程。(事实上,明星开发者往往是一个障碍,因为认为自己是这样的人通常不是很自律。) 这些技术实践的技能并不难学,通常问题是找到一个好老师,并形成使纪律具体化的习惯。一旦团队掌握了流程,通常会感觉很舒服、很顺利 - 而且很快。
引入持续集成
描述如何引入像持续集成这样的实践的难点之一是,路径很大程度上取决于你的起点。在写这篇文章的时候,我不知道你正在处理什么样的代码,你的团队拥有什么样的技能和习惯,更不用说更广泛的组织环境了。像我这样的人所能做的就是指出一些常见的路标,希望能帮助你找到自己的道路。
在引入任何新实践时,重要的是要清楚我们为什么要这样做。我上面列出的好处包括最常见的原因,但不同的环境会导致它们的重要程度不同。有些好处比其他好处更难理解。减少集成中的浪费解决了一个令人沮丧的问题,并且在我们取得进展时可以很容易地感觉到。启用重构以减少系统中的混乱并提高整体生产力则更难看到。它需要时间才能看到效果,而且很难感觉到反事实。然而,这可能是持续集成最有价值的好处。
上面列出的实践表明了一个团队为了使持续集成发挥作用而需要学习的技能。其中一些技能甚至在我们接近高集成频率之前就能带来价值。自测试代码为系统增加了稳定性,即使提交不频繁也是如此。
一个目标可以是将集成频率提高一倍。如果功能分支通常运行十天,那就想办法将其缩短到五天。这可能涉及到更好的构建和测试自动化,以及关于如何将一个大型任务分解成更小的、独立集成的任务的创造性思维。如果我们使用集成前评审,我们可以在这些评审中加入明确的步骤来检查测试覆盖率并鼓励更小的提交。
如果你正在开始一个新项目,我们可以从一开始就进行持续集成。我们应该关注构建时间,并在开始比十分钟规则慢的时候立即采取行动。通过快速行动,我们将在代码库变得如此之大以至于成为一个主要问题之前进行必要的重构。
最重要的是,我们应该得到一些帮助。我们应该找一个以前做过持续集成的人来帮助我们。像任何新技术一样,当我们不知道最终结果是什么样子的时候,很难引入它。获得这种支持可能需要花钱,但否则我们将付出时间和生产力上的损失。(免责声明/广告 - 是的,我们在 Thoughtworks 确实在这个领域做一些咨询。毕竟我们已经犯了所有可能犯的错误)。
常见问题
持续集成从何而来?
持续集成是由 Kent Beck 在 20 世纪 90 年代作为极限编程的一部分开发的一种实践。在当时,发布前集成是常态,发布频率通常以年为单位。当时,人们一直在普遍推动迭代开发,缩短发布周期。但很少有团队考虑在几周内发布。Kent 定义了这种实践,并在他参与的项目中进行了开发,并确定了它如何与它所依赖的其他关键实践进行交互。
微软以进行每日构建(通常是隔夜构建)而闻名,但没有持续集成中如此关键的测试方案或对修复缺陷的关注。
有些人认为 Grady Booch 创造了这个词,但他只是在他的面向对象设计书籍中用一句话简单地描述了这个词。他没有把它当作一个定义的实践,事实上它没有出现在索引中。
持续集成和基于主干的开发之间有什么区别?
随着 CI 服务的流行,许多人使用它们在功能分支上定期运行构建。正如上面解释的那样,这根本不是持续集成,但这导致许多人说(和认为)他们在做持续集成,而实际上他们在做一些明显不同的事情,这造成了很多混乱。
有些人决定通过创造一个新词来解决这种语义扩散问题:基于主干的开发。总的来说,我认为这是持续集成的同义词,并且承认它不会与“在我们的功能分支上运行 Jenkins”相混淆。我读到有些人试图在两者之间做出一些区分,但我发现这些区分既不一致也不令人信服。
我不使用“基于主干的开发”这个术语,部分原因是我认为创造一个新名词并不是应对语义扩散的好方法,但主要原因是重命名这种技术粗暴地抹杀了那些倡导和开发持续集成的人的工作,尤其是 Kent Beck。
尽管我避免使用这个术语,但在“基于主干的开发”的旗帜下,有很多关于持续集成的好信息。特别是,Paul Hammant 在他的网站上写了很多优秀的资料。
我们可以在功能分支上运行 CI 服务吗?
简单的答案是“是的 - 但你没有做持续集成”。这里的关键原则是“每个人每天都提交到主线”。在功能分支上进行自动化构建很有用,但这只是半集成。
然而,一个常见的误解是,以这种方式使用守护进程构建就是持续集成的全部内容。这种误解来自于将这些工具称为持续集成服务,一个更好的术语应该是“持续构建服务”。虽然使用持续集成服务对进行持续集成很有帮助,但我们不应该将工具与实践混淆。
持续集成和持续交付之间有什么区别?
早期对持续集成的描述侧重于开发人员在团队开发环境中与主线集成的周期。这些描述并没有过多地谈论从集成的主线到生产发布的过程。这并不意味着它们不在人们的脑海中。像“自动化部署”和“在生产环境的克隆中测试”这样的实践清楚地表明了人们对生产路径的认识。
在某些情况下,主线集成之后就没有什么了。我记得 Kent 在 90 年代后期向我展示了他在瑞士工作的一个系统,他们每天都自动部署到生产环境中。但这是一个 Smalltalk 系统,没有复杂的生产部署步骤。在 21 世纪初的 Thoughtworks,我们经常遇到生产路径复杂得多的情况。这导致了一种观念,即在持续集成之外还有一项活动来解决这条路径。这项活动后来被称为持续交付。
持续交付的目标是产品应该始终处于可以发布最新版本的状态。这实质上是确保发布到生产环境是一个业务决策。
如今,对许多人来说,持续集成是关于将代码集成到开发团队环境中的主线,而持续交付是通向生产发布的部署管道的其余部分。有些人将持续交付视为包含持续集成,而另一些人则将它们视为紧密相连的伙伴,通常使用 CI/CD 的称号。还有一些人认为持续交付仅仅是持续集成的同义词。
持续部署如何与所有这些结合?
持续集成确保每个人至少每天将他们的代码集成到版本控制的主线中。然后,持续交付执行任何必要的步骤,以确保产品在任何人希望的时候都可以发布到生产环境。持续部署意味着产品在通过部署管道中的所有自动化测试后,就会自动发布到生产环境。
在持续部署中,作为持续集成的一部分,每次提交到主线的代码都将自动部署到生产环境,前提是部署管道中的所有验证都是绿色的。持续交付只是确保这是可能的(因此是持续部署的先决条件)。
我们如何进行拉取请求和代码审查?
拉取请求是 GitHub 的产物,现在被广泛应用于软件项目中。从本质上讲,它们提供了一种为推送到主线添加一些流程的方法,通常涉及集成前代码审查,要求另一位开发人员在推送被接受到主线之前批准。它们主要是在开源项目的特性分支的背景下发展起来的,以确保项目的维护者可以审查贡献是否符合项目的风格和未来意图。
集成前代码审查对持续集成来说可能是个问题,因为它通常会给集成过程增加很大的摩擦。我们必须找到人来进行代码审查,安排他们的时间,并在审查被接受之前等待反馈,而不是一个可以在几分钟内完成的自动化过程。尽管一些组织可能能够在几分钟内进入流程,但这很容易就会变成几个小时甚至几天 - 打破了持续集成起作用的时间安排。
那些进行持续集成的人通过重新定义代码审查如何适应他们的工作流程来解决这个问题。结对编程很受欢迎,因为它在代码编写时创建了持续的实时代码审查,为审查提供了更快的反馈循环。发布/展示/询问流程鼓励团队只在必要时才使用阻塞式代码审查,因为他们认识到集成后审查通常是更好的选择,因为它不会干扰集成频率。许多团队发现精炼代码审查是维护健康代码库的重要力量,但在持续集成产生有利于重构的环境时,它才能发挥最佳作用。
我们应该记住,集成前审查起源于开源环境,在这种环境中,贡献来自联系松散的开发人员,而且是即兴的。在这种环境中有效的实践需要重新评估,以适应紧密联系的全职团队。
我们如何处理数据库?
随着我们提高集成频率,数据库带来了一个特殊的挑战。将数据库模式定义和测试数据的加载脚本包含在版本控制的源代码中很容易。但这对我们处理版本控制之外的数据没有帮助,比如生产数据库。如果我们更改数据库模式,我们需要知道如何处理现有数据。
对于传统的预发布集成,数据迁移是一个相当大的挑战,通常需要专门的团队来执行迁移。乍一看,尝试高频集成会带来难以承受的数据迁移工作量。
然而,在实践中,视角的改变消除了这个问题。我们在 Thoughtworks 早期的项目中就遇到了这个问题,当时我们使用的是持续集成,并通过转向演进式数据库设计方法解决了这个问题,该方法由我的同事 Pramod Sadalage 开发。这种方法的关键是通过一系列迁移脚本来定义数据库模式和数据,这些脚本可以改变数据库模式和数据。每次迁移都很小,因此很容易推理和测试。迁移是自然组合的,因此我们可以按顺序运行数百次迁移,以执行重大的模式更改并在迁移过程中迁移数据。我们可以将这些迁移存储在版本控制中,与应用程序中的数据访问代码同步,从而允许我们构建任何版本的软件,并使用正确的模式和正确结构的数据。这些迁移可以在测试数据和生产数据库上运行。
最后的想法
大多数软件开发都是关于更改现有代码的。向代码库添加新功能的成本和响应时间很大程度上取决于该代码库的状况。一个粗糙的代码库更难修改,成本也更高。为了将粗糙代码降到最低,团队需要能够定期重构代码,更改其结构以反映不断变化的需求,并整合团队从产品开发中学到的经验教训。
持续集成对于健康的软件产品至关重要,因为它是这种进化设计生态系统中的一个关键组成部分。它与自测试代码一起,并得到自测试代码的支持,是重构的基础。这些技术实践诞生于极限编程,可以让团队定期交付产品的增强功能,以利用不断变化的需求和技术机会。
延伸阅读
像这样的文章只能涵盖这么多内容,但这是一个重要的话题,所以我创建了一个指南页面在我的网站上,为您提供更多信息。
要更详细地了解持续集成,我建议您阅读 Paul Duvall 关于该主题的同名书籍(该书获得了 Jolt 奖 - 比我获得的任何奖项都多)。有关更广泛的持续交付流程的更多信息,请参阅Jez Humble 和 Dave Farley 的书 - 这本书也让我获得了 Jolt 奖。
我关于管理源代码分支的模式的文章着眼于更广泛的背景,展示了持续集成如何适应选择分支策略的更广泛的决策空间。一如既往,选择何时分支的驱动因素是知道你要进行集成。
关于持续集成的原始文章描述了我们在 2000 年 Matt 帮助在 Thoughtworks 项目中进行持续集成时的经验。
正如我前面指出的,许多人使用“基于主干的开发”一词来描述持续集成。Paul Hammant 的网站包含了许多有用和实用的信息。Clare Sudbery 最近写了一份信息丰富的报告,可以通过 O'Reilly 获得。
致谢
首先要感谢 Kent Beck 和我在克莱斯勒综合薪酬 (C3) 项目中的许多同事。这是我第一次有机会看到持续集成与大量单元测试一起发挥作用。它向我展示了什么可能,并给了我多年来的灵感。
感谢 Matt Foemmel、Dave Rice 以及所有在 Atlas 上构建和维护持续集成的人。该项目是更大规模持续集成的标志,并展示了它为现有项目带来的好处。
Paul Julius、Jason Yip、Owen Rodgers、Mike Roberts 和许多其他开源贡献者参与了第一个持续集成服务 CruiseControl 的构建。虽然持续集成服务不是必需的,但大多数团队发现它很有帮助。CruiseControl 和其他持续集成服务在普及和使软件开发人员能够使用持续集成方面发挥了重要作用。
2023 年秋季,Michael Lihs 通过电子邮件向我发送了对本文的修改建议,这促使我进行了重大修改。Birgitta Böckeler、Camilla Crispim、Casey Lee、Chris Ford、Clare Sudbery、Evan Bottcher、Jez Humble、Kent Beck、Kief Morris、Mike Roberts、Paul Hammant、Pete Hodgson、Rafael Detoni、Rouan Wilsenach 和 Trisha Gee 审阅并评论了此修订版。
我在 Thoughtworks 工作的原因之一是可以很好地接触到由人才完成的实际项目。我访问过的几乎每个项目都提供了持续集成信息的美味佳肴。
重大修订
2024 年 1 月 18 日:发布修订版
2023 年 10 月 18 日:开始重写以使其更新
2006 年 5 月 1 日:完全重写文章以使其更新并阐明该方法的描述。
2000 年 9 月 10 日:原始版本发布。