[译] 管理源码分支的模式

date
Aug 6, 2021
slug
[译]-管理源码分支的模式
status
Published
tags
版本控制工作流
summary
现代源码控制系统提供了强有力的工具,使得在源码中创建分支变得容易。但最终这些分支都不得不合并到一块儿,许多团队还花了大量的时间把这些分支杂乱的盘根错节粘合在一起。有几个模式可以让团队有效地使用分支,并让团队专注在集成多个开发者的工作和组织通往生产(production)发布的路径周围。贯穿整体的主题是,分支应该被频繁地集成,并且努力都应该集中在可以以最小代价发布到生产环境的健康主线上。
type
Post
原文作者:Martin Fowler
现代源码控制系统提供了强有力的工具,使得在源码中创建分支变得容易。但最终这些分支都不得不合并到一块儿,许多团队还花了大量的时间把这些分支杂乱的盘根错节粘合在一起。有几个模式可以让团队有效地使用分支,并让团队专注在集成多个开发者的工作和组织通往生产(production)发布的路径周围。贯穿整体的主题是,分支应该被频繁地集成,并且努力都应该集中在可以以最小代价发布到生产环境的健康主线上。


源代码对于任何软件开发团队来说都是至关重要的资产,在过去几十年的时间里,大量的源码管理软件被开发出来来让代码保持规整。这些工具让变更可被追踪,让我们可以重建先前版本的软件,并且可以看见它是怎么随着时间发展的。这些工具也是拥有多个程序员团队的坐标中心,所有人都在一个共同的代码库上工作。通过记录每个开发者所作的变更,这些系统可以一次性跟踪多条工作线,并帮助开发者想出如何将这些多条工作线合并到一起。
这种工作线的分裂与合并是软件开发团队的核心工作流,并且一些模式已经进化出一种方法来帮助我们处理这些活动。就像大多数软件模式一样,它们都不是团队必须遵守的黄金法则。软件开发的工作流非常依赖于所处的环境(context),特别是团队的社会结构和团队遵循的其它实践。
在这篇文章中,我的任务是讨论这些模式,并且是在单篇文章的上下文中这样做的。在这篇文章中我描述了模式,但在模式的解释中穿插了叙述性的部分,这些叙述性的部分可以更好的解释环境和模式之间的相互关系。为了更轻易的区分它们,我使用“✣”符号标记了模式的部分。

基本模式

在思考这些模式时,我发现发展两个主要类别是很有用的。一组着眼于集成,即多个开发者如何将他们的工作结合成一个连贯的整体。另一组则着眼于通往生产的路径,使用分支来帮助管理从集成的代码库到在生产中运行的产品的路径。一些模式对它们两者都进行了支撑,我会把这类视为基础模式。剩下的一些模式既不是基本模式,也不能归于这两个主要组别之中——所以我将把它们留到最后。

源码分支行为 ✣

创建一个副本并记录该副本的所有变更。
如果几个人在同一个代码库中工作,那么在相同的文件上进行工作将很快变得不可能。如果我想运行编译,而我的同事正在输入表达式,那么编译就会失败。我们将不得不对彼此吼叫:“我正在编译,别改东西”。即使只有两个人,这也很难持续,更别说一个更大的团队了。
这个问题的简单答案是,每个开发者都持有一份代码库的副本。现在我们可以很容易地工作在自己的功能上了,但一个新的问题出现了:当我们完成工作后,如何将我们的两个副本再合并到一起?
源码控制系统使这一过程更加容易。关键在于,它将每个分支的每一个改动都记录为提交(commit)。这不仅可以确保没有人忘记他们对utils.java所做的小改动,而且记录改动可以使合并更容易进行,特别是当几个人修改了同一个文件时。
这将我引向本文要用到的分支的定义。我把一个分支定义为代码库中的一个特定的提交序列。分支的头部,或者说顶端,是该序列中的最新提交。
1. 一个分支是一系列的提交
2. 头部(或顶端)是这一系列的最新提交
1. 一个分支是一系列的提交 2. 头部(或顶端)是这一系列的最新提交
这是名词,但也有动词,“进行分支(to branch)”。这时我指的是创建一个新的分支,也可以看成是把原来的分支拆分成两个。当一个分支的提交被应用到另一个分支时,分支会合并。
1. 当有两个提交被平行的创造时,一个分支将会分开
2. 一个分支可以被合并到另一个分支。将会有一些工作来处理这些平行的变更
3. 如果自上次合并都没有变更,在一个分支上的一个变更可以简单地被应用到另一个上去
1. 当有两个提交被平行的创造时,一个分支将会分开 2. 一个分支可以被合并到另一个分支。将会有一些工作来处理这些平行的变更 3. 如果自上次合并都没有变更,在一个分支上的一个变更可以简单地被应用到另一个上去
我对“分支”所使用的定义符合我所观察到的大多数开发者谈论它们的方式。但源码控制系统倾向于以一种更特殊的方式使用“分支”。 我可以用一个在现代开发团队中常见的情形来说明这一点(该团队将源代码保存在一个共享的git仓库中)。一位名叫Scarlett的开发者需要做一些修改,所以她克隆了git仓库,并检出(checkout)了master分支。她做了几处改动,并提交到了她的master。与此同时,另一个开发者,我们叫她Violet,把仓库克隆到她的桌面上,并检出了master分支。那么Scarlett和Violet究竟是工作在同一个分支,还是不同的分支上?她们都在“master”上工作,但她们的提交是相互独立的,并且当她们把自己的修改推送到共享仓库时,需要进行合并。如果Scarlett对自己所做的修改不确定,所以她给最后一个提交打上标签,并将她的master分支重置为origin/master(她从共享仓库克隆的最后一个提交),那么会发生什么呢?
notion image
根据我之前给出的对分支的定义,Scarlett和Violet是工作在分开的分支上的,她们彼此分开,也与共享仓库的master分支分开。当Scarlett把她的工作用标签放在一边时,根据我的定义,它仍然是一个分支(而且她很可能认为它是一个分支),但用git的说法,它是一个打了标签的代码线。
在git这样的分布式版本控制系统中,这意味着每当我们进一步克隆一个仓库时,我们也会得到额外的分支。如果Scarlett克隆了她的本地仓库,放到她的笔记本上(为了坐火车回家而做的准备),她就创建了第三个master分支。在GitHub上fork时也会发生同样的效果——每个被fork的仓库都有自己的额外的一组分支。
当我们遇到不同的版本控制系统时,这种术语上的混乱就更严重了,因为它们都对什么是分支有各自的定义。Mercurial中的分支与git中的分支是完全不同的,git中的分支更接近Mercurial中书签的概念。Mercurial也可以用未命名的head创建分支,并且Mercurial的使用者经常通过克隆仓库的方式创建分支。
所有有关这个术语上的混乱导致了一些人回避它。一个更通用的术语出现在这儿将很有用,那就是代码线(codeline)。我把代码线定义为代码库的一个特定版本序列。它可以以一个标签结束,也可以是一个分支,或者遗失在git的reflog中。你会注意到我的对分支的定义和我的对代码线的定义之间有强烈的相似性。Codeline在很多方面是更有用的术语,我也确实在使用它,但它在实践中并没有被广泛使用。所以在这篇文章中,除非处于git(或其他工具)术语的特定上下文,否则我将交替使用分支和代码线这两个词语。
这个定义的结果就是,无论你使用的是什么版本控制系统,每个开发者只要做了本地修改,就会在自己机器上的工作副本中至少有一条个人代码线。如果我克隆了一个项目的git仓库,检出了master,并更新了一些文件——即使在我提交任何东西之前,这也是一条新的代码线。同样,如果我创建了自己的一个subversion仓库的主干的工作副本,这个工作副本就是它自己的代码线,即使这里并没有涉及到subversion分支。
何时使用它
一个古老的笑话说,如果你从高楼坠落,坠落并不会伤害你,但落地会。所以,对于源代码来说:创建分支很容易,合并却更难。
在提交上记录每一个变更的源码控制系统确实使合并的过程更容易,但它们并没有使合并变得微不足道。如果Scarlett和Violet同时改变了某个变量到不同的名字,那么源码管理系统在没有人为干预的情况下就无法解决的冲突。更加尴尬的是,至少这种文本冲突是源码控制系统可以发现的,并提醒人类去看一看。但经常出现另一种冲突:文本合并没有问题,但系统仍然无法工作。想象一下,Scarlett改变了一个函数的名称,而Violet在她的分支中添加了一些代码,以其旧名称调用这个函数。这就是我所谓的语义冲突。当这些冲突发生时,系统可能无法构建,或者它可以构建但在运行时会出现错误。
Jonny LeRoy喜欢指出人们(包括我)在绘制分支图时的这个缺陷
1. 大多数人是如何绘制分支图的
2. 我们实际上应该如何绘制它们,以显示随着时间的推移出现的分歧
Jonny LeRoy喜欢指出人们(包括我)在绘制分支图时的这个缺陷 1. 大多数人是如何绘制分支图的 2. 我们实际上应该如何绘制它们,以显示随着时间的推移出现的分歧
对于任何从事过并发或分布式计算的人来说,这个问题都会很熟悉。我们有一些共享的状态(代码库),开发人员可以并行地进创造更新。我们需要某种将这些更新序列化为一些共识更新的方式来将它们结合起来。让一个系统正确地执行和运行意味着该共享状态具有非常复杂的有效性标准,这个事实让我们的任务更加复杂。我们没有办法创建一个确定的算法来寻找共识。人类需要找到这个共识,而这个共识可能涉及到混合不同更新所选择的部分。通常情况下,只有通过原始更新来解决冲突才能达成共识。
💡
我:“如果没有分支会怎样”。每个人都会编辑实时代码,半生不熟的改动会使系统瘫痪,人们会互相踩踏。因此,我们给人们冻结时间的错觉,他们是唯一改变这个系统的人,这些变更可以等到完全出炉后再给系统带来风险。但这是一个假象,最终要为之付出代价。谁来支付?什么时候?多少钱?这就是这些模式所讨论的:给笛手付款的替代方案。 ——Kent Beck
因此,在这篇文章的剩余部分,我列出了各种模式,它们支撑了令人愉悦的隔离和下落时你发边的疾风,但最小化了不可避免的与坚硬地面接触的后果。

主线 ✣

一个单一的、共享的、作为产品当前状态的分支。
主线是一个特殊的代码线,我们认为它是团队代码的当前状态。每当我想开始一项新工作时,我都会从主线上拉取代码到我的本地仓库,再开始工作。每当我想与团队其他成员分享我的工作时,我就会把我的工作更新到主线,最好是使用我很快就会讨论到的主线集成模式。
不同的团队对这个特殊的分支使用不同的名称,这通常是由所使用的版本控制系统的惯例所决定的。git用户通常称它为“master”,subversion用户通常称它为“trunk”。
我必须在此强调,主线是一个单一的、共享的代码线。当人们在git中谈论“master”时,他们可以指几种不同的东西,因为每个版本库的克隆都有它自己的本地master。通常这样的团队有一个中央仓库——一个共享的仓库,作为项目的单一记录点,并且是大多数克隆源头。从头开始一段新的工作意味着克隆这个中央仓库。如果我已经有了一个克隆,我就会以从中央仓库中拉取master开始,这样它就能与主线保持一致。在这种情况下,主线就是中央仓库中的master分支。
在开发我的功能时,我会有自己个人的开发分支,它可能是我的本地master,或者我可能创建一个单独的本地分支。如果我在这上面工作了一段时间,我可以通过每隔一段时间拉取主线的变更,并将其合并到我的个人开发分支中,来同步主线的最新变化。
同样地,如果我想创建一个产品的新版本用于发布,我可以始于当前主线。如果我需要修复bug来让产品稳定到可以发布,我就可以使用一个发布分支来达到目的。
何时使用它
我想起在21世纪初去和一个客户的构建工程师谈话的事情。他的工作是对团队正在开发的产品进行构建。他会给团队的每个成员发一封电子邮件,之后他们会发送他们准备好要集成的代码库的文件作为回应。然后,他将这些文件复制到他的集成树中,并尝试编译代码库。这通常要花费他几周的时间来创建一个可以编译的构建,和准备进行某种形式的测试。
相比之下,有了主线,任何人都可以从主线的顶端快速启动一个最新的产品构建。此外,主线不仅仅让我们可以更容易看到代码库的状态,它还是许多我很快就会探讨到的其他模式的基础。
主线的一个替代方案是发布列车

健康的分支 ✣

在每次提交时,执行自动检查,通常是构建和运行测试,以确保分支上没有缺陷。
由于主线具有共享和经过验证的状态,所以保持它的稳定是很重要的。还是在21世纪初,我记得和另一个组织的一个团队交谈过,这个团队以每天对他们的每个产品进行构建而闻名。这在当时被认为是相当先进的做法,而这个组织也因为这样做而受到称赞。但在这些文章中没有提到的是,这些每日构建并不总是成功的。事实上,很容易找到一些每日构建已经几个月都没有编译过的团队。
为了解决这个问题,我们可以努力使分支保持健康——这意味着它能成功构建,并且软件很少在运行时出现bug(如果有的话)。为了确保这一点,我发现编写自测代码(Self Testing Code)是至关重要的。这种开发实践意味着我们在编写生产代码的同时,也要编写一套全面的自动化测试,这样我们就可以确信,如果这些测试通过了,那么代码就不包含任何bug。如果我们这样做了,那么我们就可以通过在每次提交时运行一个构建来保持某个分支的健康,这个构建包括运行这个测试套件。如果系统编译失败,或者测试失败,那么我们的首要任务就是在该分支上做其他事情之前修复它们。这通常意味着我们要“冻结”该分支——直到修复使此分支恢复健康之前任何提交都不会被允许。
围绕着测试的程度,存在着一种张力(tension)来提供足够的健康信心。许多更彻底的测试需要大量的时间来运行,延迟了提交是否健康的反馈。团队通过在部署流水线(Development Pipeline)上将测试分成多个阶段来处理这个问题。这些测试的第一阶段应该快速运行,通常不超过10分钟,但仍应比较全面。我把这样的套件称为提交套件(尽管它经常被称为“单元测试”,因为提交套件通常大都是单元测试)。
理想情况下,所有的测试都应该在每个提交上运行。然而,如果测试速度很慢,例如性能测试需要浸泡服务器几个小时,这就不太现实了。现在,团队通常可以建立一个可以在每个提交上运行的提交套件,并尽可能频繁地运行部署流水线的后期阶段。
代码运行时没有bug还不足以说明代码是好的。为了保持稳定的交付速度,我们需要保持代码的内部质量。一个流行的方法是使用集成前审查(Pre-Integration Review),尽管我们将看到,还有其他的选择。
何时使用它
每个团队都应该对其开发工作流中每个分支的健康状况有明确的标准。保持主线的健康有巨大的价值。如果主线是健康的,那么开发人员就可以通过拉取当前的主线来开展一项新的工作,而不会被那些妨碍他们工作的缺陷所困扰。我们经常听到人们在开始一项新的工作之前,要花数天时间来修复或变通的解决他们拉下来的代码中的错误。
一个健康的主线还可以使通往生产的路径更加顺畅。一个新的生产候选程序(production candidate)可以在任何时候从主线的头部被构建。最好的团队发现他们只需要做很少的工作来稳定这样的代码库,总是能够直接从主线发布到生产。
拥有一个健康的主线的关键是自测代码(其提交套件可以在几分钟内运行完毕)。建立这种能力可能是一项巨大的投资,但是一旦我们能够在几分钟内确保我的提交没有破坏任何东西,那么就会完全改变我们的整个开发过程。我们可以更快地进行修改,自信地重构我们的代码,使其易于工作,并大大缩短从我们所需的能力到在生产中运行的代码的周期。
对于个人开发分支,保持它们的健康是明智的,因为这样可以实现对比调试(Diff Debugging)。但这种愿望与频繁提交以把当前状态检查点化(checkpoint)的做法相悖。如果我准备尝试不同的路径,我可能会在编译失败的情况下创建一个检查点。我解决这种矛盾的方法是,一旦我完成了当前的工作,就把任何不健康的提交压缩掉(squash)。这样一来,在几个小时后只有健康的提交才会保留在我的分支上。
如果我保持我的个人分支健康,这也会使我提交到主线上更容易——我会知道主线集成中出现的任何错误都是由集成导致的,而不是我代码库中的错误导致的。这将使我更快、更容易地发现和修复它们。

集成模式

分支行为是关于管理隔离和集成的相互作用的东西。让每个人一直在一个单一共享代码库中工作是行不通的,因为如果你正在输入某变量名字途中,我就不能编译程序了。所以至少在某种程度上,我们需要一个可以在上面工作一段时间的私人工作区的概念。现代源码控制工具使得操作分支和监控这些分支上的变化变得容易。然而,在某些时候,我们需要进行集成。对分支策略的思考实际上就是决定我们如何以及何时集成。

主线集成 ✣

开发人员通过从主线拉取、合并以及——如果健康的话——推回主线来集成他们的工作。
主线给出了团队软件的当前状态是什么样的的清晰定义。使用主线的最大好处之一是简化了集成。如果没有主线,我上述的团队中每个成员间的协作将是一个复杂的任务。然而,有了主线之后每个开发人员都可以靠自己集成。
我将通过一个例子来讲述这是如何起作用的。一个开发者,我叫她Scarlett,通过克隆主线到她自己的仓库开始工作。在git中,如果她还没有中央仓库的克隆,她就会克隆它并检出(checkout)master分支。如果她已经有了克隆,她会把主线拉到她本地的master。然后她就可以在本地工作,向她的本地master提交。
notion image
在她工作的时候,她的同事Violet把一些变更推送到主线上。由于她在自己的代码线中工作,Scarlett可以在专注于自己的任务时忘掉这些变更。
notion image
在某些时候,她达到了想要集成的程度。集成的第一部分是将主线的当前状态抓取(fetch)到她的本地的master分支,这将拉入 Violet的修改。由于她是在本地master上工作,提交的内容会在origin/master上显示为单独的代码线。
notion image
现在她需要把她的修改和Violet的修改结合起来。有些团队喜欢用合并(merge)的方式,有些则用变基(rebase)的方式。一般来说,人们在谈论将分支整合到一起时都会使用“合并(merge)”这个词,不管他们实际上是使用git merge还是rebase操作。我将遵循这一用法,所以除非我真的在讨论merge和rebase之间的区别,否则就把“合并”看作是可以用这两者任意一种方式实现的逻辑任务。
关于是否使用原生合并(vanilla merge),使用或避免快进(fast-forward)合并,或使用rebase,有一个完整的其他讨论。这超出了本文的范围,尽管如果人们给我发送足够多的Tripel Karmeliet,我可能会写一篇关于这个问题的文章。毕竟,最近以物换物(quid-pro-quos)很流行。
如果Scarlett是幸运的,合并Violet的代码将是一个干净的合并,如果不是,她将有一些冲突需要处理。这些可能是文本上的冲突,其中大部分源码控制系统可以自动处理。但是语义冲突就更难处理了,这就是自测代码的用武之地了。(由于冲突会产生相当多的工作,并且总是对大量的工作引入风险,所以我用明显的黄色来标记它们。)
notion image
此时,Scarlett需要验证合并后的代码是否满足主线的健康标准(假设主线是健康分支)。这通常意味着构建代码和运行任何来自主线提交套件的测试。即使是干净的合并,她也需要这样做,因为即使是干净的合并也会隐藏语义冲突。提交套件中的任何失败都应该纯粹是由合并造成的,因为两个合并父母(merge parent)都应该是正常的。知道这一点应该可以帮助她找到问题所在,因为她可以从差异(diff)中寻找线索。
通过这次构建和测试,她已经成功地将主线拉入了她的代码线,但是——这一点既重要又经常被忽略——她还没有完成与主线的集成。为了完成集成,她必须把她的变更推到主线上。除非她这样做,否则团队中的其他人都将与她的变更隔离——实际上没有集成。集成既是拉也是推——只有Scarlett已经推送的那部分才是和项目的其他部分所集成的工作。
最近许多团队都要求在把提交(commit)添加到主线之前都要进行代码审查——一种我称之为集成前审查的模式,之后我会讨论到。
最近许多团队都要求在把提交(commit)添加到主线之前都要进行代码审查——一种我称之为集成前审查的模式,之后我会讨论到。
偶尔也会有人在Scarlett进行推送之前与主线集成。在这种情况下,她不得不拉取(pull)并再次合并。通常这只是一个偶然的问题,无需进一步协调就可以解决。我曾见过有着长构建流程的团队使用集成接力棒,这样只有拿着接力棒的开发者才能集成。但近年来,随着构建时间的改善,我几乎没有再听到这样的事情了。
何时使用它
顾名思义,只有当我们的产品在使用主线时,我才能使用主线集成。
使用主线集成的一种选择是直接从主线上拉取(pull),将这些变更合并到个人开发分支中。这可能很有用——拉取至少可以提醒Scarlett注意其他人所集成的变更,并发现她的工作与主线之间的冲突。但直到Scarlett推送之前,Violet都将无法发现她的工作与Scarlett的变更之间的任何冲突。
当人们使用“集成”这个词时,他们往往忽略了这个重要的问题。经常听到有人说他们正在将主线集成到他们的分支中,而他们只是在拉取。我学会了对这种说法保持警惕,并进一步探究,看看他们是指单纯的拉取还是恰当的主线集成。两者的结果是非常不同的,所以不混淆这些术语是非常重要的。
另一种选择是,当Scarlett正在某些还没有准备好与团队的其他成员完全集成的工作的途中,但这些工作与Violet有重合的部分,并且Scarlett想分享给她。在这种情况下,她们可以选择创建一个协作分支

功能分支模式✣

把一个功能的所有工作都放到它自己的分支上,当功能完成后再集成到主线。
在使用功能分支模式的过程中,开发人员在开始工作时打开一个分支,持续工作直到完成该功能,然后再集成到主线。
例如,让我们随着Scarlett看看。她开发把本地销售税的集合添加到他们的网站上这个功能。她从当前产品的稳定版本开始,她会把主线拉到她的本地仓库,然后从当前主线的顶端创建一个新的分支。只要有需要,她就会开发这个功能,在这个本地分支上进行了一系列的提交。
notion image
她可能会把这个分支推送到项目的repo中,以便其他人可以看到她的改动。
在她工作的时候,其它提交也持续落到主线上。因此,她可能会不时地从主线上拉取,这样她就能知道是否存在可能影响到她未来的改动。
notion image
注意这不是我上面所说的集成,因为她没有推回到主线。此时只有她能看到自己的工作,其他人看不到。
有些团队喜欢确保所有的代码,无论是否集成,都保存在中央仓库。在这种情况下,Scarlett会把她的功能分支推送到中央仓库。这也会让其他团队成员看到她在做什么,即使它还没有被集成到其他人的工作中。
当她完成了该功能的工作后,她将执行主线集成,将该功能纳入产品中。
notion image
如果Scarlett同时开发一个以上的功能,她会为每个功能单独开一个的分支。
何时使用它
功能分支模式是当今业界流行的一种模式。为了讨论何时使用它,我需要介绍它的主要替代方案——持续集成。但首先我需要谈一谈集成频率的问题。

集成频率

我们集成的频率对一个团队的运作有很大的影响。来自《State Of Dev Ops Report》的研究表明,精英开发团队的集成频率明显高于表现不佳的团队——这一观察与我的经验以及许多业内同行的经验相吻合。我将通过由Scarlett和Violet主导的两个集成频率的例子来阐述这一点。

低频集成

我先说说低频的情况。在这里,我们的两位英雄开启了工作的一章:把主线克隆到她们的分支,然后完成了一些她们还不想推送的本地提交。
notion image
在她们工作的时候,另一些人把一些提交放到了主线上。(我不能很快的想出另一个生动的人名——或许Grayham?)
notion image
这个团队的工作方式是保持一个健康的分支,并在每次提交后从主线拉取。由于主线没有变化,Scarlett前两次提交后拉取不到什么东西,但现在需要把M1拉下来。
notion image
我已经用黄框标出了合并节点。这个节点合并了S1...3和M1这几个提交。很快,Violet也需要做同样的事情。
notion image
此时,两个开发者都更新到最新的主线,但她们还没有集成,因为她们相互隔离。Scarlett没有意识到Violet在V1到V3中所做出的任何变更。
Scarlett又完成了几个本地提交,然后准备进行主线集成。这对她来说很容易,因为她早些时候拉了M1。
notion image
然而,Violet有一个更复杂的作业。当她进行主线集成时,她现在必须将S1...5与V1...6集成。
notion image
我根据涉及到的提交的数量,科学地计算了合并的规模。但是,即使你无视我脸颊上的舌状凸起,你也会赞同Violet的合并是最有可能产生困难的。

高频集成

在前面的例子中,我们两个多彩的开发者在少量的本地提交后进行了集成。让我们看看如果她们在每次本地提交后都进行主线集成会发生什么。
Violet的第一次提交的变化很清晰,因为她立刻就集成了。由于主线还没有修改过,所以这只是一个简单推送。
notion image
Scarlett的首次提交也要进行主线集成,但由于Violet做过了,所以她需要进行一次合并。但由于她只需要合并V1和S1,所以合并的工作量很小。
notion image
Scarlett的下一个集成是一个简单推送,这意味着Violet的下一个提交也需要与Scarlett的最新的两个提交合并。然而,这仍然是一个相当小的合并:来自Violet的一个,来自Scarlett的两个。
notion image
当推送到主线的外部提交出现的时候,它会按照Scarlett和Violet的一贯节奏被各自所集成。
notion image
尽管这与之前所发生的类似,但集成变得更小了。Scarlett这次只需要将S3与M1集成,因为S1和S2已经在主线上了。这意味着Grayham在推送M1之前,必须集成已经在主线上的任何东西(S1...2, V1...2)。
开发人员继续进行她们剩余的工作,并在每次提交时进行集成。
notion image

比较集成频率

让我们再看一下这两张总体的图片。
低频
notion image
高频
notion image
这里有两个非常明显的区别。首先,高频集成,顾名思义,会进行更多的集成——仅在这个玩具例子中就有两倍之多。但更重要的是这些集成比低频情况下的集成要小得多。更小的集成意味着更少的工作,因为更少的变更引起的冲突就会更少。但比起更少的工作,更重要的是风险也更小。大合并的问题不在于它们所涉及的工作,而在于这些工作的不确定性。大多数时候,即使是大合并也会很顺利的,但偶尔也会出现非常非常糟糕的情况。最终这种偶尔的痛苦会比常规的痛苦更糟糕。如果我把每次集成多花10分钟和有50分之1的几率花6个小时修复一个集成相比,我会更喜欢哪一个呢?如果只看工作量,那么50分之1几率的情况更好,因为它花费六小时而不是八小时二十分钟。但这种不确定性使50分之1的情况感觉更糟,这种不确定性会导致对集成的恐惧。
💭
集成恐惧 当团队有过几次糟糕的合并体验时,他们往往会对集成保持警惕。这很容易变成一个正反馈循环——就像许多正反馈循环一样,有着非常负面的后果。 最明显的后果是团队降低了集成的频率,这将导致了更多不友好的合并发生,从而导致更低频率的集成...等等。 一个更微妙的问题是,团队会停止做那些他们认为会使集成变得更困难的事情。特别地,这将使他们抵制重构。但减少重构会导致代码库越来越不健康,难以理解和修改,从而拖累团队的功能交付。由于完成功能需要更长的时间,这将进一步降低集成频率,促进使人衰弱的正反馈循环。 这个问题的反直觉的答案正如这个口号所说——”如果痛苦......就更频繁地去做“
让我们从另一个角度看看这些频率之间的差异。如果Scarlett和Violet的最开始的提交之间就产生了冲突,会发生什么?她们什么时候会发现冲突已经发生了?在低频情况下,直到Violet的最终合并她们才会发现,因为那是S1和V1第一次被放在一起的时候。但在高频的情况下,她们在Scarlett第一次合并的时候就会发现了。
低频
notion image
高频
notion image
频繁集成增加了合并的频率,但降低了其复杂性和风险。频繁集成还能更快地提醒团队注意到冲突。当然,这两件事是相关的。惹人嫌的合并通常是由潜伏在团队工作中的冲突引起的,只有在集成发生时才会浮现出来。
也许Violet正在看一个计费计算,看到它包括评估税(作者假设了一种特定的税收机制)。她的功能需要对税收进行不同的处理,所以直接的途径是把税收从计费计算中拿出来,并在之后做成一个单独的函数。计费计算只在几个地方被调用,所以很容易使用Move Statements to Callers来处理——而且这个结果对程序的未来发展更有意义。然而,Scarlett并不知道Violet正在这样做,她在写她的功能时假设计费功能已经处理了税收问题。
自测代码在这里是我们的救命稻草。如果我们有一个强大的测试套件,把它作为健康分支的一部分来使用,就能发现冲突,这样就能大大降低bug进入生产环境的几率。但是,即使有一个强大的测试套件作为主线的守门员,大型集成也会使生活变得更艰难。我们要集成的代码越多,就越难发现bug。也会有更高的几率出现多个相互干扰的bug,而这些bug是特别难以理解的。如果提交量较小,我们不仅需要看的东西更少,而且还可以使用Diff Debugging来帮助缩小引起问题的改动的范围。
很多人没有意识到源码控制系统是一种交流工具。它允许Scarlett看到团队中的其他人在做什么。通过频繁集成,她不仅能在出现冲突时立即得到提醒,还能更清楚地知道每个人在做什么,以及代码库是如何发展的。我们不是像那些和困难独自斗争的个体,而是更像一个共同工作的团队。
减小功能大小的一个重要原因是可以增加集成频率,但也有其他好处。功能越小,构建的速度就越快,进入生产的速度就越快,开始提供价值的速度也越快。此外,较小的功能减少了反馈时间,使团队能够在更多地了解客户的同时做出更好的功能决策。

持续集成 ✣

开发人员一旦有了可以分享的健康提交,就立即进行主线集成,通常少于一天的工作量。
一旦某个团队体验到高频集成既会更有效率,压力也会更少,那自然要问的就是“我们能多频繁?”功能分支模式暗示了一个变化集大小的下限——你不能比一个内聚的功能更小。
持续集成使用了一套不同的集成触发方式——只要你在某个功能上取得了一定的进展,并且你的分支仍然是健康的,就进行集成。我们并不期望该功能是完整的,只是期望存在有价值的变更可以提交到代码库之中。经验法则是“每个人每天都要向主线提交”,或者更准确地说:在你的本地仓库中不应该有超过一天的工作没有被集成。在实践中,大多数持续集成的实践者每天都会集成很多次,并且很乐意集成一个小时或以内的工作。
💭
关于如何有效进行持续集成的更多细节,请看我的详细文章。更多的细节,请参考Paul Duvall, Steve Matyas, and Andrew Glover的这本书。Paul Hammant在trunkbaseddevelopment.com上维护了一个充满了持续集成技术的网站。
使用持续集成的开发者需要习惯频繁地达成集成点时,伴随着部分建成的功能。他们需要考虑如何在不在运行系统中暴露部分建成的功能的状况下做到这一点。通常这很容易:如果我正在实现一个依赖于优惠券代码的折扣算法,而这个代码还不在有效列表中,那么我的代码即使在生产环境下也不会被调用。同样,如果我正在新增一个询问保险索赔人是否吸烟的功能,我可以在背后的代码中建立和测试逻辑,并通过把询问问题的用户界面留到实现该功能的最后一天,来确保该功能不会在生产中被使用。通过在最后连接Keystone Interface来隐藏部分建成的功能通常是一种有效的技术。
如果没有办法轻松地隐藏部分功能,我们可以使用feature flags。除了隐藏部分建成的功能外,这种flag还允许有选择地将该功能透露给一个用户的子集——这对于渐进地推出一个新功能来说是很方便的。
集成部分建成的功能的方式也很关注那些担忧会在主线中引入bug的人。因此,使用持续集成的人也需要自测代码,这样就有信心做到即使主线上出现部分建成的功能也不会增加出bug的几率。通过这种方法,开发人员在编写部分建成的功能代码的同时也为其编写相应的测试,并将功能代码和测试一起提交到主线上(也许会使用测试驱动开发)。
就本地仓库而言,大多数使用持续集成的人都不屑使用单独的本地分支来工作。通常是直接提交到本地master,完成后进行主线集成。然而,如果开发者喜欢的话,开一个功能分支并在那里进行工作也是极好的,只要以一个频繁的间隔集成回本地master和主干就行了。 功能分支模式和持续集成的区别不在于是否有功能分支,而在于开发者何时与主线集成。
何时使用它
持续集成是功能分支模式的一个替代方案。两者之间的权衡足以让它们在本文中拥有自己的章节,现在是解决这个问题的时候了。
💭
持续集成和基于主干的开发(Trunk-Based Development) 当Thoughtworks在2000年开始使用持续集成时,我们写了CruiseControl,一个在每次提交到主线后自动构建软件产品的守护程序。从那时起,许多这样的工具(如Jenkins、Team City、Travis CI、Circle CI、Bamboo和其他许多工具)被开发出来。但大多数使用这些工具的组织都是在提交时自动构建功能分支——这虽然很有用,但意味着他们没有真正的实践持续集成。(对它们来说,一个更好的名字可能是持续构建工具)。 由于这种语义扩散,一些人开始使用”基于主干的开发“这个术语来替代”持续集成“。(有些人确实对这两个术语进行了微妙的区分,但并没有一个一致的用法。)虽然我在语言方面通常是一个描述主义者,但我更喜欢使用”持续集成“。部分原因是我不认为不断地想出新的术语是对抗语义扩散的可行方法。然而,也许最主要的原因是,我认为改变术语会粗鲁地抹杀早期极限编程先驱者的贡献,特别是Kent Beck,他在20世纪90年代创造并明确定义了持续集成的实践。

比较功能分支模式和持续集成

功能分支模式似乎是目前业界最常见的分支策略,但有一群有发言权的从业者认为,持续集成通常是一种更好的方法。持续集成的关键优势在于它支持更高的,通常是高得多的集成频率。
集成频率的不同取决于一个团队能够使其功能有多小。如果一个团队的功能都能在一天之内完成,那么功能分支模式和持续集成都是他们可以执行的方式。但大多数团队的功能长度都比这长——功能长度越大,两种模式的差异就越大。
正如我已经指出的,集成频率越高,复杂的集成就会越少,并且对于集成的恐惧也会越少。这往往是一个难以传达的事情。如果你活在一个每几周或几月才进行一次集成的世界里,那么集成很可能是一个非常令人不安的活动。可能很难相信这是一件每天可以做很多次的事情。但是,集成是一种使用频率降低难度的事情。这是一个反直觉的概念——“如果痛苦——就更频繁地去做”。但是,集成越小,它们就越不可能变成痛苦和绝望的史诗级合并。功能分支模式主张更小的功能:几天的而不是几周的(几个月的已经不存在了)。
持续集成让团队获得高频集成的好处,同时将功能长度与集成频率解耦。如果一个团队偏好一周或两周的功能长度,持续集成允许他们这样做的同时,仍然可以获得来自最高的集成频率所带来的所有好处。合并更小,需要处理的工作就更少。更重要的是,正如我在上面解释的那样,更频繁地进行合并,可以减少令人不悦的合并的风险,这既减少了由此带来的不好的意外,也减少了对合并的整体恐惧。如果代码中出现了冲突,高频集成会在它们导致那些讨厌的集成问题之前迅速地发现它们。这些好处足够强大,以至于有的团队的功能只需占据几天的时间,却仍然进行持续集成。
持续集成的明显缺点是,它缺乏到主线的高潮集成的闭合。这不仅仅丢失了庆祝仪式,而且对于一个不善于保持健康分支的团队也会是个风险。把所有功能的提交放在一起也可能推迟是否把一个功能加入即将到来的发布的决策。尽管feature flags允许从用户的视角打开或关闭功能,但该功能的代码仍在产品中。对这一点的担心往往被夸大了,毕竟代码没有重量,但这确实意味着想要进行持续集成的团队必须制定一套强大的测试准则,这样他们就可以确信,即使每天有很多集成,主线仍然能够保持健康。有些团队认为这种技能难以想象,但另一些团队认为它既是可能的又是解放的。这个先决条件确实意味着功能分支模式更适合那些不强求健康分支,并且需要用于发布前稳定代码的发布分支的团队。
尽管合并的规模和不确定性是功能分支模式最明显的问题,但它最大的问题可能是它会阻止重构。当重构定期进行且摩擦较少时,重构是最有效的。重构会引入冲突,如果这些冲突不能被迅速地发现并解决,合并就会变得困难重重。因此,重构在高频集成中效果最好,所以它作为极限编程的一部分变得流行也就不足为奇了(极限编程也把持续集成作为原始实践之一)。功能分支模式也阻碍了开发者对非正在建成的功能的部分进行修改,这暗中削弱了重构稳定改进代码库的能力。
当我遇到关于软件开发实践的科学研究时,由于他们的方法存在严重问题,我通常都不信服。一个例外是State Of Dev Ops Report,该报告制定了软件交付绩效的指标,他们将其与更广泛的组织绩效衡量标准相关联,而这个标准又反过来与投资回报和盈利能力等业务指标相关联。2016年,他们首次评估了持续集成,发现它有助于提高软件开发绩效,这一发现在此后的每次调查中都得到了重复。
💭
我们发现,在被合并到主干上之前,分支或分叉(fork)的生命周期非常短(少于一天),并且总共少于三个活跃的分支,这些都是持续交付的重要方面,并且都有助于提高绩效。每天将代码合并到thunk或master也是如此。 ——State of DevOps Report 2016
使用持续集成并不会消除保持功能小规模的其他优势。频繁地发布小功能提供了一个快速的反馈周期,这对改善产品有很大帮助。许多使用持续集成的团队也努力构建薄薄的产品,并尽可能频繁地发布新功能。
🆚
功能分支模式 ✅ 一个功能中的所有代码可以作为一个单元进行质量评估 ✅ 功能代码只有在功能完成后才会添加到产品上 ❌ 更低频率的合并 持续集成 ✅ 支持比功能长度更高的频率集成 ✅ 找到冲突的时间更少 ✅ 更小的合并 ✅ 鼓励重构 ❌ 需要保持健康分支的承诺(因此需要自测代码) ✅ 科学证据表明它有助于提高软件交付绩效

功能分支模式和开源

很多人把功能分支模式的流行归功于GitHub和源于开源开发的pull-request模型。鉴于此,我们有必要了解开源工作和许多商业软件开发之间的巨大的情况差异。开源项目有许多不同的结构,但一个常见的结构是由一个人或一个小团体作为维护者,做大部分的编程。维护者与更大一批的作为贡献者的程序员一起工作。维护者通常不认识贡献者,所以对他们贡献的代码的质量没有预期。维护者也不能确定贡献者究竟会在工作中投入多少时间,更不用说他们在完成工作方面的效率了。
在这种情况下,功能分支模式有很大的意义。如果有人要添加一个或大或小的功能,而我不知道它何时(或是否)会完成,那么在集成之前等待它完成是有意义的。此外,更重要的是能够审查代码,以确保它通过我对代码库设置的任何质量门槛。
但许多商业软件团队有一个非常不同的工作环境。它们会有一个全职团队,这些团队的人会专注于软件。项目的领导者很了解这些人(除了他们刚开始的时候),并且可以对代码质量和交付能力有一个可靠的期望。由于他们是带薪雇员,领导们对投入项目的时间以及编码标准和小组习惯等方面也有更大的控制。
💭
Pull Requests 我认为pull requests是一种旨在支持功能分支模式集成前审查的组合机制。要决定是否以及如何使用pull requests,我首先会思考那些在团队工作流中的底层模式的作用。
鉴于这种非常不同的环境,应该清楚的是,商业团队的分支策略不需要与运作于开源世界中的策略相同。持续集成对于开源工作的临时贡献者来说几乎是不可能适合的,但对于商业工作来说却是一个现实的选择。团队不应该假定在开源环境中有效的方法对他们不同的环境自动正确。

集成前审查 ✣

每一个对主线的提交在被接受之前都要经过同行审查(peer review)。
长期以来,人们一直鼓励将代码审查视作提高代码质量、改善模块化、可读性和消除缺陷的一种方式。尽管如此,商业组织常常发现它难以适应软件开发的工作流。然而,开源世界却广泛采纳了这样的想法:对项目的贡献应该在接受它们进入项目的主线之前被审查。这种方法近年来在开发组织中广泛传播,特别是在硅谷。像这样的工作流与GitHub的pull-requests机制特别合适。
当Scarlett完成了她希望集成的一大块工作,这样的工作流程就开始了。一旦她成功构建,她就进行主线集成(假设她的团队是这样实践的),但在她推送到主线之前,她会把她的提交发出去进行审查。团队中的其他成员,比如Violet,会对该提交进行代码审查。如果她对该提交有问题,她会发一些评论,然后来来回回,直到Scarlett和Violet都满意。只有当她们完成后,提交才会被放到主线上。
集成前审查在开源中逐渐流行起来,它与专门维护者和临时贡献者的组织模式非常合适。它使维护者能够密切关注任何贡献。它还能与功能分支模式很好地结合,因为一个已完成的功能标志着一个明确的节点来做这样的代码审查。如果你不确定一个贡献者即将完成一个功能,为什么要去审查他们的部分工作?最好是等到功能完成后再审查。这种做法也在较大的互联网公司广泛传播,Google和Facebook都建立了专门的工具来帮助这样的工作顺利进行。
培养及时进行集成前审查的纪律是很重要的。如果一个开发人员完成了一些工作,然后去干几天其它的事情,那么当审查意见回馈时,这项工作就不再是他心目中的首要任务了。这对于一个已经完成的功能来说是令人沮丧的,对于一个部分完成的功能来说就更糟糕了,因为直到审查被确定之前,可能很难再取得进一步的进展。原则上说,在进行持续集成的同时进行集成前审查是可能的,并且在实践中也是可行的——Google就采用了这种方法。但是,尽管这是有可能的,但这是很难的,也是相对少见的。集成前审查和功能分支模式是更常见的组合。
何时使用它
尽管集成前审查在过去十年中已经成为一种流行的实践,但也存在着缺点和替代方法。即使做得很好,集成前审查也会在集成过程中引入一些延迟,并鼓励了更低的集成频率。结对编程提供了一个连续的代码审查过程,有着比等待代码审查更快的反馈周期。(像持续集成和重构一样,它是极限编程的原始实践之一。)
💭
Camille Fournier:质疑强制性代码审查的价值绝对是我所知道的高级工程师所持有的最流行的地下信念。 Ian Nowland:我认为强制审查,以及像要求注释*每个*函数的目的这样的做法,是企业工程师对开源的羡慕和/或货物崇拜的表现。 Camille Fournier:将开源软件和私人软件开发团队的需求混为一谈,就像当前软件开发仪式的原罪一样。
许多使用集成前审查的团队做得不够快。他们所能提供的有价值的反馈来得太晚了,以至于没有用处。在这种情况下,出现了尴尬的选择:是大量的返工,还是接受一些可能有效,但却会暗地里破坏代码库质量的东西。
代码审查并不局限于代码进入主线之前。许多技术领导发现,在一个提交后就审查代码是非常有用的,当他们发现值得注意的东西时,可以跟进开发人员。此时重构文化很有价值。如果做得好的话,这就建立了一个社区,团队中的每个人都会定期审查代码库,并修复他们看到的问题。
围绕集成前审查的权衡主要取决于团队的社会结构。正如我已经提到的,开源项目通常有一个由几个可信的维护者和许多不可信的贡献者组成的结构。商业团队通常都是全职的,但也可能有类似的结构。项目领导(就像一个维护者)信任一小部分(可能是单一的)维护者,并对团队其他成员贡献的代码保持警惕。团队成员可能同时被分配到多个项目中,这使得他们更像开源贡献者。如果存在这样的社会结构,那么集成前审查和功能分支模式就有很大的意义。但是,一个具有更高可信度的团队通常都会找到不在集成过程中增加摩擦,同时保持代码高质量的其他机制。
因此,虽然集成前审查可以是一种有价值的实践,但它绝不是通向健康代码库的必要途径,特别是如果你想发展一个均衡的,不过度依赖最初始领导者的团队,

集成摩擦

集成前审查的问题之一,是它常常使得集成更加麻烦。这是集成摩擦的一个例子(集成摩擦是使集成需要时间或努力去做的活动)。集成摩擦越多,开发者就越倾向于更低的集成频率。想象一下,一些(功能紊乱的)组织,它们坚持要求所有到主线的提交都要填写一个需要花费半小时的表单。这样的制度将阻止人们频繁地进行集成。无论你对功能分支模式和持续集成的态度如何,检查任何增加这种摩擦的东西都是有价值的。除非它明显地增加了价值,否则任何这样的摩擦都应该被移除。
💭
Pull requests增加了开销来应对低可信情况,例如,允许你不认识的人向你的项目提供贡献。将pull requests强加给你自己团队中的开发者,就像让你的家人通过机场安检站后再进入你的家一样。——Kief Morris
人工操作是一个常见的摩擦来源,尤其是当它涉及到与不同组织协调。这种摩擦通常可以通过使用自动化流程,对开发人员进行培训(以消除对这种操作的需要),以及将步骤推后到部署流水线生产环境中的QA的后期步骤来减少。你可以在持续集成和持续交付的材料中找到更多消除这种摩擦的想法。这种摩擦也出现在通往生产环境的道路上,有着同样的困难和处理方法。
让人们不愿意考虑持续集成的一个原因是,他们可能只在拥有高度集成摩擦的环境中工作过。如果每次集成需要一个小时,那么一天做几次显然是很荒谬的。加入一个集成是一件令人扫兴的事的团队,或是可以在几分钟内迅速完成的团队,会感觉是完全不同的世界。我怀疑大部分关于功能分支模式和持续集成的优点的争论都是模糊的,因为人们没有同时经历过这两个世界,因此不能完全理解这两种观点。
文化因素会影响集成摩擦——特别是团队成员之间的信任。如果我是一个团队的领导,而我不相信我的同事会做得很好,那么我很可能会想阻止那些破坏代码库的提交。自然地,这也是集成前审查的驱动力之一。但是,如果我在一个我信任同事的判断力的团队中,我可能更适应提交后审查,或者完全砍掉审查,依靠共同的重构来清理任何问题。在这种环境下,我的收获是消除了提交前审查带来的摩擦,从而鼓励了更高频率的集成。通常,团队的信任是功能分支模式与持续集成争论中最重要的因素。

模块化的重要性

大多数关心软件架构的人都会强调模块化对一个良好系统的重要性。如果我面临着对一个模块化程度很低的系统做一个小小的改动,我就必须了解它的几乎所有内容,因为即使是一个小改动也可能波及代码库的许多部分。然而,有了良好的模块化,我只需要了解一两个模块的代码,以及另几个模块的接口,而可以忽略剩下的。这种降低我理解难度的能力,就是为什么随着系统的发展,值得在模块化上投入如此多的努力的原因。
模块化也会影响集成。如果一个系统有着良好的模块,那么大多数时候,Scarlett和Violet会在代码库中分离的部分工作,她们的变更不会引起冲突。良好的模块化也增强了像Keystone InterfaceBranch By Abstraction这样的技术,以避免对分支提供的隔离的需求。通常情况下,团队被迫使用源码分支行为,因为缺乏模块化使得他们没有其他选择。
💭
功能分支模式是穷人的模块化架构,不是构建在运行时/部署时能够轻松开启和关闭功能的系统,而是它们将自己与源码控制耦合,通过手动合并来提供这种机制。 ——Dan Bodart
这种支持是双向的。尽管做了很多尝试,但在我们开始编程之前建立一个良好的模块化架构仍然非常困难。为了实现模块化,我们需要在系统成长的过程中不断观察,并将其导向于更加模块化的方向。重构是实现这一目标的关键,而重构需要高频集成。因此,模块化和快速集成在一个健康的代码库中是相互支持的。
这一切都是在说,模块化虽然很难实现,但为其付出的努力都是值得的。这种努力包括好的开发实践,学习设计模式,以及从代码库的经验中学习。混乱的合并不应该因为一种可以理解的想要忘记它们的欲望就被草草的放在一旁,而是要问为什么合并是混乱的。这些答案往往是如何改进模块化的重要线索,这样提升代码库的健康度,从而提高团队的生产力

关于集成模式的个人看法

我当作家的目的不是说服你遵循一条特定的道路,而是告诉你在决定走哪条路时应该考虑的因素。尽管如此,我还是会在这说一下在之前提到的模式中我更偏好哪一种。
总的来说,我更喜欢在一个使用持续集成的团队工作。我认识到,环境是关键,在很多情况下,持续集成并不是最好的选择——但我的反应是要做工作来改变这种环境。我之所以有这样的偏好,是因为我希望在这样一个环境中,每个人都能轻松地不断重构代码库,提高它的模块化程度,保持它的健康——所有这些都是为了让我们能够快速地响应不断变化的业务需求。
这些日子我更多的时候是一个作家,而不是开发者,但我仍然选择在Thoughtworks工作,这个公司里有很多赞成这种工作方式的人。这是因为我相信这种极限编程的风格是我们开发软件最有效的方式之一,我想关注团队进一步发展这种方式,以提高我们这个行业的效率。

主线到生产发布的路径

主线是一个活跃的分支,伴随着定期投放的新的和修改过的代码。保持它的健康是很重要的,这样当人们开始新的工作时,他们就会从一个稳定的基础开始。如果它足够健康,你也可以直接从主线发布代码到生产环境。
notion image
这种保持主线始终处于可发布状态的哲学是持续交付的核心信条。要做到这一点,必须有决心和技能来维持主线作为一个健康的分支,通常有部署流水线(Deployment Pipelines)来支持所需的密集测试。
以这种方式工作的团队通常可以通过在每个发布的版本(version)上使用标签来跟踪他们的发布(releases)。但不使用持续交付的团队需要另一种方法。

发布分支 ✣

一个只接受这些提交的分支,即这些提交之所以被接受,是为了创建一个准备好发布的产品的稳定版本。
一个典型的发布分支会复制自当前的主线,但不允许在其上添加任何新功能。主开发团队会继续向主线添加功能,这些功能会被放到到未来的发布中。在该发布上工作的开发人员只专注于消除妨碍该发布生产就绪(production-ready)的任何缺陷。任何对这些缺陷的修复都会在发布分支上创建,并合并到主线上。一旦没有更多的缺陷需要处理,该分支就可以进行生产发布(production release)了。
notion image
虽然发布分支上修复的工作范围(但愿)比新功能代码要小,但随着时间的推移,要把它们合并到主线上会越来越困难。分支不可避免地会发生差异,所以随着越来越多的提交修改主线,将发布分支合并到主线的难度也越来越大。
以这种方式将提交应用到发布分支的一个问题是,太容易忘记将它们复制到主线上去了,尤其是由于差异的发生将变得更困难。由此产生的回归(regression)是非常尴尬的。因此,有些人支持在主线上创建提交,一旦它们在主线上行得通了,再把它们cherry-pick发布分支上。
notion image
💭
cherry-pick是指把一个提交从一个分支复制到另一个分支,但两个分支并不合并。也就是说,只有一个提交被复制过来,而不是自该分支点以来的所有提交。在这个例子中,如果我把 F1 合并到发布分支,那么这将包括 M4 和 M5。但cherry-pick只会拿取 F1。cherry-pick可能并不能完全地适用于发布分支,因为它可能依赖于M4和M5的修改。
在主线上编写发布修复让许多团队觉得更困难,而且令人沮丧的是,在主线上的单向修复,发布前不得不在发布分支上做同样的工作。在对发布有进度压力的情况下,这一点尤其明显。
在生产环境每次只有一个版本的团队只需要一个单一的发布分支,但有些产品在生产使用中会有很多版本。在客户的套件上运行的软件,只有在客户希望升级时才会升级。许多客户不愿意升级,除非有吸引他们的新功能,因为他们曾被升级失败所伤。然而,这样的客户仍然希望得到错误修复,尤其是涉及安全问题时。在这种情况下,开发团队为每个仍在使用的版本保持打开的发布分支,并根据需要对其进行修复。
notion image
随着开发的进行,对旧版本进行修复变得越来越困难,但这往往是做生意的成本。这只能通过鼓励客户经常升级到最新版本来缓解。保持产品的稳定性对于这一点至关重要,曾经受过伤的客户将很难有意愿再进行不必要的升级。
(我听说的发布分支的其他术语包括。“发布准备分支(release-preparation branch)”、”稳定分支(stabilization branch)“、”候选分支(candidate branch)“、”硬化分支(hardening branch)“。但”发布分支“似乎是最常见的。)
何时使用它
当一个团队无法将其主线保持在健康状态时,发布分支是一个有价值的工具。它允许团队中的一部分人专注于必要的bug修复,以便为生产做好准备。测试人员可以从这个分支的顶端拉出最稳定的最新候选版本(recent candidate)。每个人都可以看到为了稳定产品做了些什么。
尽管发布分支很有价值,但大多数优秀的团队并不对单一生产(single-production)的产品使用这种模式,因为它们不需要这样做。如果主线保持足够的健康,那么对主线的任何提交都可以直接发布。在这种情况下,发布产物应该被标记上公开可见的版本和构建编号。
你可能已经注意到我在上一段中加入了”单一生产“这个笨拙的形容词。这是因为当团队需要管理生产中的多个版本时,这种模式就变得至关重要。
当发布过程中存在显著的摩擦时,发布分支也可以派上用场——比如有一个必须批准所有生产版本的发布委员会。正如Chris Oldwood所说:”当企业的齿轮缓慢转动时,在这些情况下,发布分支的作用更像是一个隔离区“。一般来说,这种摩擦应该尽可能地从发布过程中消除,就像我们需要消除集成摩擦一样。然而,在某些情况下,例如移动应用商店,可能是不行的。在许多情况下一个标签就足够了,只有在对源码有一些必要的修改时才需要打开分支。
发布分支也可以是环境分支,需要注意的问题也从属于那种模式。还有一种长期发布分支的变体,我很快就会讲到它。

成熟分支 ✣

一个其头部标志着代码库成熟度的最新版本的分支。
团队经常想知道源码的最新版本是什么,这个事实在一个成熟度不同的代码库中可能会很复杂。一个QA工程师可能想看看产品的最新预发布(staging)版本,一个正在调试生产故障的人想看看最新的生产版本。
成熟分支提供了一种进行这种跟踪的方式。这样的想法是,一旦代码库的某个版本达到一定的准备程度,它就会被复制到特定的分支中。
考虑一个用于生产的成熟分支。当我们准备好一个生产版本时,我们会打开一个发布分支来稳定产品。一旦它准备好了,我们就把它复制到一个长期运行的生产分支。我认为这是复制而不是合并,因为我们希望生产代码与在上游分支上测试过的代码完全相同。
notion image
成熟分支的魅力之一在于,它能清楚地显示在发布工作流中每个达到该阶段的代码的版本。所以在上面的例子中,我们只希望在生产分支上有一个将 M1-3和F1-2结合的提交。要做到这一点需要几个供应链管理的骗人伎俩,但无论如何,这都丢掉了与主线上细粒度提交的联系。这些提交应该被记录在提交信息中,以帮助人们日后追踪它们。
成熟分支通常以开发流中的适当阶段命名。因此有”生产分支“、”预发布分支(staging branch)“、”QA分支“等说法。偶尔我也听到有人把生产成熟分支称为”发布分支(release branch)“。
何时使用它
源码控制系统支持协作和追踪代码库的历史。使用成熟分支可以让人们,通过出现在发布工作流中的特定阶段的历史版本,来获取一些少量却重要的信息。
我可以通过查看相关分支的头部,找到最新的版本,比如目前正在运行的生产代码。如果出现了一个我确定之前不存在的bug,我可以查看该分支上以前的版本是怎样的,然后再看生产中具体代码库的变更。
自动化可以与特定分支的变更挂钩——例如,只要生产分支有提交,自动化流程就可以将一个版本部署到生产环境中。
成熟分支的一个替代选择是应用标记方案(tagging scheme)。一旦一个版本准备好进行QA,它就可以被标记为QA——通常是以包含构建编号的方式。因此,当版本762准备好用于QA时,可以标记为”qa-762“,当准备好用于生产时,可以标记为”prod-762“。然后我们可以通过搜索代码库中与我们的标签方案相匹配的标签来获得历史记录。自动化挂钩同样可以基于标签的分配。
因此,一个成熟分支可以为工作流增加一些便利,而许多组织发现标签工作得非常好。我把这看成是那些没有强烈好处或成本的模式之一。然而,通常情况下,需要使用源码管理系统来进行这样的跟踪,是一个团队的部署流水线的工具贫弱的表现。

变体:长期发布分支

我可以把这看作是发布分支模式的一个变体,它与发布候选(release candidate)的成熟分支相结合。当我们想要发布时,我们将主线复制到这个发布分支。与每个发布分支一样,只有为了提升稳定性才会在发布分支上进行提交。这些修正也会被合并到主线上。我们在发布时给它打上标签,当我们想做另一次发布时,可以再把主线复制进去。
notion image
提交可以被复制或合并进来,就像在更典型的成熟分支里的那样。如果是合并进来的,我们必须注意让发布分支的头部与主线的头部完全匹配。要做到这一点的其中一个方法是,在合并之前,revert所有应用到主线过的修复。有些团队也会在合并后squash提交,以确保每个提交都代表一个完整的候选版本。(觉得这样很麻烦的人有充分的理由选择为每个发布切一个新分支来替代此做法)。
这种方法只适合于每次只有一个单一发布的产品。
💭
团队喜欢这种方法的一个原因是,它能确保发布分支的头部总是指向下一个候选发布,而不是不得不去挖出最新发布分支的头部。然而,至少在git中,我们可以通过设置一个”发布“分支的名字来达到同样的效果,当团队切出一个新的发布分支时,这个分支的名字会随着一个hard reset命令移动,并在旧的发布分支上留下一个标签。

环境分支 ✣

通过源码提交,配置一个产品让其在新环境中运行。
软件通常需要在不同的环境中运行,如开发人员的工作站,生产服务器,也许还有各种测试和预发布环境。通常在这些不同的环境中运行需要进行一些配置上的修改,比如用于访问数据库的URL,消息传递系统的位置以及关键资源的URL。
环境分支是指包含,应用于源码来重新配置产品在不同环境中运行的提交,的分支。我们可能有版本2.4运行在主线上,现在希望在我们的预发布服务器上运行它。为此,我们从版本2.4切出一个新的分支,做出适当的环境修改,重新构建产品,并将其部署到预发布环境。
1. 用于预发布的分支
2. 用于预发布环境的配置变更
1. 用于预发布的分支 2. 用于预发布环境的配置变更
这些修改通常是手工进行的,尽管如果负责的人对git熟悉,他们可以从较早的分支中cherry pick修改。
环境分支模式经常与成熟分支相结合。一个长期的QA成熟分支可能包括对QA环境的配置调整。合并到环境分支后,就要再拿掉这些配置变动。同样地,一个长期发布分支也可能包括这些配置变化。
何时使用它
环境分支是一种有吸引力的方法。它允许我们以任何方式调整应用程序,使其为新环境做好准备。我们可以将这些修改保存在一个diff中,以便它可以被cherry-pick到产品的未来版本中。然而,它是一个反模式的经典例子——有些东西在你开始时看起来很有吸引力,但很快就会导向一个充满痛苦、恶龙和冠状病毒的世界。
如果应用程序的行为随着我们把它从一个环境转移到另一个时发生了改变,那么危险将赫然耸现。如果我们不能把在生产中运行的版本在开发人员的工作站上进行调试,那么修复问题就会变得更加困难。我们可能会引入只在特定环境下出现的bug,最危险的是生产环境。由于这种危险性,我们希望尽可能地确保在生产环境中运行的代码与在其他地方运行的代码相同。
环境分支的问题就在于使它们如此吸引人的超强灵活性。由于我们可以在那些diff中修改代码的任何方面,我们就可以很轻易地引入配置补丁,从而导致不同的行为和随之而来的bug。
因此,许多组织明智地坚持一个铁则:一旦一个可执行程序被编译出来,那么在所有环境都应该运行这个同样的程序。如果需要不同的配置,那么它们必须通过某些机制来隔离,比如显示的配置文件或环境变量。这样,它们可以被最小化为简单的在执行过程中不会发生变化的常量设置,为bug的滋生留下更少的空间。
可执行文件和配置之间的简单分界线很容易在直接执行其源码的软件中变得非常模糊(例如,JavaScript、Python、Ruby),但同样的原则也适用。保持任何环境变更最小化,并且不要对其使用源码分支行为。一般的经验法则是,你能够检出的产品的任何版本,都应该能在任何环境下运行,所以任何纯粹由于不同的部署环境而改变的东西都不应该在源码控制中。有一个是否在源码控制中存储默认参数的组合的争论,但是每个版本的应用程序都应该能够根据需要,基于像环境变量这样的动态因子,在这些不同的配置之间切换。
环境分支是一个将源码分支行为作为穷人的模块化架构的例子。如果一个应用程序需要在不同的环境中运行,那么在不同的环境中切换的能力需要成为其设计的一等考量。对于缺乏这种设计的应用程序来说,环境分支作为一种偷工减料的机制是很有用的,但之后应该优先考虑用一种可持续的替代方法来移除它。

热修复分支 ✣

一个用于捕捉修复紧急生产缺陷的工作的分支。
如果在生产环境中出现一个严重的bug,那么它需要尽快被修复。这个bug的修复优先级将高于团队正在进行的其他任何工作,并且其他工作也不能拖累这个热修复的完成进度。
热修复工作需要在源码控制中完成,以便团队能够对其恰当地记录和协作。他们可以通过在最新发布的版本上打开一个分支,并把任何用于热修复的变更应用到该分支上来做到这一点。
notion image
一旦修复被应用于生产环境,每个人就有机会睡个好觉,然后可以把热修复应用到主线,以确保在下一个版本中不会出现回归。如果有一个为下个版本打开的发布分支,那么热修复也需要在这个分支上进行。如果两次发布的间隔过长, 那么这个热修复很可能会建立到已经变化的代码之上, 所以将更难以合入。在这种情况下可以暴露bug的好的测试将会非常有帮助。
如果一个团队使用发布分支,热修复工作可以在发布分支上完成,结束后一个新的版本也做好了。从本质上说,这就把旧的发布分支变成了一个热修复分支。
notion image
与发布分支一样,也可以在主线上做热修复,然后把它们cherry-pick发布分支上。但这并不常见,因为热修复通常是在巨大的时间压力下完成的。
如果一个团队实施持续交付,那么它可以把热修复直接发布到主线上。他们可能仍然会使用一个热修复分支,但他们会从最新的提交开始,而不是从最后一个发布提交开始。
notion image
我给新版本贴上了2.2.1的标签,因为如果一个团队以这种方式工作,那么M4和M5很可能不会暴露新功能。如果他们暴露了,那么这个热修复就可能就被折叠成了2.3版本。当然,这说明了在持续交付中,热修复不需要回避正常的发布流程。如果一个团队有反应足够灵敏的发布流程,那么热修复就可以像正常的那样来处理——这是持续交付思想的一个重要好处。
一个适合持续交付团队的特殊处理方式是,在热修复完成之前不允许向主线提交任何内容。这符合“没有人有比修复主线更重要的任务”这一口号——事实上,在主线上发现的任何缺陷都是如此,即使是那些尚未送入生产的缺陷。(所以我想这并不是真正特殊的处理。)
何时使用它
热修复通常是在压力相当大的时候进行的,而一个团队在压力最大时也最可能犯错。在这种情况下,使用源码控制比平时更有价值,比平时更频繁地提交也似乎更合理。让这个工作在一个分支上进行,可以让大家知道为了处理这个问题正在做些什么。唯一的例外是简单的修复可以直接应用到主线上。
这里更有趣的问题是判断什么是需要修复的热bug(hot bug),什么是可以留到正常开发工作流中去解决的东西。一个团队发布的频率越高,它就越能把生产错误修复留到常规的开发节奏中。在大多数情况下,此判断主要取决于这个bug的业务影响,以及它是如何与团队的发布频率配合的。

发布列车 ✣

以一个固定的时间间隔发布,就像列车以一个有规律的时间表出发。当开发人员完成他们的功能时,选择一辆列车来搭乘。
使用发布列车的团队会设定一个规律的发布节奏,比如每两周或每六个月一次。日期是根据团队每次为发布切出发布分支的时机来确立的,就像是列车的时刻表一样。人们决定他们的某项功能搭上哪趟列车,并以那趟列车来确立他们的工作,当列车正在装载时将他们的提交放到合适的分支上。一旦列车开走,该分支就会是发布分支,并且只接受修复。
一个使用月度列车的团队会在二月发布的基础上,为三月开启一个分支。他们会随着这个月时间的前进添加新的功能。在某个设定的日期,也许是这个月的第三个星期三,这趟列车就会出发——将该分支的功能冻结。他们为四月列车开启一个新分支,并在其上添加新的功能。同时,一些开发者会稳定三月列车,当它准备好的时候将其发布到生产中。任何应用于三月列车的修复都会被cherry-pick到四月列车上。
notion image
发布列车经常和功能分支模式一起使用。当Scarlett感觉出她的功能什么时候能完成时,她就会决定搭乘什么列车。如果她认为她可以在三月的发布中完成,她就会集成到三月列车上,但如果不能,她就会等待下一班并在那里集成。
有些团队在列车出发前几天使用软冻结(列车出发既为硬冻结)。一旦发布列车处于软冻结状态,那么开发人员就不应该把工作推到该列车上,除非他们确信他们的功能是稳定且为发布做好准备的。任何在软冻结后加入的有bug的功能都会被还原(推下列车),而不是在列车上修复。
近来,当人们听到“发布列车”时,他们经常听到来自SAFe的敏捷发布列车的概念。SAFe的敏捷发布列车是一种团队组织结构,指的是一个大规模的团队中的团队,它共享一个公共的发布列车时间表。虽然它使用了发布列车的模式,但与我在这所描述的并不相同。
何时使用它
发布列车模式的一个核心概念是发布流程的规律性。如果你提前知道发布列车什么时候会出发,你就可以计划完成哪些功能来搭乘那趟列车。如果你认为你不能在三月列车出发前完成你的功能,那么你就知道你会赶下一班。
当发布过程中存在明显的摩擦时,发布列车就特别有用。一个外部测试小组需要几周的时间来验证一个版本,或者一个发布委员会需要在产品的新版本出现之前达成一致。如果是这种情况,通常比较明智的做法是尝试消除发布摩擦,允许更频繁的发布。当然,在有些情况下,这几乎是不可能的,比如移动设备上的应用商店所使用的验证流程。调整发布列车以匹配这样的发布摩擦,可能会使情况变得最好。
发布列车机制有助于将大家的注意力集中到什么功能应该出现在什么时候上,从而有助于预测功能的完成时间。
这种方法的一个明显的缺点是,在列车的早期完成的功能会在等待出发的时候坐在上面看书。如果这些功能很重要,那就意味着这个产品会错过一个重要的功能达几周或几个月的时间。
发布列车在改善团队的发布流程的方面可能是一个有价值的阶段。如果一个团队很难进行稳定的发布,那么一下跳到持续交付可能步子迈得太大了。挑选一个合适的发布周期,一个困难但似乎合理的周期,可能是一个好的开始。随着团队习得技能,他们可以提升列车的频率,随着他们能力的增长,最终放弃列车而进行持续交付。

变体:装载未来列车

功能列车的基本例子是在前一列列车离开的同时,有一列新的列车到达站台来搭载功能。但另一种方法是让多列列车同时接收功能。如果Scarlett认为她的功能不会在三月列车期间完成,她就可以将她的大部分完成的功能推到四月列车,并在列车出发前进一步推送提交以完成它。
notion image
每隔一段时间,我们就从三月列车拉取到四月列车。有些团队喜欢在三月列车出发时才这样做,这样他们就只需要合并一次,但我们这些知道小规模合并可以指数级的更容易的人们则希望尽快拉取每个三月的提交。
装载未来列车可以让那些正在开发四月功能的开发者进行合作,而不影响三月列车上的工作。它的缺点是,四月的人们做出的修改与三月工作有冲突时,三月的人们得不到反馈,从而使未来的合并更加复杂。

与定期的发布落到主线的模式进行比较

发布列车的主要好处之一是发布到生产的定期节奏。但是为新的开发设置多个分支会增加复杂性。如果我们的目标是定期发布,我们也可以用主线来实现这个目标。决定好发布时间表是什么,然后基于这个时间表从主线上的尖端切出一个发布分支。
notion image
如果有一个发布就绪的主线,就不需要一个发布分支。对于像这样的定期发布,如果是在定期的发布日期之前,开发者仍然可以选择通过不推送到主线的方式,把为下次发布的近乎完成的功能进行搁置。对于持续集成,如果人们想要一个功能在下一个定期发布之前等待,那么他们可以总是通过延迟放置keystone或让一个feature flag关闭来达成这一点。

发布就绪的主线 ✣

保持主线足够健康,使得主线的头部(head)总是可以直接投入到生产环境。
当我开始这部分时,我说过,如果你把主线变成健康分支,并且让健康检查达到足够的高度,那么你就可以在任何你愿意的时候从主线上直接发布,并用标签来记录版本。
notion image
我已经花了很多时间来描述这种简单机制的替代模式,所以我认为是时候强调这个机制了,因为如果一个团队能够实践它的话,它会是一个很棒的选择。
每个应用于主线上的提交都是可以发布的,但并不意味着它应该被发布。这就是持续交付持续部署之间的微妙区别。使用持续部署的团队会在主线接收每个变更时进行发布,但在持续交付中,尽管每一个变更都是可发布的,但是否发布却会根据业务来决定。(因此,持续部署是持续交付的一个子集。)我们可以认为持续交付给了我们在任何时候发布的选择,我们执行这个选择的决定则取决于更广泛的议题。
何时使用它
除了作为持续交付一部分的持续集成,发布就绪的主线也是高效团队的一个共同特征。鉴于这一点,以及我对持续交付众所周知的热情,你可能会认为,我会说发布就绪的主线总是优于我在这一部分所描述的替代选择。
然而,模式都是依情形而定的。一个在某种情况下很好的模式在另一种情况下可会是一种陷阱。发布就绪的主线的有效性是由团队的集成频率决定的。如果团队使用功能分支模式,并且通常情况下每个月只对一个新功能集成一次,那么这个团队很可能处于一个糟糕的境地,坚持使用发布就绪的主线可能反而会对他们的提升产生障碍。糟糕的地方是他们无法回应不断变化的产品需求,因为从想法到生产的周期太时间太长了。他们还可能有复杂的合并和验证,因为每个功能都很大,会导致许多冲突。这些东西可能会在集成时显现,或者是一种开发人员拉取主线到他们的功能分支时的持续消耗。这种拖累阻碍了重构,而阻碍重构又降低了模块化,从而加剧了问题的严重性。
走出这个陷阱的关键是提高集成频率,但在很多情况下,在维护发布就绪的主线的同时做到这一点会很困难。在这种情况下,放弃发布就绪的主线,鼓励更频繁的集成,并使用发布分支来稳定用于生产的主线,往往是更好的做法。当然,随着时间的推移,我们希望通过改进部署流水线来消除对发布分支的需求。
在高频集成的环境下,发布就绪的主线有着简单性这个明显的优势。不需要为我所描述的各种分支的复杂性所烦恼。即使是热修复也可以应用到主线上,然后再应用到生产中,让它们不再特殊到拥有一个名字。
此外,保持主线发布就绪鼓励了一种有价值的纪律。它让生产预备在开发人员的脑海中占据首位,确保问题不会逐渐侵入系统,无论是作为bug还是作为拖慢产品周期的流程问题。持续交付的全部纪律——开发人员每天在不破坏主线的情况下对其进行多次集成——对许多人来说似乎是令人生畏的困难。然而,一旦实现并成为一种习惯,团队就会发现它显著地减少了压力,并且保持起来也相对容易。这就是为什么它是Agile Fluency® Model的交付区域(delivering zone)的一个关键因素。

其他分支模式

本文的主旨是讨论那些围绕团队集成和通往生产的路径的模式。但我还想提及一些其他的模式。

实验分支 ✣

把代码库中的实验性工作收集在一起,这些工作预期上不会直接合入产品。
实验分支是开发人员用来尝试一些想法,但不想让他们的变更简单地集成回主线的地方。我可能发现了一个新的库,认为它可以是我们正在使用的一个库的很好的替代品。为了帮助我决定是否替换,我开启了一个分支,并尝试用它来编写或重写系统的相关部分。作业的目的不是为代码库贡献代码,而是了解一个新工具在我的特定上下文中的适用性。我可以独自做这件事,也可以和一些同事在上面一起做。
同样地,我有一个新的功能要实现,可以看看有几种方法来接近它。我花几天时间研究每一种方法来帮助我决定采用哪一种。
这里的关键点是,我们会预期实验分支上的代码都会被抛弃,不会合并到主线上。这不是绝对的——如果我碰巧喜欢这个结果,而且代码可以很轻易地被集成,那么我就不会忽略这个机会——但我并不期望会是这样的情况。我可能会没那么遵守一些平时的习惯,更少的测试,一些随意的代码重复,而不是试图把它重构地很干净。我希望,如果我喜欢这个实验,我会从头开始把这个想法应用到生产代码中,用实验分支作为提醒和指导,但却不使用任何这些提交。
一旦我在一个实验分支上完成了工作,在git中我通常会添加一个标签,然后删除该分支。标签将代码线保留下来,以备我以后重新审视它——我使用一种惯例,比如把标签名称以“exp”开头,以明确其性质。
何时使用它
每当我想尝试一些东西,但不确定最终是否会使用它时,实验分支就很有用。这样,我就可以做任何我喜欢的事情,无论多么古怪,我都可以有信心轻松地把它放在一边。
有时我以为自己在做常规工作,但最终意识到我所做的其实是一个实验。如果发生这种情况,我可以开一个新的实验分支,并将我的主要工作分支重置到最后的稳定提交。

未来分支 ✣

一个单一分支,它为了那些太具侵略性以至于不能用其它手段处理的变更而存在。
这是一种罕见的模式,但在人们使用持续集成时偶尔会出现。有时候,一个团队需要做一个对代码库有很大侵入性的改变,而用于集成进行中的工作(work-in-progress)的通常技术并不适用。在这种情况下,团队会做一些看起来很像功能分支模式的事情,他们切出一个未来分支,并只从主线进行拉取,直到最后才做主线集成。
未来分支和功能分支的最大区别是未来分支只存在一个。因此,在未来分支上工作的人永远不会偏离主线太远,并且也不会有其它岔开的分支需要处理。
可能有一些开发人员在未来分支上工作,在这种情况下,他们在未来分支上进行持续集成。在做集成时,他们首先拉取主线到未来分支,然后再集成他们的变更。这将减缓集成流程,但这是使用未来分支的代价。
何时使用它
我应该强调这是一种罕见的模式。我怀疑大多数实施持续集成的团队从不需要使用它。我曾见过它用于对系统中的架构进行特别有侵略性的修改。一般来说,这是最后的手段,只有在我们无法想出如何使用类似Branch By Abstraction的方法来处理时才会使用。
未来分支仍然应该保持尽可能的短,因为它们在团队中创造了一个分区,就像任何分布式系统中的分区一样,我们需要把它们保持在一个绝对的最小限度。

协作分支 ✣

一个用于开发人员在不进行正式集成的情况下与团队其他成员分享工作的分支。
当一个团队使用主线时,大多数协作是通过主线进行的。只有当主线集成发生时,团队的其他成员才会看到某个开发者正在做什么。
有时候某个开发者想在集成之前分享他们的工作。开设一个用于协作的分支,可以让他们在一个临时的基础上做到这点。这个分支可以被推送到团队的中央仓库,协作者可以直接从他们的个人仓库拉取和推送,或者建立一个短期仓库来处理协作事宜。
协作分支通常是临时性的,一旦工作被集成到主线上就会关闭。
何时使用它
协作分支随着集成频率的降低而逐渐变得更有用。如果团队成员需要把一些修改协调到对某些人很重要的代码区域,那么长期功能分支就经常需要非正式的协作。而一个使用持续集成的团队很有可能永远都不需要打开一个协作分支,因为他们的工作对彼此不可见的时间非常的短。这方面的主要例外是实验分支,根据定义,它永远不会被集成。如果几个人一起做一个实验,他们需要让这个实验分支也成为一个协作分支。

团队集成分支

在与主线集成之前,让一个子团队互相集成。
较大的项目可能会有几个团队对一个单一逻辑代码库进行操作。一个团队集成分支可以在不需要使用主线与项目的所有成员集成的情况下,让团队成员相互集成。
实际上团队将团队集成分支视为内部的主线,与之集成,就像他们会与整体项目的主线集成一样。除了这些集成之外,团队还会付出额外的努力来与项目主线集成。
何时使用它
使用团队集成分支的最明显的动力是那些由许多开发人员积极开发的代码库,并且将这些开发人员分成不同的团队是有意义的。但我们应该对这种假设保持警惕,因为我遇到过很多团队,他们似乎大到不可能一起在一个单一主线上工作,然而还是设法做到了。(我曾对这个多达一百名开发人员的团队进行过报道。)
使用团队集成分支的一个更重要的动力是期望的集成频率的不同。如果项目整体上希望团队使用几周长度的功能分支,但子团队更喜欢持续集成,那么这个团队可以建立一个团队集成分支,用这个分支做持续集成,一旦他们正从事的功能完成,就将它与主线集成。
如果用于健康分支的整体项目使用的标准和子团队使用的健康标准之间存在差异,也会发生类似的效应。如果更大范围的项目不能将主线维持在足够高的稳定程度上,那么子团队可能会选择以更严格的健康等级运作。同样,如果子团队挣扎于让它的提交足够健康,以达成一个控制良好的主线,他们可能会选择使用团队集成分支,并在进入主线之前使用自己的发布分支来稳定代码。这不是我通常喜欢的情况,但某些特别困难的情况下可能是必要的。
我们同样可以把团队集成分支当作协作分支的一种更加结构化的形式,它基于正式的项目组织而不是临时的协作。

一些分支策略

Git-Flow

Git-Flow已经成为我遇到的最常见的分支策略之一。它是由Vincent Driessen在2010年书写的,出现在git开始流行的时候。在git之前的日子里,分支模式通常被看作是一个高级话题。Git让分支更有吸引力,部分原因是工具的改进(比如更好的文件移动处理),但也因为克隆的一个仓库本质上就是一个分支,在推送回中央仓库时需要进行类似关于合并问题的思考。
Git-Flow在一个单一的“origin”仓库中使用主线(称其为“develop”)。它使用功能分支模式来协调多个开发者。开发者被鼓励将他们的个人仓库用作协作分支来与进行类似工作的开发者合作。
传统上将git的核心分支称为“master”,在Git-Flow中,master被当作一个生产成熟分支来使用。Git-Flow使用一个发布分支来让工作从 “develop”通过它传递到“master”。热修复是通过热修复分支来组织的。
Git-Flow对功能分支的长度方面没有任何讨论,因此也就不期待有关集成频率的内容了。它也没有提到主线是否应该是一个健康分支,如果是的话,需要怎样的健康程度。发布分支的存在意味着它拥有的不是一个发布就绪的主线
正如Driessen今年在附录中指出的,Git-Flow是为那种在生产中发布多个版本的项目而设计的,例如安装在客户站点的软件。当然,拥有多个活跃版本是使用发布分支的主要诱因之一。然而,许多用户在是单一生产环境的webapp的情况下选择了Git-Flow——这样的分支结构可以很轻易地变得比所需要的更加复杂。
尽管Git-Flow非常流行,感觉上很多人都说在使用它,但经常会发现那些说他们在使用Git-Flow的人实际上在做一些完全不同的事情。通常,他们的实际做法更接近于GitHub Flow。

GitHub Flow

尽管Git-Flow真的很流行,但它的分支结构对于web应用来说有着不必要的复杂性,因此催生了许多替代方案。随着GitHub的流行,它的开发者使用的分支策略(称为GitHub Flow)变得知名就一点也不让人惊讶了。Scott Chacon做出了最好的说明
有了GitHub Flow这样的名字,毫不奇怪,它是有意地在基于和对抗Git-Flow。这两者的本质区别在于适用产品的种类不同,这意味着使用情景的不同,因此模式也就不同。Git-Flow假定产品在生产中拥有多个版本。而GitHub Flow则假定产品在生产环境中只有一个单一版本,并会频繁地向发布就绪的主线上集成。在这种情况下,发布分支就不再需要了。生产问题的修复方式与开发常规功能的方式相同,所以不需要热修复分支,因为热修复分支通常意味着对正常流程的偏离。去除这些分支大大地简化了分支结构,剩下主线和功能分支。
GitHub Flow称其主线为“master”。开发人员使用功能分支模式进行工作。他们定期向中央仓库推送功能分支,以提供可见性,但功能分支完成之后才会被集成到主线。Chacon指出,功能分支可能只有一行代码,也可能运行几个星期。这个流程在这两种情况下都会以同样的方式运作。作为GitHub,pull-request机制是主线集成的一部分,并且会使用集成前审查
Git-Flow和GitHub Flow经常被混淆,所以和以往一样,要深挖名字背后的东西来理解到底发生了什么。两者的大体上的主题是使用主线和功能分支。

Trunk-Based Development(基于主干的开发)

正如我之前所写的,大多数我听到“trunk-driven development(主干驱动的开发)”的时候都会把它当作持续集成的同义词。但把Trunk-Driven Development看作是Git-Flow和GitHub Flow的替代分支策略也是合理的。Paul Hammant写了一个深入的网站来解释这种方法。Paul是我在Thoughtworks的长期同事,他使用他那可靠的利刃化解客户僵化的分支结构,并有着坚实的记录。
Trunk-Based Development侧重于在主线(称为“主干(thunk)”,是“主线(mainline)”的常见同义词)上进行所有的工作,从而避免任何形式的长期分支。规模较小的团队使用主线集成直接提交到主线上,规模较大的团队可能使用短期功能分支模式,其中 “短期”意味着不超过几天——在实践中这可能和持续集成差不多。团队可以使用发布分支(称为“用于发布的分支”)或发布就绪的主线(“从主干上发布”)。

最后的思考和推荐

从最早的程序开始,人们就发现,如果他们想要一个与现有程序有一点不同的程序,就可以通过拿到一份源码的副本,然后把它调整成想要的样子来轻易地做到。有了所有的源码,我就有能力做出任何我想要的改变。但随着这一行为进行,我的副本更难接受原始源码中的新功能和错误修复。最终,它也许会变得不可能,正如许多企业在他们早期的COBOL程序中发现的那样,并在如今广泛的定制ERP包中遭受痛苦。即使没有使用那个名字,任何我们复制源代码并对其进行修改的时候,我们都是在进行源码分支行为,虽然没有涉及到版本控制系统。
正如我在这篇长文的开头所说:创建分支很容易,合并更难。分支是一种强大的技术,但它让我想到了goto语句、全局变量和并发的锁。强大、易用,但很容易过度使用,它们常常成为不谨慎的和没有经验的人的陷阱。源码控制系统可以通过仔细跟踪变化来帮助控制分支行为,但最终它们只能作为问题的见证者。
我不是一个会说分支是邪恶的的人。在一些日常问题上,例如多个开发者对一个单一代码库进行贡献,明智地使用分支是非常必要的。但我们应该始终对它保持警惕,并记住Paracelsus的观察:良药和毒药之间的区别在于剂量。
因此,我对分支的第一个建议是:每当你考虑使用一个分支的时候,要想清楚你会怎样去合并。你使用任何技术的任何时候,你都是在在替代方案之间进行权衡。如果不了解一项技术的所有代价,你就无法做出明智的权衡决定,在分支行为中,当你合并时,吹笛人就会索取她的费用。
因此,下一条指导是:确保你了解分支的替代方案,它们通常都更好。记住Bodart的法则,是否有办法通过提高模块化来解决你的问题?你能改进你的部署流水线吗?一个标签就够了吗?对你的流程做出哪些改变会使这个分支变得不必要?很有可能实际上分支是现在最好的路线——但它是一种提醒你有一个更深层的,应该在未来几个月内解决的问题的气味。摆脱对分支的需求通常是一件好事。
记住LeRoy的插图:随着工作的进行,如果不进行集成,分支会呈指数级产生差异。所以要考虑你集成分支的频率。争取使你的集成频率翻倍。(这里显然有一个极限,但除非你处于持续集成的区域,否则你不会接近它。)更频繁的集成会有一些障碍,但这些障碍往往正是需要被给予过度的炸药,来改进你开发流程的东西。
由于合并是分支行为中最难的部分,所以要注意是什么让合并变得困难。有时是流程问题,有时是因为架构的失败。不管是什么,不要向斯德哥尔摩综合症屈服。任何合并问题,特别是导致危机的问题,都是提高团队效率的一个路标。记住,只有当你从错误中学习到东西时,错误才是有价值的。
我在这里描述的模式概述了我和我的同事在旅途中遇到的常见分支配置。我希望通对它们命名、解释,并且最重要的是,解释它们何时是有用的,来帮助你评估何时使用它们。请记住,就像任何模式一样,它们很少是普遍的好或坏的——它们对你的价值取决于你所处的环境。当你遇到分支策略(无论是众所周知的如Git-Flow或Trunk-Based Development,还是在开发组织中自创的东西)时,我希望了解其中的模式将帮助你决策它们是否适合你的情况,以及把哪些其他模式混合进来将提供帮助。
 
鸣谢
Badri Janakiraman、Brad Appleton、Dave Farley、James Shore、Kent Beck、Kevin Yeung、Marcos Brizeno、Paul Hammant、Pete Hodgson和Tim Cochran阅读了本文的草稿,并就如何改进给了我反馈。
Peter Becker提醒我,forks也是分支的一种形式。我从Steve Berczuk的《软件配置管理模式》中取得了“主线”这个名称。
延伸阅读
有很多关于分支的材料,我没法对所有材料进行认真的调查。但我确实想强调Steve Berczuk的书:《软件配置管理模式》。Steve以及他的贡献者Brad Appleton的著作,对我思考源码管理的方式产生了持久的影响。
重大修订
2021年1月4日:增加了关于pull requests的侧边栏。
2021年1月2日:将《被审查的提交》改名为《集成前审查》,我认为这是个更清晰的名字。
2020年5月28日:发表了最后一章。
2020年5月27日:发表了《一些分支的策略》。
2020年5月21日:发表了《协作分支》和《团队集成分支》。
2020年5月20日:起草了最终思考。
2020年5月19日:发表了《未来分支》。
2020年5月18日:发表了《实验分支》。
2020年5月14日:发表了《发布就绪的主线》。
2020年5月13日:起草了关于分支策略的章节。
2020年5月13日:发表了《发布列车》。
2020年5月12日:发表了《热修复分支》。
2020年5月11日:起草了《发布就绪的主线》。
2020年5月11日:发表了《环境分支》。
2020年5月7日:发表了《成熟分支》。
2020年5月6日:发表了《发布分支》。
2020年5月5日:发表了《集成摩擦》《模块化的重要性》以及《我对集成模式的个人思考》。
2020年5月4日:发表了《被审查的提交》。
2020年4月30日:发表了《持续集成与功能分支模式的对比》。
2020年4月29日:发表了《持续集成》。
2020年4月28日:草稿:增加了关于模块化的部分。
2020年4月28日:发表了《集成频率》。
2020年4月27日:草稿:大体的《生产分支到成熟分支》。
2020年4月27日:发表了《功能分支模式》。
2020年4月23日:发表了《主线集成》。
2020年4月22日:发表了《健康的分支》。
2020年4月21日:发表了《主线》。
2020年4月20日:发表了第一章节:《源码分支行为》。
2020年4月5日:第五稿:处理了对发布模式的评审意见,编写了发布列车,修订了源码分支行为。
2020年3月30日:第四稿:处理了对基础和集成部分的大部分评审意见。使源码分支行为成为一种模式。
2020年3月12日:第三稿:将模式改写为特别章节。
2020年3月5日:第二稿:将文本重新组织为集成模式和通往生产的路径。为发布分支和热修复增加了插图,并重写了文字与之匹配。
2020年2月24日:第一稿:与审稿人分享。
2020年1月28日:开始写作。
 
译于2021年8月6日,部分内容可能会随着时间的改变有所发展。

© ryougifujino 2021 - 2022