持续集成(CI)是什么?核心实践、优势与适用场景

持续集成(Continuous Integration,简称 CI)是一种软件开发实践:团队成员频繁地将代码变更集成到共享主线,并通过自动化构建和测试尽早发现问题。它的核心价值在于缩短反馈周期、降低交付延期风险、减少集成过程中的浪费,并帮助团队长期维护健康的代码库。

本文将从实际开发流程出发,介绍持续集成的工作方式、关键实践、常见误区、适用场景,以及它与特性分支、持续交付和持续部署之间的区别。

持续集成(CI)是什么?核心实践、优势与适用场景

目录

  • 持续集成是什么
  • 使用持续集成构建功能
  • 持续集成的核心实践
  • 持续集成与特性分支的区别
  • 持续集成的主要优势
  • 何时不应使用持续集成
  • 如何引入持续集成
  • 持续集成常见问题

持续集成是什么

持续集成是一种软件开发实践:团队中的每位成员至少每天一次将自己的变更集成到共享代码库中,使其与其他成员的变更共同存在于同一条主线上。每次集成都通过自动化构建(包括自动化测试)进行验证,以便尽早发现集成错误。实践表明,这种方式能够降低交付延期的风险,减少集成工作量,并帮助团队维护健康的代码库,从而更快地添加新功能。

我至今仍清楚记得第一次见到大型软件项目的情景。当时我在一家海外大型电子公司做暑期实习。我的经理是质量保证(QA)团队的一员,他带我参观了一个项目现场。我们走进一间巨大、昏暗、没有窗户的仓库,里面挤满了在隔间里工作的程序员。我听说,这些程序员已经为这个软件编写了好几年的代码。虽然他们各自负责的代码单元已经完成,但项目现在进入了集成阶段,而且集成工作已经持续了数月。我的向导告诉我,没有人真正知道集成还需要多久才能完成。那次经历让我认识到软件项目中的一个普遍现象:把多个开发人员的工作整合到一起,往往是一个漫长且难以预测的过程。

如今,我已经很多年没有听说哪个团队陷入如此漫长的集成困境了,但这并不意味着集成过程就一定顺利。设想一位开发人员已经连续几天开发一个新功能,并定期把主线上的变更拉取到自己的功能分支中。就在她准备推送变更时,主线上出现了一个重大修改,恰好涉及她正在交互的那部分代码。她不得不中断即将完成的功能开发,转而思考如何把自己的工作与这次变更整合起来。这个变更对她的同事来说也许是好事,但对她来说却并非如此。她更希望遇到的问题只是源代码层面的合并冲突,而不是那种只有在运行应用时才会暴露的隐蔽错误,迫使她调试一段自己并不熟悉的代码。

至少在这种情况下,她可以在提交拉取请求之前发现问题。但等待变更审核本身就令人焦虑。审核可能需要时间,迫使她从下一个功能的开发中切换回来。这个过程中出现的集成难题会进一步增加不确定性,并拖长审核流程。更糟的是,事情可能还没有结束,因为集成测试通常只有在拉取请求合并之后才会运行。

久而久之,这个团队可能会意识到,对核心代码进行大规模修改会引发此类问题,于是便逐渐避免这样做。但由于定期重构受到阻碍,代码库最终会充斥大量冗余代码。后来接手这种代码库的人往往会疑惑:它怎么会变成这样?答案通常是:集成过程中的摩擦太大,以至于人们不愿意移除冗余代码。

但事情并不必然如此。我在海外某些软件咨询公司和世界各地许多团队中看到的情况是,在绝大多数项目里,集成并不是一件大事。任何开发人员的工作都能在几个小时内合并到项目的共享状态中,并且只需几分钟就能重新集成到该状态。任何集成错误都能被快速发现,并迅速修复。

这种差异并不是昂贵复杂的工具带来的。它的本质在于,团队中的每个人都频繁地——至少每天一次——把代码集成到受控的源代码库中。这种实践被称为“持续集成”;在某些语境中,它也被称为“基于主干的开发”。

本文将解释什么是持续集成,以及如何做好持续集成。我写这篇文章有两个原因。首先,软件行业总会有新人加入,我希望他们知道如何避免陷入那种令人沮丧的“仓库式”流程。其次,持续集成是一个经常被误解的概念,因此需要清晰阐释。许多人声称自己在做持续集成,但只要他们描述一下实际工作流,就会发现其中遗漏了关键环节。清晰理解持续集成,有助于我们更好地沟通,也能让我们在描述工作方式时准确预期对方的反应。它还能帮助团队意识到:他们还有很多事情可以做,以改善自己的开发体验。

我最早于 2001 年写下这篇文章,并在 2006 年进行过更新。从那时起,软件开发团队的常规预期已经发生了巨大变化。我在 20 世纪 80 年代见到的、持续数月的集成过程已经成为遥远记忆,版本控制和构建脚本等技术也变得司空见惯。2023 年,我再次重写本文,希望它更贴近当下开发团队的处境,并用二十多年的经验来验证持续集成的价值。

使用持续集成构建功能

对我来说,解释持续集成是什么以及它如何运作,最简单的方式是举一个开发小功能的例子。假设我正在与一家大型魔法药水制造商合作,我们要扩展他们的产品质量系统,使其能够计算药水的持续效果。系统已经支持十几种药水,现在我们需要扩展飞行药水的逻辑。(我们发现,飞行药水过早失效会严重影响客户留存率。)飞行药水引入了一些新的考虑因素,其中之一是二次混合过程中的月相。

持续集成(CI)是什么?核心实践、优势与适用场景
持续集成(CI)是什么?核心实践、优势与适用场景
持续集成(CI)是什么?核心实践、优势与适用场景
持续集成(CI)是什么?核心实践、优势与适用场景
持续集成(CI)是什么?核心实践、优势与适用场景
持续集成(CI)是什么?核心实践、优势与适用场景
持续集成(CI)是什么?核心实践、优势与适用场景

首先,我把最新的产品源代码复制到本地开发环境中。具体来说,我会通过 git pull 从中央仓库拉取当前主线版本。

源代码进入本地环境后,我会执行一个命令来构建产品。这个命令会检查本地环境是否配置正确,将源代码编译成可运行的产品,启动产品,并运行一套完整测试。这个过程应该只需要几分钟。在此期间,我可以开始阅读代码,思考如何添加新功能。这个构建过程几乎不会失败,但我仍然会先运行一次,以防万一。因为如果它失败了,我希望在开始修改之前就知道原因。否则,如果我在一个本来就失败的构建基础上进行修改,很容易误以为失败是由我的变更造成的,从而陷入困惑。

接着,我拿出工作副本,开始处理月相相关逻辑。这包括修改产品代码,以及新增或调整一些自动化测试。在此期间,我会频繁运行自动化构建和测试。大约一个小时后,我完成了月相逻辑的集成,也更新了测试。

现在我准备把变更合并回中央仓库。第一步是再次拉取代码,因为在我工作期间,同事很可能已经把变更推送到了主线。事实上,确实有一些新的提交。我把这些变更拉取到自己的工作副本中,并将自己的修改合并到它们之上,然后再次运行构建。通常这一步感觉有些多余,但这一次有个测试失败了。测试结果给了我一些线索,但我发现查看刚刚拉取的提交记录更有帮助。看起来,有人调整了一个函数,把其中一部分逻辑移到了调用者中。他们修复了主线代码中的所有调用点,但我在自己的变更中新增了一个调用点,而他们当然无法看到。我做了同样的调整,然后重新运行构建,这次构建通过了。

由于我花了几分钟处理这个问题,所以我又一次拉取代码。结果又出现了一个新的提交。不过这次构建没有问题,于是我通过 git push 将自己的变更推送到了中央仓库。

然而,推送并不意味着一切就此结束。一旦我把代码推送到主线,持续集成服务(CI 服务)就会检测到这次提交,把变更后的代码检出到 CI 代理上,并在那台机器上执行构建。由于构建已经在我的本地环境中正常通过,我并不认为它会在 CI 服务上失败。但“在我的机器上能跑”这句话在程序员世界里早已声名狼藉。CI 服务构建失败的情况虽然少见,但“少见”并不等于“永不发生”。

集成机器上的构建过程并不长,但对于急着继续往前走的开发者来说,也足够让人开始琢磨下一步该如何计算飞行时间。不过我年纪大了些,所以趁机伸展了一下身体,读了一封邮件。很快,我收到了持续集成服务的通知:一切正常。于是,我又开始重复这个过程,处理下一部分变更。

持续集成的核心实践

上面的例子展示了持续集成的基本过程,希望它能让你感受到普通程序员在持续集成下的工作体验。不过,和任何实践一样,把持续集成应用到日常工作中也有许多需要注意的地方。下面介绍一些关键实践。

将所有内容纳入版本控制的主线

如今,几乎所有软件团队都会把源代码保存在版本控制系统中。这样,每位开发人员不仅可以轻松找到产品的当前状态,也能查看产品的完整变更历史。版本控制工具允许我们把系统回滚到开发过程中的任意时间点,这对理解系统历史非常有帮助,也能用于通过差异调试来定位错误。在我撰写本文时,主流版本控制系统是 Git。

虽然版本控制已经很常见,但有些团队并没有充分利用它。我判断版本控制是否做得足够好,有一个标准:即使我只有一个极简环境——例如一台只安装了基础操作系统的笔记本电脑——只要克隆代码仓库,就能轻松构建并运行产品。这意味着代码仓库应该能够可靠地提供产品源代码、测试用例、数据库架构、测试数据、配置文件、IDE 配置、安装脚本、第三方库,以及构建软件所需的任何工具。

注意,我说的是仓库应该能够“提供”这些元素,但这并不等于必须把它们全部“存储”在仓库里。我们不必把编译器本身放进仓库,但必须能够获取正确版本的编译器。如果我检出去年某个时间点的产品源代码,我可能需要使用当时的编译器来构建它,而不是我今天使用的版本。仓库可以通过保存指向不可变资源存储的链接来实现这一点。所谓不可变,是指资源一旦以某个 ID 存储,我之后总能获取到完全相同的资源。库代码也可以这样处理,前提是我信任这个资源存储,并且始终引用具体版本,而不是引用“最新版”。

类似的资源存储方案也适用于体积过大的文件,例如视频。克隆一个仓库通常意味着下载所有内容,即使并不需要。通过在仓库中保存资源引用,构建脚本就可以只下载特定构建所需的资源。

一般来说,我们应该把构建任何项目所需的所有代码都纳入源代码控制,但实际构建出来的产物不应纳入其中。有些团队确实会把构建产物保存在源代码控制系统里,但我认为这是一种不良实践。它往往表明存在更深层的问题,通常是团队无法可靠地重现构建过程。缓存构建产物有时是有用的,但它们应始终被视为可丢弃资源;通常最好在构建结束后及时删除,以免人们在不该依赖它们时产生依赖。

这一原则的第二个要素是,团队应该能够轻松找到特定代码。这需要清晰的命名和 URL 方案,无论是在单个代码库内部,还是在更广泛的企业范围内都应如此。这也意味着,开发者不应花时间纠结在版本控制系统中应该使用哪个分支。持续集成依赖一条清晰的主线:一个单一、共享的分支,代表产品的当前状态,也代表即将部署到生产环境的下一个版本。

使用 Git 的团队通常把主线分支命名为 main,但我们也偶尔会看到 trunk,或早期默认名称 master。这里的主分支指的是中央仓库上的主分支。因此,如果我要向名为 main 的主分支添加一个提交,就需要先在本地 main 分支上提交,然后把该提交推送到中央服务器。像 origin/main 这样的跟踪分支,是本地机器上对远程主分支状态的记录。但在持续集成环境中,每天都会有许多提交被推送到主线,因此本地记录很可能已经过时。

我们还应尽可能使用文本文件来定义产品及其运行环境。这样做的原因是,版本控制系统虽然可以存储并跟踪非文本文件,但通常无法方便地展示不同版本之间的差异。这会让人更难理解到底发生了什么变化。未来也许会有更多存储格式能够生成有意义的差异,但目前,清晰的差异几乎仍然只存在于文本格式中。即便如此,我们也应选择那些能够生成易于理解差异的文本格式。

自动化构建

将源代码转化为可运行系统,通常是一个复杂过程,可能涉及编译、文件移动、加载数据库模式等步骤。然而,就像软件开发中这一阶段的大多数任务一样,它可以自动化,因此也应该自动化。让人输入复杂命令或点击一连串对话框,既浪费时间,又容易出错。

大多数现代编程环境都包含用于自动化构建的工具,而这类工具已经存在很久。我最早接触的构建工具是 make,它是最早期的 Unix 工具之一。

所有构建指令都应存储在代码仓库中。实际上,这意味着它们必须以文本形式存在。这样我们就能轻松检查这些指令,了解其工作原理;更重要的是,我们还能查看构建指令发生变化时的差异。因此,采用持续集成的团队应避免使用那些必须通过用户界面点击才能执行构建或配置环境的工具。

我们当然可以使用通用编程语言来实现构建自动化。事实上,简单构建通常用 shell 脚本就能完成。但随着构建过程越来越复杂,最好使用专门为构建自动化设计的工具。部分原因是这类工具内置了常见构建任务所需的函数;更主要的原因是,构建工具只有在采用特定逻辑组织方式时才能发挥最佳效果。我把这种组织方式称为“依赖网络”——一种不同于常规程序流的计算模型。依赖网络把逻辑组织成一组任务,并以依赖关系图的形式连接这些任务。

一个极其简单的依赖网络可能会说明:“测试”任务依赖于“编译”任务。如果我调用测试任务,它会检查是否需要运行编译任务;如果需要,就先调用编译任务。如果编译任务本身也有依赖项,网络会继续检查这些依赖项是否需要先运行,如此沿着依赖链向前追溯。这样的依赖网络非常适合构建脚本,因为许多任务耗时较长,如果并不需要执行,就会浪费大量时间。假如自上次运行测试以来,没有任何源文件被修改,那么我就可以避免执行可能耗时很长的编译操作。

判断某个任务是否需要运行,最常见也最直接的方法是查看文件修改时间。如果编译任务的任何输入文件比输出文件更新,那么当该任务被调用时,我们就知道需要重新执行编译。

自动化构建中一个常见错误,是遗漏必要步骤。构建过程应该包括从代码仓库获取数据库架构,并在执行环境中启动它。让我进一步明确前文提到的经验法则:任何人都应该能够使用一台干净的机器,检出代码仓库中的源代码,执行一条命令,然后在自己的环境中运行系统。

简单程序可能只需要一两行脚本就能构建;复杂系统则通常拥有庞大的依赖关系图,需要精细调整以尽量缩短构建时间。以这个网站为例,它包含一千多个网页。我的构建系统知道,如果我只修改了某个网页的源文件,就只需要构建那一个网页;但如果我修改了发布工具链中的核心文件,就需要重新构建所有网页。无论是哪种情况,我都在编辑器中执行同一条命令,构建系统会自动计算需要完成多少工作。

根据需求,我们可能需要构建不同类型的组件。我们可以构建包含或不包含测试代码的系统,也可以构建带有不同测试集的系统。某些组件还可以独立构建。构建脚本应该允许我们针对不同场景构建不同目标。

让构建能够自测试

传统意义上的构建,是指编译、链接,以及所有让程序运行起来所需的额外步骤。程序也许可以运行,但这并不意味着它能正确运行。现代静态类型语言可以捕获许多错误,但仍有更多错误会漏网。若我们想像持续集成那样频繁地进行集成,这一点就至关重要。如果错误进入产品,我们就会面对一项艰巨任务:在快速变化的代码库中修复错误。手动测试太慢,无法应对如此频繁的变更。

面对这种情况,我们需要尽可能确保缺陷从一开始就不要进入产品。实现这一目标的主要方法,是在每次集成之前运行一套全面的测试套件,尽可能多地发现缺陷。当然,测试并不完美,但它可以捕获大量缺陷——足以产生显著效果。我早年使用的计算机在启动时会进行可见的内存自检,因此我把这种代码称为“自测试代码”。

编写自测试代码会影响程序员的工作方式。任何编程任务都包含两个方面:一是修改程序功能,二是扩展测试套件,以验证修改后的行为。程序员的工作不只是让新功能正常运行,还要提供自动化测试,证明它确实能够正常运行。

自本文初版发表以来的二十多年里,我看到编程环境越来越重视为程序员提供构建测试套件的工具。其中一个重要推动力来自早期某个轻量级测试框架。它在一种主流编程语言社区中产生了显著影响,并启发了其他语言中类似测试框架的出现。这些框架通常被称为 XUnit 框架。它们强调轻量级、对程序员友好的机制,使程序员能够轻松地在编写产品代码的同时编写测试。这些工具通常带有图形化进度条:测试通过时显示绿色,任何测试失败时显示红色。于是便出现了“绿色构建”或“红条”之类的说法。

衡量这类测试套件的标准是:当所有测试通过时,我们应该有信心认为产品中不存在任何重大缺陷。我喜欢想象一个调皮的小精灵,它可以对产品代码做一些简单修改,比如注释掉某些行,或者反转条件语句,但它不能修改测试本身。一套良好的测试绝不会允许这个小精灵在没有任何测试失败的情况下造成破坏。而且,只要有一个测试失败,构建就应被视为失败;即使 99.9% 的测试都是绿色,也仍然是失败。

自测试代码对于持续集成至关重要,是不可或缺的前提。通常,实施持续集成最大的障碍就是测试能力不足。

自测试代码与持续集成紧密相连并不令人意外。持续集成最初是极限编程的一部分,而测试一直是极限编程的核心实践。这种测试通常以测试驱动开发(TDD)的形式进行。TDD 要求我们:除非新代码能够让先前编写的测试通过,否则就不要编写新代码。TDD 并非持续集成的必要条件,因为只要测试在集成之前完成,测试也可以在生产代码之后编写。但我的经验是,在大多数情况下,TDD 是编写自测试代码的最佳方式。

这些测试可以自动检查代码库的健康状况。虽然测试是自动验证代码的关键要素,但许多编程环境也提供了其他验证工具。例如,代码检查工具(Linter)可以发现不良编程实践,并确保代码遵循团队偏好的格式规范;漏洞扫描器则可以发现安全漏洞。团队应该评估这些工具,并将它们纳入验证流程。

当然,我们不能指望测试发现所有问题。正如常言所说:测试无法证明 bug 不存在。然而,自测试构建的价值并不取决于完美。频繁运行的不完美测试,远胜于从未编写出来的完美测试。

每个人每天都向主线提交代码

集成的核心在于沟通。集成使开发人员能够把自己所做的更改告知其他开发人员。频繁沟通使人们能够迅速了解变更进展。

开发者向主线提交代码的唯一前提,是自己的代码能够正确构建。这当然包括通过构建中的测试。与任何提交流程一样,开发者首先更新自己的工作副本,使其与主线一致;然后解决与主线之间的任何冲突;接着在本地机器上执行构建。如果构建成功,就可以把代码推送到主线。

如果每个人都频繁向主线提交代码,开发人员就能很快发现彼此之间的冲突。快速解决问题的关键在于快速发现问题。由于开发人员每隔几个小时就提交一次代码,冲突可以在发生后数小时内被检测出来。此时问题还没有造成太大影响,也更容易解决。那些持续数周才被发现的冲突,则可能非常难以处理。

半集成:它不是持续集成

关于“集成”这个术语,人们常常存在误解。许多在分支上工作的人员,例如在特性分支或个人开发分支上工作的人,会定期从主线拉取变更。他们会在拉取之后通过构建和测试,检查这些变更是否影响自己的工作。他们也可能使用持续集成服务来完成这项工作,以确保主线上的任何变更不会破坏自己的分支。

但这并不是完整的集成流程。完整的主线集成要求开发人员把自己的工作推送回主线。如果他们不这样做,其他团队成员就看不到他们的工作,也无法检查是否存在冲突。这种半集成无法防止分支分歧、冲突长期存在,以及低频集成带来的各种问题。持续集成要求完整的主线集成,任何代码都不应在分支上停留超过几个小时而不被推送到主线。

代码库中的冲突形式多种多样。最容易发现和解决的是文本冲突,通常称为“合并冲突”,即两位开发者以不同方式编辑了同一段代码。版本控制工具会在另一位开发者把更新后的主线代码合并到自己的工作副本时,轻松检测到这些冲突。更棘手的是语义冲突。如果我的同事修改了一个函数名,而我在新添加的代码中调用了该函数,版本控制系统对此无能为力。在静态类型语言中,这通常会导致编译失败,因而很容易检测;但在动态语言中,我们就得不到这种帮助。即使有静态类型编译,如果同事修改的是我调用的函数体,并导致函数行为发生细微变化,编译器也无能为力。这就是自测试代码如此重要的原因。

测试失败表明存在变更冲突,但我们仍需找出冲突在哪里,以及如何解决。由于两次提交之间通常只有几个小时的变更,问题可能隐藏的位置非常有限。此外,由于变更量很小,我们还可以使用差异调试来帮助定位错误。

我的经验法则是,每位开发者每天都应该向主线提交代码。实际上,有持续集成经验的开发者会更频繁地集成。集成越频繁,我们需要查找冲突错误的范围就越小,解决冲突也越快。

频繁提交代码会促使开发者把工作拆分成几个小时即可完成的小块。这有助于跟踪进度,也会带来成就感。人们一开始常常觉得几个小时内无法完成任何有意义的事情,但我们发现,导师指导和实践练习可以帮助团队学会这种工作方式。

每次向主线推送代码都应触发一次构建

如果团队中的每个人至少每天集成一次代码,主线理论上应该保持健康。然而在实践中,问题仍然会发生。原因可能是纪律松懈、推送前没有更新和构建,也可能是开发人员工作空间之间存在环境差异。

因此,我们需要确保每次提交都能在一个参考环境中得到验证。通常做法是使用持续集成服务(CI 服务)来监控主线代码。常见的 CI 服务包括开源自动化服务器、代码托管平台内置流水线,以及一些商业 CI 平台。每当主线收到提交,CI 服务就会把主线的最新代码检出到集成环境中,并执行完整构建。只有当集成构建通过后,开发人员才能认为集成完成。通过确保每次推送都触发构建,一旦构建失败,我们就能知道问题出在最近一次推送中,从而缩小修复范围。

我要强调一点:当我们使用持续集成服务时,我们只把它用于主线,也就是版本控制系统参考实例上的主分支。虽然使用 CI 服务监控和构建多个分支很常见,但集成的最终目标,是确保所有提交都位于同一条分支上。使用 CI 服务为不同分支执行自动化构建或许有用,但这并不等同于持续集成。采用持续集成的团队,只需要 CI 服务监控产品的单一分支。

虽然现在几乎所有团队都会使用持续集成服务,但即使没有 CI 服务,也完全可以实践持续集成。团队成员可以手动把主线分支的最新代码检出到集成服务器上,然后执行构建来验证集成。不过,既然自动化工具如此普及,手动操作就显得没有必要了。

值得一提的是,早期有一些海外软件团队为持续集成贡献了许多开源工具,其中包括最早一批持续集成服务。

立即修复损坏的构建

持续集成只有在主线保持健康的前提下才能有效运作。如果集成构建失败,就必须立即修复。正如早期极限编程实践者所说:“没有什么任务比修复构建更重要。”这并不意味着团队中的每个人都必须停下手头工作去修复构建;通常只需要几个人就能让系统恢复正常。但它确实意味着,团队应把修复构建视为紧急且高优先级的任务,并有意识地优先处理。

通常,修复构建问题的最佳方法是回滚主线分支上的最新提交,使系统恢复到上一个已知正常的构建版本。如果问题原因显而易见,也可以直接提交一个修复版本;否则,回滚主线可以让部分开发人员在独立开发环境中排查问题,同时让团队其他成员继续基于健康的主线开展工作。

有些团队倾向于使用待定分支,也称为预测试提交、延迟提交或门控提交,以彻底消除破坏主线的风险。为此,CI 服务需要被设置成:准备集成到主线的提交不会立即合并到主线,而是先放到另一个分支上,等构建完成且成功之后,再迁移到主线。虽然这种方法避免了破坏主线的风险,但高效团队本应很少看到主线失败。一旦失败,其可见性本身就能促使团队学习如何避免此类问题再次发生。

保持快速构建

持续集成的核心在于快速反馈。没有什么比漫长的构建更能扼杀持续集成的活力。老实说,对于所谓“长时间构建”,我多少有点像个爱嘲讽的老顽固。我的大多数同事都认为,一小时的构建时间完全不可接受。我还记得过去有些团队曾梦想把构建时间缩短到一小时,而今天我们偶尔仍会遇到很难达到这个目标的情况。

然而,对大多数项目而言,极限编程中“十分钟构建”的指导原则是完全合理的。我们的大多数现代项目都能做到这一点。为此投入精力是值得的,因为构建时间每减少一分钟,就意味着每位开发人员每次提交代码时少等待一分钟。持续集成要求频繁提交代码,因此这会节省大量时间。

如果构建时间长达一小时,加快构建速度看起来可能是一项艰巨任务。即便是新项目,想持续保持较快构建速度,也可能并不容易。至少在企业级应用中,我们发现常见瓶颈在于测试,尤其是那些涉及数据库等外部服务的测试。

最关键的一步或许是搭建部署流水线。部署流水线,也称构建流水线或分阶段构建,其核心思想是按顺序执行多个构建。提交到主线会触发第一个构建,我称之为提交构建。每当有人向主线推送提交,就需要执行提交构建。提交构建必须快速完成,因此会采取一些捷径,这会降低它发现错误的能力。关键在于平衡错误发现能力与速度需求,确保提交构建足够稳定,可以供其他人继续工作。

一旦提交构建通过验证,其他人就可以放心继续开发。不过,我们还可以运行一些耗时更长的测试。其他机器可以对这个构建版本运行更复杂的测试程序,这些程序需要更长时间才能完成。

一个简单例子是两阶段部署流水线。第一阶段负责编译并运行测试,这些测试主要是局部单元测试。其中耗时较长的服务会被测试替身替代,例如使用模拟的内存数据库,或使用外部服务的桩对象。这类测试运行速度很快,通常可以控制在十分钟以内。然而,任何涉及大规模交互,尤其是与真实数据库交互的错误,都无法在这一阶段被发现。第二阶段构建会运行另一套测试,这些测试访问真实数据库,并涉及更多端到端行为。它们可能需要几个小时才能完成。

在这种情况下,团队会把第一阶段作为提交构建,并将其作为主要的持续集成周期。如果辅助构建失败,虽然可能还不至于“停止所有工作”,但团队的目标应是在保持提交构建正常运行的同时,尽快修复此类错误。由于辅助构建速度较慢,可能不会在每次提交后都运行。此时,它会尽可能频繁地运行,并从提交阶段中选择最后一个成功的构建版本。

如果辅助构建检测到错误,就表明提交构建需要增加额外测试。我们希望尽可能确保:任何后续阶段的失败,都能推动我们在提交构建中新增测试,从而捕获该错误,并让该错误在提交构建中得到修复。这样,每当有错误穿过提交构建的测试防线,提交构建的测试就会得到加强。有些情况下,我们无法构建一个快速暴露错误的测试,因此可能决定只在辅助构建中测试该场景。幸运的是,大多数情况下,我们都可以向提交构建添加合适的测试。

另一种加速方法是利用并行技术和多台机器。云环境尤其方便,它允许团队轻松启动小型服务器集群用于构建。如果测试能够相对独立地运行——编写良好的测试应当做到这一点——那么使用这样的集群可以显著缩短构建时间。这种并行云构建对开发人员的集成前构建也可能很有价值。

在讨论更广泛的构建流程时,还值得提到另一类自动化:与依赖项的交互。大多数软件都依赖许多由不同组织开发的软件。这些依赖项发生变化,可能会导致产品出现故障。因此,团队应该自动检查依赖项的新版本,并像对待团队成员的变更一样,把它们集成到构建过程中。这项工作应该频繁进行,通常至少每天一次,具体取决于依赖项变化的频率。运行契约测试时也应采用类似方式。如果这些依赖项交互出现异常,虽然它们未必像常规构建失败那样立刻导致“全线停工”,但团队仍应迅速调查并修复。

隐藏正在进行中的工作

持续集成意味着:一旦开发取得一些进展,并且构建正常通过,就立即进行集成。这通常意味着,在用户可见的功能完全开发完成并准备发布之前,就已经把相关代码集成进来。因此,我们需要考虑如何处理潜在代码:也就是存在于已发布版本中、但对应功能尚未完成的代码。

有些人担心潜在代码,因为它会把尚未达到生产质量的代码放入已发布的可执行文件中。采用持续集成的团队会确保所有提交到主线的代码都达到生产质量,并配有用于验证它们的测试。潜在代码也许永远不会在生产环境中执行,但这并不妨碍它在测试中被执行。

我们可以使用“基石接口”(Keystone Interface)来防止代码在生产环境中执行:也就是确保提供新功能路径的接口,是我们最后才添加到代码库中的部分。测试仍然可以检查除最终接口之外的所有层级代码。在设计良好的系统中,这类接口元素应该尽可能少,因此只需少量编程就能轻松添加。

借助暗发布技术,我们可以在正式向用户发布变更之前,先在生产环境中测试这些变更。这种技术对于评估性能影响非常有用。

基石接口可以处理大多数潜在代码场景;对于无法使用基石接口的情况,我们会使用特性标志。特性标志会在每次执行潜在代码之前进行检查。它们作为环境的一部分进行设置,可能位于特定环境的配置文件中。这样,潜在代码可以在测试环境中启用,而在生产环境中禁用。除了支持持续集成之外,特性标志还简化了 A/B 测试和金丝雀发布中的运行时切换。一旦某个特性完全发布,我们会及时移除这些逻辑,以免特性标志让代码库变得臃肿。

抽象分支是另一种管理潜在代码的技术,尤其适用于代码库中的大型基础设施变更。本质上,它为正在变化的模块创建一个内部接口。该接口可以在新旧逻辑之间路由,并随着时间推移逐步替换执行路径。我们已经看到这种方法被用于切换影响范围很广的元素,例如持久化平台变更。

引入新功能时,我们始终应确保出现问题时可以回滚。并行变更,也称为扩展—收缩模式,会把变更拆分成一系列可逆步骤。例如,如果我们要重命名一个数据库字段,首先创建一个使用新名称的新字段,然后同时写入新旧字段;接着把现有旧字段中的数据复制到新字段;然后从新字段读取数据;最后才删除旧字段。我们可以撤销其中任何一步,而如果一次性完成全部变更,就做不到这一点。采用持续集成的团队通常会以这种方式拆分变更,使变更保持小而可回滚。

在生产环境的克隆环境中测试

测试的目的是在受控条件下找出系统在生产环境中可能出现的问题。在这其中,生产系统的运行环境至关重要。如果我们在不同环境中测试,任何环境差异都可能导致测试中出现的问题在生产环境中不会重现,或者生产环境中的问题在测试中无法出现。

因此,我们希望尽可能精确地模拟生产环境。使用相同的数据库软件,并且版本相同;使用相同版本的操作系统;把生产环境中所有相关库都部署到测试环境中,即使系统实际上并不使用它们;使用相同的 IP 地址和端口,并运行在相同硬件上。

虚拟环境让这一切比以往更容易实现。我们可以在容器中运行生产软件,并可靠地构建完全相同的测试容器,甚至可以在开发人员自己的工作空间中这样做。这是值得的,因为与追踪一个由环境不匹配引发的 bug 相比,这样做的成本通常微不足道。

有些软件被设计为可在多种环境中运行,例如不同操作系统和平台版本。部署流程应安排这些环境下的并行测试。

还要注意一点:生产环境可能并不如开发环境稳定。生产软件是否会运行在网络连接不稳定的设备上,例如智能手机?如果是,请确保测试环境能够模拟糟糕的网络连接。

让每个人都能看到正在发生的事情

持续集成的核心在于沟通,因此我们希望确保每个人都能轻松看到系统当前状态,以及对系统所做的更改。

最重要的沟通内容之一,是主线构建的状态。CI 服务会提供仪表盘,让每个人都能查看正在运行的构建状态。这些服务通常也会与其他工具集成,把构建信息广播到内部协作工具。集成开发环境(IDE)通常也会集成这些机制,使开发人员能在日常使用的工具中收到提醒。许多团队只在构建失败时发送通知,但我认为构建成功时也应该发送消息。这样,大家会习惯定期收到通知,也能了解构建通常需要多久。更不用说,每天听到一句“干得好”也让人心情愉快,哪怕它只是来自 CI 服务器。

在实际落地中,团队还可以借助研发管理与协作工具,把这些状态信息与需求、缺陷、迭代、测试和发布关联起来。例如,PingCode 可以帮助研发团队串联目标、反馈、需求、开发、测试、发布和知识沉淀,使持续集成产生的数据进入完整的研发管理闭环;如果团队更偏通用项目协作,也可以通过 Worktile 承接任务、项目、文档、日历等协同信息,让跨角色沟通更顺畅。

共享办公空间中的团队,通常会配备某种始终开启的物理显示屏,用来展示构建进展。常见形式是一块显示简化仪表盘的大屏幕。这对于提醒所有人构建失败尤其重要,通常会用主线提交构建的红绿状态进行标记。

我以前很喜欢的一种实体显示方式,是红绿熔岩灯。熔岩灯的特点是,打开一段时间后才会开始冒泡。这个设计的理念是:如果红灯亮起,团队就应该在它开始冒泡之前修复构建问题。用于展示构建状态的实体装置往往很有趣,会为团队工作空间增添一些独特个性。我至今还记得一只会跳舞的兔子。

除了显示当前构建状态,这些显示屏还可以展示一些有用的历史信息,作为项目健康状况指标。世纪之交,我曾与一个团队合作,他们一直无法创建稳定构建。我们在墙上挂了一张显示整年的日历,每一天都用一个小方格表示。每天,如果 QA 团队收到一个通过提交测试的稳定构建版本,就在那天贴上绿色贴纸;否则就贴上红色方格。随着时间推移,这张日历展示出构建过程的状态,也显示出稳定改进的趋势。直到绿色方格出现得越来越频繁,这张日历最终消失了——它已经完成了自己的使命。

自动化部署

为了实现持续集成,我们需要多个环境:一个用于运行提交测试,可能还需要更多环境来运行部署流水线的其他部分。由于我们每天都要在这些环境之间多次迁移可执行文件,因此需要把部署自动化。所以,编写脚本,使我们能够轻松地把应用程序部署到任何环境中,是至关重要的。

借助现代虚拟化、容器化和无服务器工具,我们还可以更进一步。我们不仅可以编写部署产品的脚本,也可以编写从零开始构建所需环境的脚本。这样,我们可以从一个现成的精简环境出发,创建产品运行所需的环境,安装产品,然后运行它——所有操作都完全自动化。如果我们使用特性标志隐藏开发中的功能,那么这些环境可以配置为启用所有特性标志,以便测试这些功能及其内部交互。

这些脚本自然也让我们能够以同样便捷的方式部署到生产环境。许多团队每天使用这些自动化流程多次将新代码部署到生产环境。即使我们选择较低的部署频率,自动部署也能加快流程并减少错误。此外,由于它使用的正是部署到测试环境时已经使用的能力,因此也是一种经济划算的选择。

如果我们采用自动化生产部署,那么自动回滚也尤其有用。意外总会发生。一旦局势突然失控,能够快速回滚到上一个已知正常状态就非常重要。自动回滚还能大幅降低部署压力,鼓励人们更频繁地部署,从而更快把新功能交付给用户。蓝绿部署模式允许我们通过在已部署版本之间切换流量,快速上线新版本,也能在需要时同样快速地回滚。

自动化部署还让金丝雀发布更容易设置:先把产品新版本部署给一小部分用户,以便在发布给所有用户之前发现问题。

移动应用很好地说明了自动化部署到测试环境的重要性。在这个场景中,测试环境就是设备。只有这样,我们才能在正式提交到应用商店之前测试新版本。事实上,任何与设备绑定的软件,都需要一种能够轻松把新版本部署到测试设备上的方法。

部署这类软件时,请务必确保版本信息可见。“关于”页面应包含与版本控制系统关联的构建 ID;日志应便于查看当前运行的软件版本;同时应提供用于获取版本信息的 API 接口。

持续集成、特性分支与预发布集成的区别

到目前为止,我描述了一种集成方法。但如果这种方法并非唯一,那么显然还有其他方法。任何分类都会存在模糊边界,但我发现,把集成方式分为三类很有用:预发布集成、特性分支和持续集成。

我见过最古老的版本,是 20 世纪 80 年代在那间仓库里看到的——预发布集成。它把集成视为软件项目中的一个阶段,这种观念是瀑布式开发流程的自然组成部分。在这类项目中,工作被划分为若干单元,这些单元可以由个人或小团队完成。每个单元都是软件的一部分,与其他单元的交互很少。各单元被独立构建和测试——这也是“单元测试”一词最初的用法。单元准备好之后,我们再把它们集成为最终产品。集成过程只进行一次,之后是集成测试,最后发布。因此,如果回顾整个工作流,可以看到两个阶段:第一阶段是所有人并行开发功能,第二阶段是集中进行集成。

持续集成(CI)是什么?核心实践、优势与适用场景

在这种模式下,集成频率与发布频率紧密相关,通常对应软件的主要版本。发布周期往往以月甚至年为单位。这些团队会采用不同流程来处理紧急 bug 修复,以便把它们从常规集成计划中分离出来并单独发布。

如今最流行的集成方法之一,是使用特性分支。在这种方法中,特性会分配给个人或小团队,类似于传统方法中的单元。但与传统方法不同,特性分支的开发者不必等到所有单元完成后才集成,而是在某个特性完成后立即把它集成到主线。有些团队会在每次特性集成后发布到生产环境,另一些团队则倾向于把多个特性组合后统一发布。

使用特性分支的团队通常期望每个人都定期从主线拉取代码,但这只是半集成。如果我和另一位同事分别开发不同特性,我们可能每天都从主线拉取代码,但只有当其中一人完成自己的特性并将其集成到主线后,我们才能看到彼此的变更。之后,另一人会在下次拉取代码时看到这些代码,并将它们集成到自己的工作副本中。因此,每个特性分支推送到主线后,其他所有开发人员都需要做一次集成,把最新主线变更合并进自己的特性分支。

持续集成(CI)是什么?核心实践、优势与适用场景

这之所以只是半集成,是因为每个开发者都只是把主线上的变更合并到自己的本地分支中。只有当开发者把自己的变更推送出去时,才算完成了完整集成。而这个推送又会引发新一轮半集成。即使我和同事都从主线拉取了相同变更,我们也只是集成了这些主线变更,而没有集成彼此分支中的工作。

通过持续集成,我们每天都会把各自的变更推送到主线,并把其他人的变更合并到自己的工作中。这会大幅增加集成次数,但每次集成的工作量都小得多。把几个小时的工作合并到代码库中,远比合并几天的工作容易。

持续集成(CI)是什么?核心实践、优势与适用场景

持续集成的主要优势

在讨论三种集成方式的相对优劣时,大部分讨论实际上都围绕集成频率展开。预发布集成和特性分支都可以以不同频率运行,而且可以在不改变集成方式的情况下调整集成频率。采用预发布集成时,每月发布与每年发布之间存在巨大差异。特性分支通常以更高频率运行,因为集成发生在每个特性单独推送到主线时,而不是等多个单元批量合并。如果一个团队使用特性分支,并且所有特性都能在一天之内完成,那么它实际上已经非常接近持续集成。但持续集成的不同之处在于,它被定义为一种高频集成方式。持续集成把集成频率本身作为目标,而不是把它绑定在特性完成或发布频率上。

因此,大多数团队都可以通过提高集成频率,在不改变现有风格的前提下显著改善下文将讨论的各项因素。把功能开发周期从两个月缩短到两周,显然会带来好处。持续集成的优势在于,它把高频集成设定为基准,并培养可持续的习惯和实践。

降低交付延期风险

完成一次复杂集成需要多久,往往很难估计。有时 Git 合并很困难,但最终一切顺利;有时合并很快,但一个不易察觉的集成错误却需要几天才能找到并修复。两次集成之间间隔越长,需要集成的代码越多,所需时间也越长。更糟的是,不可预测性也随之增加。

这一切都会让预发布集成变成噩梦。由于集成是发布前最后几个步骤之一,时间本来就紧,压力也很大。在临近发布时出现一个难以预测的阶段,意味着我们面临着难以规避的重大风险。这也是我对 20 世纪 80 年代那次经历记忆如此深刻的原因,而且那绝不是我唯一一次目睹项目陷入集成困境。每修复一个集成错误,就冒出两个新的错误。

任何提高集成频率的措施,都能降低这种风险。需要集成的工作越少,新版本发布前的未知时间就越短。特性分支通过把集成工作推送到各个特性流中来解决这个问题。这样,如果特性流保持不受干扰,那么一旦特性准备好,就可以立即推送到主线。

但这里有一个关键问题:如果其他人先把代码推送到了主线,那么在功能完成之前,我们仍然需要进行一些集成工作。由于分支是隔离的,在一个分支上工作的开发人员很难了解其他分支可能会推送什么功能,也很难预测集成这些功能需要多少工作。虽然高优先级功能可能面临集成延迟风险,但我们可以通过阻止低优先级功能推送来控制这种情况。

持续集成实际上消除了交付风险。集成操作非常小,通常无需讨论即可完成。真正棘手的集成,是那些需要几分钟以上才能解决的问题。最糟糕的情况是发生冲突,导致有人不得不重新做一部分工作;但这通常也不会损失一整天的工作,因此不太可能引起利益相关者的担忧。此外,我们在软件开发过程中持续进行集成,因此能够在更早、更有余裕的时候遇到问题,并练习解决方案。

即使团队并不定期发布到生产环境,持续集成也至关重要,因为它能让所有人清楚了解产品状态。发布前不再存在隐藏的集成工作;所有集成工作都已经融入产品之中。

减少集成过程中的时间浪费

我还没有见过严谨研究来衡量集成耗时与集成规模之间的关系,但我的经验表明,二者并不是线性关系。如果需要集成的代码量翻倍,集成耗时很可能变成原来的四倍。这就像连接三个节点只需要三条线,而连接四个节点却需要六条线一样。集成的核心在于连接,因此耗时并不会线性增长。我的同事们也有同样感受。

在使用特性分支的组织中,大部分时间损失都由个人承担。花费数小时尝试把自己的分支变基到主线上的一次重大变更之后,会令人沮丧。完成拉取请求后等待数天代码审查,而等待期间主线又发生了另一次重大变更,更令人抓狂。不得不搁置新功能开发,转而调试两周前完成的功能在集成测试中暴露的问题,也会严重削弱生产力。

当我们进行持续集成时,集成过程通常不会造成什么问题。我拉取主线代码,运行构建,然后推送。如果出现冲突,由于我写的代码不多,而且还记忆犹新,所以通常很容易发现问题所在。工作流固定,我们对此非常熟练,也有动力尽可能将其自动化。

和许多非线性效应一样,集成很容易变成一个陷阱,让人从中吸取错误教训。一次艰难的集成可能给团队造成很大创伤,以至于他们决定减少集成频率,而这只会加剧未来的问题。

事实是,团队成员之间的协作变得更加紧密了。如果两位开发人员的决策发生冲突,我们会在集成时发现。因此,集成间隔越短,发现冲突就越早,我们也就能在冲突扩大之前解决它。通过高频集成,我们的源代码控制系统变成了一个沟通渠道,传递那些原本难以言说的信息。

减少缺陷

软件缺陷——这些讨厌的东西会摧毁信心、扰乱进度、损害声誉。已部署软件中的缺陷会让用户对我们不满。常规开发过程中出现的缺陷则会阻碍我们,让软件其他部分更难正常运行。

持续集成并不能消除所有 bug,但它确实能显著简化 bug 的查找和修复。这并不主要归功于高频集成本身,而是归功于自测试代码。如果没有自测试代码,持续集成就无法发挥作用,因为没有完善测试,我们就无法维护主线健康。因此,持续集成建立了一套常规测试机制。如果测试不足,团队会很快发现,并采取纠正措施。如果 bug 是语义冲突引起的,也很容易检测出来,因为需要集成的代码量很小。频繁集成还与差异调试配合良好,因此即使是几周后才发现的 bug,也能被定位到很小的改动范围内。

缺陷还会累积。缺陷越多,修复每个缺陷就越困难。一部分原因是缺陷之间会相互影响,导致故障表现为多个缺陷共同作用的结果,使每个缺陷都更难查找。此外,还有心理因素:当缺陷数量很多时,人们会缺乏精力去查找和修复它们。因此,通过持续集成强化代码自测试,可以显著降低缺陷带来的问题。

这引出另一个许多人觉得反直觉的现象。由于引入变更往往意味着引入缺陷,人们便得出结论:为了获得高可靠性软件,就需要放慢发布速度。然而,海外某研究项目有力地反驳了这一观点。该研究发现,优秀团队能够更快、更频繁地将代码部署到生产环境,同时故障率显著更低。研究还发现,当团队的应用代码库中活跃分支不超过三个、每天至少将分支合并到主线一次,并且没有代码冻结或集成阶段时,团队绩效更高。

支持重构,从而持续提高生产力

大多数团队都会发现,代码库会随着时间推移逐渐退化。早期决策在当时看来合理,但经过六个月的工作后,可能已经不再是最佳选择。然而,要把团队后来学到的经验融入代码,就需要对现有代码进行深入修改,而这可能导致合并过程困难重重、耗时且充满风险。每个人都记得,曾经有人做出了一项看似对未来有益的修改,却破坏了其他人的工作成果,耗费了数天时间。鉴于这种经历,即使现在代码结构已经变得难以维护,并拖慢了新功能交付,也没有人愿意重新整理现有代码。

重构是减缓甚至逆转代码衰退的关键技术。定期重构的团队会掌握一套严谨技巧,通过小规模、不改变行为的转换来改进代码库结构。这些转换的特性大大降低了引入 bug 的可能性,而且执行速度很快,尤其是在自测试代码的支持下。团队抓住每一个机会进行重构,就能持续改进现有代码库结构,从而更轻松、更快速地添加新功能。

但这种良性局面可能会被集成难题破坏。一次为期两周的重构或许能显著提升代码质量,但由于其他成员在过去两周里都还在使用旧架构,合并过程可能会非常漫长。这使得重构成本高得令人难以承受。频繁集成通过确保重构人员和其他成员定期同步工作,解决了这一难题。使用持续集成时,如果有人对我正在使用的核心库做了重大修改,我只需要调整几个小时的编程工作即可。如果他们的修改方向与我的修改方向冲突,我会立即知道,并有机会与他们沟通,共同寻找更好的解决方案。

到目前为止,本文已经提出了几个关于高频集成优势的反直觉观点:集成越频繁,花在集成上的时间反而越少;频繁集成还能减少 bug。而软件开发中最重要的反直觉观点也许是:那些投入大量精力维护代码库健康的团队,能够更快、更低成本地交付功能。花时间编写测试和重构代码,能够显著提升交付速度,而持续集成正是团队协作中实现这一点的关键。

发布到生产环境是一项业务决策

设想我们正在向一位利益相关者演示某个新开发的功能。她回应道:“这太棒了,对业务影响巨大。多久能正式上线?”如果这个功能展示在一个尚未集成的分支上,答案可能是几周甚至几个月,尤其是在生产部署自动化程度不高的情况下。持续集成使我们能够维护一条随时可以发布的主线,这意味着何时把最新版本发布到生产环境,完全可以成为业务决策。如果利益相关者希望最新版本上线,只需运行几分钟的自动化流水线即可。这让软件用户能够更好地控制功能发布时间,也鼓励他们与开发团队更紧密地合作。

持续集成和随时可发布的主线,消除了频繁部署的最大障碍之一。频繁部署的价值在于,它能让用户更快获得新功能,更快提供反馈,并在开发周期中更有效地协作。这有助于打破客户与开发团队之间的壁垒,而我认为这些壁垒是成功软件开发面临的最大障碍。

何时不应使用持续集成

这些好处听起来确实诱人。但像我这样经验丰富,或者说有些愤世嫉俗的人,总会对单纯罗列好处保持怀疑。世上没有不付出代价的事。架构和流程方面的决策通常都是权衡取舍的结果。

但我必须承认,持续集成是少数几种对一支专注且技术熟练的团队几乎没有明显缺点的实践之一。间歇性集成的代价如此巨大,以至于几乎所有团队都能从提高集成频率中获益。收益增长最终会达到上限,但这个上限是以小时而非天为单位的,而这正是持续集成的优势所在。代码自测试、持续集成与重构之间的相互作用尤其强大。海外某些软件咨询公司已经运用这种方法二十多年,真正的问题通常不是它是否有效,而是如何更有效地使用它——其核心方法已经被长期实践所验证。

但这并不意味着持续集成适合所有人。你可能注意到我说的是:对于一支“专注且技术熟练”的团队,持续集成几乎没有缺点。这两个形容词揭示了持续集成不适用的具体场景。

我所说的“专注”,指的是一支全职投入产品开发的团队。一个典型反例是经典开源项目。这类项目通常只有一两个维护者,却有许多贡献者。在这种情况下,即使是维护者每周也可能只投入几个小时。他们并不熟悉所有贡献者,也不知道贡献者何时会提交代码,更无法确保贡献者遵循哪些标准。正是这种环境催生了特性分支工作流和拉取请求。在这种情况下,持续集成并不现实,尽管努力提高集成频率仍然很有价值。

持续集成更适合由全职团队开发产品的场景,例如商业软件开发。但传统开源模式与全职团队模式之间存在很大的中间地带。我们需要根据团队投入程度,判断采用何种集成策略。

第二个形容词关注的是团队遵循必要实践的能力。如果一个团队在没有完善测试套件的情况下尝试持续集成,就会遇到各种问题,因为他们缺乏筛除缺陷的机制。如果没有自动化测试,集成就会耗时过长,并干扰开发流程。如果团队成员没有严格遵守规范,确保每次推送到主线的代码都经过验证,那么主线最终会长期处于损坏状态,阻碍所有人的工作。

任何考虑引入持续集成的人,都必须牢记这些技能要求。在没有代码自测试的情况下实施持续集成是行不通的,而且会让人对真正有效的持续集成产生错误印象。

话虽如此,我认为这些技能要求并不高。我们不需要顶尖开发人员,也能让这套流程在团队中顺利运转。事实上,顶尖开发人员有时反而会成为障碍,因为那些自认为很厉害的人,往往缺乏必要的自律。这些技术实践本身并不难学,真正的难点通常在于找到一位好老师,并养成能够巩固这些技能的习惯。一旦团队掌握了流程,通常会觉得它轻松、流畅且高效。

如何引入持续集成

描述如何引入持续集成这类实践,难点在于路径很大程度上取决于你的起点。写这篇文章时,我并不了解你正在编写什么代码,你的团队拥有哪些技能和习惯,更不用说更广泛的组织环境了。像我这样的人所能做的,只是指出一些常见方向,希望它们能帮助你找到适合自己的道路。

引入任何新实践时,明确目的都至关重要。我前面列出的好处涵盖了最常见的原因,但不同情境会让这些原因的重要性有所不同。有些好处比其他好处更难观察。减少集成中的浪费解决的是一个令人沮丧的问题,而且随着进展推进,这种效果很容易被感知。而支持重构、减少系统冗余、提高整体生产力,则更难观察。我们需要一段时间才能看到效果,而且很难与假设中的反事实进行比较。然而,这或许是持续集成最有价值的好处。

上文列出的实践表明,团队需要学习哪些技能才能有效实施持续集成。其中一些实践甚至在接近高频集成之前就能带来价值。例如,即使提交频率不高,代码自测试也能增强系统稳定性。

一个可行目标,是先把集成频率提高一倍。如果特性分支通常持续十天,那就想办法把它缩短到五天。这可能需要改进构建和测试自动化,也需要创造性地思考如何把大型任务拆分成更小、可独立集成的任务。如果我们采用集成前评审,可以在评审中加入明确步骤,检查测试覆盖率,并鼓励提交更小的变更。

如果你要启动一个新项目,就可以从一开始采用持续集成。我们应该密切关注构建时间,一旦构建速度超过十分钟规则,就立即采取措施。通过快速行动,我们可以在代码库规模过大并造成严重问题之前完成必要的重构。

最后,寻求帮助很重要。我们应该找一位有持续集成经验的人来指导团队。任何新技术都是如此:如果我们不知道最终状态应该是什么样子,就很难贸然引入。获得这种支持可能需要花钱,但否则我们将付出时间和生产力上的代价。海外某些软件咨询公司也提供这方面的服务,毕竟这些团队已经犯过许多可能犯的错误,并从中积累了经验。

持续集成常见问题

持续集成技术起源于哪里?

持续集成(CI)是 Kent Beck 在 20 世纪 90 年代作为极限编程(XP)的一部分发展出来的实践。当时,预发布集成是常态,发布周期通常以年为单位。虽然迭代开发和更快发布周期已经成为普遍趋势,但很少有团队会考虑以周为单位发布。Kent 定义了持续集成实践,并在自己参与的项目中不断完善它,同时明确了它与其他关键实践之间的相互作用。

海外某大型软件公司长期以来以每日构建,通常是夜间构建而闻名,但它缺少持续集成中至关重要的测试机制和缺陷修复优先级。

有些人认为“面向对象设计”这个术语是 Grady Booch 创造的,但他只是在一本关于面向对象设计的书中顺带使用过这个短语。他并没有把它视为一种既定实践;事实上,它甚至没有出现在索引中。

持续集成和基于主干的开发有什么区别?

随着持续集成服务普及,许多人开始用它们在特性分支上运行常规构建。正如前文所说,这根本不是持续集成,但它让许多人误以为自己在做持续集成,实际上他们做的是完全不同的事情,于是造成了很大误解。

有些人决定创造一个新术语来解决这种语义扩散问题:基于主干的开发(Trunk-Based Development)。总体来说,我认为它是持续集成(Continuous Integration)的同义词,同时也承认它不容易与“在特性分支上运行 CI 服务”混淆。我看到有些人试图区分二者,但这些区分既不一致,也不令人信服。

我不使用“基于主干的开发”这个术语,部分原因是我认为创造新名称并不是对抗语义扩散的好办法;更主要的原因是,重新命名这项技术,会粗暴地抹去那些最初倡导并发展持续集成的人的工作。

尽管我一直坚持使用“持续集成”这个词,但确实有许多关于持续集成的优质信息来自“基于主干的开发”这一领域。尤其是一些海外技术作者发表过大量优秀文章。

我们可以在特性分支上运行持续集成服务吗?

简单回答是:可以——但那并不意味着你真正实现了持续集成。这里的关键原则是:“每个人每天都要向主线提交代码。”在特性分支上运行自动化构建当然有用,但这只是半集成。

然而,人们常常误以为,以这种方式使用守护进程构建就是持续集成的全部含义。这种误解源于这些工具被称为“持续集成服务”。更准确的说法也许应该是“持续构建服务”。虽然使用持续集成服务有助于实现持续集成,但我们不应把工具与持续集成实践混为一谈。

团队能否同时进行持续集成和特性分支开发?

一般来说,持续集成和特性分支是互斥的方法。大多数认为自己同时采用两者的团队,实际上是在特性分支上运行持续集成服务。正如上一个问题所解释的,这并不是真正的持续集成。

有一种情况可以同时做到两点:所有功能都非常小,可以在一天之内完成。但这种情况似乎非常罕见,而且大多数人会直接称之为持续集成。

其次,完全可以在单独分支上进行个人开发,然后在集成时把它合并到主分支并推送。如果我担心自己会在 IDE 中误操作,意外推送一个损坏的本地主分支,我可能会这样做。关键问题在于我是否在持续集成,而不是我如何管理个人工作区。

持续集成和持续交付有什么区别?

早期对持续集成的描述,主要集中在开发人员在团队开发环境中与主线代码集成的循环上。这类描述很少提及从主线集成到生产发布的完整过程。但这并不意味着人们没有考虑到这一点。“自动化部署”和“在生产环境克隆中测试”等实践清楚表明,人们已经认识到通往生产环境的路径。

在某些情况下,主线集成之后并没有太多额外工作。我记得一位早期实践者在 20 世纪 90 年代末向我展示过他在欧洲开发的一个系统,他们每天都会自动部署到生产环境。但那是一个 Smalltalk 系统,生产部署步骤并不复杂。到了 21 世纪初,在一些海外软件项目中,我们经常遇到这样的情况:部署到生产环境的路径要复杂得多。这让我们意识到,除了持续集成之外,还需要另一种方法来解决这个问题。这种方法后来被称为持续交付。

持续交付的目标,是确保产品始终处于可以发布最新版本的状态。它本质上是在确保:发布到生产环境是一项业务决策。

如今,对许多人来说,持续集成指的是将代码集成到开发团队环境中的主线分支;持续交付则指部署流水线中通往生产发布的其余部分。有些人认为持续交付包含持续集成;有些人认为它们是紧密相关的伙伴,通常统称为 CI/CD;还有人认为持续交付只是持续集成的同义词。

持续部署在其中扮演什么角色?

持续集成确保每个人至少每天把自己的代码集成到版本控制系统的主线分支中。持续交付则执行所有必要步骤,确保产品随时可以发布到生产环境。持续部署意味着:产品一旦通过部署流水线中的所有自动化测试,就会自动发布到生产环境。

通过持续部署,作为持续集成一部分推送到主线的每一次提交,都会在部署流水线所有验证通过后自动部署到生产环境。持续交付只是确保这一点能够实现,因此它是持续部署的前提条件。

我们如何处理拉取请求和代码审查?

拉取请求(Pull Request)最初由某代码托管平台推广,如今已广泛用于软件项目。本质上,它为推送到主线增加了一道流程,通常涉及集成前代码审查,要求另一位开发者批准之后,代码才能被推送到主线。拉取请求主要在开源项目的特性分支环境中发展起来,用来确保项目维护者能够审查贡献代码是否符合项目风格和未来方向。

集成前代码审查可能会给持续集成带来问题,因为它通常显著增加集成过程的阻力。原本几分钟就能完成的自动化流程,现在却需要找到人审查代码,协调他们的时间,并等待反馈,才能接受审查结果。虽然有些组织可能在几分钟内完成这个流程,但它很容易耗费数小时甚至数天,从而破坏持续集成赖以运作的时间节奏。

采用持续集成的团队,会通过重新安排代码审查在工作流中的位置来应对这个问题。结对编程之所以流行,是因为它可以在代码编写过程中实现持续的实时审查,从而大大缩短审查反馈循环。“发布 / 展示 / 询问”流程鼓励团队只在必要时使用阻塞式代码审查,并承认集成后审查通常是更好的选择,因为它不会影响集成频率。许多团队发现,细致的代码审查是维护健康代码库的重要力量,但只有当持续集成营造出有利于重构的环境时,这种审查才能发挥最佳效果。

我们应该记住,集成前审查源于开源环境。在那种环境中,贡献往往来自联系不紧密的开发者,并且具有偶发性。在那里行之有效的实践,需要针对关系紧密的全职团队重新评估。

我们如何处理数据库?

随着集成频率提高,数据库带来了特殊挑战。把数据库模式定义和测试数据加载脚本纳入版本控制中的源代码并不困难。但这对于版本控制之外的数据,例如生产数据库,并没有直接帮助。如果我们更改数据库模式,就需要知道如何处理现有数据。

在传统预发布集成模式中,数据迁移是一项相当大的挑战,通常需要专门组建团队来执行迁移。乍看之下,尝试高频集成似乎会带来难以承受的数据迁移工作量。

然而在实践中,改变视角就能解决这个问题。早期一些持续集成项目中就遇到过这个问题,并通过转向演进式数据库设计解决了它。这种方法的关键在于通过一系列迁移脚本来定义数据库模式和数据。这些脚本会同时修改数据库模式和数据。每次迁移都很小,因此容易理解和测试。迁移脚本可以自然组合,因此我们可以按顺序运行数百个迁移脚本,从而完成重要的模式变更,并同时迁移数据。我们可以把这些迁移脚本存储在版本控制系统中,并与应用程序中的数据访问代码保持同步。这样,我们就能构建任意版本的软件,并确保它拥有正确的数据库模式和结构化数据。这些迁移脚本既可以在测试数据上运行,也可以在生产数据库上运行。

最后想说的话

大多数软件开发工作,都是对现有代码进行修改。向代码库添加新功能的成本和响应时间,很大程度上取决于代码库的状况。臃肿的代码库更难修改,成本也更高。为了尽量减少代码冗余,团队需要能够定期重构代码,根据不断变化的需求调整代码结构,并把团队在产品开发过程中积累的经验教训融入其中。

持续集成对于健康的产品至关重要,因为它是演进式设计生态系统中的关键组成部分。它与自测试代码协同工作,共同支撑重构。这些技术实践源于极限编程,能够帮助团队定期改进产品,以应对不断变化的需求和技术机遇。

文章包含AI辅助创作,作者:liu,如若转载,请注明出处:https://docs.pingcode.com/baike/5242967

(0)
liuliu
免费注册
电话联系

4008001024

微信咨询
微信咨询
返回顶部