源代码分支管理模式

现代的源代码控制系统提供了强大的工具,可以非常轻松的在源代码上创建分支。但最终分支还是要合并在一起,许多团队不得不花相当多的时间去处理相互纠缠的分支。这里有几种模式让团队可以有效地使用分支,专注于集成多个开发人员的工作并组织产品发布的路线。最重要的一点,分支应该频繁集成,尽力保持一个无需过多干预就可部署生产的健康主线。

原作者:Martin Fowler
原文地址:https://martinfowler.com/articles/branching-patterns.html

对任何软件开发团队来说,源代码都是重要的资产。几十年来,已有一系列源代码管理工具被开发出来,用于维护代码。这些工具可以跟踪变更,因此我们可以恢复软件的历史版本并查看它的演进过程。这些工具还是开发团队的协作中心,团队中的所有程序员都在一个公共的代码库上工作。通过记录每位开发人员所做的更改,这些系统可以一次跟踪多行工作内容,并帮助开发人员解决如何把这些内容合并到一起。

将开发活动划分为分解和合并的工作流,是软件开发团队工作流程的核心,并且已演化出多种模式帮助我们处理所有这些活动。像大多数软件模式一样,几乎没有哪种模式是所有团队都应遵循的黄金法则。软件开发工作流程依赖于具体环境,特别是团队的社会结构和团队遵循的其他实践。

本文将详述这些模式,并在模式描述中夹杂可以更好地说明模式背景和相互关系的叙事部分。为便于区分,模式描述的章节将附以图标“✣”。

基本模式

在思考这些代码分支模式时,我发现它们可以分为两大类。一类模式着眼于集成,即多个开发人员如何将他们的工作成果组合成一个连贯的整体。另一类则着眼于生产路径,即使用分支帮助管理从集成代码库到生产环境运行产品的路径。一些模式为这两大类模式提供支撑,我将它们归类为基本模式,在本节中讲述。还有一些模式既不基本也不适合于归类到集成和生产路径这两大类模式,我把它们留到最后来讲。

✣ 源分支 ✣

创建一个副本并记录对该副本的所有更改。

如果几个人在同一代码基础上工作,那么很快他们就无法在相同文件上工作。如果我想运行一个编译,而我的同事还正在敲入一个表达式,那么编译将失败。我们不得不相互呼喊:“我正在编译,什么都不要更改!”即使团队只有两个人,这也难以维持正常工作;如果是更大的团队,这种混乱场景会更加令人难以想象。

对此场景案例的简单解决办法是让每个开发人员都获取一个代码库的副本,然后我们就可以轻松地进行自己负责的功能开发。但是又会出现一个新问题:开发完成后,如何将两个副本再次合并在一起?

源代码控制系统使此过程更加容易。关键在于它会将每个分支上所有的更改都记录为提交。这不仅可以确保没有人忘记他们对 utils.java 所做的微小更改,而且记录更改使执行合并更加容易,尤其是当几个人更改了同一文件时。

这就引出了本文中使用的分支(branch)的定义。我将分支定义为对代码库的特定提交序列。分支的 headtip 指向该序列中的最新提交。

image.png

分支是个名词,但也有动词“ 创建分支”的意思。这里我的意思是创建一个新分支,我们也可以将其视为将原始分支分为两个分支。当来自一个分支的提交被应用到另一分支时,即为分支合并。
image.png

我用于“分支”的定义与我观察大多数开发人员谈论它们的方式相对应。但是源代码控制系统更倾向于以特定的方式使用“分支”。

以一种常见情况来说明这一点,一个现代开发团队,该团队将其源代码保存在共享的 git 仓库中。一名开发人员 Scarlett (以猩红色表示) 需要进行一些更改,因此她克隆了 git 仓库并检出了 master 分支。她做了几处更改,然后重新提交给她的 master 分支。同时,另一个开发人员,Violet (以紫色表示) 将仓库克隆到自己桌面上,并签出 master 分支。那么 Scarlett 和 Violet 是在同一个分支上工作还是分别在另一个分支上工作?答案是:他们都在 “master” 上工作。但是他们的提交彼此独立,并且当他们将更改推回到共享仓库时都需要合并。如果 Scarlett 不确定自己所做的更改,会发生什么情况,因此她标记了最后的提交,并将她的 master 分支重置回 origin/master(她克隆共享仓库时的最后一次提交)。


image.png

根据我前文给出的分支定义,Scarlett 和 Violet 分别在单独的分支上工作,这两个分支彼此分开,并且与共享仓库上的 master 分支隔离。当 Scarlett 放弃带有标签的分支开发时,根据定义,它仍然是一个分支(并且她很可能将其视为分支),但是在 git 看来,这是一个带标签的代码行。

使用 git 这样的分布式版本控制系统,这意味着每当我们进一步克隆仓库时,就会获得其他分支。如果 Scarlett 在回家的火车上克隆了自己的本地仓库到笔记本电脑上,那么她将创建第 3 个 master 分支。在 GitHub 中派生也会产生相同的效果 —— 每个派生的仓库都有自己额外的分支集。

当我们遇到不同的版本控制系统时,这种术语的混乱会变得更糟,因为它们对分支的构成都有自己的定义。Mercurial 中的分支与 git 中的分支完全不同,后者更接近 Mercurial 的书签。Mercurial 也可以用未命名的 head 创建分支,使用 Mercurial 的人们经常通过克隆仓库来创建分支。

所有这些术语上的混乱导致一些人避免使用该术语。在这里更通用的术语是代码线(CodeLine)。我将代码线定义为代码库的一系列特定版本。它可以以标签结尾,或是一个分支,又或者淹没在 git 的 reflog 中。你会注意到我对分支和代码线的定义是如此相似。代码线在许多方面都是更有用的术语,我确实使用过,但是在实践中并未广泛使用。因此,对于本文而言,除非我处于 git(或其他工具)术语的特定上下文中,否则我将交替使用分支和代码线。

此定义的结果是,无论你使用的是哪种版本控制系统,一旦有开发人员在进行本地更改后,每个开发人员在本地的工作副本中都至少具有一条个人代码线。如果我克隆一个项目的 git 库,检出 master 分支并更新一些文件 —— 这就是一条新的代码线,即使我还没有提交任何内容。同样,如果我从 subversion 库的主干建了自己的工作副本,即使不涉及任何 subversion 分支,该工作副本也是独立的代码线。

适用场景

一个老话说,如果你从高楼上摔下来,坠落不会伤害到你,但是着陆会。对源代码来说也是一样的道理:创建分支容易,但合并困难。

记录提交中所有更改的源代码控制系统确实让合并过程更加容易,但并没有使合并过程不再重要。如果 Scarlett 和 Violet 都将变量的名称更改为不同的名称,则存在冲突,如果没有人工干预,源管理系统将无法自行处理。为了凸显这种文本冲突的尴尬,源代码控制系统至少还可以发现并提醒人们看一下。但是在文本合并没有问题的地方也经常会出现冲突,系统仍然无法正常工作。想象一下,Scarlett 更改了函数的名称,而 Violet 向其分支添加了一些代码,以其旧名称调用该函数。这就是我所说的语义冲突。当发生此类冲突时,系统可能无法构建,也可能会构建成功但在运行时失败。

Jonny LeRoy 喜欢指出人们(包括我)绘制分支图的这个瑕疵


image.png

任何有并行计算或分布式计算工作经验的人都熟悉的问题是:当多个开发人员同时更新时,代码仓会处于某个共享状态。我们需要通过将这些更新序列化为某个共识更新的方式,把这些开发人员的更新结合起来 。事实上,使系统正确执行和运行意味着该共识状态的有效性标准非常复杂,这使我们的任务也变得更加复杂。无法创建确定性算法来找到共识。人们需要寻求共识,并且共识可能涉及混合不同更新的选择部分。通常,只有通过原始更新解决冲突才能达成共识。

我说:“如果没有分支该怎么办”。每个人都将实时编辑代码,考虑不周的更改会使系统崩溃,人们会互相踩踏。因此,我们给个人一种时间冻结的错觉,认为他们是唯一更改系统的人,这些变更可以等到他们对系统风险考虑充分后才变更。但这是一种错觉,最终代价还是该来的会来。谁买单?什么时候?代价是多少?这些模式正在讨论的就是:选择如何支付代价。—— Kent Beck

因此,在下文中我将列出各种模式,这些模式支持友好的隔离,就像当你从高处落下时,风穿过发丝,同时又把不可避免的与坚硬地面的碰撞后果降到最低。

✣ 主线 ✣

单一、共享、代表产品当前状态的分支

主线(mainline)是一个特殊的代码线,代表团队代码的当前状态。当我想开始一项新工作,我会从主线中拉取代码到我的本地版本库,在本地版本库上工作。当我要与团队的其他成员分享我的工作成果时,我会用我的工作成果更新主线,理想状态下将应用后面要讨论的主线集成模式。

不同的团队使用不同的名称称呼这一特殊分支,通常会受使用的版本控制系统惯例的影响。Git 用户通常称之为 “master”, subversion 用户通常称之它为 “主干”。

在这里必须强调,主线是一个单一的、共享的代码线。当人们在 git 中谈论 “master” 时,他们可能在说几件不同的事情,因为每个代码库的克隆都有自己的本地 master。通常,团队会有一个中央仓库 —— 一个作为项目单一记录点的共享仓库,并且是大多数克隆的起源。从头开始一项新工作意味着克隆该中央仓库。如果已经有了一个克隆,我会首先从中央仓库拉取 master 分支,以保持与主线同步。在这种情况下,主线就是中央仓库的 master 分支。

当我在开发自己的功能时,我在使用自己的开发分支,这个分支可以是我本地版本库的 master 分支,也可以是其他本地分支。如果需要在自己的开发分支上工作较长时间,我可以每隔一段时间拉取主线的更改,并把这些更改合并到我自己的开发分支上,以获取主线上最新的更改。

同样,如果我想创建产品发布的新版本,我可以从当前主线开始。如果我需要修复错误,以发布足够稳定的产品,我可以使用某一发布分支

适用场景

我记得在 21 世纪初常和一个客户端构建工程师讨论。他的工作是集成团队正在开发的产品。他会给团队的每个成员发一封电子邮件,团队成员则会发回各自代码库中等待集成的各种不同文件。这位构建工程师就把这些文件复制到他的集成树中,并尝试编译代码库。创建一个能够编译,并可供某种形式进行测试的构建,通常需要耗费这位构建工程师几周的时间。

相比之下,通过主线,任何人都可以从主线的一部分快速开始产品最新的构建。更重要的是,主线不仅仅使得观察代码库状态更容易,它还是许多其他模式的基础,这些模式将后文中描述。

主线的一个替代方案是发布火车

✣ 健康的分支 ✣

在每次提交时执行自动检查,以确保分支没有缺陷,自动检查通常包括构建和运行测试。

由于主线具有共享的并且是已被认可的状态,因此保持主线处于稳定状态非常重要。还是在 21 世纪初,我记得曾和某一组织的一个开发团队一起讨论,这个组织因对所有产品执行每日构建而广为人知。在当时,每日构建被认为是相当先进的做法,这个组织也因此而获得赞誉。在这些赞扬的文章中没有提到的是,那些每日构建并不总是成功的。实际上,一些团队的日常构建连续数月都无法编译成功,这在当年并不罕见。

为了解决这个问题,我们可以努力去保持一个分支是健康的——也就是这个分支是可以成功构建并且运行时几乎没有 bug 的。为了确保这一点,我发现编写自测代码是至关重要的。这种开发实践是指我们在编写生产代码时,还要编写一套全面的自动化测试,让我们可以确信,如果这些测试通过,那么这些代码就不会有 bug。如果我们这样做,就可以通过每次提交运行一个构建来保持分支健康,这个构建过程也包括运行这套测试。如果系统无法编译,或者测试失败,那么我们的第一要务就是在我们对该分支进行任何其他操作之前就先对其进行修复。通常这意味着我们“冻结”了这个分支——除为了修复以使其恢复正常的提交之外,不会允许在这个分支进行任何提交。

为了给保持分支健康提供足够的信心,在测试的程度上存在一定矛盾。许多更彻底的测试需要大量的时间去运行,这就会延迟对提交是否正常的反馈。一些团队通过将测试分散到部署流水线的多个阶段来解决这个问题。这些测试的第一个阶段应运行快速,一般不超过十分钟,但仍应相当全面。我将这样的测试集称为提交套件 (不过它通常会被称为“单元测试”,因为这样的提交套件中的测试大多数是单元测试)。

理想情况下,应在每次提交时运行全方位的测试。但是,如果测试执行很慢,例如需要占用服务器几个小时的性能测试,那就有点不切实际。如今,团队通常会构建一个提交套件,在每次提交时运行,而对部署流水线后续的阶段,会尽可能频繁地运行。
代码运行没有错误并不足以说明就是好的代码。为了保持稳定的交付节奏,我们需要保持足够高的代码内建质量。一种流行的方法是使用提交审核(Reviewed Commits),然而我们也要看到还有其他选择。

适用场景

每个团队都应当在他们的开发工作流程中明确每个分支的健康状况标准。保持主线健康有无比重要的价值。如果主线是健康的,那么开发人员只要从当前的主线拉取代码就可以开始新的工作,而不会纠结于那些可能会妨碍他们工作的缺陷。我们经常听说有人在开始新的工作前要花几天时间去尝试修复或绕过他们拉取代码中的问题。

健康的主线也可以简化生产路径。可以随时从主线的最新版本构建新的生产候选对象。最好的团队发现他们几乎不需要做任何工作来稳定这样的代码库,这些代码库通常能够直接从主线发布到生产环境。

主线健康的关键是自测代码,以及一个可在几分钟内运行完成的提交套件。建设这样的能力会是很有意义的投入,一旦我们可以在几分钟之内确保我的提交不会搞砸任何东西,那将彻底改变我们的整个开发过程。我们可以更快地进行更改,自信地重构我们的代码让它更好用,并大大减少从期望功能到生产中运行代码的交付周期。

保持个人开发分支的健康是明智的做法,因为这样可以启用差异调试。但是,这种期望和频繁提交当前状态为检查点是背道而驰的。如果我要尝试一个不同的路径,那么即使编译失败可能也会去创建一个检查点。解决这种矛盾的方法是,一旦完成我最近的工作,就去除所有不健康的提交。这样,只有健康的提交会在我的分支上保留超过几个小时。

如果我保持个人分支的健康,这也能使提交到主线变得更加容易——我会知道任何在主线集成(Mainline Integration)中突然出现的错误都纯粹是由于集成问题引起的,而不单单是我代码库中的错误。这将使查找和修复错误变得更快也更容易。

集成模式

分支开发涉及到在管理分离和合并时的相互影响。由于所有人始终使用同一套共享代码库,如果你正在输入变量名,我这边就无法编译程序,这是行不通的。因此,至少在某种程度上,我们需要有一个私有工作区的概念,让我可以暂时在这个私有工作区里工作。现代的源代码控制工具使得创建分支和监视这些分支的变更变得很容易。然而,在某些时候,我们还需要合并分支。考虑分支开发策略实际上就是决定我们合并分支的方式和时机。

✣ 主线集成 ✣

开发人员通过从主线中拉取、合并,以及(在健康的情况下)推回主线来集成他们的工作。

主线清晰定义了团队软件当前的状态。使用主线的最大好处之一是简化了集成。如果没有主线,这就是我前面描述的要与团队中每个人进行协调的复杂任务。然而,有了主线,每个开发人员都可以自己集成。

我将通过一个例子来说明它的工作原理。有一个名为 Scarlett 的开发人员,通过将主线克隆到自己的仓库中开始某项工作。在 git 中,如果她还没有中央仓库的克隆,她将会克隆中央仓库,检出 master 分支。如果她已经有了中央仓库的克隆,她将拉取主线到她的本地 master 分支。然后,她就可以在本地工作,在她的本地 master 分支上进行提交。


image.png

当她工作的时候,她的同事 Violet 把一些变更推送到了主线上。由于 Scarlett 是在自己的代码线上工作,所以当她在做自己的事情时,可以忽略这些变化。

image.png

在某个时间点,Scarlett 达到了可以集成的程度。第一步,是将当前的主线状态提取(fetch)到本地主分支中,这将拉取到 Violet 的变更。当她在本地分支工作时,提交将在 origin/master (本地主分支名)上作为一个单独的代码线显示。

image.png

现在她需要把她的变更和 Violet 的变更合并起来。有些团队喜欢通过 merge(合并)来做到这一点,而另一些团队则喜欢通过 rebase(变基)来实现。通常,人们在谈到将分支融合在一起时,无论是实际使用 git merge 还是 rebase 操作,都会使用“merge(合并)”一词。我将遵循这种用法,因此,除非我实际上正在讨论合并和变基之间的区别,否则请考虑将“merge(合并)”作为可以以两者中任意一个方法实现的逻辑操作。

关于是使用普通的合并,还是使用或避免 fast-forward 快速合并,或者是使用 rebase ,另外还有一些其他的讨论。这超出了本文的范围,但是如果人们寄给我足够多的 Tripel Karmeliet(卡美里特啤酒)的话,我可能会写一篇关于这个问题的文章,毕竟如今比较流行“投桃报李”嘛。

如果 Scarlett 幸运的话,合并 Violet 的代码将是一个清晰的过程,否则,她将会遇到一些冲突。这些可能是文本冲突,大部分源代码控制系统可以自动处理这些冲突。但是语义冲突更难处理,这就是有“自测代码”的方便之处。(由于冲突会产生很多的工作量,而且总是会引入许多工作中的风险,所以我用一块醒目的黄色来标记它们。)

image.png

此时,Scarlett 需要验证合并的代码满足主线的健康标准 (假设主线是一个健康分支)。这通常意味着构建代码并运行构成主线提交套件的所有测试。即使这是一个干净的合并,她也需要做这些工作,因为尽管是一个干净的合并也可能隐藏语义冲突。提交套件中的任何故障都应该完全归因于这次合并,因为用于合并的两个父版本都应该是绿色的(译者注:即没有故障,在套件中测试通过显示为绿色)。知道这一点将有助于她追踪问题,因为她可以查看差异以寻找线索。

通过这个构建和测试,她已经成功地把主线拉到了她的代码线,但是——还有一件既重要又常常被人忽略的事——她还没有完成与主线的集成。要完成集成,她必须将所做的更改推入主线。如果她不这么做,团队中的其他人都将与她的变更隔离开来——本质上没有集成。集成既是拉取也是推送——只有在 Scarlett 把更改推入主线之后,她的工作内容才与项目中的其余部分集成。


image.png

现在许多团队在将代码提交添加到主线之前,需要一个代码评审的步骤——我称之为“提交评审”模式,后面会进行讨论。

有时候,在 Scarlett 进行推送前,其他人会和主线集成。在这种情况下,她必须再次拉取和合并分支。通常,这只是一个偶然的事件,在不需要任何进一步协调的情况下就可以被解决。我见过长时间构建的团队使用集成接力棒,这样只有持有接力棒的开发人员才能集成。但是近年来,随着构建时间的缩短,我还没有听到太多这样的情况。

适用场景

顾名思义,只有当我们在产品上使用主线时,我才能使用主线集成。

使用主线集成的一个替代方法是从主线拉取这些变更,合并到个人开发分支中。这可能是有用的——至少拉取时可以让 Scarlett 意识到其他人已经集成了变更,并发现她的工作和主线之间的冲突。但是,在 Scarlett 推送上传之前, Violet 将无法发现她的工作内容与 Scarlett 的变更之间有任何冲突。

当人们使用“integrate(集成)”这个词时,他们往往忽略了一个要点。经常听到有人说,他们正在集成主线到他们的分支,而实际上他们只是在从主线拉取。我已经学会了对此保持警惕,并进一步确认,看看它们是指拉取还是真正的主线集成。两者的结果是有很大差异的,所以不要混淆术语是很重要的。

另一种选择是,当 Scarlett 在做的一些工作还没有准备好与团队其他成员的工作完全集成,但和 Violet 的有重叠之处,并想和她一起共享。在这种情况下,他们可以开启一个协作分支

✣ 特性分支开发 ✣

为某个功能特性建立独立的分支,在该分支上完成与该特性相关的所有工作,在功能特性完成后集成到主线中。

按照特性分支开发这种模式,当开发人员要开始开发某个功能特性时,他们会开启一个分支,并持续在这个分支上工作直到功能特性完成,然后再与主线集成。

例如,让我们来看下 Scarlett。她领取的是一个给他们的网站中增加本地营业税集合的功能。她从产品最新的稳定版本开始,从主线拉取到她的本地仓库,然后从当前主线的顶端创建一个新的分支。不管多久,她会为完成这个功能,在这个本地分支上进行一系列提交。


image.png

她可能会将该分支推送到项目仓库,以便其他人可以查看她的更改。

当她在工作时,主线上也会有其他提交。因此,她可能要不时地从主线拉取版本,以便获知是否有任何改变可能会影响她正在开发的功能。


image.png

请注意,这不是我们上文说过的集成,因为她没有推送回主线。在这个点上,只有她在看自己的工作内容,其他人则没有。

一些团队希望确保所有代码都保存在中央仓库中,无论这些代码是否已被集成。在这种情况下,Scarlett 会将她的特性分支推送到中央仓库中。这将允许其他团队成员查看她正在进行的工作,即使该工作尚未集成到其他人的工作中。

当她完成了这个功能特性的开发后,她将执行主线集成,将这个功能特性集成到产品中。

image.png

如果 Scarlett 同时进行多个功能特性的工作,那她将为每个特性开启一个独立的分支。

适用场景

特性分支开发是如今业界一种流行的模式。要讨论何时使用它,我需要介绍它的主要替代方案——持续集成。但是首先我要谈谈集成频率的作用。

✣ 集成频率 ✣

我们进行集成的频率对团队的运作有着显著的影响。《DevOps现状调查报告》的研究表明,精英开发团队的集成频率要比绩效低下的团队高得多 —— 这一观察结果符合我和众多业界同行的经验。我将通过由 Scarlett 和 Violet 为主角的两个集成频率的案例来说明这一点。

低频集成

我先从低频集成的示例开始。在这里,我们的两个主人公从克隆主线到各自的本地分支展开工作,然后各自执行了几个还不愿推送的本地提交。


image.png

当他们工作时,另外有人向主线进行了一个提交。(我不能很快想出另一个人名,那是一种颜色,就叫Grayham?)

image.png

这个团队通过保持一个健康分支,并在每次提交后拉取主线代码进行团队协作。Scarlett 的前两个提交没有任何新代码可拉取,因为当时主线没有变化,但现在她需要拉取标记为 M1 的代码。

image.png

我用黄色框标记了此次合并。这次是将 S1 到 S3 与 M1 合并。很快,Violet 需要做同样的事情。

image.png

这时,两个开发人员的本地代码都已跟上主线的变化,但由于他们的本地代码彼此隔离,所以他们尚未彼此集成。Scarlett 不知道 Violet 在 V1 到 V3 的更改。

Scarlett 进行了更多的本地提交,准备好了进行主线集成。对她来说,这是一个轻松的推送,因为她较早拉取了 M1。


image.png

而 Violet 的操作则更为复杂。当她进行主线集成时,她现在需要集成 S1..5 与 V1..6。

image.png

我已经根据涉及的提交个数科学地计算了合并工作量的大小。然而,即使你没注意到上图的那些舌状凸起,你也会意识到 Violet 的合并很有可能比较困难。

高频集成

在前面的示例中,我们两个多彩的开发人员是在进行了几个本地提交之后集成的。让我们看看如果他们在每个本地提交之后进行主线集成会发生什么。

当 Violet 在第一个本地提交后就立即集成到主线时,第一个变更是显而易见的。由于主线没有任何更改,因此这就是一个简单的推送。


image.png

Scarlett 的第一个提交也需要主线集成,但是由于 Violet 先进行了集成,因此 Scarlett 需要做一次合并。但是由于她只需合并 V1 与 S1,所以合并的工作量很小。

image.png

Scarlett 的下一个集成是一个简单的推送,这意味着 Violet 的下一个提交也将需要与 Scarlett 的最近两个提交合并。但这仍然是一个很小的合并,仅仅是 Violet 的一个提交和 Scarlett 的两个提交的合并。

image.png

当有外部的提交推送到主线时,它会按照 Scarlett 和 Violet 正常的集成节奏被提取过来。

image.png

尽管它与以前发生的情况相似,但集成难度较小。Scarlett 这次只需要将 S3 与 M1 集成在一起,因为 S1 和 S2 已经在主线上了。这意味着 Grayham 在推 M1 之前就必须集成主线上已经存在的内容(S1..2,V1..2)。

开发人员继续进行剩余的工作,并在每次提交时进行集成。


image.png

集成频率对比

让我们再整体看一下这两张图

低频

image.png

高频
image.png

这里有两个非常明显地区别。首先,顾名思义,高频集成意味着做更多的集成——在这个小例子中,后者集成次数是前者的两倍。但更重要的是,这些集成比低频例子中的集成要小得多。较小的集成意味着更少的工作量,因为可能引起冲突的代码更改会更少。但是比减少工作量更重要的是,它也降低了风险。大规模合并的问题与其认为是处理合并产生的工作量,还不如说是这里面的不确定性。多数情况下,大规模的合并也会很顺利,但有时候,大规模合并会非常非常糟糕。偶尔的痛苦最后会比常态化的痛苦更糟。如果比较两种情况,一种是每次集成需要额外花费 10 分钟,另一种是有 1/50 的概率需要花费 6 小时做一次集成修复——我更喜欢哪个?如果仅看花费工作量,那么 1/50 看起来更好,因为它是 6 小时而不是 8 小时 20 分钟。但是不确定性使 1/50 的案例变得更加糟糕,这种不确定性会导致集成恐惧。

集成恐惧

当团队获得一些糟糕的合并体验时,他们往往会更谨慎地进行集成。这很容易变成一种正反馈回路——像许多正反馈回路一样,有着非常消极的后果。(译者注:正反馈回路也叫自增强回路,是一种叠加增强的过程)

最明显的结果是,团队进行集成的频率降低了,这会导致更严重的合并冲突,而合并冲突会导致更低的集成频率……从而陷入恶性循环。

一个更加不易察觉的问题是,团队会停止执行那些他们认为会使集成变得更加困难的事情。尤其是,这会让他们抗拒重构。但是减少重构会导致代码库变得越来越不健康,难以理解和修改,从而降低了团队的功能特性交付速度。由于完成功能特性所需的时间更长,因此进一步增加了集成频率(译者注:原文可能有误,这里应该是降低集成频率),从而使这种正反馈环路变得更不堪一击。

这个问题有个反直觉的答案——“如果一件事令人痛苦……那就更频繁地去做它”

让我们从另一个角度来看这些频率之间的差异。如果 Scarlett 和 Violet 在第一次提交时发生冲突,会发生什么?他们将在何时发现出现了冲突?在低频的例子中,直到 Violet 最后一次合并,他们才发现冲突,因为那是 S1 和 V1 第一次放到一起。但是在高频的例子中,在 Scarlett 的第一次合并中就会发现它们。

低频

image.png

高频
image.png

频繁的集成会增加合并的频率,但可以降低合并的复杂性和风险。频繁的集成还可以提醒团队更快地解决冲突。当然,这两件事是联系在一起的。糟糕的合并通常是团队工作中隐藏着冲突的结果,只有在进行集成时才浮现出来。

比如 Violet 正在看账单计费功能,并且看到代码的编写者有按一种特定的税收制度评估税额。而她的功能特性需要用不同的方式处理税额,因此最直接的方法是将税额从账单的计算中剔除,一会儿再把它作为独立的功能进行开发。计费功能仅在少数的几个地方被调用,因此使用“ 搬移语句到调用者”(译者注:《重构:改善既有代码的设计》8.4 )进行重构很容易——这让程序在未来的演进中更为合理。然而,Scarlett 不知道 Violet 正在做这件事,她按账单函数处理税款的假定实现她的功能特性。

自测代码是我们的救命稻草。如果我们有一个强大的测试套件,把它作为健康分支的一部分使用,将可以发现那些冲突,从而让问题进入生产环境的可能性大大降低。但是,即使有强大的测试套件充当了主线的看门人,大规模集成依然令人头疼。我们需要集成的代码越多,发现问题的难度就越大。我们也会有更大的概率遇到各种各样妨碍运行且难以理解的问题。除了通过较小的提交来降低影响,我们还可以使用“差异调试”来帮助定位哪一次变更导致问题。

很多人没有意识到的是,源代码控制系统其实是一种交流工具。它使 Scarlett 可以看到团队中其他人在做什么。通过频繁的集成,她不仅会在出现冲突时立即得到警告,而且她还能更了解每个人都在干什么,以及代码库是如何演进的。我们不是一个人在冲锋,而是和团队在一起工作。

增加集成频率是缩减功能特性大小的重要原因,同时这还有其他优点。功能越小,构建速度越快,投生速度越快,价值交付的启动也就越迅速。此外,较小的功能特性减少了反馈时间,使团队可以在更加了解客户后做出更好的功能决策。

✣ 持续集成 ✣

一旦有可共享的健康提交,开发人员就进行主线集成,这样的工作量通常是不到一天。

一旦团队在体验到高频集成既高效又轻松后,很自然地就会问“我们的集成频率能有多快?”。特性分支意味着变更集粒度的下限 —— 不可能有比内聚的特性更小的粒度。

持续集成为集成提供了一种不同的触发方式——只要在特性功能开发上取得了大的进展,并且分支仍然健康,就可以集成。我们不指望功能特性已经完整实现,只要对代码库有足够的修改就行。经验法则是“每个人每天都要提交到主线”,或者更确切地说,“本地代码库中永远不要存放超过 1 天未经集成的代码”。实际上,大多数持续集成的践行者每天会多次集成,他们乐于集成 1 小时或更少的工作。

要了解更多关于如何有效持续集成的详细信息,请查看我的详细文章。欲了解更多细节,请查阅 Paul Duvall, Steve Matyas 和 Andrew Glover 的著作。Paul Hammant 维护了trunkbaseddevelopment.com,其中有很多持续集成的技术。

使用持续集成的开发人员需要习惯集成半成品达成频繁集成的想法。他们还要考虑如何在运行的系统中不暴露半成品来做到这一点。通常这并不复杂——如果我正在实现一个依赖优惠码的折扣算法,而这个优惠码还不在有效列表中,那么我的代码就不会被调用,即使已经是生产版本。同样,如果我添加了一个功能,询问保险索赔人是否是吸烟者,我可以构建和测试代码背后的逻辑,并通过将询问问题的用户界面留到构建这个特性的最后一天再做,来确保它不会在生产中被使用。通过最后连接接口映射(Keystone Interface)来隐藏半成品通常是一种有效的技术。

如果没法轻松地隐藏掉半成品,我们可以使用 特性开关。除了隐藏半成品之外,特性开关还可以有选择地向其中一部分用户显示某一功能特性——这通常便于逐步推出一个新的功能特性。

集成半成品尤其会引起那些担心主线中有错误代码者的忧心。因此,使用持续集成需要自测代码,这样就有信心把半成品合并到主线,而不会增加出现错误的几率。这种方法要求开发人员在编写功能代码时,为半成品编写测试,并将功能代码和测试一起提交到主线中 (或许可以用测试驱动开发)。

就本地代码库而言,大多数使用持续集成的人不会想要在单独的本地分支上工作。通常是直接在本地 master 分支上提交,工作完成后进行主线集成。然而,如果开发人员喜欢的话,开一个特性分支并在上面工作,每隔一段时间就集成回本地 master 分支和主线,那也相当不错。特性分支开发与持续集成之间的区别,不在于是否有特征分支,而是在于开发人员何时与主线集成。

适用场景

持续集成是特性分支开发的另一种选择。两者之间的权衡值得在本文中用单独的章节描述,下面将对这两者进行对比。

持续集成和基于主干开发

在 ThoughtWorks 于 2000 年开始使用持续集成时,我们编写了 CruiseControl, 这是一个守护程序,每当有代码提交到主线后,就会自动构建软件产品。从那时起,许多这样的工具 (如 Jenkins、TeamCity、Travis CI、Circle CI、Bamboo 等等) 被开发出来。但是大多数使用这些工具的组织都是在提交时自动构建特性分支——这虽然有用,但也意味着这些组织并非真正在实践持续集成。(还不如叫它们持续构建工具。)

因为这样的语义扩散,有些人开始使用 “主干开发” 一词来代替“持续集成”。(部分人确实对这两个术语进行了细微的区分,但是没有一致的用法)。虽然在语言方面我通常是描述派,但我更喜欢使用 “持续集成”。一部分原因是我不认为试图不断提出新术语是对抗语义扩散的可行方法。然而,或许更主要的原因是,我认为改变术语将粗暴地抹杀早期的极限编程先驱者们的贡献,尤其是 Kent Beck 的,他在 20 世纪 90 年代创造并明确定义了持续集成的实践。

✣ 对比特性分支开发和持续集成 ✣

目前,特性分支看起来是业界最常见的分支策略,但是一些实践者强烈认为持续集成是一种更好的方法。持续集成的主要优势是支持更高的集成频率,而且通常是高很多的集成频率。

集成频率的差异取决于团队能够把功能拆分到多小。如果团队拆分的所有功能特性都可以在一天之内完成,那么他们既可以实行特性分支开发,也可实行持续集成。但是大多数团队的特性持续时间都比这更长——特性持续的时间越长,这两种模式之间的差异就越大。

正如我已经指出的那样,更高的集成频率可以减少复杂的集成,并减少对集成的恐惧。这通常是一件很难沟通的事情。如果你生活在每隔几周或几个月进行集成的世界中,那么集成很可能是一项令人焦虑的活动。很难相信一天可以进行很多次集成。但集成是可通过加快频率降低难度的事情之一。这是一种违反直觉的想法——“如果一件事令人痛苦——那就更频繁地去做它”。集成的规模越小,集成就越不可能变成充满痛苦和绝望的史诗般的合并。对于特性分支开发,高频集成鼓励更小的特性规模:几天而不是几周(几个月根本行不通)。

持续集成使团队可以从高频集成中受益,同时将特性规模与集成频率解耦。如果团队更喜欢一两个星期的特性粒度,持续集成支持这样的粒度拆分,同时仍让团队获得最高集成频率的所有好处。合并规模越小,所需的工作越少。更重要的是,正如我在上文中所解释的,更频繁地进行合并可以减少出现极为糟糕的合并的风险,这既消除了这种合并带来的惊吓,也减少了合并的整体恐惧感。如果代码中出现冲突,则高频集成会在导致这些讨厌的集成问题之前迅速发现它们。持续集成可为团队带来极强的效益,以至于有的团队,有些功能只需几天完成,还依旧在做持续集成。

持续集成的明显缺点是,缺乏向主线进行最重要的集成的封闭。如果一个团队不善于保持健康的分支,这不仅是一个对失败的庆祝(译者注:庆祝失败是为了改进),更是一个风险。将一个功能特性的所有提交聚在一起,还可以在后期决定是否在即将发布的版本中包含一个特性。虽然功能开关允许从用户角度打开或关闭功能,但该功能的代码仍在产品中。对这一点的担忧通常会被过分夸大,毕竟代码不会太重要,但这确实意味着想要实行持续集成的团队必须开发一组强大的测试集,以便他们可以确信主线能保持健康,即使每天进行多次集成。有些团队觉得这种技能是难以想象的,但另一些团队则认为这不仅是可能的而且游刃有余。此先决条件确实意味着,特性分支开发这种方式更适合那些不强制保持健康分支、并且需要用发布分支在发布之前稳定代码的团队。

虽然合并的规模大小和不确定性是特性分支开发最明显的问题,但最大的问题可能是特性分支开发遏制重构。定期进行且几乎没有冲突的重构最为有效。重构会引入冲突,如果这些冲突没有被发现并迅速解决,合并就会变得困难重重。因此,重构在高频集成中效果最好,所以重构作为极限编程 (Extreme Programming)的一部分流行起来也不足为奇,而且持续集成也是极限编程最初的实践之一。特性分支开发也不鼓励开发人员做当前特性外的更改,这会破坏团队的重构能力,影响代码库稳定性的提升。

当我遇到有关软件开发实践的科学研究时,由于他们的方法学存在严重问题,通常我并不买账。但《DevOps 现状调查报告》是一个例外,该报告揭露了软件交付效能的度量指标,并将其与更广泛的组织绩效度量相关联,而组织绩效又与投资回报率和盈利能力等业务度量指标相关。在2016年,他们首先评估了持续集成,发现它有助于提高软件开发效能,此后的每项调查中都重复印证了这一发现。

我们发现在合并到主干之前,具有极短生命周期(少于一天)的分支或派生,并且总共少于三个活动分支,是持续交付的重要特征,并且所有这些都有助于提高绩效。每天将代码合并到主干或 master 中也是如此。

——《 2016 年 DevOps 现状调查报告》

使用持续集成并不会消除保持功能粒度小的其他优势。频繁发布小的功能特性可提供快速的反馈周期,从而为改进产品创造奇迹。许多使用持续集成的团队还在努力构建产品的分层,并尽可能频繁地发布新功能。

特性分支开发 持续集成
√ 一个特性的所有代码可以作为一个单元质量评估 √ 支持比功能特性粒度更高频的集成
√ 功能特性代码仅在功能完成后才添加到产品中 √ 缩短发现冲突的时间
× 较低频率的合并 √ 较小的合并
√ 鼓励重构
× 需要维持健康分支(和相关自测代码)的投入
√ 科学证据表明它有助于提高软件交付绩效

特性分支和开源

许多人将特性分支开发的流行归因于github 和起源于开源开发的拉取请求模型。有鉴于此,有必要了解一下开源工作与许多商业软件开发之间截然不同的环境。开源项目的结构有许多不同的方式,但是一个常见的结构是一个人或一小群人作为开源项目的维护者,承担大部分编程工作。维护者与更多的开发贡献者一起工作。维护者通常不了解贡献者,因此对他们贡献的代码的质量一无所知。维护者还不确定贡献者将在开源项目中实际投入多少时间,更不用说他们的工作成效。

在这种情况下,特性分支开发非常有意义。如果有人要添加一个或大或小的功能,而我不知道这项功能什么时候(或者是否)会被完成,那么对我来说,等到它完成后再集成是有意义的。另外,更为重要的是要能够审核代码,以确保它通过我为代码库设置的任何质量门禁。

但是许多商业软件团队的工作环境截然不同。有一个全职的团队,他们全都为软件开发投入大量时间,通常是全职的。项目负责人非常了解这些人(除了刚开始的时候),并且可以对代码质量和交付能力有可靠的预期。由于他们是带薪雇员,项目负责人对项目投入的时间,编码标准和团队习惯也有更好的掌控。

在这迥然不同的环境下,应该清楚地知道,此类商业团队的分支策略不必与在开源世界运用的分支策略相同。持续集成几乎不可能适合偶尔为开源工作做出贡献的人,但是对于商业工作而言,这是一个现实的选择方案。团队不应假定那些在开源环境行得通的做法可以自动适应他们与之不同的工作环境。

✣ 对提交评审 ✣

每个对主线的提交都要先经同行评审才会被接纳。

长期以来,代码审查一直被推荐用于提升代码质量,提高模块化和可读性,以及消除缺陷。尽管如此,商业机构往往发现很难把代码审查融入到软件开发工作流程中。然而,开源世界广泛采用了这样的信念:在项目贡献被接受纳入项目主线之前,应先对其进行评审,并且这种方式近年来在开发组织中广泛传播,尤其是在硅谷。这样的工作流程特别适合 GitHub 的拉取请求机制。

类似这样的流程会在 Scarlett 完成希望被集成的工作内容时开始。一旦她成功完成构建,就要进行主线集成(如果她的团队有这样的惯例),但是在推送到主线前,她要先发送她的提交进行评审。团队的其他成员,例如 Violet,接着对这个提交进行代码审核。如果她认为提交有问题,会反馈一些意见,然后会有一些反复,直到 Scarlett 和 Violet 都满意为止。提交只有在通过评审后才会被纳入主线。

对提交评审(Reviewed Commits)在开源中越来越受欢迎,它非常适合由提交维护者和临时贡献者组成这样模式的组织。对提交评审使得维护人员可以密切关注任何一个贡献,也非常适合特性分支开发,因为一个完成的特性清晰地标记出需要代码评审的节点。如果您不确定贡献者是否完成了功能,为什么还要评审他们的半成品?最好还是等功能完成时再做。这种做法在更大的互联网公司中也广泛传播,Google 和 Facebook 都开发有专用工具支持平滑开展对提交评审。

约定及时对提交评审的行为准则非常重要。如果开发人员完成了某项工作,并花了几天时间进行其他工作,那么当他们收到返回的评审意见时,他们对被评审工作的印象已经不再清晰。如果被评审的提交是已经完成的功能,这会令人沮丧,但对于部分完成的功能,情况会严重得多,因为在确认评审通过之前,工作可能很难进一步开展。理论上,可以结合对提交评审来进行持续集成,而且实践上也确实是有可能的—— Google 就遵循这个方法。但是,尽管可能,但很难执行,而且相对罕见。对提交评审和特性分支开发是更为常见的组合。

适用场景

将开源软件和私有软件开发团队的需求混为一谈就像是当前软件开发仪式的原罪。—— Camille Fournier

尽管在过去十年中,对提交评审已成为一种流行的做法,但仍有弊端和替代方案。即使做得很好,对提交评审也总是会在集成过程中引入一些延迟,从而导致了更低的集成频率。结对编程提供了持续的代码审核过程,带来比等待代码评审更快的反馈周期。(就像持续集成和重构一样,结对编程是极限编程最初的实践之一)。

许多使用对提交评审的团队并没有做到足够迅速。他们能够提供有价值的反馈往往因为来得太迟而不再有效。那时就会面临一个令人尴尬的选择,要么大量返工,要么接受能行得通但损害代码库质量的工作。

代码评审并不局限于只在代码合入主线前进行。许多技术领导者发现在提交后评审代码会很有用,当他们发现问题时,就可以及时与开发人员联系。重构文化在这里是非常有价值,做得好可以形成一种社区氛围,团队中的每个人都将定期评审代码库中的代码并修复他们看到的问题。

围绕对提交评审的利弊权衡主要取决于团队的社会结构。正如我已经提到的,开源项目通常具有一些受信任的维护者和许多不受信任的贡献者的结构。商业团队通常都是全职的,但结构可能相似。项目负责人(类似于一个维护者)信赖一小组(也可能是某个)维护者,并且对团队其他成员贡献的代码保持警惕。团队成员可能同时分配到多个项目中,使他们更像开源贡献者。如果存在这样的社会结构,那么对提交评审和特性分支开发将具有很大的意义。但是,团队在互相具有较高信任度时,通常能找到机制来保持代码高质量,且不会增加集成过程的冲突。

因此,尽管对提交评审可以是一种有价值的实践,但并不是通向健康代码库的必要途径。如果你希望团队平衡成长,而不过度依赖其最初的领导者时尤其如此。

集成阻力

对提交评审的问题之一,是它往往让集成变得更加麻烦。这是集成阻力(Integration Friction)的一个例子——这些活动让集成耗时或费力。集成阻力越多,开发人员就越倾向于降低集成频率。想象某个 (功能不健全的) 组织坚持认为所有对主线的提交都要填写一份需要耗时半小时的表格。这样的制度会阻碍人们频繁集成。无论你对特性分支开发和持续集成的态度如何,审视任何增加这种冲突的东西都是有价值的。任何这样的冲突都应该被移除,除非它有明显的增值作用。

拉取请求增加了额外的开销以应对低信任度情景,例如,允许你不认识的人为你的项目做出贡献。而把拉取请求强加给你自己团队中的开发人员,就像让你的家人通过机场安检进入你家一样。—— Kief Morris

手动过程是这里常见的冲突源,尤其是当涉及与不同组织的协调时。这种摩擦通常可以通过使用自动化流程、加强开发人员培训 (以消除需求) 以及将步骤推到部署流水线或生产中质量保证的后续步骤来减少。您可以在关于持续集成和持续交付的资料中找到更多消除这种冲突的方法。这种冲突也会在生产的路径中出现,有着同样的困难和处理方法。

让人们不愿意考虑持续集成的原因之一是,设想他们只在集成阻力严重的环境中工作过。如果做一次集成需要一个小时,那么一天做几次集成显然是荒谬的。而如果加入一个团队,在那里集成是一个分分钟可以完成的小事,就会感觉像是一个完全不同的世界。关于特征分支开发和持续集成优点的许多争论是混乱复杂的,我怀疑就是因为人们没有经历过这两个世界,因此不能完全理解这两种观点。

文化因素影响集成阻力——尤其是团队成员之间的信任。如果我是一个团队的领导者,而我不信任我的同事会做得很好,那么我很可能会想要阻止损害代码库的提交。这自然也是对提交评审的驱动因素之一。但如果我在信任同事判断的一个团队里,那么我可能会更愿意接受提交后的审查,或者完全砍掉审查,而去依靠集体重构来解决问题。在这种环境下,我的收获是消除了提交前评审所带来的摩擦,从而鼓励了更高频率的集成。团队信任通常是特性分支与持续集成争论的最重要因素。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345