虽然持续集成已经讲了很多年了,为了保持知识的连贯性,还是总结一篇吧,文中很多内容来自网络
-
持续集成的目的,就是让产品可以快速迭代,同时还能保持高质量。它的核心措施是,代码集成到主干之前,必须通过自动化测试。只要有一个测试用例失败,就不能集成。
Martin Fowler说过,"持续集成并不能消除Bug,而是让它们非常容易发现和改正。"
为什么要做持续集成
在《Code Complete》里提到了,对于持续集成(在书中,Steve McConnell使用Incremental Integration的术语)有以下几点好处:
- 易于定位错误。也就是当你的持续集成失败了,说明你新加的代码或者修改的代码引起了错误,这样你很容易的就可以知道到底是谁犯了错误,可以找谁来讨论。
- 及早在项目里取得系统级的成果。因为代码已经被集成起来了,所以即使整个系统还不是那么可用,但至少你和你的团队都已经可以看到它已经在那了。
- 改善对进度的控制。这点非常明显,如果每天都在集成,当然每天都可以看到哪些功能可以使用,哪些功能还没有实现。如果你是程序员,你不用在汇报任务的时候说我完成了多少百分比而烦恼,而如果你是项目经理的话,那么你也不再烦恼程序员说完成了编码的50%到底是个什么概念。
- 改善客户关系。理由同上。
- 更加充分地测试系统中的各个单元。这也是我们常讲的Daily Build与Smoke Test相结合带来的绝大好处。
- 能在更短的时间里建造整个系统。这点恐怕要你实施以后才能得出结论。就我们而言,持续集成并没有为每个项目都缩短时间,但却比没有实施时,项目更加可控,也更加有保证。
随着时间的推移,持续集成带来的更多好处,也逐渐被认识到了,比如说:
- 有助于项目的开发数据的收集。比如说,项目代码量的变化,经常出错的Tests,经常出错的source code,等等。
- 与其它工具结合的持续代码质量改进。如与CheckStyle, PMD, FindBugs, Fxcop等等等等的结合。
- 与测试工具或者框架结合的持续测试。如与xUnit,SilkTest, LoadRunner等等的结合。
- 便于Code Review。在每个build里,我们都可以知道与前一个build之间有什么改动,然后针对这些改动,我们就可以实施Code Review了。
- 便于开发流程的管理。比如说,要把一个开发的build提交给测试组作测试,测完满意了,再提交到发布组去发布。
持续集成实践
实践的意思简单说就是怎么做。从最初martin fowler 这老爷子的最初文章中有10个实践,下面我们会一一来讲。
- 维护一个单一的代码库
软件项目需要大量的文件协同工作来构建出最终的产品。跟踪所有的文件需要大量的工作,尤其是在多个开发者参与的项目中。因此,我们可以并不惊奇的看到,不同的软件开发团队都在开发用于管理这些文件的工具——源代码管理工具,也叫配置管理,版本控制系统,代码库等。这些工具是多数软件项目不可分的组成部分。然而,令人伤心并吃惊的是,并不是所有的项目都使用了这样的工具。我的确见到(虽然很少)不使用这些工具的项目,它们使用本地和共享磁盘这种混乱的结合来共同工作。
因此,做为最基本的持续集成实践,请保证你使用一款体面的代码管理系统。成本不是问题,有许多高质量的开源代码管理工具存在。当前的选择为Subversion(译者注:现在有了更新的hg和Git)。(更老的开源工具CVS如今仍然被大量使用,虽然比没有强,但是Subversion是更现代的选择。)有趣的是,当我和一些开发者聊天时,我发现相比起多数商业化的代码管理系统,他们更喜欢Subversion。据我所知,唯一值得花钱买的只有Perforce。
当你有了代码管理系统之后,确保每个开发者都能方便的获得到源代码。不应该有人还在问:“foo-whiffle 文件在哪儿?”所有东西都必须在代码库里。
虽然许多团队都在使用代码库,但是我经常发现,他们并不把所有东西都放在里面。如果大家需要使用一个文件,他们知道该文件放到代码库中,但是,构建所需的所有都应该包含在代码库里,包括测试脚本,属性文件,数据库模式文件,安装脚本和第三方库等。我所知道的有项目将编译器加到代码库中的(对于早期脆弱的C++编译器来说非常重要)。基本原则是:在一台新机器上check out代码后构建也能构建成功。新机器上的东西应该尽量的少,通常包括很大的,难于安装的,并且稳定的软件,比如操作系统,Java开发环境或者数据库管理系统等。
你需要将构建所需的所有东西都加到代码管理系统中,同时也需要将大家经常操作的东西方进去,IDE配置便是一个很好的例子,这样便于大家共享IDE配置。
版本控制系统的一大功能是它允许你创建多个分支,以此来处理不同的“开发流”。这种功能很有用,但却经常被过度使用以至给开发者带来了不少麻烦。所以,你需要将分支的使用最小化,特别建议使用主线,即项目中只有单一的开发分支,并且每人在多数时间里都在“离线”工作。
总之,你应该将构建所需的所有东西都放在代码管理系统中,而不应该将构建的输出放进去。有些朋友确实将构建输出放在代码管理系统中,但我认为这是一个坏味道,可能导致更深的问题——通常是你无法完成重新构建。
- 使构建自动化
将源代码变成一个能运行的软件系统通常是一个复杂的过程,包括编译,文件搬移,加载数据库模式等等。但其中大多数任务都是可以自动化的,并且也应该被自动化。让人去输入奇怪的命令或点击对话框是非常耗时的,而且从根本上来说也是个错误的做法。
构建所需的自动化环境对于软件系统来说是一个通用功能。Unix的Make已经诞生好多年了,Java社区有Ant, .NET社区有Nant,现在又有了MSBuild。当你用这些工具构建和启动系统时,请确保只使用一个命令完成任务。
一个常见的错误是在自动化构建里并没有完全包括构建所需的东西。构建过程中应该从代码库里取得数据库模式文件并自动执行之。结合我上文所讲的原则来看,任何人都应该能够在一台新机器上拉下代码库中的代码,并只用一个命令将系统运行起来。
构建脚本是多种多样的,通常特定于某个平台或社区,但情况并不必须如此。我们的多数Java项目都使用Ant,而另外有些用Ruby(Ruby世界的Rake是一个非常不错的构建工具)。我们用Ant完成了早期的一个微软COM工程的构建自动化,并从中大获裨益。
大型的构建通常需要很长的时间,而在你只做了很小的修改的情况下,你是不想运行所有的构建步骤的。因此,优秀的构建工具能够分析出哪些地方需要做相应的修改,并将这个分析过程本身做为整个构建过程的一部分。通常的做法是检查源代码和目标文件的修改日期,只有当源代码的修改日期晚于其对应的目标文件时才执行编译。依赖关系因此变得微妙起来了:如果一个目标文件发生了修改,那些依赖于它的文件也需要重新构建。有些编译器能够处理这种依赖关系,而有些就不见得。
根据自己的需要,你可以选择不同的东西进行构建。构建中既可以包括测试,也可以不包括,甚至可以包括不同的测试板块。有些组件可以进行单独构建。构建脚本应该能够允许你针对不同的情形进行不同的构建目标。
我们大多数都使用IDE,而多数IDE都或多或少地集成了构建管理功能。但是这样构建文件通常是特定于IDE的,而且非常脆弱。此外,它们需要依赖于IDE才能工作。虽然对于开发者个人来说,在IDE中做这样的构建配置并无不妥,但对于持续集成服务器来说,一份能够被其它脚本调用的主构建脚本却是至关重要的。比如一个Java项目,各个开发者可以在自己的IDE中进行构建,但应该还有一个Ant主构建脚本来保证构建能在集成服务器上顺利完成。
- 使构建自测试
传统意义上的构建包括只编译,链接等过程。此时程序也许能运行起来,但这并不意味着系统就能正确地运行。虽然现在的静态语言已经能够捕捉到许多bug,但是漏网之鱼却更多。
一种快速并高效发现bug的方法是将自动化测试包含到构建过程中。当然,测试也不见得完美,但的确能发现很多bug——足够多了。特别是随着极限编程(XP)的升温,测试驱动开发(TDD)也使自测试代码流行起来,越来越多的人开始注意到这种技术的价值所在。
经常读我著作的读者可能知道我是一个TDD和XP的大粉丝,然而我想强调的是这两种方法和自测试并没有必然联系。TDD和XP都要求先写测试代码,再写功能代码使测试通过。在这种模式下,测试既用于发现bug,又用于完成系统设计。这是非常好的,但对于持续集成来说不必如此,因为此时我们自测试代码的要求并不那么高。(然而TDD是我写自测试代码的首选。)
对于自测试代码而言,你需要一组自动化测试来检测一大部分代码库中的bug。测试能通过一个简单得命令来运行并且具备自检功能。测试的结果应该能指出哪些测试是失败的。对于自测试的构建来说,测试失败应导致构建失败。
过去这些年里,TDD使开源的XUnit家族流行起来,成为了理想的测试工具。在ThoughtWorks,XUnit已经是非常有用的测试工具,我也经常建议人们使用。这组工具起初由Kent Beck开发,它们使自测试环境的搭建变得非常简单。
XUnit当之无愧地是你进行代码自测试的起点。当然,你也应当多看看那些更侧向于端到端测试的工具,包括FIT,Selenium,Sahi,Watir,FITnesse等等,我就不逐一列举了。
当然,别指望测试就是万能的。常言道,测试并不代表就没有bug。
- 每人每天都向主线提交代码
集成首先在于交流,它使其他成员能够看到你所做的修改。在这种频繁的交流下,大家都能很快地知道开发过程中所做的修改。
在向主线提交代码之前,开发人员必须保证本地构建成功。这当然也包括使测试全部通过。另外,在提交之前需要更新本地代码以匹配主线代码,然后在本地解决主线代码与本地代码之间的冲突,再在本地进行构建。如果构建成功,便可以向主线提交代码了。
在这种频繁提交下,开发者可以快速地发现自己代码与他人代码之间的冲突。快速解决问题的关键在于快速地发现问题。几个小时的提交间隔使得代码冲突也可以在几个小时内发现,此时大家的修改都不多,冲突也不大,因此解决冲突也很简单。对于好几周都发现不了的冲突,通常是很难解决的。
在更新本地代码库时就进行构建,这意味着我们既可以发现文本上的冲突,又可以发现编译冲突。既然构建是自测试的,那么运行时的冲突也可以被检测出来,而这样的冲突往往是一些特别烦人的bug。由于提交间隔只有短短的几个小时,bug便没多少藏身之处了。再者,因为每次提交的修改都不多,你可以使用diff-debugging来帮你找出这些bug。
我的基本原则是:每个开发者每天都应当向代码库进行提交。在实践中,越是频繁提交,可能导致冲突的地方就越少,因而也越容易发现。
频繁提交鼓励开发人员以几个小时为单位来分割他们的代码,这样便于跟踪进度。通常,人们一开始认为在短短的几个小时内做不了什么事情,但我们发现找个导师和多实践可以帮助他们学习。
- 每次提交都应在集成机上进行构建
有了每日提交,也就又了每日测试,这应该表明主线处于健康状态。但是在实践中,的确有出错的时候,原因之一在于纪律——有人并没有在提交之前进行本地更新和构建。另外,不同开发机之间的环境不同也是一个原因。
因此,你应该保证在集成机上进行构建,只有当集成机上构建成功后,才表明你的任务完成了。由于提交者需要对自己的提交负责,他就得盯着主线上的构建,如果失败,马上修改。如果下班之前你提交的修改失败了,那么,对不起,请修改好了才回家。
我见到过两种方式来保证主线构建的成功:一是手动构建,二是使用持续集成服务器。
手动构建是最简单的,基本上与开发者在本地做的构建差不多——先到集成机上拉下主线的最新代码,然后运行构建命令,在构建过程中你得盯着构建过程,如果构建成功,表明你的任务完成。(另见Jim Shore的描述。)
持续集成服务器则一直监视着代码库,一旦检测到有提交,便自动拉下代码到本机,然后开始构建,并将结构通知提交者。只有当提交者收到通知后——通常是以电子邮件的方式,才表明自己的任务完成。
在ThoughtWorks,我们是持续集成服务器的忠实粉丝,我们领导了CruiseControl和CruiseControl.NET的初期开发,此两者均是广为使用的CI服务器。从那时起,我们也开发了商业化的Cruise。在几乎每个项目中,我们都使用了CI服务器,并且结果是令人愉悦的。
不是所有人都倾向于使用CI服务器的,Jim Shore便给出了一个很好的论述,在此论述中,他解释了为什么他更倾向于手动构建。我同意他的看法——CI不过是安装一些软件而已,所有的实践都应当旨在有效地完成持续集成。但同样,许多使用CI服务器的团队的确发现CI服务器是很好的工具。
有很多团队定期的进行构建,比如每晚构建。这和持续构建并不是一回事,而且对于持续集成来说,也是不够的。持续集成的关键在于尽快地发现问题。而每晚构建意味着整个白天都发现不了bug,如此,需要很长的时间发现并清楚这些bug。
持续构建的重点在于,如果主线构建失败,你应该马上进行修改。在持续集成中,你一直是在一个稳定的代码库基础上进行开发。主线构建失败并不是一件坏事,但是,如果这样的情况经常发生,那么就意味着开发人员对于本地更新并没在意或者在提交之前并没在本地构建。主线构建一旦失败,必须马上修正。为了避免主线构建失败,也许你可以试试 pending head。
- 快速构建
持续集成的关键在于快速反馈,需要长时间构建的CI是极其糟糕的。我的多数同事都认为一个小时的构建时间对于CI来说决无道理可言。我也记得曾经有团队梦想着他们的构建能有多么多么的快,但有时我们不得不面对很难快速构建的情况。
对于多数项目来说,将构建时间维持在10钟之内是合理的,这也是XP的方针之一,我们多数项目也达到了这个目标。这种做法是值得的,因为这样省下的时间是为开发者节约的。
如果你的构建长到了一小时,那么想使其加速便不是那么容易了。对于企业级应用来说,我们发现构建时间的瓶颈通常发生在测试上,特别是那些需要于外部交互的测试——比如数据库。
可能最好的解决办法是引入阶段性构建(也叫构建管道或者部署管道),因为构建事实上是分阶段性的。代码提交后首先触发的是构建称为提交构建,提交构建应该快速完成,而棘手的是怎么保持速度与查找bug之间的平衡。
提交构建成功后,其他人便可自信的工作了。但是,你可能还有其它跑得比较慢的测试需要写,这时可以用额外的机器来专门跑这些耗时的测试。
一个简单的例子是将构建分为两个阶段,第一个阶段完成编译,并且跑那些不需要外部交互的单元测试,数据库交互也通过stub的方式完全消除掉。这些测试可以很快跑完,原则是将其保持在10分钟之内。但是,对于那些需要大量外部交互——特别是涉及到真实数据库交互时才能发现的bug,这个阶段便无能为力了。第二个阶段跑的测试则需要操作真实的数据库了,同时还应包括端到端测试。这个阶段可能需要几个小时。
在这种情况下,通常将第一阶段视为提交构建,并将此做为主要的CI周期。第二阶段则可在有必要时才进行,如果这个阶段构建失败,它也不需要像第一阶段那样“停下全部手头的工作”,但也应该得到尽快的修改。第二阶段的构建不见得需要保持一直通过,对于已经发现的bug来说,可以在之后几天修改。对于这个案例来说,第二阶段全是测试,因为通常情况下最慢的即是测试。
如果第二阶段构建发现了bug,通常意味着应该在第一阶段中引入新的测试来予以保证。
当然,以上的两阶段构建只是一个例子,你完全可以加入多个构建阶段。提交构建之后的其它构建是可以并行完成的,如果这些阶段的构建需要好几个小时,那么可以用几台机器来并行完成。通过这种并行化,你可以将提交构建之外的所有测试都引入到构建过程中来,比如性能测试。
- 在与生产环境的拷贝环境中运行测试
测试旨在发现可能在生产环境中出现的问题,因此如果你的测试环境与生产环境不同,那么测试很有可能发现不了生产环境中的bug。
因此,你的测试环境应该尽量与生成环境相同。使用相同的数据库,相同的操作系统,并且版本都应该一样。另外,将生产环境中的库文件也放到测试环境中,即使构建时用不到这些库。IP地址和端口号也应当相同,当然还包括硬件。
但事实上这是有限制的。如果你开发的是桌面软件,很难预测你的客户在使用哪些第三方库。再者,生产环境可能非常昂贵。即便存在这么多限制,你依然应当尽量去复制生产环境,并熟知因测试环境和生产环境的不同而可能导致的风险。
如果你搭建的环境足够简单并没有多少烦人的外部交互,那么你的提交构建便可在仿真环境中进行。但是,由于系统反应慢等原因,你可能需要test doubles。因此,通常情况是在人工环境下跑提交构建以获取速度,而用一个生产环境的拷贝环境来跑其它测试。
我注意到,虚拟化技术越来越引起人们的兴趣。由于虚拟机可以保存构建所需的所有东西,故在虚拟机中运行构建和测试相对比较容易。另外,虚拟机技术也允许你在一台机器上运行多个测试,或者可以模拟多台机器同时访问网络的情况。随着虚拟机性能逐渐提升,它将引起更多的注意。
- 使任何人都能轻易获得可执行文件
软件开发最困能的事情之一便是你不能保证所开发的是正确的软件。我们发现人们往往很难预知自己究竟想要什么,而相反,对已有的东西进行评判和修改却容易的多。敏捷开发过程则恰恰是符合人类这种行为习惯的。
为此,项目中的所有成员都应能够获得最新的可执行文件并能成功的运行,目的可以包括做演示,浏览测试或者仅仅看看项目本周有何修改。
这是很容易达到的:确保一个通用的地方来存放最新可执行文件。在同一个地方存放多个可执行文件也是很有用的。对于最新的可执行文件,应当保证能够通过提交测试。
如果你的开发过程有一个很好的迭代计划,将每次迭代最后一次构建生成的可执行文件存放起来也是明智的做法。
- 人人都能看到正在发生什么
持续集成主要在于交流,因此应当保证每人都能轻易看到当前系统的状态和已做的修改。
主线的构建状态是非常重要的,Cruise服务器包含一个网站,你可以在该网站上看到当前的构建状态和最后一次主线构建的结果,许多团队喜欢用比较显眼的标识来反应构建状态,比如在屏幕上放一盏灯,灯绿表示构建成功,灯红表示失败。尤其常见的是lava lamps——不仅表明构建状态,还可显示构建时间。如果红灯中有了气泡,则表明构建已经失败了很长一段时间了。每个团队都有自己的选择,当然,适合自己的才是最好的。
对于手工完成的持续集成过程,这种可见性也是很重要的,构建机器的显示器应该能显示主线构建的状态。通常,正在做集成的人会放一个token在桌上来表明他正在做集成。人们喜欢在构建成功后播放一些简单的声音,比如闹铃之类的。
当然,CI服务器的网站可以展示更多的信息。Cruise不但能可以显示是谁在构建,并且能显示最新提交的修改。另外,Cruise还可以查看提交历史,这样,团队成员便可以很清楚项目的进展情况。据我所知,有些团队的头便是通过这种方式来了解项目成员的工作情况和整个系统的修改情况。
使用CI网站的另一个好处是,哪怕不在一起工作的人都可以看到当前项目的状态。再者,你也可以将不同项目的构建信息放到一起。
并不是只有CI网站才能展示显示构建信息。由于构建的不稳定性是一直存在的,这时我们可以将全年的日历画在一张墙上,每天对应一个方块,如果构建成功,QA则在该天的方块贴上绿色标签,否则贴上红色标签。时间一久,这份日历便可显示出项目的稳定性进展情况。
- 自动化部署
做持续集成需要多种环境,不同的构建阶段需要不同的环境。每天,项目的可执行文件都会在这些环境之间搬来移去,于是你希望将这些过程自动化。因此,自动化部署脚本便很重要了,不仅包括测试环境的脚本,也包括针对生产环境的部署脚本。虽然我们不是每天都向生产环境部署,但自动化部署不仅可以加速部署过程,并且能够减少部署错误。
如果你已经有了生产环境的自动化部署,那么也应该考虑一下相应的自动化回滚。由于失败是时而会发生的事情,在这种情况下,我们希望能快速回滚到失败之前的状态。这样一来,我们在部署是也不用那么畏首畏尾了,于是我们可以频繁的发布软件,用户亦能尽快的享受到新的功能。(Ruby on Rails社区有一款名为Capistrano的工具即是一个典型的例子。)
在集群环境中,我看到有每次只向一个节点部署的情况,由此在几个小时之内逐渐完成所有节点的部署。
对于一些面向大众的web应用,我所了解的另外一种很有趣的部署方式是,先试验性针对一部分用户进行部署,再通过这些用户的试用情况来决定是否向所有用户部署。自动化部署,做为CI的一项原则,能够很好的胜任这些工作。
持续集成的难点
持续集成有那么多好处,实践起来的思路也很清晰,那么为啥还有那么多的团队做得不够好呢?做好持续集成有很多难点,下面将会分析一下。
- 很多维护期的产品分散于很多代码库或者很多分支,难以统一维护
这种情况尤其会出现在那些已经存活多年,却依然为公司带来利润的软件产品上。另外一种情况就是,公司收购来的软件产品,因为历史原因,也很难整合到一起。 - 自动化测试
这是最难的。实话实说,我们可以扭过头看看周边的团队,自动化测试的程度到底如何。我觉得80%以上的项目在这一点上都做得不好。之所以很难做好自动化测试,A)有软件产品自身架构组织的原因。比如软件架构耦合度很高或者过于分散,难以自动化测试。 B)也有软件产品形态的原因。比如如果软件产品主要提供的是接口或者明确的服务,那么则比较容易自动化;如果产品主要是web界面,这自动化测试起来相对比较难。C)还有一个很重要的原因就是自动化测试的维护成本。自动化测试用例不是一蹴而就的。在写业务代码的同时,还要完成相应的测试用例。这是需要成本的。团队是否有精力和时间去做这件事?一开始也许还好说,一旦业务压力上来,就会发现很多测试用例过不去,最后也就不了了之。 -
快速构建
构建是需要花费时间和成本的。有硬件上的因素,也有语言上的因素,还有软件架构的因素。- 我们愿意花多少钱去买机器做这件事?当有的公司早已经用顶配垃圾桶编译自己的APP了,还有的公司用那种不是SSD的mac mini 硬撑。这就是差距。
- 有些系统是php,ruby,python等解释型语言的,有的仅仅需要压缩、混淆打包一下就可以上线了,这速度当然快;但是还有很多系统用的是C/C++,golang等,这就相对比较耗时。这是语言上的因素。
- 软件架构。这对构建时间影响也很大。比如一个大的C++系统,如果模块相对比较独立,互相依赖少,则完全可以把整个产品划分成很多个小的模块,进行并行编译。那么最终整体的时间就由耗时最长的那个模块决定了。一下子就可以把整体构建时间降下来。
- 有很多的并行分布式构建系统是收费的,这就涉及到一个许可证购买到问题。
-
很多环境难以模拟
虽然现在有些公司的产品就是一个网站,一个app;但是不得不说还有很多银行的大型系统存在着。这些公司的线上系统就一套,很难找到一个预上线环境,很多都是写好了,直接线上测试。出错很难避免。
虽然我们现在可以使用更好更强的CPU,更大的内存,更快的存储(如SSD),并行构建、分布式构建去加速我们的构建过程,这些都是显性的看得到的成本;而要花很多时间去做的自动化测试,则要花费很多的人力成本在上面;而对于有些特殊行业,有的时候是很难找到一个预生产环境的,这就很尴尬了。
小结
刚开始做持续集成容易,真的做好还是需要下一番功夫的。
缩写解释:
- EE : Electrical Engineering,电子工程 俗称EE或Double E
- CS : Computer Science, 计算机科学
- SWE : Software Engineering, 软件工程
参考资料:
- Code Complete ([美国] 史蒂夫·迈克康奈尔)
- Continuous Integration ([美国] Martin Fowler)
- 持续集成 ([中国] Jack Zhou译)
有疑问加站长微信联系(非本文作者)
![](https://static.golangjob.cn/static/img/footer.png?imageView2/2/w/280)