版本控制系统(也叫源文件控制或修订控制系统)用于维护应用程序每次修改的完整历史,包括源代码、文档、数据库定义、构建脚本和测试,等等。然而,它也有另一个重要的用途,让团队一起开发应用程序的不同部分,同时维护系统记录,即应用程序的权威代码基。
一旦团队人数超过一定数量,就很难让所有人全职工作在同一个版本控制代码库上了。大家会因失误破坏彼此的功能,还通常会有一些小冲突。所以,本章的主要内容是考查团队如何使用版本控制更加高效地工作。
本章和前一章则解决了另外两个维度:分支和依赖。分支的理由有三种。
分支的唯一目的就是可以对代码进行增量式或“通过抽象来模拟分支”方式的修改。
所有版本控制系统的鼻祖都是SCCS,是由贝尔实验室的Marc J. Rochkind在1972年写的。目前在用的大多数知名开源版本控制系统都是从它演化而来,比如RCS、CVS和Subversion。当然,市场上有很多种商业工具,每个工具都用各自的方法帮助软件开发者管理协作。其中最流行的工具是Perforce、StarTeam、ClearCase、AccuRev和微软的Team Foundation System。
版本控制系统的演变速度并没有减慢,现在的趋势是DVCS(Distributed Version Control System,分布式版本控制系统)。DVCS是为支持大型开源团队(比如Linux内核开发团队)的开发方式而创建的。14.4节将讨论DVCS。由于SCCS和RCS现在很少有人用,所以这里也不讨论了,版本控制系统的爱好者可在网上找到大量相关资料。
CVS是Concurrent Versions System(并发版本控制)的缩写。在这里,“并发”是指多个开发人员同时在同一个代码库上工作。CVS是一个开源工具,它把RCS包装了一下,并提供了一些额外的特性,比如它的架构变成了客户端/服务器模式,而且支持更强大的分支和打标签方法。最初,它是由Dick Grune在1984~1985年编写的,1986年作为一组shell脚本发布,1988年被Brian Berliner移植到了C语言上。CVS作为最著名的版本控制工具流行了很多年,主要因为它是过去唯一免费的VCS。
SVN(Subversion)就是为了克服CVS的缺点而设计的。因此,它修复了CVS的很多问题。而且可以说在任何情况下,它都比CVS更好用。它是为熟悉CVS的用户而设计的,特别保留了同样的命令格式。在应用软件开发中,这种熟悉使SVN迅速地代替了CVS。
SVN中很多好用的特性就是因为放弃了SCCS、RCS及其衍生物的常见形式才带来的。SCCS和RCS都把文件作为版本控制的基本单元:每个提交到代码库中的文件都对应一个文件。而在SVN中,版本控制的单元是修订(revision),它由多个目录内文件的一组变更构成。可以把每个修订看成当时代码库中所有文件的一个快照。除了对文件进行修改的相关描述信息以外,delta还包含复制和删除文件的相关指令。在SVN中,每次提交都会应用所有变更,这一过程是原子式的,并且创建一个新的修订版本。
软件工具的世界发展很快,所以本节的内容可能已经过时了。请访问http://continuousdelivery.com
获得最新信息。在撰写本书时,仅有的几种值得推荐的商业版本控制系统如下。
如果版本控制系统支持乐观锁(即编辑本地工作副本的一个文件时,不会阻止别人在他们自己的工作区对其进行修改),那么就应该使用它。悲观锁是指,为了编辑某个文件,必须申请一个额外的锁,这看上去是阻止合并冲突的好方法。然而,事实上,它降低了开发流程的效率,尤其是在大型团队中。
支持悲观锁机制的版本控制系统是考虑到了代码所有权问题。悲观锁策略可以确保,在任意时刻只有一个人工作在一个对象上。如果Tom打算申请一个组件A的锁,而Amrita已经把它从版本控制库中签出了,Tom就会被告知,他需要等待。如果他想提交某个修改,但没有先申请锁的话,提交操作就会失败。
乐观锁以一种完全不同的方式来工作。它不使用访问控制方式,而是依赖于一个假设,即大多数时间里,大家不会同时工作在同一个文件上,并且允许系统相关的所有人修改他们能控制的所有对象。这种版本控制系统会追踪在其控制之下的所有对象的修改,并且在提交修改时,它会使用一些算法来合并这些修改。通常,合并是完全自动化的,但是如果版本控制系统发现了一个无法自动合并的修改,它会突出显示这个修改,并要求提交修改的人来解决这个冲突。
根据其所管内容的特性,乐观锁系统的工作方式也会相应变化。对于二进制文件,它会忽略那些delta,而只保留最后一次提交的修改。然而,它们的强大体现于处理源代码的方式。对于每个对象来说,乐观锁通常假设某个文件中的某一行是一个可变的最小单位。所以,如果Ben正在开发组件A,并修改了第5行,而与此同时Tom也在开发组件A,并修改了第6行,当他们俩都提交之后,版本控制系统既会保留Ben的第5行,也会保留Tom的第6行。如果两个人都决定修改第7行,并且Tom先提交了,当Ben提交时,版本控制系统会提示他解决这个合并冲突。他有三种选择,保持Tom的修改,保持他自己的修改,或者手工编辑这个冲突的代码把重要的部分都保留下来。
使用悲观锁的唯一机会就是对二进制文件的处理,比如图片或文档。此时,合并两个二进制文件并没有什么意义,此时悲观锁就发挥作用了。SVN可以根据你的需要来锁定文件,并对这些文件使用属性svn:needs-lock,以强制使用悲观锁。
悲观锁常常迫使开发团队根据组件来指派工作,从而避免因要修改同一处代码而长时间等待。而且,开发者发挥创造力的思路(开发过程中的一个自然且关键的部分)经常被打断,因为他没有意识到,还有其他人员同时也要签出相同的文件进行修改。在不打扰其他人的情况下对很多文件进行修改也几乎是不可能的事情。另外,在使用主干开发方式的大型团队中,如果使用悲观锁,重构基本是不可能的事情。
乐观锁对开发流程仅有很少的约束。版本控制系统不会将任何策略强加于你。总之,在使用乐观锁时会感到没有很多干扰并且很轻便,且不会丧失任何灵活性和可靠性,尤其是对大型分布式团队来说,还有很好的扩展性。如果版本控制系统有这个选项的话,就请选择乐观锁吧。如果没有的话,请考虑切换到支持这一选项的版本控制系统上。
在一个代码基上创建分支(或流)
的能力是版本控制系统最重要的特性。这个操作是在版本控制系统中对选定的基线创建一个副本。然后这个副本就可以像它的源一样(但它们之间是相互独立的)进行操作,并和源分道扬镳。分支的主要目的是帮助并行开发,即在同一时刻能够同时在两个或更多的工作流上面开发,而不会互相影响。比如,常常见到在需要发布时进行分支操作,这样在主干上可以开发,而在发布分支上修复缺陷。团队使用分支有如下几种原因。
这些分类并不互相排斥,但它们很好地解释了为什么要分支。当然,可以同时在几个维度上创建分支。如果各分支间不需要交互,这样做也没什么问题。然而,事实往往不是这样的;通常我们必须把分支上的一些修改复制到另一个分支上,这个过程叫做合并
。
在讨论合并之前,值得考虑一下由分支带来的问题。在大多数情况下,在创建分支后,整个代码基会在每个分支上各自向前演变,包括测试用例、配置、数据库脚本等。首先,必须对所有内容进行版本控制。在开始为代码基创建分支之前,确保你已做好准备,即确保构建软件所需要的所有东西都在版本控制之下。
分支
和分流
(streaming)可能看起来是解决影响大型团队中软件开发流程很多问题的一个很不错的方法。然而,合并分支的需求意味着,在创建分支之前仔细考虑,并确保有一个合理的流程来支持这种合并,这一点非常重要。特别是要为每个分支定义一个规则,来描述该分支在交付流程里所扮演的角色,并指定谁、符合什么样的条件,才能提交代码。比如,一个小的团队可能有一个主干,所有开发人员都可以向这个主干提交,而在一个发布分支上只有测试团队能审批修改。然后测试团队负责将修复缺陷的代码合并回主干。
在更大且更严格的组织中,每个组件或产品可能都有一个主干,开发人员向该主干提交,而集成分支、发布分支和维护分支(maintenance branch)只有运维人员才能修改。当需要对这些分支进行修改时,可能要先提交一个变更请求,并且还要经过一些测试(手工测试或自动化测试)。另外再定义一个晋级流程,比如只有将变更从主干上合并到集成分支之后,才能再将其晋级到发布分支上。
分支就像是由量子力学的多世界来解释推测宇宙无限性。每个分支都是完全独立的,且完全忽视其他分支的存在。然而,在现实中,除非是那些为了发布或技术预研而创建的分支,否则总会遇到要将某个分支上的某次变更合并到另一个分支上的情况。
当你想将两个分支上有差异且存在冲突的变更合并在一起时,问题才真正出现。此时,这些变更会按字面方式相互覆盖,而修订控制系统会检测到这些冲突,并提醒你。然而,这些代码间的冲突可能是因为代码意图不同,但修订控制系统并不知道这些,所以将它们自动“合并”了。当两次合并之间的间隔时间较长时,合并的冲突通常是功能实现上有差异的症兆。为了让两个分支上所做的修改能够协调一致,可能会导致大段大段的代码重写。不知道原代码作者的意图而合并这样的修改是不可能的,所以必须进行沟通,而此时可能这段需要合并的代码已经是几个星期前写的了。
另外,版本控制系统无法发现语义上的冲突,而这样的冲突有时可能是最致命的。例如,Kate做了一个重构,并对某个类进行了重命名,而Dave在他自己本地的修改中用原名引用了该类。此时的合并不会有冲突。静态类型的语言中,在代码编译时会发现这个问题。而在动态语言中,这个问题直到运行时才能发现。更多细小的语义冲突会在合并时被引入,如果没有全面的自动化测试,可能直到缺陷发生时才能发现。
两次合并之间的间隔时间越长,在每个分支上工作的人越多,那么合并时的麻烦就越多。主要有如下两种办法可将这种痛苦最小化。
热心的读者可能会注意到,使用分支和持续集成之间会有某种相互制约的关系。如果一个团队的不同成员在不同分支或流上工作的话,那么根据定义,他们就不是在做持续集成。让持续集成成为可能的一个最重要实践就是每个人每天至少向主干提交一次。因此,如果你每天将分支合并到主线一次(而不只是拉分支出去),那就没什么。如果你没这么做,你就没有做持续集成。的确,有一种思想流派认为,从精益的角度来讲,分支上的工作就是浪费,即它们是库存,因为它们没有被放到最终的产品里。
持续集成基本上被忽略,人们胡乱地创建分支,从而导致发布过程涉及很多分支,这种情况并不少见。我们的同事Paul Hammant 提供了一个其曾经工作过的项目使用的分支图,如图14-1所示。
在这个例子中,是为同一个应用程序创建了多个项目,并且每个项目对应一个分支。向主干(或者图14-1中所指的“集成分支”)合并活动相当不规律,而且当合并时,很容易对主干的应用程序造成破坏。因此,主干上的应用程序可能总是处于无法工作的状态,直到进入发布前的“集成阶段”,它才会被修复。
不幸的是,这种现象相当典型。这种策略的问题在于:这些分支会在很长时间内一直处于不可发布的状态。而且,这些分支通常对其他分支都有一些软依赖(soft dependency)。在上面的例子中,每个分支都要从集成分支上将修复缺陷的代码拿过去,而且每个分支还要从性能调优的分支上将性能调优的代码拿过去。而应用程序的一个定制版本
(custom version)还在开发中,且长时间处于不可部署的状态。
在这种情况下,要持续地跟踪分支,找出哪些需要合并,以及在什么时间合并。然而,即便利用像Perforce和SVN这样的工具来合并,真正执行这些合并时仍要耗费很多资源。而且,即使合并完成了,团队还要继续做一些代码调整,使代码基恢复至可部署状态,而这正是持续集成应该解决的问题。
一个更可控的分支策略(我们强烈推荐的,可以说是业界标准)是:只为发布创建长周期的分支,如图14-2所示。
在这种模式下,新开发的代码总是被提交到主干上。只有在发布分支上修改缺陷时才需要合并,而且这个合并是从分支合并回主干。而只有非常严重的缺陷修复才会从主干合并到发布分支上。这种模式要好一些,因为代码一直处于可发布状态,所以也就更容易发布。分支越少,合并和跟踪分支的工作就越少。
你可能会担心,假如不使用分支的话,怎么可能在创建新功能的同时,还不会影响别人呢?如何在不创建分支的前提下,进行大规模的重构呢?在13.2节中已经详细地讨论过了。
与通过创建分支的方式把重构任务和开发新功能分开相比,这种增量方式当然需要更多的纪律和细心(也需要更多的创造性)。但这能显著地减少因变更导致应用程序无法工作的风险,并会节省很多合并、修复问题使应用程序达到可部署状态所需的时间。因为这类活动很难被计划、管理和跟踪,所以它最终的消费会远远多于有纪律性的主干开发实践。
如果你在中型或大型团队工作,可能会对这个观点直摇头,表示怀疑。在一个大项目上工作,却不让创建分支,这怎么可能呢?如果200人每天都提交代码,那就是200次合并和200次构建。这种情况下,没人能真正做什么工作,因为他们会花上所有的时间进行合并!
然而,事实上,所有人都工作在一个很大的代码基的大型团队也可以这么做。假设每个人都修改不同的功能领域,并且每次修改都很小的话,200次的合并也没什么问题。在大项目中,如果几个开发人员常常要修改同一部分代码,这表明代码基的结构很差,缺乏良好的封闭性,耦合度很高。
如果在每个发布的最后阶段再进行合并的话,事情会更糟糕。到了那时候,毫无疑问,每个分支都会和其他分支有冲突。我们看到过很多项目在集成阶段会花几星期来解决合并的冲突问题,让应用程序可以运行起来。只有应用程序能够运行起来之后,项目的测试阶段才真正开始。
对于大中型团队来说,正确的解决方案是将应用程序分解成多个组件,并确保组件之间是松耦合的。这些原则是设计良好的系统应该具备的属性。通过增量合并使主干上的代码一直保持可工作状态的方法仅会对项目施加一些徐缓而微小的压力,这会让软件的设计更为良好。而“如何将所有组件集成到一起,形成可工作的应用程序”就成了一个复杂而有意思的事情了。我们在前一章讨论过这个问题,这正是解决大型应用程序的开发问题的一种无比优雅的方式。
值得再次强调的是,你根本不应该使用长生命周期且不频繁合并的分支作为管理大项目复杂度的首选方式。因为这么做就是在为部署或发布软件时积攒麻烦。集成过程会变成一个极高风险的活动,无法预测将消耗多少时间和金钱。所有的版本控制系统供应商都会告诉你:“用我们提供的合并工具来解决问题吧!”。其实,这只是商业化的推销行为而已。
在过去的几年里,DVCS(Distributed Version Control System,分布式版本控制系统)已经变得非常流行。甚至还有几个很强大的开源DVCS,比如Git 和Mercurial。在本节中,我们会解释DVCS的特殊性,以及如何使用它们。
DVCS背后的根本性设计原则是,每个使用者在自己的计算机上都有一个自包含的一等(first-class)代码库,不需要一个专属的“主”代码库,尽管根据惯例,大多数团队都会指定一个(否则的话,持续集成就做不了了)。从这一设计原则出发,引入了很多有意思的特性,如下所述。
你可能认为,使用DVCS不是相当于每个人都有自己的SCCS和RCS嘛?的确,你是对的。DVCS与前面几节中介绍的方法之间的不同之处在于,它可以处理多用户,或者说并发。与“通过中央服务器来保证几个人可以在同一时间在代码基的同一分支上工作”不同,DVCS使用了相反的方法:每个本地代码库本身就是一个分支,而且也没有“主干”,如图14-3所示。
在DVCS的设计工作中,很大一部分精力是花在如何让用户彼此之间非常容易地共享他们的修改。正如Mark Shuttleworth(创造了Ubuntu的Canonical公司的创始人)所说:“分布式版本控制的美丽来自于‘自发地组成团队’这一形式。当对同一个bug或特性感兴趣的人们开始工作后,他们通过公开分支并相互合并的方式来交流代码。当分支和合并的成本下降后,这种团队更容易形成,而且对于开发人员来说,非常值得在合并体验上投入工作量。”
这种方式的代表作就是GitHub、BitBucket和Google Code。使用这些网站,开发人员很容易复制一个已有项目的代码库,修改一些代码,然后将这些修改很容易地让那些对它感兴趣的其他开发人员拿到。而该原始项目的维护者也能够看到这些修改。如果维护者喜欢这些修改,就可以将它们拿到其项目的主代码库中。
这代表了协作方式的一种转变。之前,代码贡献者要将补丁发送给项目拥有人,由项目拥有人将补丁放在项目代码库中。而使用新的方式以后,大家可以公开自己的版本,让其他人自己来体验。这使项目演进得更快,有更多的试验,以及更快地特性开发和缺陷修复。如果某人做了一些非常好的特性,那么其他人就可以使用它。这就意味着“提交入口”不再是开发新功能和修复缺陷的瓶颈了。
很多年前,Linux内核的开发是没有使用源代码控制的。Linus Torvalds在他自己的机器上开发,并将源代码打包成tar文件,它就会被迅速地复制到全球范围的大量系统中。所有的修改作为补丁反馈给他,而他可以很容易地应用和取出这些补丁。因此,他不需要源代码控制,既不需要备份源代码,也不允许多人同时工作在这个代码库上。然而,在1999年12月,Linux PowerPC项目开始使用BitKeeper,它是在1998年出现的一种私有DVCS。Linus开始考虑采用BitKeeper来维护内核。接下来的几年里,有一部分维护内核部分代码的开发者开始使用它。最终在2002年2月Linus采纳了BitKeeper,并认为它是“完成这个工作最好的工具”,尽管它不是一个开源产品。
BitKeeper是第一个被广泛应用的DVCS,它是在SCCS之上开发出来的。实际上,一个BitKeeper代码库就是由一套SCCS文件组成的。与DVCS的原则一致,每个用户的SCCS代码库本身都是一个一等代码库。BitKeeper是在SCCS之上的一层,让用户把某个指定版本之上的deltas或变更作为一级领域对象来对待。
在BitKeeper之后,出现了一批DVCS的开源项目。其中出现最早的是Arch,由Tom Lord在2001年开始开发。Arch已经无人维护,并且被Bazaar所取代了。现在,已经有很多有竞争力的开源DVCS了。其中,最流行且功能丰富的产品是Git(由维护Linux内核的Linus Torvalds创建,并被很多其他项目所用)、Mercurial(Mozilla Foundation、OpenSolaris和OpenJDK都在使用)和Bazaar(Ubuntu在使用)。其他开发活跃的开源DVCS包括Darcs和Monotone。
在撰写本书时,已有商业组织开始逐步采纳DVCS。对于“在公司中使用DVCS”这件事来说,除了通常的“保守”原因以外,还有以下三个明显的反对意见。
实际上,在很多情况下,这些因素不应该成为企业采纳DVCS的障碍。虽然理论上用户可以不提交到指定的中央代码库,但这么做根本毫无意义,因为对于持续集成系统来说,不提交代码就不可能做构建。如果将修改的代码推送给同事而不是推送到中央代码库的话,这种做法带来的麻烦要比带来的价值多得多。当然除非在某种情况下,你真的需要这么做,此时使用DVCS就非常有用了。只要指定一个中央代码库,集中式版本控制系统所具有的特点就都有了。
需要记住的是,使用DVCS后,许多工作流可能就很少需要开发人员和管理员的工作量。相反,为了支持非集中模式(比如分布式团队、共享工作区的能力以及审批工作流等),集中式版本控制系统就只能通过开发一些复杂特性的方式,而这些特性可能会破坏原本的集中式模型。
DVCS与集中式版本控制系统的主要区别在于,代码提交到本地的代码库中,相当于你自己的分支,而不是中央服务器中。为了与别人共享你的修改,需要执行一些额外的步骤。DVCS有两种新的操作:(1) 从远程代码库把代码取回到本地库;(2) 将本地修改推送到远程代码库。
比如,使用SVN时的一个典型工作流程如下。
而在DVCS中,工作流程如下。
这里以Mercurial为例,因为它的命令语法与SVN相似,但其原则与其他DVCS完全一致。
因为有了步骤(4),这个合并流程比SVN的合并要更安全一些。这个额外的签入步骤确保:即使合并错了,也可以退回到合并之前的版本,重新再做一次。也就是说,本地记录了合并的那次修改,这样就可以准确地知道合并了哪些内容。而且,假如后来认为合并不正确(同时,还没有推送这些修改的话),就可以直接回退操作。
在执行第(9)步,将你的修改发送到持续集成构建之前,可以多次重复前八步。甚至可以用Mercurial和Git中非常强大的功能——rebasing——来修改本地代码库的历史,比如,可以将几次修改合并成单次提交。这样,就可以不断地签入代码,保存修改,与其他人的修改进行合并,当然要运行本地的提交测试套件,但这些都不会影响到别人。当要开发的功能全部完成后,就可以做rebasing操作,并把这些修改作为一次修改提交到主代码库里。
对于持续集成,使用DVCS与使用集中式版本控制系统没什么差别。仍旧可以有一个中央代码库,并且它会触发部署流水线。当然,如果你愿意的话,DVCS还能让你尝试一下其他几种可能的工作流程。在3.8节中做过详细地讨论。
直到将代码从本地代码库推送到那个能触发部署流水线的中央代码库之后,才算做了代码集成。频繁提交修改是持续集成的基本实践。为了做持续集成,必须至少每天向中央代码库推送一次修改,理想情况下要更频繁一些。因此,如果DVCS使用方法不当,其带来的一些好处可能会损害持续集成的效果。
IBM的ClearCase不但是在大型组织中最流行的版本控制系统,而且也向版本控制系统这一领域引入了一种新的形式——流(stream)。在本节中将会讨论流是如何工作的,以及如何使用基于流的系统做持续集成。
基于流的版本控制系统(比如ClearCase和AccuRev)可以把一系列修改一次性应用到多个分支上,从而减少合并时的麻烦。在这种“流”方式上,分支被更强大的概念“流”所代替。其中,最大的区别在于,流之间是可以相互继承的。所以,如果你把某次修改应用到一个指定的流上,它的所有子孙流都会继承那些修改。
想想这种方式对下面两种状况有什么样的帮助:(1) 将某个缺陷的修复补丁应用到软件的多个版本上,(2) 向代码基中添加第三方库的新版本。
当发布中有长生命周期分支时,第一种情况就很常见。假如在某个发布分支上做了一次缺陷修复,如何将这次修复同时应用到其他所有代码分支上呢?没有基于流的工具,答案是手工合并它。这是一个令人厌烦且易出错的工作,尤其是当有多个分支需要合并这次修改时。在使用基于流的版本控制后,只要将这次修改补丁合并到需要它的所有分支的共同祖先分支上即可。这些分支就会得到该补丁并更新,再触发一次包含该补丁的新构建。
当管理第三方库或共享代码时,可以用同样方式来操作。比如你想将一个图片处理的库升级到某个新版本上,那么,对该库有依赖的每个组件都需要升级一下。当使用基于流的版本控制系统后,可以将其提交至某个祖先分支上,那么所有继承自该祖先分支的所有分支都会更新这个库到新版本上。
可以把基于流的版本控制系统看作一个联合文件系统,但是这个文件系统是一个树形结构(一个相互连接的有向无环图,即DAG)。因此,每个代码库都有一个根流,其他的流都继承自这个根流。可以基于任意一个已存在的流来创建一个新流,如图14-5所示。
在图14-5的例子中,根流包含一个文件foo(修订版本是1.2)和一个空目录。流Release 1和Release 2都继承自它。在Release 1上,可以看到基本流中的两个文件,以及两个新文件:a和b。在Release 2上,有两个不同的文件:c和d。而foo已经被修改过,现在的修订版本是1.3。
两个开发人员各自在自己的工作区上对流Release 2进行开发。开发人员1正在修改文件c,开发人员2正在修改文件d。当开发人员1提交他的修改时,在Release 2上工作的每个人都可以看到这些修改。如果文件c是一个缺陷修复,也需要放到Release 1中,那么开发人员1可以将文件c晋级至根流上,此时,所有的流上就都能够看到这个修改了。
所以,除非修改被晋级到上级,否则一个流上的修改不会影响到其他的流。一旦晋级之后,继承了初始流的其他流都能看到这次修改。必须记住的是,这种修改的晋级方式并不会对历史进行修改,而是将新的修改覆盖在原有内容之上。
基于流的版本控制系统鼓励开发人员在自己的工作区中进行开发。这样,开发人员可以在不影响其他人的情况下执行重构,试验不同的解决方案,并且开发新功能。当做好以后,他们就可以晋级这些修改,使其对其他人可见。
比如,你可能正在用之前创建的某个流来开发某个特定的功能。当功能开发完成后,可以将这个流中的所有修改晋级到团队的流中,而团队的流是可以进行持续集成的。当测试人员想要测试已完成的功能时(他们自己有测试使用的流),他们就可以将需要手工测试的所有功能晋级到测试用的流上。然后,已通过测试的那些功能就能被晋级到某个发布流上了。
所以,大中型团队可以同时开发多个功能,而不会相互影响,测试人员和项目经理可以挑选他们想要的功能。与之前大多数团队在发布前面临的困境相比,这的确是一个真正的改进。通常,发布操作需要为整个代码基创建分支,并让该分支上的代码稳定下来。然而,当创建分支时,并没有什么简单方法将你想要的东西分捡出来(关于这个问题的更多详情和解决方式请参见14.7节)。
当然,现实生活中的事情不会这么简单。功能之间完全独立是不现实的,尤其是在团队遵循“需要做重构时就要不遗余力地做好重构”这种原则下,当把一大堆重构后的代码晋级到其他流上时,就时常会发生代码合并的问题。因此,当下列情况发生时,遇上集成问题是不足为奇的。
当有更多的团队或分更多的层级时,这些问题会更严重。这种影响常常会产生乘积效果,因为应对更多团队最常见反应就是创建更多的层级。其目的是隔离各团队互相之间的影响。某个大公司有五个层级的流:团队级、领域级、架构级、系统级和最后的产品级。在到达生产环境之前,每个修改后的代码都要依次通过每个层级。不用说,他们在发布问题上面临着很大的问题,因为每次晋级到上一层级时,都会遇到这些问题。
需要记住的是,不能每天向共享主干提交代码是不符合持续集成实践要求的。有很多办法来解决这一问题,但这需要很强的纪律性,而且仍旧不能完全解决大中型团队所遇到的窘境。最佳规则是尽可能频繁地晋升修改,并在开发人员所共享的流上尽可能频繁且尽可能多地运行自动化测试。在这方面,该模式与后面将要描述的“按团队分支”模式非常相似。
这不完全是坏事儿。Linux内核开发团队使用的开发流程与上面描述的非常相似,但是每个分支都有一个特定的所有者,他的责任是维护该流的稳定性,当然“发布流”(release stream)由Linus Torvalds维护,他对哪些内容可以放到他的流中有非常严格的要求。对于Linux内核团队的这种工作方式来说,所有的流组成了一个“金字塔”形状的结构,而Linus的流在最顶端,哪次修改能够进入代码库,都是由流的所有者决定的,而不是别人硬塞给他们的。这与目前大多数组织中的结构正好相反,这些组织中的运维或构建团队的责任就是,试着合并所有的内容。
最后,在使用这种开发方式时,值得注意的一点是,其实并不需要一个支持流操作的工具才能做到这一点。的确,Linux内核开发团队使用Git管理他们的代码,而像Git或Mercurial这样的DVCS的生力军也足以处理这样的流程,尽管它们没有像AccuRev那样花哨的图形工具来支持。
ClearCase有一个特性,叫做“动态视图”(dynamic view)。当某个文件合并到某个祖先流上时,它会立即更新对应的子孙流上工作的每个开发人员的视图。而在更传统一些的静态视图中,直到开发人员决定更新时,才会看到相应的修改。
如果想要在提交后就马上能看到被修改的代码,那么动态视图的确是个不错的方法,有助于消除合并冲突,更容易做集成。但是,其前提条件是,开发人员频繁且有规律地提交代码。然而,从技术层面和实际的变更管理层面来说,这些做法都有一些问题。在技术层面上,这个特性效率相当低。根据我们的经验,它会让开发人员的文件系统变得非常慢。因为大多数开发人员要频繁执行一些与文件系统紧密相关的任务(比如编译),所以牺牲速度是不可接受的。更实际的情况是,当你正在做某件事情且正当关键时刻时,突然来了一个合并需求,它就会破坏你的思路,打乱了你对问题的思考。
基于流的版本控制系统的卖点之一就是:开发人员很容易在自己的私有流上工作,并且还承诺,之后再做合并也很容易。然而,在我们看来,这种方法有一个根本性的缺点:当代码修改被频繁向上晋级(比如每天多于一次)时,不会有什么问题,但是,如此频繁地晋级也会令这种方法所鼓吹的好处大打折扣。如果晋级频繁,较简单的解决方案没有什么问题,甚至效果更好。如果没做频繁晋级,那么当要发布版本时,很可能会遇到一些麻烦。因为你不知道会花费多长时间才能搞定所有的事情,例如将每个人认为应该可以工作的功能集成在一起,修复那些由于复杂的合并而引入的bug。可这些正是持续集成应该解决的问题。
在本节与下一节中,将会讨论多种分支和合并模式及其优缺点,以及适合哪些环境中使用。首先从主干开发说起,因为这种开发方法经常被忽视。实际上,它是一个极其有效的开发方法,也是唯一使你能执行持续集成的方法。
在这种模式中,开发人员几乎总是签入代码到主干,而使用分支的情况极少。主干开发有如下三个好处。
使用这种模式时,在正常开发过程中,开发人员在主干上工作,每天至少提交一次代码。当需要做复杂修改(比如开发一个全新的功能,对系统的某个部分进行重构,做一个深远的容量提升,或对系统各层的架构进行修改)时,分支并不是默认的选项。相反,这些修改会被分成一系列小的增量步骤有计划地实现,而且每个步骤都会通过测试且不会破坏已有的功能。这在13.2节中有详细说明。
主干开发并不排斥分支。更确切地说,它意味着“所有的开发活动在某一时间点上都会以单一代码基线而告终”(Berczuk, 2003, p. 54)。然而,只有当不需要合并回主干时,才创建分支——比如发布时,或者做某种试验时。Berczuk(同前)引用了Wingerd和Seiward关于主干开发的优点:“90%的配置管理过程(SCM process)都在强调代码基线的晋级,用来弥补缺少主线的问题。”(Wingerd, 1998)。
主干开发的一个结果就是:每次向主干签入并不都是可发布状态。如果你使用分支方式做特性开发,或者使用基于流的开发通过多级直至发布级别来晋级变更,那么这可能看上去是对主干开发实践的一个“击倒性”反驳。如果每次都晋级到主干,那么如何管理一个有很多开发人员,且有多个版本发布的大型团队呢?这个问题的答案是:软件需要良好的组件化、增量式开发和特性隐藏(feature hiding)。这要求在架构和开发中更加细心,而它的收益是:不需要设定一个无法预期的较长的集成阶段将多个流合并到一起创建一个可发布的分支,因为这些工作的精力远比花在架构和开发上要多得多。
部署流水线的目标之一就是让大型团队可以频繁签入主干(这可能会引起临时性的不稳定),并仍旧可以进行稳固的发布。从这个角度上看,部署流水线与源晋升模型(source promotion model)相对立。部署流水线的主要优点在于:每次在完全集成的应用程序上做的修改都能快速得到反馈,而这在源晋升模型上是办不到的。这种反馈的价值在于:任何时刻你都确切地知道应用程序当前所处的状态,即你不需要等到最后的集成阶段才发现应用程序还需要数周或数月的额外工作才能够发布。
不用分支也可以做复杂的修改
当你想对代码基进行某种非常复杂的修改时,通常会创建一个分支,然后在该分支上进行修改,从而避免打断其他开发人员的工作,这么做看起来是最简单的方式。然而,事实上,这种方法会导致多个长生命周期的分支,与主干产生很多的代码分歧。每到发布时,分支合并几乎总是最复杂的过程,无法预期会花费多长时间。每次新的合并总会破坏某些原有功能,所以,做下一次合并之前,还要有个过程先让主干稳定下来。
最终的结果是,发布时间超过了计划,而且功能比预期的少,质量比预期的低。除非代码基是松耦合的且遵守迪米特法则,否则的话,在这种工作模式下会令重构更加困难,也就是说偿还“技术债”的速度非常慢。这会迅速导致代码基无法维护,甚至会使新功能增加、缺陷修复和重构更加困难。
简而言之,所面临的问题正是持续集成应该解决的问题。创建长生命周期的分支与成功的持续集成策略背道而驰。
我们这里的建议并不是一个技术上的解决方案,而是一种实践:一直向主干提交代码,并且至少每天一次。假如你认为,对代码做重大修改时不适合这么做的话,那我们有理由认为,你也许根本没有努力尝试过。根据我们的经验,虽然使用一系列小的增量步骤来实现某个功能,又要保持软件一直处于可用状态的这种做法有时需要花更长的时间,但其收益也是巨大的。让代码一直处于可工作状态是非常基本的要求,要想持续交付有价值、可工作的软件,怎么强调这个实践都不过分。
这些方法在有些时候不合适,但这种情况极少,而且即使在这种情况下,也有办法减轻这种影响(参见13.2节)。然而,即使这样,最好也不要在第一时间就放弃这种做法。在开发过程中,通过频繁向主干提交的方式做这种增量式修改几乎总是最正确的做事方法,所以请一直把它作为备选列表中的第一项。
有一种情况,“创建分支”是可以接受的,那就是在某个版本即将发布之前。一旦创建了这个分支,该发布版本的测试和验证全部在该分支上进行,而最新的开发工作仍旧在主干上进行。
为了发布而创建分支取代了“冻结代码”这种邪恶的做法,即几天内不许向版本控制库签入代码,有时甚至是几个星期。通过创建发布分支,开发人员仍旧可以向主干签入代码,而在发布分支上只做严重缺陷的修复。为了发布创建分支如图14-2所示。
在这种模式中,要遵循如下规则。
“按发布创建分支”的场景是这样的。开发团队需要开始做新功能,而当前发布版本正在测试或准备部署当中,同时测试团队希望能够在当前发布中修复缺陷,但不要影响正在进行当中的新功能开发。在这种情况下,在逻辑上将新功能的开发与分支上的缺陷修复分开是可以的。但要记住的是,缺陷修复必须被合并回主干。一般来说,当把缺陷修复提交到分支上之后,最好立即就合并回主干。
在产品开发中,维护性发布(maintenance release)需要解决那些在下一个新版本完成之前必须解决的问题。例如,某个安全问题需要在某个指定的发布中马上修复。有时候,新功能和缺陷修复之间的分界线很难界定,这会在一个分支上导致很复杂的开发。对于那些正在使用该软件早期版本的已付费客户来说,他们可能不愿意(或不能)升级到最新版本,而且他们还要求在较老的版本上增加一些新的功能。团队应该尽可能将这类需求最小化。
这种分支方式在真正的大项目中效果并不太好,因为很难让一个大型团队或多个团队在同一个版本上同时完成他们所有的工作。在这种情况下,理想的方法是有一个组件化的架构,每个组件都有一个发布分支,以便在其他团队还在开发组件时,该团队可以在创建分支后继续在他们的组件上开发新的功能。如果做不到这一点,请参见本章的“按团队分支”模式,看看是否更可行。如果你想做到“可以挑选特性”的话,请参见“按功能特性分支”模式。
当使用“按发布创建分支”的方式时,有一点非常重要,那就是不要在已有的发布分支上再创建更多的分支。所有的后续分支都应该是从主干上创建的,而不是已有分支上。从已有分支上创建分支是一种“梯型”结构(Berczuk, 2003, p. 150),这样很难发现两个版本之间哪些代码是公共的。
一旦发布频率达到了一定频度(比如一周一次左右),那么按发布创建分支的策略就没有必要了。在这种情况下,发布一个新版本要比在已发布的分支上打补丁更容易,成本更低。而且,部署流水线机制可能为你保留一份记录,包括执行了哪些发布,什么时间执行的,以及发布的软件在版本控制库中对应的修订版本号是哪个。
这种模式是为了让开发团队更容易在“特性”层次上并行工作,并保持主干的可发布状态。每个用户故事或特性在不同的分支上开发完成。一个故事只有通过测试人员验证无问题后,才会被合并到主干,以确保主干一直是可发布的。
该模式的动因是希望一直保持主干的可发布状态。这样的话,所有的开发都在分支上,不会被其他人或团队打扰。在代码全部完成之前,很多开发人员不喜欢暴露和公开他们的代码。另外,如果每个提交都是一个完整的特性或缺陷修复,那么版本控制的历史记录将更加富有完整的语义性。
要想让这种模式有效果,就要有如下一些前提条件。
值得强调的是,按特性分支的确与持续集成是对立的,我们关于“如何使这种模式能够工作起来”的所有建议只是为了确保在合并时情况不至于太糟糕。如果能在源头避免痛苦,那会更简单一些。当然,就像软件开发中的所有“原则”一样,也会有一些特例情况出现,比如像开源项目或使用DVCS且由丰富经验的开发者组成的小团队。可是,需要提醒你的是,当采纳这种模式时,就是在“刀尖上跳舞”(Runing with Scissors)
这种模式试图解决如下状况:在一个大型团队里,有很多开发人员同时工作在多个工作单元流上,并且还要维持主干总是处于可发布状态。与按功能特性分支一样,这种模式的主要意图是确保主干一直是可发布的。为每个团队创建一个分支,并且只有当该分支稳定后才将其合并回主干。每次合并后,其他分支都要立即将这次变更与自己合并在一起。如图14-7所示。
下面是按团队分支的工作流程。
(1) 创建多个小团队,每个团队自己都有对应的分支。
(2) 一旦某个特性或用户故事完成了,就让该分支稳定下来,并合并回主干。
(3) 每天都将主干上的变更合并到每个分支上。
(4) 对于每个分支,每次签入后都要运行单元和验收测试。
(5) 每次一个分支合并回主干时,在主干上都要运行所有的测试(包括集成测试)。
这种模式的目的也是维持主干处于可发布状态。然而,这种模式中的每个分支也都面临同样的问题,即只有当该分支“稳定”时,才能将其合并到主干。如果合并回主干后,不会破坏任何自动化测试,包括验收测试和回归测试,那么则认为分支是稳定的。所以,每个分支都需要有一个自己的部署流水线,以便团队可以决定哪次构建是好的,从而知道源代码的哪个版本可以合并回主干,且不会违反这个规则。在执行这次构建之前,还要把主干上的最新版本先合并回该分支上,以便保证当该分支合并回主干时,不会使主干的构建失败。
从持续集成的角度来说,这种策略有一些缺点。一个根本问题就是,这种策略上的工作单元是一个分支,而不是一次特定的修改。换句话说,无法将一次修改单独合并到主干,而只能将整个分支合并回去。否则无法知道是否破坏了主干上的规则。如果在合并之后,团队又发现了一个缺陷,而此时这个分支上又包含了其他修改的话,就不能只将这次修复合并回主干,团队要么让这个分支再次稳定下来,要么仅为这次修改创建另一个分支。
实际上,这种模式与按特性拉分支很相似。它的优点是:分支较少,所以集成工作会更频繁一些,至少在团队级别是这样的。它的缺点是:各分支很快会变得差异很多,因为每个分支都对应着一个小团队的提交。所以,与按特性拉分支相比,合并操作可能会有更显著的复杂性。主要的风险是,各团队不能充分遵守关于合并回主干以及从主干更新代码的规则。团队分支很快就会和主干变得很不一样,彼此之间的差异也会很大,所以,合并冲突可能很快就变得极其痛苦了。在现实生活中使用这种模式的地方几乎最终都是这种结果。
正如在13.2节所详细阐述的那样,我们推荐通过“功能隐藏”的方式进行增量开发,从而做到应用程序随时可发布。即使某个功能特性正在开发当中,也可把它隐藏起来。一般来说,尽管这种方法需要更多的纪律性,但与管理多个分支相比,这种方法的风险相当小,而且多个分支的方式需要不断的合并,而且无法真正地快速提供某个变更对整个应用程序影响的反馈,而这些正是真正的持续集成可以提供的。
然而,如果面对的是一个庞大且像“铁板一块”的代码基,那么这种模式与“通过抽象来模拟分支”相结合就可以形成非常有效的一种策略,使其走向松耦合组件的系统架构。
“在软件开发过程中能够对所创建和依赖的资产进行有效控制”这一点对于任何项目的成功都是至关重要的。版本控制系统的演进以及围绕其所做的配置管理实践是软件开发史上非常重要的一部分。现代版本控制系统的复杂性及其良好的可用性使其对于当代基于团队的软件开发来说,已经具有非常重要的核心地位。
我们花大量时间来讨论这个看似无关的问题,原因有两个。首先,对于部署流水线的设计来说,项目所采用的版本控制模式是非常关键的。其次,根据我们的经验,很差的版本控制实践是达到“快速且低风险发布”这一目标最常见的阻碍之一。在那些版本控制系统的强大功能中,对于某些功能的不恰当使用方式会对“安全、可靠且低风险的软件发布”产生威胁。了解那些可用的功能,拿到正确的工具,并恰当地使用它们是成功软件项目的一个重要特性。
本章中已经讨论了一系列为了获得更高的团队开发效率,才对持续集成进行一定程度妥协的备选方法。然而,很重要的一点是,每次创建分支,都要认识到它带来的成本。这种成本在于“增加了风险”,而唯一最小化风险的方法就是无论由于什么样的理由创建了分支,都要努力保证任何活跃分支每天(甚至更频繁地)合并回主干。不这么做的话,这个过程就不再是持续集成了。
如前所述,我们推荐使用分支而无须说明的唯一情况就是:为了发布或技术调研创建分支,以及在极困难的情况下没有更合适的方式通过别的方法对应用程序做进一步的修改时才创建分支。