现代源代码控制系统提供了强大的能力,让创建源代码分支变得非常容易。但分支最终必须被合并,许多团队也因此花费大量时间处理错综复杂的分支关系。对于希望改进 Git 分支策略、持续集成、主干开发、特性分支和发布分支管理方式的软件研发团队来说,理解不同分支模式的适用场景至关重要。
本文介绍几种常见的源代码分支管理模式,帮助团队更有效地使用分支,尤其关注如何整合多个开发者的工作,以及如何组织从代码库到生产发布的路径。其核心在于:频繁集成分支,并将主要精力放在维护一条健康的主线上,使代码能够以尽可能低的成本部署到生产环境。

源代码是任何软件开发团队的重要资产。几十年来,人们开发出了一系列源代码管理工具,用来维护代码的良好状态。这些工具能够追踪代码变更,使我们能够重现软件的早期版本,并观察代码库随时间推移的演化过程。它们对于协调多名程序员共同维护同一个代码库也至关重要。通过记录每位开发者所做的修改,源代码控制系统可以同时跟踪多条工作线,并帮助开发者将这些工作线重新合并到一起。
将开发工作拆分到多个分支上,再进行拆分与合并,是软件开发团队工作流程中的核心环节。围绕这些工作,业界逐渐形成了多种源代码分支管理模式。但与大多数软件模式一样,这些模式中很少有哪一种可以被视为所有团队都必须遵循的黄金标准。软件开发工作流高度依赖具体情境,尤其取决于团队的社会结构,以及团队正在采用的其他工程实践。
本文旨在探讨这些模式。我会在一篇文章中逐一说明这些模式,同时穿插一些叙述性章节,以便更好地解释它们的背景和相互关系。为了便于区分,我会用“✣”符号标记模式章节。
基础模式:源分支、主线与健康分支
在思考这些模式时,我发现将它们分为两大类会很有帮助。
第一类关注集成,即多个开发者如何将各自的工作整合成一个连贯的整体。第二类关注生产路径,即如何利用分支来管理从集成代码库到生产环境运行版本之间的路径。
还有一些模式是这两类模式的基础,因此我会先介绍这些基础模式。剩下的一些模式既不属于基础模式,也不属于上述两大类,我会放在最后讨论。
源分支 ✣
创建一份副本,并记录这份副本上的所有更改。
如果几个人同时维护同一个代码库,很快就会出现无法同时编辑同一个文件的情况。比如,我想编译代码,而我的同事正在输入表达式,结果编译失败。我们只能互相喊:“我正在编译,别改任何东西!”即使只有两个人,这种方式也很难持续,更不用说更大的团队了。
最简单的解决办法,是让每个开发者都复制一份代码库。这样,每个人都可以轻松开发自己的功能。但这也带来了一个新问题:完成工作后,我们如何把这些副本重新合并到一起?
源代码控制系统让这个过程变得容易得多。关键在于,它会把每个分支上的每一次更改都记录为一次提交。这不仅能确保没有人忘记自己对 utils.java 做过的细微修改,也能让合并操作更加便利,尤其是在多人修改同一个文件时。
这就引出了我在本文中使用的“分支”定义:我把分支定义为代码库中一组特定的提交序列。分支的头部,也就是 tip,是这组提交序列中的最新提交。
这是名词意义上的“分支”,但它也有动词意义上的用法。当我说“创建一个分支”时,意思是创建一条新的提交线,也可以理解为把原有分支拆分成两条。当一个分支中的提交被应用到另一个分支时,这个分支就被合并了。
我对“分支”的定义,基本符合我观察到的大多数开发者谈论分支时的用法。但源代码控制系统本身对“分支”的使用,往往更加具体。
我可以用现代开发团队中常见的场景来说明这一点。假设他们的源代码保存在一个共享的 Git 仓库中。开发者 Scarlett 需要做一些修改,于是她克隆了这个 Git 仓库,并检出了 master 分支。她做了一些修改,然后提交到自己的本地 master 分支。与此同时,另一位开发者 Violet 也把仓库克隆到自己的电脑上,并检出了 master 分支。
那么,Scarlett 和 Violet 是在同一个分支上工作,还是在不同的分支上工作?她们都在名为 master 的分支上工作,但她们的提交彼此独立。当她们将修改推回共享仓库时,需要合并这些提交。假设 Scarlett 对自己所做的修改不确定,于是她给最后一次提交打了一个标签,并把自己的 master 分支重置为 origin/master,也就是她从共享仓库克隆时得到的最后一次提交。那么会发生什么?
按照我前面对分支的定义,Scarlett 和 Violet 实际上分别在不同的分支上工作。这两个分支彼此独立,也都与共享仓库中的主分支分离。当 Scarlett 使用标签保存自己的工作时,按照我的定义,这仍然是一条分支线;她本人也可能会这么认为。但在 Git 术语中,它只是一个被标签引用的代码线。
对于 Git 这样的分布式版本控制系统来说,这意味着每当我们进一步克隆仓库时,都会创建额外的分支。如果 Scarlett 把本地仓库再克隆到笔记本电脑上,以便在回家的火车上查看代码,那么她就创建了第三条主分支。某代码托管平台中的 fork 操作也是如此:每一个 fork 出来的仓库都有自己的一组额外分支。
遇到不同的版本控制系统时,这种术语混乱会更加严重,因为它们对分支的定义各不相同。Mercurial 中的分支与 Git 中的分支截然不同,后者更接近于 Mercurial 中的书签。Mercurial 还允许创建未命名分支,而且 Mercurial 用户通常通过克隆仓库来创建分支。
这些术语上的混乱,使一些人倾向于使用更通用的说法:代码线。我将“代码线”定义为代码库中某一系列特定版本。它可以以标签结束,可以是一个分支,也可以隐藏在 Git 的 reflog 中。你会注意到,我对分支和代码线的定义非常相似。在很多方面,“代码线”这个术语更实用,我也确实会使用它。但在实践中,它的使用并不广泛。因此在本文中,除非我明确指的是 Git 或其他工具中的专门术语,否则我会交替使用“分支”和“代码线”。
根据这个定义,无论使用什么版本控制系统,只要开发者在本地修改了代码,他们的工作副本上就至少会有一条个人代码线。如果我克隆一个项目的 Git 仓库,切换到 master 分支,然后修改一些文件,即使我还没有提交任何代码,也已经形成了一条新的代码线。同样,如果我创建了一个 Subversion 仓库主干分支的工作副本,即使没有创建任何 Subversion 分支,这个工作副本本身也是一条代码线。
何时使用
有个老笑话说:从高楼上掉下来时,真正伤人的不是坠落过程,而是落地那一下。源代码也是如此:创建分支很容易,合并分支才困难。
源代码控制系统会记录每次提交的更改,这确实简化了合并过程,但合并并不总是轻松的。如果 Scarlett 和 Violet 都修改了同一个变量名,却改成了不同的名称,那么就会产生冲突,源代码管理系统在没有人工干预的情况下无法解决。
更棘手的是,这种文本冲突至少还能被源代码控制系统检测出来,并提醒人工检查。但很多时候,即使文本合并没有问题,系统仍然可能无法正常工作。例如,Scarlett 修改了一个函数名,而 Violet 在自己的分支中添加了一些代码,这些代码仍然使用旧名称调用该函数。这就是我所说的语义冲突。发生这类冲突时,系统可能构建失败,也可能构建成功但在运行时出错。
Jonny LeRoy 经常指出,我们这些人,包括我自己,在绘制分支图时存在一个缺陷。
任何从事过并发或分布式计算的人都会熟悉这个问题。我们有一些共享状态,也就是代码库,开发者并行地对它进行更新。我们需要把这些更新串行化,最终形成一个共识版本,把所有更新合并起来。由于共享状态必须正确执行、正确运行,而判断其有效性的标准又非常复杂,这使得任务更加困难。我们无法创建一个确定性的算法来找到这种共识。最终,还是需要人来达成共识,而这种共识可能需要混合不同更新中的部分内容。通常,只有理解原始更新,才能真正解决冲突。
我首先会提出这样一个问题:“如果没有分支会怎样?”所有人都会直接编辑实时代码,不成熟的改动会破坏系统,大家会互相阻塞。因此,我们制造出一种时间静止的幻觉,仿佛只有自己在修改系统,而且这些改动可以等到完全成熟之后再承担风险。但这只是幻觉,最终一定要付出代价。谁来承担?什么时候承担?承担多少?这些模式讨论的正是这些问题:如何以不同方式承担这些代价。
——肯特·贝克
因此,本文的其余部分将介绍各种模式。这些模式既能带来愉快的隔离感,也能让人在坠落时感受到风吹过头发的快感,同时尽可能降低最终撞上坚硬地面的代价。
主线 ✣
一条单一的、共享的分支,用来代表产品当前的状态。
主线是一条特殊的代码线,我们把它视为团队代码的当前状态。每当我想开始一项新工作时,都会从主线拉取代码到本地仓库,然后开始开发。每当我想与团队其他成员分享自己的工作时,都会更新主线。理想情况下,我会使用稍后要讨论的主线集成模式。
不同团队对这条特殊分支使用不同名称,这通常受版本控制系统的惯例影响。Git 用户通常称它为 master,Subversion 用户通常称它为 trunk。
我必须强调,主线指的是一条单一的、共享的代码线。在 Git 中,master 这个词可能指代多种不同分支,因为每个仓库克隆都有自己的本地 master 分支。通常,这类团队会有一个中央仓库,也就是一个共享仓库。它是项目的唯一记录点,也是大多数克隆的来源。从头开始参与一个项目,通常意味着克隆这个中央仓库。如果我已经有了一个克隆,我会先从中央仓库拉取 master 分支,使其与主线保持同步。在这种情况下,主线指的是中央仓库中的 master 分支。
在开发功能时,我会有自己的个人开发分支。它可能就是本地 master 分支,也可能是我单独创建的一个本地分支。如果开发时间较长,我可以定期从主线拉取变更并合并到个人开发分支中,从而与主线保持同步。
同样,如果我想创建一个新版本的产品用于发布,可以从当前主线分支开始。如果我需要修复 bug,使产品足够稳定以便发布,可以使用发布分支。
何时使用
我记得在 2000 年初,我拜访过一位客户的构建工程师。他的工作是为团队正在开发的产品构建一个版本。他会给团队每个成员发邮件,成员们回复邮件,把他们代码库中已经准备好集成的各种文件发送给他。然后他会把这些文件复制到自己的集成目录下,尝试编译代码库。通常,他需要花上几个星期,才能创建出一个可以编译通过并进行某种测试的版本。
相比之下,如果使用主线,任何人都可以快速从主线的最新版本开始构建产品。此外,主线不仅让代码库状态更容易查看,它还是许多其他模式的基础。稍后我会继续讨论这些模式。
主线之外的另一种选择是发布列车。
健康分支 ✣
每次提交时都执行自动化检查,通常包括构建和运行测试,以确保分支上没有缺陷。
由于主线具有共享且被认可的状态,因此保持它稳定至关重要。我记得在 2000 年代初,曾与另一个组织的团队交流过。他们以每天为每个产品进行构建而闻名,在当时这被认为是一种相当先进的实践,该组织也因此备受赞誉。然而,当时的报道中没有提到的是,这些每日构建并不总是成功。事实上,有些团队的每日构建版本几个月都无法编译,这种情况并不少见。
为了应对这种情况,我们可以努力保持分支健康。所谓健康,是指分支能够成功构建,并且软件运行时几乎没有错误。为了确保这一点,我认为编写自测试代码至关重要。这种开发实践意味着,在编写生产代码的同时,也编写一套完整的自动化测试。这样,只要这些测试通过,我们就可以相信代码中不存在已知错误。
如果我们这样做,就可以通过在每次提交后运行构建来保持分支健康,而每次构建都包括运行这套测试。如果系统编译失败或测试失败,我们的首要任务就是先修复这些问题,再对这个分支做任何其他操作。通常,这意味着我们会“冻结”这个分支:除了修复错误使其恢复健康之外,不允许向该分支提交其他代码。
在判断一次提交是否健康时,应该测试到什么程度,这一点存在争议。许多更全面的测试需要消耗大量时间,会延迟我们获得提交是否健康的反馈。团队通常通过把测试拆分为部署流水线中的多个阶段来解决这个问题。第一阶段测试应该快速运行,通常不超过十分钟,但仍要足够全面。我把这样的测试套件称为提交套件,尽管它通常被称为“单元测试”,因为提交套件通常主要由单元测试组成。
理想情况下,每次提交都应该运行所有测试。但是,如果某些测试耗时很长,例如需要服务器长时间高负载运行的性能测试,那就不现实。如今,团队通常可以构建一个提交测试套件,在每次提交时运行;同时尽可能频繁地运行部署流水线后续阶段的测试。
代码能够运行且没有 bug,并不意味着代码质量一定良好。为了保持稳定的交付速度,我们还需要保证代码的内部质量。一种常见做法是使用集成前评审,但正如稍后会看到的,也存在其他替代方案。
何时使用
每个团队都应该为开发工作流中各个分支的健康状况制定清晰标准。保持主线健康至关重要。如果主线健康,开发者只需拉取当前主线代码就可以开始新工作,而不会被已有缺陷阻碍。我们经常听到有人花费数天时间修复或绕过刚拉取代码中的错误,然后才能真正开始自己的工作。
健康的主线也有助于简化上线流程。可以随时从主线的最新版本构建新的生产候选版本。优秀团队通常发现,他们几乎不需要做额外工作来稳定这样的代码库,通常可以直接从主线发布到生产环境。
保持主线健康至关重要,而实现自测试代码的关键在于,提交测试套件能够在几分钟内运行完毕。构建这项能力可能需要大量投入,但一旦我们能够在几分钟内判断一次提交是否破坏了功能,整个开发流程就会彻底改变。我们可以更快地进行更改,更有信心地重构代码以保持其易用性,并大幅缩短从实现功能到代码上线运行的周期。
对于个人开发分支来说,保持健康状态也是明智之举,因为这样才能进行差异调试。但这与频繁提交代码以保存当前状态的做法存在矛盾。如果我准备尝试不同的方法,即使代码无法编译,我也可能提交一个检查点。我解决这种矛盾的方式是,在完成当前工作后,将所有不健康的提交整理或压缩到分支中。这样,几个小时后,我的分支上就只剩下健康的提交。
如果我能保持个人分支健康,那么提交到主线也会容易得多。因为我知道,主线集成过程中出现的任何错误都纯粹是集成问题,而不是我个人代码库中原本就存在的问题。这样就能更快、更轻松地定位并修复问题。
集成模式:特性分支、持续集成与主线集成
分支机制的核心,在于管理隔离与集成之间的平衡。让所有人始终在同一个共享代码库上工作是行不通的,因为如果你正在输入变量名,我就无法编译程序。所以,至少在某种程度上,我们需要一个暂时属于自己的私有工作空间。
现代源代码控制工具让创建分支和监控分支变更变得轻而易举。然而,我们最终还是需要进行集成。思考分支策略的本质,就是决定如何以及何时进行集成。
主线集成 ✣
开发者通过从主线拉取代码、合并代码,并在代码运行良好时将其推回主线,来集成自己的工作。
主线清晰定义了团队软件的当前状态。使用主线最大的好处之一,是简化了集成。如果没有主线,我们就需要像前面描述的那样,与团队中每个人进行复杂协调。但有了主线,每个开发者都可以独立完成集成工作。
我用一个例子说明它如何运作。假设开发者 Scarlett 开始工作。她首先将主线代码克隆到自己的仓库中。使用 Git 时,如果她还没有中央仓库的克隆,就会克隆它并检出 master 分支。如果她已经有了克隆,就会从主线拉取代码到本地 master 分支。然后,她就可以在本地工作,并向本地 master 分支提交更改。
在她工作时,她的同事 Violet 将一些更改推送到了主线。由于 Scarlett 使用的是自己的代码库,她可能暂时不会注意到这些更改。
到某个阶段,Scarlett 需要进行代码集成。第一步是把主线的当前状态拉取到自己的本地 master 分支,这样就引入了 Violet 的更改。由于 Scarlett 一直在本地 master 分支上工作,她自己的提交会在 origin/master 之上表现为一条单独的代码线。
现在,她需要将自己的更改与 Violet 的更改合并。有些团队喜欢使用 merge,有些则喜欢使用 rebase。通常,人们在谈论分支合并时都会使用“合并”这个词,无论实际使用的是 Git merge 还是 rebase。我也会沿用这种用法。所以,除非我专门讨论 merge 和 rebase 的区别,否则请把“合并”理解为一种逻辑任务,可以通过这两种方式实现。
至于是否使用普通合并、是否使用或避免 fast-forward,还是使用 rebase,这是另一个值得展开的话题。它超出了本文的讨论范围,不过如果大家寄给我足够多的 Tripel Karmeliet,我也许会专门写一篇文章探讨这个问题。毕竟,如今互惠互利才是王道。
如果 Scarlett 运气好,合并 Violet 的代码会很顺利;否则,她就要处理一些冲突。这些冲突可能是文本冲突,其中很多可以由源代码控制系统自动处理。但语义冲突要难处理得多,而这正是自测试代码发挥作用的地方。由于冲突会带来大量工作,而且总是存在工作量失控的风险,我在图中用醒目的黄色标记它们。
此时,Scarlett 需要验证合并后的代码是否符合主线的健康标准,前提是主线是一条健康分支。通常,这意味着构建代码,并运行主线提交套件中的所有测试。即使合并过程没有文本冲突,她也需要这样做,因为干净的文本合并仍然可能隐藏语义冲突。提交套件中的任何失败都应该完全由这次合并引起,因为两个父版本都应该是绿色的。知道这一点有助于她追踪问题,因为她可以查看差异来寻找线索。
通过这次构建和测试,Scarlett 已经成功将主线代码拉取到自己的代码库中。但是,这一点既重要又常常被忽略:她还没有完成与主线的集成。要完成集成,她必须将自己的更改推送到主线。否则,团队其他成员无法访问她的更改。实际上,这并没有完成集成。集成既包括拉取,也包括推送。只有当 Scarlett 推送更改之后,她的工作才真正与项目其他部分集成在一起。
如今,许多团队要求在代码提交到主线之前进行代码审查。我把这称为集成前评审,稍后会详细讨论。
偶尔,在 Scarlett 推送代码之前,其他人已经把代码集成到了主线。这时,她需要再次拉取并合并代码。通常这只是偶发问题,不需要额外协调就可以解决。我见过一些构建时间较长的团队使用“集成接力棒”,只有持有接力棒的开发者才能进行集成。但近年来,随着构建时间缩短,我已经很少听到这种做法了。
何时使用
顾名思义,只有当产品也使用主线时,我们才能使用主线集成。
除了主线集成之外,还有一种做法是直接从主线拉取更改,并将这些更改合并到个人开发分支中。这很有用。拉取至少可以提醒 Scarlett,其他人已经集成了哪些更改,并检测她的工作与主线之间的冲突。但是,在 Scarlett 推送之前,Violet 无法检测自己正在处理的内容与 Scarlett 的更改之间是否存在冲突。
人们在使用“集成”这个词时,常常忽略一个重要区别。我们经常听到有人说,他们正在把主线集成到自己的分支上,而实际上他们只是从主线拉取数据。我已经学会对这种说法保持警惕,并会进一步询问,以确认他们指的是简单拉取,还是完整的主线集成。这两种操作的后果截然不同,因此务必要区分清楚。
另一种情况是,Scarlett 正在做一些尚未准备好完全集成到团队其他成员工作的内容,但这项工作与 Violet 的工作有重叠,她想先与 Violet 分享。这时,她们可以创建一个协作分支。
特性分支 ✣
将某个功能的全部工作放在单独分支上,等功能完成后再合并到主线。
采用特性分支时,开发者在开始开发某个特性时创建一个分支,然后继续在这个分支上工作,直到特性完成,再将其集成到主线中。
例如,我们看看 Scarlett 会怎么做。她要在网站上添加本地销售税收取功能。她从当前稳定的产品版本开始,将主线代码拉取到本地仓库,然后基于主线最新版本创建一个新分支。她会持续开发这个功能,直到完成,并在本地分支上提交一系列代码。
她可能会把这个分支推送到项目仓库,以便其他人查看她的更改。
在她工作期间,其他提交也会被合并到主线。因此,她会不时从主线拉取代码,以了解主线中的变化是否会影响她正在开发的功能。
请注意,这并不是我前面描述的集成,因为她没有把代码推送到主线。目前,只有她自己能看到她的工作成果,团队其他成员看不到。
有些团队喜欢把所有代码都保存在中央代码库中,无论这些代码是否已经集成到主线。在这种情况下,Scarlett 会把自己的特性分支推送到中央代码库。这样,即使她的工作尚未与其他人的工作集成,其他团队成员也能看到她正在开发的内容。
当她完成该功能后,就会执行主线集成,将这个功能整合到产品中。
如果 Scarlett 同时负责多个功能,她会为每个功能创建单独的分支。
何时使用
特性分支是当今业界非常流行的一种模式。要讨论何时使用它,我需要先介绍它的主要替代方案:持续集成。但在此之前,我需要先谈谈集成频率的作用。
集成频率
团队集成工作的频率,对团队运作方式有极其重要的影响。《DevOps 现状报告》的研究表明,优秀开发团队的集成频率明显高于低绩效团队。这一观察结果也与我以及许多业内同行的经验相符。接下来,我将通过 Scarlett 和 Violet 这两个例子来说明集成频率的影响。
低频集成
先从低频情况说起。在这种情况下,两位主角首先将主线代码克隆到各自的分支中,然后进行一些本地提交,但暂时不打算推送这些提交。
在她们工作时,其他人向主线提交了一个更改。我一时想不出另一个与颜色有关的名字,也许就叫 Grayham 吧。
这个团队的工作方式是维护一条健康分支,并在每次提交后从主线拉取更新。Scarlett 在前两次提交时不需要拉取内容,因为主线没有变化。但现在,她需要拉取 M1。
我用黄色方框标记合并操作。这次合并会将 S1 到 M1 合并。很快,Violet 也需要执行相同操作。
目前,两位开发者都已经更新到主线版本,但由于彼此独立,她们之间仍然没有集成。Scarlett 对 Violet 在 V1 到 V3 中所做的任何更改都毫不知情。
Scarlett 又提交了几次本地代码,然后准备进行主线集成。对她来说,这很容易,因为她之前已经拉取了 M1。
然而,Violet 的集成则更复杂。当她进行主线集成时,需要将 S1 到 S5 与 V1 到 V6 集成在一起。
我在图中根据涉及的提交数量,科学地计算出了合并操作的大小。即使你忽略我鼓起来的舌头状脸颊,也会明白 Violet 的合并操作很可能更加困难。
高频集成
在前面的例子中,两位风格迥异的开发者是在几次本地提交后才进行代码集成。现在我们看看,如果她们每次本地提交后都进行主线集成,会发生什么。
Violet 的第一次提交带来明显变化,于是她立即进行集成。由于主线代码没有变化,这只是一次简单的推送。
Scarlett 的第一次提交也包含一次主线集成。由于 Violet 先完成了集成,所以 Scarlett 需要进行一次合并。但由于她只是合并 V1 和 S1,合并范围很小。
Scarlett 的下一次集成是一次简单推送。这意味着 Violet 的下一次提交需要与 Scarlett 最近的两次提交合并。不过,这次合并范围仍然很小,只涉及 Violet 的一次提交和 Scarlett 的两次提交。
当外部对主线的提交出现时,它会按照 Scarlett 和 Violet 常规的集成节奏被接收。
虽然情况与前面类似,但这一次集成规模更小。Scarlett 只需要将 S3 与 M1 集成,因为 S1 和 S2 已经存在于主线上。这意味着 Grayham 在推送 M1 之前,也需要把主线上已有的内容,也就是 S1、S2、V1 和 V2,全部集成进去。
开发者继续完成剩余工作,并在每次提交时进行集成。
比较集成频率
让我们重新比较这两种整体情况:低频集成和高频集成。
集成恐惧
当团队经历几次糟糕的合并体验后,往往会对集成工作产生畏惧。这很容易形成恶性循环,而且像许多恶性循环一样,会带来非常负面的后果。
最明显的后果是,团队会降低集成频率,从而导致更多糟糕的合并事件发生,进而进一步降低集成频率,如此循环往复。
一个更隐蔽的问题是,团队会停止做那些他们认为会增加集成难度的事情。这尤其会导致他们抵制重构。但减少重构会让代码库变得越来越糟糕,越来越难理解和修改,从而减慢团队交付功能的速度。由于功能交付时间变长,集成频率也会进一步降低,形成新的恶性循环。
对此,一个反直觉的答案可以用一句口号概括:如果做起来很痛苦,那就更频繁地做。
这里有两个明显区别。首先,顾名思义,高频合并涉及更多合并操作。即使在这个简单示例中,合并次数也是低频集成的两倍。但更重要的是,这些合并的规模远小于低频合并。较小的合并意味着更少的工作量,因为可能造成冲突的代码变更更少。更重要的是,风险也更低。
大型合并的问题不只是工作量本身,而是工作的不确定性。大多数情况下,即使是大型合并也能顺利完成,但偶尔会出现非常糟糕的情况。这种偶发的麻烦最终比经常性的小麻烦更令人难以忍受。如果让我比较两种情况:每次合并额外花十分钟,或者有五十分之一的概率花六小时修复一次合并问题,我会怎么选?如果只从工作量看,五十分之一的概率似乎更好,因为总计只需要六小时,而不是八小时二十分钟。但这种不确定性会让五十分之一的概率感觉更糟,也正是这种不确定性导致了对合并的恐惧。
换一个角度来看这些频率之间的差异。如果 Scarlett 和 Violet 在第一次提交中就发生冲突,会发生什么?她们什么时候会发现冲突?在低频情况下,直到 Violet 最终合并时,才会检测到冲突,因为那是 S1 和 V1 第一次合并到一起。但在高频情况下,冲突会在 Scarlett 第一次合并时就被检测到。
频繁集成会增加合并次数,但会降低合并复杂性和风险。频繁集成还能让团队更早发现冲突。当然,这两点是相互关联的。糟糕的合并通常源于团队工作中潜藏的冲突,而这些冲突只有在集成过程中才会浮出水面。
也许 Violet 在查看账单计算逻辑时发现,其中包含税额计算,而原作者假设了一种特定的税收机制。她的功能需要不同的税务处理方式,因此最直接的做法是把税款从账单计算中移除,并在之后作为一个单独函数处理。账单计算只在几个地方被调用,所以修改起来很容易。把税务声明交给调用方处理,也更符合程序未来演进的方向。然而,Scarlett 并不知道 Violet 正在做这件事,她在编写功能代码时,仍然以为计费功能会处理税款。
自测试代码在这里至关重要。如果我们有一套强大的测试套件,并把它作为健康分支的一部分使用,就能发现冲突,从而大大降低 bug 进入生产环境的概率。但即便有强大的测试套件作为主线代码的守门人,大规模集成仍然会带来更大挑战。需要集成的代码越多,查找 bug 的难度越大。此外,我们更有可能遇到多个相互干扰的 bug,而这些 bug 更难理解。
较小的提交不仅减少了需要检查的代码量,还能让我们使用差异调试缩小范围,确定是哪一次更改引入了问题。
很多人没有意识到,源代码控制系统其实也是一种沟通工具。它让 Scarlett 能够了解团队其他成员正在做什么。通过频繁集成,她不仅能在冲突出现时立即收到警报,也能更清楚地了解每个人的工作内容以及代码库的演进过程。我们不再是各自为政的独立开发者,而更像是一个协同工作的团队。
提高集成频率,是缩小功能规模的一个重要原因。除此之外,还有其他优势。功能越小,构建速度越快,上线速度越快,交付价值的速度也越快。此外,较小的功能还能缩短反馈时间,让团队能够在更深入理解客户的同时,做出更明智的功能决策。
持续集成 ✣
开发者一旦提交了可以共享的健康版本,就立即进行主线集成;通常,这个版本包含不到一天的工作量。
一旦团队体验到高频集成既高效又压力更小,就会自然而然地问:“我们可以多久集成一次?”特性分支意味着变更集的大小存在一个下限:它不能小于一个完整特性。
如果想了解如何高效实践持续集成,可以参考关于持续集成的详细资料。若想进一步深入了解,也可以阅读相关专业书籍。某主干开发资料网站也包含很多持续集成和主干开发方面的技巧。
持续集成采用不同的集成触发机制:只要你在功能开发上取得了一定进展,并且分支仍然健康,就应该进行集成。持续集成并不要求功能必须完全完成,只要求代码库中已经有了足够多有价值的更改。经验法则是:“每个人每天都要向主线提交代码。”更准确地说,本地仓库中未集成的工作量不应超过一天。实际上,大多数持续集成实践者每天都会进行多次集成,他们乐于集成一个小时甚至更短时间的工作量。
使用持续集成的开发者,需要习惯于频繁地在集成点提交部分完成的功能。他们需要思考,如何在不把部分完成的功能暴露给运行系统的情况下实现这一点。通常,这并不难做到。比如,如果我正在实现一个依赖优惠券代码的折扣算法,而该代码还没有进入有效列表,那么即使在生产环境中,我的代码也不会被调用。同样,如果我正在添加一个功能,询问保险理赔人是否吸烟,我可以先构建并测试背后的逻辑,再把询问该问题的 UI 留到功能构建的最后一天。通过最后再接入关键接口来隐藏部分完成的功能,通常是一种有效方法。
持续集成与主干开发
某海外咨询公司的团队在 2000 年开始使用持续集成时,编写过一种工具。它是一个守护进程,可以在每次提交到主线后自动构建软件产品。此后,出现了许多类似的持续集成工具。但大多数使用这些工具的组织,只是在每次提交代码后自动构建特性分支。虽然这很有用,但这意味着他们实际上并没有实践持续集成。更准确的说法应该是:他们使用的是持续构建工具。
由于语义扩散,一些人开始使用“主干开发”来代替“持续集成”。虽然有些人确实会对这两个术语做细微区分,但并没有统一用法。尽管我通常在语言使用上倾向于描述性表达,但在这里我更倾向于使用“持续集成”。一部分原因是,我认为不断创造新术语并不是对抗语义扩散的有效办法。更主要的原因也许是,我认为改用新术语会粗暴地抹去早期极限编程先驱们的贡献,尤其是肯特·贝克。他在 20 世纪 90 年代提出并清晰定义了持续集成实践。
如果无法轻松隐藏部分功能,我们可以使用功能开关。除了隐藏部分功能外,这些开关还可以选择性地向一部分用户展示某个功能,这对于新功能的渐进式发布通常非常有用。
集成部分完成的功能,尤其会让那些担心主线代码存在缺陷的人感到不安。因此,使用持续集成的开发者也需要自测试代码,以确保部分完成的功能不会增加主线缺陷概率。在这种方法中,开发者在编写部分功能代码的同时,也要编写相应测试,并将功能代码和测试一起提交到主线。这也可以采用测试驱动开发的方式完成。
就本地代码库而言,大多数使用持续集成的人都不会特意创建单独的本地分支。通常,只需要把代码提交到本地 master 分支,完成后再进行主线集成即可。当然,如果开发者愿意,也可以创建一个特性分支,在该分支上开发,然后定期把代码集成回本地 master 分支和主线。特性分支和持续集成的区别,不在于是否存在特性分支,而在于开发者何时将代码集成到主线。
何时使用
持续集成是特性分支的替代方案。二者之间的权衡相当复杂,值得在本文中单独讨论。现在正是讨论这个问题的时候。
特性分支与持续集成怎么选?
目前,特性分支似乎是业界最常见的分支策略,但也有相当一部分从业者认为,持续集成通常是更优选择。持续集成的关键优势在于,它支持更高的集成频率,通常是高得多的集成频率。
集成频率的差异取决于团队开发功能的速度。如果团队所有功能都能在一天内完成,那么他们可以同时采用特性分支和持续集成。但大多数团队的功能周期都比这更长。功能周期越长,这两种模式之间的差异就越大。
正如我之前提到的,更高的集成频率意味着更少的集成投入和更少的集成恐惧。这一点往往很难理解。如果你一直习惯于每隔几周或几个月才进行一次集成,那么集成很可能是一件令人焦虑的事。你可能很难相信,它竟然可以一天进行多次。但集成正是那种“频率越高,难度越低”的事情。听起来似乎违反直觉:如果痛苦,那就更频繁地做。但集成周期越短,就越不容易演变成痛苦和绝望的深渊。对特性分支而言,这意味着应当使用更小的功能:以天为单位,而不是以周为单位,更不要以月为单位。
持续集成让团队能够获得高频集成的好处,同时将功能开发周期与集成频率解耦。如果团队倾向于开发周期为一到两周的功能,持续集成允许他们在保持这种功能开发节奏的同时,享受最高集成频率带来的所有优势。合并操作规模更小,需要处理的工作量也更少。更重要的是,如前所述,更频繁的合并降低了糟糕合并的风险,既避免意外情况发生,也降低团队对合并的整体恐惧。如果代码中出现冲突,高频集成能够迅速发现这些冲突,避免它们演变成棘手的集成问题。这些优势非常显著,以至于有些团队即使功能开发只需要几天,仍然会采用持续集成。
持续集成的明显缺点在于,它缺少特性最终集成到主线时那种“闭环”感。这不仅意味着少了一些庆祝的时刻,如果团队不擅长维护健康分支,还会带来风险。把所有功能提交分散到持续过程中,也意味着某个功能是否包含在即将发布的版本中,可能需要在后期做出决定。虽然功能开关允许从用户角度启用或禁用功能,但功能代码仍然存在于产品中。对此的担忧通常被夸大了,毕竟代码本身没有重量。但这确实意味着,想要实践持续集成的团队必须建立完善的测试机制,以确保即使每天多次集成,主线也能保持健康。有些团队觉得这项能力难以想象,而另一些团队则认为这既可行又令人安心。这个前提条件意味着,对于那些不强制维护健康分支,而是需要发布分支来稳定代码才能发布的团队来说,特性分支更合适。
虽然合并操作的规模和不确定性是特性分支最明显的缺点,但它最大的问题或许在于会阻碍重构。重构只有在定期且顺畅进行时,才能发挥最大价值。重构会引入冲突。如果这些冲突不能及时发现和解决,合并就会变得困难重重。因此,重构在高频集成下效果最好,这也并不奇怪,因为持续集成本来就是极限编程的原始实践之一。特性分支还会阻碍开发者进行那些并非当前功能一部分的更改,从而削弱通过重构持续改进代码库的能力。
我们发现,分支或 fork 的生命周期非常短,不超过一天;在合并到主干之前就停止维护;活跃分支总数不超过三个,这些都是持续交付的重要方面,都有助于提高绩效。每天将代码合并到主干或主分支也是如此。
——2016 年《DevOps 现状报告》
当我看到关于软件开发实践的科学研究时,通常会因为其方法论上的严重问题而持怀疑态度。但《DevOps 现状报告》是个例外。该报告建立了一组软件交付绩效指标,并将其与更广泛的组织绩效指标关联起来,而后者又与投资回报率和盈利能力等业务指标相关。2016 年,他们首次评估持续集成,发现它有助于提高软件开发绩效。此后,每次调查都重复了这一发现。
使用持续集成并不会削弱小功能带来的其他优势。频繁发布小功能可以带来快速反馈循环,这对产品改进非常有益。许多使用持续集成的团队,也致力于构建精简的产品模块,并尽可能频繁地发布新功能。
特性分支
- 可以把功能中的所有代码作为一个整体进行质量评估。
- 只有功能完成后,功能代码才会被加入产品。
- 合并频率较低。
持续集成
- 支持高于功能开发周期的集成频率。
- 缩短发现冲突所需的时间。
- 合并规模更小。
- 鼓励重构。
- 需要团队致力于维护健康分支,因此需要自测试代码。
- 科学证据表明,它有助于提高软件交付绩效。
特性分支与开源
许多人将特性分支的流行归因于某代码托管平台,以及源自开源开发的拉取请求模型。鉴于此,理解开源工作与许多商业软件开发之间的巨大差异就显得非常重要。
开源项目的结构多种多样,但常见结构是由一个人或一个小组担任维护者,负责大部分编程工作。维护者与一群贡献者程序员合作。维护者通常并不认识这些贡献者,因此无法了解他们贡献代码的质量。维护者也无法确定贡献者实际会投入多少时间,更不用说他们的工作效率。
拉取请求
我认为,Pull Request 是一种支持特性分支与集成前评审相结合的机制。要决定是否以及如何使用 Pull Request,我会首先考虑这些底层模式在团队工作流中的作用。
在这种情况下,特性分支非常有意义。如果有人要添加一个功能,无论大小,而我不知道它何时甚至是否能够完成,那么我最好等到它完成后再进行集成。此外,代码审查也更加重要,因为我要确保它符合我对代码库质量的要求。
但许多商业软件团队的工作环境截然不同。这类团队由全职人员组成,所有成员都会投入大量时间,通常是全职参与软件开发。项目负责人对这些成员非常了解,除了他们刚加入的时候,因此可以对代码质量和交付能力形成可靠预期。由于他们是受薪员工,项目负责人对他们投入项目的时间,以及编码规范和团队习惯等事项,也拥有更大的控制力。
鉴于商业环境与开源环境的差异,商业团队的分支策略显然不必与开源领域的策略相同。持续集成几乎不适用于偶尔参与开源项目的贡献者,但对于商业项目而言,却是一个切实可行的替代方案。团队不应想当然地认为,适用于开源环境的方法也同样适用于他们自身不同的环境。
集成前评审 ✣
每次提交到主线之前,都必须经过同行评审才能被接受。
长期以来,代码审查一直被视为提高代码质量、增强模块化、提升可读性和消除缺陷的有效途径。尽管如此,商业组织往往发现,很难将代码审查融入软件开发工作流。然而,开源世界广泛接受了这样的理念:对项目的贡献在被纳入主线之前,应该经过审查。近年来,这种方法在开发组织中迅速传播,尤其是在硅谷。这种工作流与某代码托管平台的 Pull Request 机制非常契合。
这样的工作流始于 Scarlett 完成一项她希望集成到主线的工作。在她进行主线集成时,假设她的团队遵循此流程,一旦构建成功,但在推送到主线之前,她会提交这些代码进行审查。团队中的其他成员,比如 Violet,会对这次提交进行代码审查。如果 Violet 对提交的代码有疑问,她会提出意见,然后双方反复沟通,直到 Scarlett 和 Violet 都满意为止。只有当她们都满意后,这次提交才会被推送到主线。
预集成代码审查在开源项目中越来越受欢迎,因为它非常契合开源项目中资深维护者与偶尔贡献者组成的组织模式。预集成代码审查允许维护者密切关注所有贡献。它也与特性分支机制完美契合,因为特性的完成标志着进行代码审查的明确节点。如果你不确定贡献者是否会完成某个特性,为什么要审查他们未完成的部分呢?最好等到特性完成后再进行审查。这种做法也在某些海外大型互联网公司中广泛传播,它们还开发了专门工具来帮助顺利完成这项工作。
培养及时进行集成前评审的习惯至关重要。如果开发者完成一项工作后,转而去做其他事情几天,那么当评审意见返回时,他们可能已经忘记了这项工作。对于已经完成的功能来说,这令人沮丧;而对于部分完成的功能来说,情况更糟,因为在评审结果确认之前,很难继续推进开发。原则上,集成前评审可以与持续集成结合,实际上也确实可行,某海外大型互联网公司就采用了这种方法。但尽管可行,它很难做到,也相对少见。集成前评审与特性分支是更常见的组合。
何时使用
把开源软件和私有软件开发团队的需求混为一谈,就像是当前软件开发惯例中的原罪。
——卡米尔·富尼耶
尽管预集成评审在过去十年中已经成为一种流行实践,但它也存在缺点和替代方案。即使执行得当,预集成评审也总会给集成过程带来一定延迟,从而降低集成频率。结对编程提供了一种持续的代码审查流程,其反馈周期比等待代码审查更短。与持续集成和重构一样,它也是极限编程的原始实践之一。
许多采用集成前评审的团队,未能及时完成评审。他们提供的宝贵反馈往往来得太晚,难以发挥作用。此时,团队会面临一个尴尬选择:要么进行大量返工,要么接受一个可能有效但会损害代码库质量的方案。
代码审查并不限于代码提交到主线之前。许多技术负责人发现,在代码提交后进行审查也很有用,这样可以在发现问题时及时与开发者沟通。重构文化在这里至关重要。如果做得好,就能建立这样一个团队:团队中的每个人都会定期审查代码库,并修复发现的问题。我把这种做法称为代码细化评审。
集成前代码审查的利弊,主要取决于团队的社会结构。正如前面提到的,开源项目通常由少数值得信任的维护者和大量不受信任的贡献者组成。商业团队通常由全职人员组成,但其结构也可能类似。项目负责人像维护者一样信任少数维护者团队,甚至可能只有一个人,同时对团队其他成员贡献的代码保持警惕。团队成员可能同时参与多个项目,这使他们更像开源贡献者。如果存在这样的社会结构,那么集成前代码审查和特性分支就非常有意义。但信任度更高的团队通常会发现,其他机制也能在不增加集成阻力的情况下保持代码质量。
因此,虽然集成前评审可能是一种有价值的做法,但它绝不是构建健康代码库的必要路径,尤其是在你希望发展一个均衡团队,而不是过度依赖初始领导者时。
集成摩擦
Pull Request 会增加处理低信任场景所需的开销,例如允许你不认识的人为项目做贡献。
强迫自己团队的开发者提交 Pull Request,就像强迫家人回家前都要经过机场安检一样。
——基夫·莫里斯
预集成评审的一个问题是,它往往会增加集成的麻烦。这就是集成摩擦的一个例子:那些让集成变得耗时费力的活动。集成摩擦越多,开发者就越倾向于降低集成频率。想象一下,如果某个运转不良的组织坚持要求所有提交到主线的代码都必须填写一份需要半小时才能完成的表格,会发生什么?这样的制度会阻碍人们频繁集成。无论你对特性分支和持续集成持什么态度,都应该仔细审视任何会增加这种摩擦的因素。除非它能明显带来价值,否则这类摩擦都应该被消除。
手动流程是造成摩擦的常见原因,尤其是在需要与不同组织协调时。这类摩擦通常可以通过自动化流程、改进开发者培训,从而消除手动操作需求,以及将某些步骤推迟到部署流水线或生产环境测试后期阶段来减少。你可以在持续集成和持续交付的相关资料中,找到更多关于消除此类摩擦的建议。这类摩擦在产品上线过程中也会出现,并面临相同难题和应对方法。在实际研发管理中,如果需求、任务、代码提交、测试、发布和复盘知识分散在不同系统里,摩擦会被进一步放大;这时可以借助 PingCode 这类研发管理工具,把从目标制定、需求拆解、评审排期到开发、测试、发布和 Wiki 知识沉淀的流程串联起来,并与代码仓库、CI/CD 等研发工具打通,让团队更容易看到每一次变更从业务目标到代码上线的完整链路。
人们对持续集成犹豫不决的原因之一,是他们此前的工作环境往往集成阻力很大。如果一次集成需要花一个小时,那么一天多次集成显然不合理。加入一个集成轻而易举、几分钟就能完成的团队,感觉就像进入了另一个世界。我怀疑,关于特性分支和持续集成优劣的争论之所以如此混乱,很大程度上是因为人们没有同时体验过这两种环境,因此无法全面理解双方观点。
文化因素会影响集成摩擦,尤其是团队成员之间的信任。如果我是团队负责人,而且不信任同事能做好工作,那么我可能会想阻止那些可能破坏代码库的提交。这自然是集成前评审的驱动因素之一。但如果我所在的团队信任同事的判断,我可能更倾向于提交后评审,或者干脆取消评审,依靠定期的代码细化评审来解决问题。在这种情况下,我的优势在于消除了提交前评审带来的摩擦,从而鼓励更频繁的集成。通常,团队信任是特性分支与持续集成之争中最关键的因素。
Rouan Wilsenach 的 Ship/Show/Ask 方法很有意思。它在必要时保留集成前审查,同时鼓励减少摩擦。该方法将变更分为三类:Ship,直接集成到主线;Show,集成到主线,但创建一个 Pull Request 用于沟通和讨论变更;Ask,创建一个 Pull Request 进行集成前审查。
模块化的重要性
大多数关注软件架构的人都会强调,模块化对于构建良好系统非常重要。如果我需要对一个模块化程度低的系统做一点小改动,往往必须理解几乎所有代码,因为即使是微小改动也可能波及代码库的许多部分。然而,如果系统模块化程度高,我只需要理解一两个模块的代码,以及另外几个模块的接口,其余部分就可以暂时忽略。正因为模块化能够显著降低理解成本,所以随着系统规模扩大,在模块化方面投入大量精力是值得的。
模块化也会影响集成。如果系统拥有良好的模块化设计,那么 Scarlett 和 Violet 大部分时间都会在代码库中彼此隔离的区域工作,她们的修改也不容易产生冲突。良好的模块化还能增强关键接口和基于抽象的分支等技术,从而避免使用源代码分支来提供隔离。很多时候,团队被迫使用源代码分支,是因为缺乏模块化,导致他们别无选择。
特性分支是一种低配版的模块化架构:它不是构建一个能够在运行时或部署时轻松替换特性的系统,而是通过手动合并,把自己与源代码控制系统提供的机制绑定在一起。
——丹·博达特
这种支持是双向的。尽管我们做过很多尝试,但在开始编程之前就设计出良好的模块化架构,仍然极其困难。为了实现模块化,我们需要持续监控系统的增长,并让它朝着更加模块化的方向发展。重构是实现这一目标的关键,而重构又需要高频集成。因此,模块化和快速集成在健康代码库中是相辅相成的。
总而言之,模块化虽然难以实现,但绝对值得投入。这需要良好的开发实践,需要学习设计模式,也需要从代码库的经验中吸取教训。对于混乱的代码合并,我们不应该只是因为想要忘记它而草草了事,而应该探究合并混乱的原因。这些答案往往能为如何改进模块化、提升代码库健康状况以及提高团队效率提供重要线索。
关于集成模式的个人思考
我作为作者的目的,并不是说服你走某条特定道路,而是告诉你,在决定走哪条路时应该考虑哪些因素。尽管如此,我还是会在这里补充一下自己对前面几种模式的看法。
总体来说,我更喜欢在采用持续集成的团队中工作。我明白具体情况很重要,在很多情况下,持续集成并不一定是最佳选择,但我会努力改变这种现状。我之所以有这样的偏好,是因为我希望身处一个每个人都能轻松重构代码库、提升模块化程度、维护代码健康的环境中。所有这些,都是为了让我们能够快速响应不断变化的业务需求。
如今,我更倾向于写作而不是开发,但我仍然选择与某海外咨询公司保持长期合作。这家公司里有很多人推崇这种工作方式。原因是,我相信极限编程是目前最有效的软件开发方式之一,我希望看到更多团队进一步发展这种方法,从而提升整个行业的效率。
从主线版本到生产版本的路径
主线是一条活跃分支,会定期接收新增和修改后的代码。保持主线健康至关重要,因为这样人们在开始新工作时,就能建立在一个稳定基础之上。如果主线足够健康,你还可以直接从主线把代码发布到生产环境。
保持主线始终处于可发布状态,是持续交付的核心原则。为此,团队必须具备维护主线健康的决心和技能,通常还需要部署流水线来支持所需的密集测试。
采用这种方式工作的团队,通常可以通过在每个已发布版本上打标签来跟踪发布情况。但未使用持续交付的团队,则需要另一种方法。
发布分支 ✣
一条只接受稳定产品版本所需提交的分支,用于让产品达到可发布状态。
典型的发布分支会从当前主线复制代码,但不允许添加任何新功能。主开发团队会继续向主线添加这些功能,这些功能将在未来版本中合并。负责发布分支的开发者,则专注于修复所有阻碍版本进入生产环境的缺陷。所有缺陷修复都会在发布分支上完成,然后合并回主线。一旦所有缺陷都修复完毕,这条分支就可以用于生产发布。
尽管发布分支上的修复工作量通常小于新增功能代码,但随着时间推移,将它们合并回主线会变得越来越困难。分支不可避免会产生分歧,因此,随着越来越多提交修改主线,将发布分支合并回主线的难度也会增加。
以这种方式将提交应用到发布分支的一个问题是,很容易忘记把它们复制回主线,尤其是在分支分歧加剧时。这会导致令人尴尬的回归。因此,有些人更倾向于在主线上创建修复提交,只有在需要时才将它们 cherry-pick 到发布分支。
cherry-pick 操作是指将一个提交从一个分支复制到另一个分支,但并不合并这两个分支。也就是说,它只复制这一个提交,而不是从分支点开始复制所有提交。在这个例子中,如果我将 F1 合并到发布分支,那么这会包括 M4 和 M5。但 cherry-pick 只复制 F1。由于发布分支可能依赖 M4 和 M5 中的更改,因此 cherry-pick 操作可能无法完全应用到发布分支。
在主线上编写发布修复的缺点在于,许多团队发现这样做更难,而且令人沮丧的是,他们可能在主线上用一种方式修复了问题,却不得不在发布分支上进行调整才能最终发布。当发布压力较大时,这种情况尤其明显。
如果团队每次只在生产环境中使用一个版本,那么只需要一个发布分支。但有些产品在生产环境中会同时存在多个版本。运行在客户设备上的软件,通常只有在客户要求时才会升级。许多客户不愿升级,除非有令人信服的新功能,因为他们曾经因升级失败而吃过亏。然而,这些客户仍然希望获得错误修复,尤其是涉及安全问题的修复。在这种情况下,开发团队会为每个仍在使用的版本保留一个发布分支,并根据需要应用修复。
随着开发进程推进,修复旧版本的问题会变得越来越困难,但这通常是商业运作的代价。唯一能缓解这种情况的方法,是鼓励客户频繁升级到最新版本。保持产品稳定至关重要,因为一旦客户因升级遭受损失,他们就不愿意再次进行不必要的升级。
我还听说过其他一些用来指代发布分支的术语,例如“发布准备分支”“稳定分支”“候选分支”和“强化分支”。但“发布分支”似乎是最常用的说法。
何时使用
当团队无法维护主线代码健康状态时,发布分支是一个非常有用的工具。它允许团队的一部分成员专注于必要的错误修复,以确保产品能够顺利上线生产环境。测试人员可以从该分支最新版本中提取最新的稳定候选版本。所有人都可以看到为了稳定产品所做的工作。
尽管发布分支很有价值,但大多数优秀团队不会在单一生产产品中使用这种模式,因为他们不需要。如果主线代码保持足够健康,那么任何提交到主线的代码都可以直接发布。在这种情况下,发布版本应该使用公开可见的版本号和构建号进行标记。
你可能已经注意到,我在上一段使用了略显笨拙的限定语“单一生产产品”。这是因为当团队需要在生产环境中管理多个版本时,这种模式就变得至关重要。
当发布流程中存在显著摩擦时,发布分支也尤其重要。例如,所有正式发布都需要经过发布委员会审批。正如某位工程实践专家所说:“在这种情况下,发布分支更像是一个隔离区,让公司内部流程慢慢运转起来。”总体来说,我们应该尽可能消除发布流程中的此类摩擦,就像我们需要消除集成摩擦一样。然而在某些情况下,比如移动应用商店,这一点可能无法实现。在很多情况下,使用标签就足够了,只有当源代码需要进行一些必要更改时,才需要创建分支。
发布分支也可以是环境分支,但需要注意使用该模式时的一些问题。此外,还有一种长期存在的发布分支变体,我稍后会详细介绍。
成熟度分支 ✣
分支头部标记代码库达到某一成熟度的最新版本。
团队通常想知道源代码的最新版本是什么,但对于成熟度不同的代码库来说,这可能很复杂。例如,QA 工程师可能需要查看产品最新的测试版本,而负责调试生产环境故障的人,则需要查看最新的生产版本。
成熟度分支提供了一种跟踪代码版本的方法。其核心理念是,一旦某个版本的代码库达到一定成熟度,就将其复制到相应分支中。
以生产环境的成熟度分支为例。当我们准备发布生产版本时,会创建一个发布分支来稳定产品。一旦准备就绪,就将其复制到长期存在的生产分支。我认为这更像复制,而不是合并,因为我们希望生产代码与上游分支中经过测试的代码完全一致。
成熟度分支的优势之一在于,它清晰展示了发布流程中每个阶段对应的代码版本。因此,在上面的例子中,我们只需要在生产分支上提交一个合并了 M1 到 M3 和 F1 到 F2 的版本。虽然需要一些源代码管理技巧才能做到这一点,但无论如何,这样做都会丢失与主线细粒度提交的关联。这些细粒度提交应该记录在提交信息中,以便后续人员追踪。
成熟度分支通常以开发流程中的对应阶段命名,因此会有“生产分支”“测试分支”“QA 分支”等术语。有时,我也听到有人把生产成熟度分支称为“发布分支”。
何时使用
源代码控制系统支持协作,并跟踪代码库的历史记录。使用成熟度分支,可以让用户通过发布工作流中特定阶段的版本历史来获取一些重要信息。
我可以通过查看相关分支的头部来找到最新版本,例如当前正在生产环境中运行的代码。如果出现一个此前不存在的错误,我可以查看该分支上的历史版本,了解生产环境中具体发生过哪些代码库变更。
自动化流程也可以与特定分支上的更改关联起来。例如,每当生产分支发生提交时,自动化流程就可以将版本部署到生产环境。
除了使用成熟度分支,也可以采用标签方案。一旦某个版本准备好进入质量保证阶段,就可以给它打标签,通常标签会包含构建编号。例如,当构建版本 762 准备好进行质量保证时,可以标记为 qa-762;当它准备好进入生产环境测试时,则标记为 prod-762。之后,我们可以通过在代码库中搜索符合标签方案的标签,获取版本历史记录。自动化流程也可以基于标签分配来实现。
因此,成熟度分支可以为工作流带来一些便利,但许多组织发现使用标签已经足够。所以我认为这种模式既没有明显优势,也没有明显劣势。然而,通常情况下,如果需要使用源代码管理系统来做此类跟踪,往往说明团队的部署流水线工具还不够完善。
变体:长期发布分支
我把它视为发布分支模式的一种变体,它结合了发布分支和成熟度分支,用于发布候选版本。当我们想发布新版本时,会将主线代码复制到这条发布分支中。与每个发布版本都使用单独分支一样,只有为了提高稳定性,才会向发布分支提交代码。这些修复也会合并回主线。发布完成后,我们会给该版本打标签。需要再次发布新版本时,可以再次将主线代码复制到这条发布分支中。
提交可以像成熟度分支中常见的那样复制,也可以合并。如果选择合并,就必须确保发布分支的头部与主线分支的头部完全一致。一种方法是在合并之前撤销所有已经应用于主线分支的修复。有些团队还会在合并后压缩提交,以确保每个提交都代表一个完整的发布候选版本。如果觉得这种方法麻烦,他们完全有理由为每个版本创建一个新分支。
这种方法只适用于每次只生产一个版本的产品。
团队喜欢这种方法的原因之一是,它确保发布分支的头部始终指向下一个候选版本,而不需要手动查找最新发布分支的头部。不过,至少在 Git 中,我们可以通过设置一个名为 release 的分支来实现同样的效果。当团队创建新的发布分支时,该分支名会被硬重置,并在旧发布分支上留下一个标签。
环境分支 ✣
通过提交源代码更改,配置产品,使其能够在新环境中运行。
软件通常需要在不同环境中运行,例如开发者工作站、生产服务器,以及各种测试和预发布环境。在这些不同环境中运行,通常需要进行一些配置更改,例如访问数据库的 URL、消息系统的位置,以及关键资源的 URL。
环境分支是指包含配置更改提交的分支,这些提交用于重新配置产品,使其能够在不同环境中运行。例如,我们可能正在运行主线版本 2.4 的产品,现在希望把它部署到预发布服务器上。为此,我们需要创建一条从版本 2.4 开始的新分支,应用相应的环境更改,重新构建产品,然后将其部署到预发布环境。
这些更改通常是手动应用的,不过如果负责人熟悉 Git,也可以从较早的分支中 cherry-pick 这些更改。
环境分支模式通常会与成熟度分支结合使用。一条长期存在的 QA 成熟度分支,可能包含 QA 环境的配置调整。合并到该分支的代码会应用这些配置更改。类似地,一条长期存在的发布分支也可能包含这些配置更改。
何时使用
环境分支是一种很有吸引力的方法。它允许我们根据需要对应用程序进行任何调整,使其适应新环境。我们可以把这些更改保存在差异文件中,以便在未来产品版本中选择性合并。然而,这恰恰是反模式的经典案例:乍看很有吸引力,但很快就会导致一系列问题,甚至引发各种麻烦。
环境变更带来的潜在风险在于,应用程序的行为可能随着环境迁移而改变。如果我们无法把生产环境中运行的版本移植到开发者工作站进行调试,修复问题就会变得更加困难。我们可能会引入一些只在特定环境中出现的错误,其中生产环境风险最高。正因如此,我们希望尽可能确保生产环境中运行的代码与所有其他环境中的代码保持一致。
环境分支的问题,恰恰在于它的灵活性,而这正是它看起来如此吸引人的原因。由于我们可以在这些差异中修改代码的任何方面,因此很容易引入配置补丁,从而导致不同行为以及随之而来的各种错误。
因此,许多组织明智地坚持一条铁律:一旦可执行文件编译完成,它必须在所有环境中运行,而且版本必须完全相同。如果需要进行配置更改,必须通过显式配置文件、环境变量等机制进行隔离。这样可以将更改限制为对运行过程中不会改变的常量进行设置,从而最大限度减少 bug 滋生的可能性。
对于直接执行源代码的软件,例如 JavaScript、Python、Ruby,可执行文件与配置之间的简单边界很容易变得模糊,但基本原则仍然适用。尽量减少环境变更,并且不要使用源代码分支来应用这些变更。一般而言,你应该能够检出产品的任何版本,并在任何环境中运行它。因此,任何纯粹由于部署环境不同而产生的变更,都不应该纳入源代码控制。虽然有人认为,默认参数组合应该存储在源代码控制中,但应用程序的每个版本都应该能够根据环境变量等动态因素,在不同配置之间按需切换。
环境分支是使用源代码分支实现模块化架构的一个例子。如果应用程序需要在不同环境中运行,那么在不同环境之间切换的能力必须是其设计的核心组成部分。环境分支可以作为一种权宜之计,用于弥补应用程序设计上的不足,但之后应该优先移除,并寻找更可持续的替代方案。
热修复分支 ✣
创建一条分支,用于处理紧急生产缺陷的修复工作。
如果生产环境中出现严重漏洞,必须尽快修复。修复这个漏洞的优先级高于团队正在进行的任何其他工作,任何其他工作都不应影响这项紧急修复的进度。
紧急修复工作需要在源代码控制系统中进行,以便团队能够正确记录和协作。他们可以基于最新发布版本创建一条分支,并将紧急修复所需的任何更改应用到这条分支上。
一旦修复程序应用到生产环境,并且所有人都睡了个好觉,就可以把热修复合并回主线,确保下一个版本不会出现回归问题。如果下一个版本有单独的发布分支,那么热修复也需要合并到该发布分支。如果两次发布之间间隔较长,那么热修复很可能是在代码已经发生变化的情况下编写的,因此合并起来会比较麻烦。在这种情况下,编写能够暴露 bug 的良好测试尤为重要。
如果团队使用发布分支,可以在发布分支上进行热修复工作,完成后再发布新版本。本质上,这会把原有发布分支转换为热修复分支。
与发布分支一样,也可以在主线上进行热修复,然后将其 cherry-pick 到发布分支。但这并不常见,因为热修复通常是在时间非常紧迫的情况下完成的。
如果团队采用持续交付,就可以直接从主线发布热修复补丁。他们可能仍然会使用热修复分支,但会从最新提交开始,而不是从上次发布的提交开始。
我将新版本命名为 2.2.1,因为如果团队采用这种工作方式,M4 和 M5 很可能不会发布新功能。如果有,那么这个热修复很可能会被合并到 2.3 版本中。这当然说明,在持续交付模式下,热修复无须绕过正常发布流程。如果团队拥有足够高效的发布流程,热修复可以像往常一样处理,这正是持续交付理念的一大优势。
对于持续交付团队来说,一种特殊处理方式是禁止向主线提交任何代码,直到热修复完成。这符合“修复主线代码是头等大事”的理念。事实上,对于主线上发现的任何缺陷,即使尚未部署到生产环境,也应该如此处理。所以我认为,这其实算不上特殊处理。
何时使用
紧急修复通常发生在压力巨大的时期,而团队在压力最大时也最容易犯错。在这种情况下,使用源代码控制,并比平时更频繁地提交代码,就显得尤为重要。把这项工作保留在分支上,可以让每个人都知道团队正在采取哪些措施解决问题。唯一的例外是可以直接应用到主线的简单修复。
这里更有意思的问题在于,如何判断哪些漏洞需要紧急修复,哪些可以保留在正常开发流程中。团队发布频率越高,就越能把生产环境中的漏洞修复纳入常规开发节奏。大多数情况下,这个决定主要取决于漏洞对业务的影响,以及这种影响与团队发布频率之间的关系。
发布列车 ✣
按照固定时间间隔发布,就像火车按时发车一样。开发者完成功能后,选择搭乘哪一趟“列车”发布。
使用发布列车的团队会设定固定发布节奏,例如每两周一次或每六个月一次。团队会设定每次发布分支的创建日期,就像火车时刻表一样。团队成员会决定自己开发的功能要赶上哪趟列车,并围绕这趟列车进行开发,在列车启动时将代码提交到相应分支。一旦列车出发,该分支就成为发布分支,并且只接受修复。
使用每月发布新版本的团队,会基于二月份的版本创建一个三月份分支。他们会在三月份陆续添加新功能。在固定日期,例如当月第三个星期三,新版本会发布,该分支的功能也会随之冻结。他们会创建一个四月份的新分支,并向其中添加新功能。与此同时,一些开发者会稳定三月份版本,并在准备就绪后将其发布到生产环境。任何应用于三月份版本的修复,都会合并到四月份版本中。
发布列车通常与特性分支一起使用。当 Scarlett 预估自己何时能够完成某个功能时,她会决定搭乘哪趟列车。如果她认为可以在三月份发布周期内完成,就会加入三月份列车;否则,她会等待下一趟列车并加入其中。
有些团队会在正式发布,也就是“硬冻结”之前几天进行软冻结。一旦发布列车进入软冻结状态,开发者就不应再向列车推送任何工作,除非确信功能稳定且已经准备好发布。软冻结之后添加的任何功能,如果存在缺陷,都将被回滚,也就是从列车上移除,而不是在列车上修复。
如今,人们一提到“发布列车”,通常指的是某规模化敏捷框架中的敏捷发布列车概念。该概念是一种团队组织结构,指的是一个由多个团队组成的大型团队,他们共享一套共同的发布列车计划。虽然它也采用了发布列车模式,但与我在这里描述的概念并不完全相同。
何时使用
发布列车模式的核心,在于发布过程的规律性。如果你提前知道发布列车什么时候发车,就可以规划功能,确保赶上那趟列车。如果你觉得无法在三月份列车前完成功能,那么你至少可以确定自己能赶上下一趟。
当发布流程中存在显著阻力时,发布列车就尤其重要。例如,外部测试团队可能需要几周时间验证版本,或者发布委员会需要在产品新版本发布前达成一致。在这种情况下,通常更明智的做法,是尽量消除发布阻力,从而实现更频繁的发布。当然,在某些情况下,这几乎不可能,例如移动应用商店的验证流程。此时,调整发布列车以适应这些发布阻力,可能是最大化利用现有资源的方式。
发布机制有助于让大家把注意力集中在哪些功能应在何时出现,从而帮助预测功能完成时间。
这种方法的一个明显缺点是,项目初期完成的功能就像坐在火车上看书一样,只能等待列车出发。如果这些功能很重要,那就意味着产品在接下来几周甚至几个月里都会缺少一项重要能力。
发布列车是改进团队发布流程的重要阶段。如果团队难以稳定发布,那么直接跳到持续交付可能步子太大。选择一个合适的发布列车周期,既要有挑战性,又要切实可行,是一个很好的第一步。随着团队技能提升,他们可以逐步增加发布列车频率,并最终随着能力增强而放弃发布列车,转向持续交付。
变体:预加载未来列车
最基本的功能列车示例是,新一列功能列车在前一列功能列车离站时抵达站台,开始接收新功能。但另一种方法是让多列功能列车同时接收功能。如果 Scarlett 认为自己的功能无法赶上三月份列车,她仍然可以把基本完成的功能放到四月份列车上,并在列车出发前继续提交更多代码以完成它。
我们会定期将三月份已合并的代码拉入四月份的集成代码库。有些团队倾向于只在三月份集成代码库发车时才进行合并,这样只需要合并一次。但我们这些深知小规模合并效率更高的人,则更倾向于尽快将三月份的所有提交拉入集成代码库。
预加载未来代码线,可以让负责四月份功能的开发者在不影响三月份代码线开发的情况下进行协作。但缺点是,如果四月份开发者修改的内容与三月份工作冲突,三月份开发者无法获得反馈,从而让未来代码合并更加复杂。
与主线之外的定期发布相比
发布列车的主要优势之一,是能够定期向生产环境发布新版本。但是,为新开发创建多个分支会增加复杂性。如果我们的目标是定期发布,那么使用主线同样可以达到目的。只需确定发布计划,然后根据这个计划,从主线头部创建发布分支即可。
如果主线已经准备就绪,就不需要创建发布分支。对于这种定期发布模式,开发者仍然可以选择将即将完成的功能推迟到下一个版本发布。方法是在常规发布日期临近时,不将其推送到主线。借助持续集成,如果开发者希望某个功能等到下一个计划版本再发布,可以延迟提交关键代码,或者关闭功能开关。
可发布的主线 ✣
保持主线足够健康,使主线头部始终可以直接投入生产。
在本节开头,我提到过,如果你把主线设置为健康分支,并把健康检查标准设得足够高,那么就可以随时直接从主线发布,并使用标签记录发布。
我花了很多时间描述这种简单机制的替代方案,所以现在是时候重点介绍这个方案了。因为如果一个团队能够做到这一点,那将是一个绝佳选择。
即使提交到主线的每个更改都可发布,也不意味着它就应该发布。这就是持续交付和持续部署之间的微妙区别。使用持续部署的团队,会发布每个被主线接受的更改;而使用持续交付时,虽然每个更改都可发布,但是否发布取决于业务决策。因此,持续部署是持续交付的一个子集。我们可以把持续交付理解为:它赋予我们随时发布的权利,而是否行使这项权利,则取决于更广泛的因素。
何时使用
持续集成是持续交付的一部分,而随时可发布的主线,是高效团队的常见特征。鉴于此,再加上我众所周知地热衷持续交付,你可能会以为我会说:随时可发布的主线,始终优于本节描述的其他替代方案。
然而,模式是否有效取决于上下文。在一种情况下表现优异的模式,在另一种情况下可能会成为陷阱。可发布主线的有效性取决于团队的集成频率。如果团队使用特性分支,而且通常每月才集成一次新功能,那么团队很可能处于不利状态,而坚持主线随时可发布,反而可能成为改进的障碍。这种不利状态体现在:团队无法响应不断变化的产品需求,因为从构想到生产的周期过长。此外,由于每个功能都很大,他们很可能面临复杂的合并和验证流程,从而导致许多冲突。这些冲突可能在集成阶段显现,也可能在开发者从主线拉取代码到特性分支时持续消耗精力。这种拖累会抑制重构,降低模块化程度,从而加剧问题。
摆脱这种困境的关键,是提高集成频率。但在很多情况下,要保持主线始终可发布并不容易。在这种情况下,通常更好的做法是先放弃主线随时可发布的要求,鼓励更频繁的集成,并使用发布分支来稳定主线的生产版本。当然,随着时间推移,我们希望通过改进部署流程,最终消除对发布分支的需求。
在高频集成环境下,可直接发布的主线显然具有简洁优势。我们不需要像前面描述的那样费心处理各种复杂分支。即使是热修复补丁,也可以直接应用到主线,然后部署到生产环境,因此不再需要为它们起特殊名称。
此外,保持主线随时可发布有助于培养一种宝贵习惯。它让开发者始终关注生产就绪状态,确保问题不会逐渐渗入系统。无论是缺陷还是流程问题,都不应拖慢产品周期。完整的持续交付流程,也就是开发者每天多次将代码集成到主线而不破坏其完整性,对许多人来说似乎难以企及。然而,一旦掌握并养成习惯,团队会发现它能显著减轻压力,而且相对容易坚持。正因如此,它是敏捷流畅度模型中交付领域的关键要素。
其他源代码分支模式
本文主要探讨团队集成模式以及上线生产路径。但还有一些其他模式,我也想简单提及。
实验分支 ✣
收集代码库中的实验性工作,这些工作预计不会直接合并到产品中。
实验分支是开发者尝试想法的地方,但他们并不期待这些更改会直接合并回主线。我可能发现一个新库,认为它可以很好地替代我们正在使用的库。为了帮助自己决定是否切换过去,我会创建一个分支,并尝试用它编写或重写系统中的相关部分。这样做的目的不是向代码库贡献代码,而是了解新工具在特定环境中的适用性。我可能独自完成这项工作,也可能与一些同事合作。
同样,如果我需要实现一个新功能,并想到了几种实现方法,我可能会花几天时间研究每种方案,以决定采用哪一种。
关键在于,我预期实验分支上的代码会被弃用,不会合并到主线。这并非绝对。如果我碰巧喜欢最终结果,而且代码也容易集成,当然不会错过这个机会。但我并不指望这种情况发生。我可能会放宽一些常规做法,减少测试,随意重复一些代码,而不是尝试干净利落地重构。如果我喜欢这个实验,预计会从头开始,把这个想法应用到生产代码中,以实验分支作为参考和指导,但不会保留它的提交历史。
完成实验分支工作后,我通常会在 Git 中添加一个标签,然后删除该分支。这个标签会保留这条代码线,以便我以后重新查看。我会使用一些约定,例如让标签名称以 exp 开头,以明确其性质。
何时使用
当我想尝试某些东西,但又不确定最终是否会使用它时,实验分支非常有用。这样,我就可以随心所欲地做任何想做的事,哪怕做法很古怪,而且我也确信自己可以轻松把它搁置一边。
有时候,我以为自己在做常规工作,但后来意识到自己其实是在做实验。如果出现这种情况,可以创建一个新的实验分支,并将主工作分支重置到上一个稳定提交。
未来分支 ✣
一条单独分支,用于处理其他方法无法处理的、侵入性过强的更改。
这种情况并不常见,但在持续集成过程中偶尔会出现。有时,团队需要对代码库进行一些影响很大的更改,而常用集成方法并不适用。在这种情况下,团队会采取类似特性分支的做法:创建一条未来分支,并且只从主线拉取代码,直到最后才进行主线集成。
未来分支与特性分支最大的区别在于,未来分支只有一条。因此,未来分支上的开发者不会偏离主线太远,也不需要处理其他分支。
可能有多名开发者在未来分支上工作。在这种情况下,他们会在未来分支上进行持续集成。集成时,他们首先会将主线代码拉取到未来分支,然后再集成自己的更改。这会减慢集成过程,但这是使用未来分支必须付出的代价。
何时使用
我必须强调,这是一种罕见模式。我怀疑大多数实施持续集成的团队都不需要它。我只在对系统架构进行特别深入更改时见过它。通常,这是最后手段,只有在我们无法找到其他替代方案,例如基于抽象的分支时,才会使用。
未来分支仍应尽可能短小,因为它们会在团队中造成分裂,就像任何分布式系统中的分区一样,我们需要将其控制在绝对最小范围内。
协作分支 ✣
为方便开发者与团队其他成员共享工作成果而创建的分支,但这些工作尚未正式集成。
当团队使用主线时,大部分协作都通过主线进行。只有发生主线集成时,团队其他成员才能看到开发者正在做什么。
有时,开发者希望在集成之前分享自己的工作成果。创建协作分支可以让他们随时进行这种协作。这条分支可以推送到团队的中央代码库,协作者也可以直接从彼此的个人代码库中拉取和推送,或者创建一个短期代码库来处理协作工作。
协作分支通常是临时的,一旦工作成果集成到主线,就会关闭。
何时使用
随着集成频率降低,协作分支的作用会越来越大。如果团队成员需要协调对某个对多人都重要的代码区域的更改,那么长期存在的特性分支通常需要非正式协作。然而,使用持续集成的团队可能永远不需要创建协作分支,因为他们彼此无法看到对方工作的时间很短。主要例外是实验分支。顾名思义,实验分支永远不会被集成。如果多人共同进行一项实验,就需要将实验分支也设置为协作分支。
团队集成分支 ✣
允许子团队之间先相互集成,然后再与主线集成。
大型项目可能涉及多个团队,但这些团队使用同一个逻辑代码库。团队集成分支允许团队成员之间先相互集成,而不必立即与使用主线代码的项目所有成员集成。
实际上,团队会把团队集成分支视为团队内部的主线,并像集成到整个项目主线一样集成它。除了这些团队内部集成之外,团队还会单独开展一项工作,把团队集成分支集成到项目主线。
何时使用
使用团队集成分支最明显的驱动因素是,当代码库由大量开发者积极开发时,将他们拆分成不同团队似乎很合理。但我们应该对这种假设保持警惕,因为我遇到过很多团队,表面看规模太大,似乎无法在同一主线上工作,但最终都做到了。我甚至收到过一百名开发者共同使用一条主线的报告。
团队集成分支更重要的驱动因素,是期望集成频率存在差异。如果项目整体要求团队以几周为周期创建特性分支,但某个子团队更倾向于持续集成,那么该团队可以创建团队集成分支,在该分支上进行持续集成,并在功能完成后将其集成到主线。
如果整个项目的健康分支标准与子团队的健康标准存在差异,也会出现类似情况。如果整个项目无法把主线维护到足够高的稳定性,子团队可能会选择更严格的健康标准。同样,如果子团队难以让提交代码达到主线所需的健康水平,他们可能会选择使用团队集成分支,并使用自己的发布分支来稳定代码,然后再将其合并到主线。我通常不赞成这种做法,但在某些特别棘手的情况下,它可能是必要的。
我们还可以把团队集成分支视为协作分支的一种结构化形式。它基于正式项目组织,而不是临时协作。
常见分支策略:Git-flow、平台流程与主干开发
在本文中,我从模式角度讨论了分支。我这样做并不是为了提倡唯一的分支方法,而是为了说明人们常用的分支方式,并反思它们在软件开发各种场景下的权衡。
多年来,人们提出过许多分支策略。为了理解它们的工作原理以及最佳应用场景,我尝试用脑海中一些模糊的模式来评估它们。现在,我终于把这些模式整理并记录下来,因此有必要审视一些策略,并结合这些模式来思考它们。
Git-flow
Git-flow 已经成为我遇到的最常见分支策略之一。它由 Vincent Driessen 于 2010 年提出,当时 Git 正逐渐流行起来。在 Git 出现之前,分支通常被视为一个高级主题。Git 使分支更具吸引力,部分原因是工具本身的改进,例如更好地处理文件移动;但也因为克隆仓库本质上就是一种分支,因此在推送回中央仓库时,需要像处理合并问题一样进行思考。
Git-flow 使用主线,称为 develop,并位于同一个 origin 仓库中。它使用特性分支来协调多个开发者。它鼓励开发者使用自己的个人仓库作为协作分支,以便与其他从事类似工作的开发者协作。
传统上,Git 的核心分支名为 master。在 Git-flow 中,master 被用作生产成熟度分支。Git-flow 使用发布分支,使工作从 develop 分支经由发布分支流向 master 分支。热修复则通过热修复分支组织。
Git-flow 并未规定特性分支的长度,因此也没有规定预期集成频率。它也没有说明主线是否应该是一条健康分支,如果是,又需要达到怎样的健康程度。发布分支的存在意味着,它并不是一条可发布的主线。
正如 Driessen 在后来附录中指出的那样,Git-flow 的设计初衷是用于那些在生产环境中发布多个版本的项目,例如安装在客户现场的软件。存在多个运行版本,当然是使用发布分支的主要原因之一。然而,许多用户是在单生产环境 Web 应用的背景下接触到 Git-flow 的。在这种情况下,这种分支结构很容易变得比实际需要更加复杂。
虽然 Git-flow 非常流行,很多人都声称自己在使用它,但实际上,很多声称使用 Git-flow 的人可能在做完全不同的事情。他们实际采用的方法往往更接近某代码托管平台流程。
某代码托管平台流程
尽管 Git-flow 的确流行起来,但它的分支结构对 Web 应用来说过于复杂,因此催生了许多替代方案。随着某代码托管平台日益普及,其开发者使用的一种分支策略逐渐成为广为人知的策略,这并不令人意外。某位技术专家对此的描述最为清晰。
这种流程的名字本身就暗示了它有意借鉴 Git-flow,同时又反其道而行之,这一点并不奇怪。两者的本质区别在于产品类型不同,这意味着不同应用场景,进而导致不同开发模式。Git-flow 假设产品在生产环境中存在多个版本。而这种代码托管平台流程假设生产环境中只有一个版本,并且高频集成到已发布的主线中。在这种情况下,发布分支就显得多余。生产环境中的问题修复方式与常规功能开发相同,因此也不需要热修复分支,因为热修复分支通常意味着偏离常规流程。移除这些分支,极大简化了分支结构,使其只剩下主线分支和特性分支。
这种流程将主线称为 master。开发者使用特性分支进行开发。为了保证可见性,他们会定期将特性分支推送到中央仓库,但特性分支只有在完成后才会与主线集成。相关实践者指出,特性分支可以只有一行代码,也可以持续数周。无论哪种情况,流程都应该保持一致。在这种代码托管平台流程中,Pull Request 机制是主线集成的一部分,并使用集成前评审。
Git-flow 和这种代码托管平台流程经常被混淆。所以和以往一样,要真正理解它们的区别,不要只看名称。它们的核心理念都是使用主线分支和特性分支。
基于主干的开发
正如我前面所写,我通常听到“基于主干的开发”被用作持续集成的同义词。但把基于主干的开发视为 Git-flow 和某代码托管平台流程之外的一种分支策略替代方案,也是合理的。某位主干开发实践者专门维护了一个网站,对这种方法进行深入解释。这位实践者曾是我的老同事,在某海外咨询公司工作期间,他凭借精湛技能,成功帮助客户打破了僵化的分支结构。
基于主干的开发专注于在主线,也就是“主干”上完成所有工作,从而避免任何长期存在的分支。小型团队使用主线集成,直接向主线提交代码。大型团队可以使用生命周期较短的特性分支,这里的“短”指不超过几天。在实践中,这可能等同于持续集成。团队可以使用发布分支,也可以使用准备好发布的主线,也就是“从主干发布”。
总结:如何选择源代码分支策略
自最早的程序出现以来,人们就发现,如果想要一个与现有程序功能略有不同的程序,只要复制源代码,并根据需要修改即可。有了源代码,我就可以随心所欲地进行任何更改。但这样做会使我的副本更难接受原始源代码中的新功能和错误修复。随着时间推移,这可能变得不可能。许多企业在早期 COBOL 程序中发现过这一点,如今,高度定制化的 ERP 软件包也面临同样问题。即使不使用“源代码分支”这个名称,任何时候我们复制源代码并对其进行修改,都属于源代码分支,即使没有使用版本控制系统。
正如我在文章开头所说:分支很容易,合并很困难。分支是一种强大的技术,但它让我想到 goto 语句、全局变量和并发锁。它们功能强大、易于使用,但也更容易被滥用,常常成为粗心者和经验不足者的陷阱。源代码控制系统可以通过仔细跟踪变更来帮助控制分支,但最终,它们只能见证问题的存在。
我并不是说分支代码是邪恶的。在日常工作中,例如多个开发者共同维护同一个代码库时,谨慎使用分支至关重要。但我们应该始终保持警惕,并牢记帕拉塞尔苏斯的名言:良药与毒药的区别在于剂量。
因此,我关于分支的第一条建议是:每当你考虑使用分支时,都要先弄清楚它将如何合并。无论你使用什么技术,本质上都是在权衡利弊。如果不了解某种技术的全部成本,就无法做出明智的取舍。而对于分支来说,它的成本会在合并时显现出来。
因此,下一条准则是:务必了解分支的替代方案,它们通常更优。记住博达特定律:能否通过提高模块化程度来解决问题?能否改进部署流程?添加标签是否已经足够?流程中哪些改动会使这条分支变得不再必要?分支很可能确实是当前最佳方案,但它也可能预示着更深层的问题,需要在接下来几个月内解决。通常来说,消除对分支的需求是一件好事。
记住 LeRoy 的图示:分支如果不进行集成,就会呈指数级发散。因此,请关注你集成分支的频率。目标是把集成频率提高一倍。显然,这里存在一个极限,但除非你已经处于持续集成阶段,否则通常很难接近这个极限。提高集成频率会遇到一些障碍,但这些障碍往往正是需要大力清除的东西,因为清除它们可以改进你的开发流程。
由于合并是分支开发中最难的部分,因此要密切关注导致合并困难的原因。有时是流程问题,有时是架构缺陷。无论原因是什么,都不要陷入斯德哥尔摩综合征。任何合并问题,尤其是那些引发危机的合并问题,都是提升团队效率的信号。请记住,只有从错误中吸取教训,错误才有价值。
我在本文中描述的模式,概括了我和同事在实践中遇到的一些常见分支配置。通过命名、解释,尤其是解释它们的适用场景,我希望能够帮助你评估何时应该使用它们。请记住,与任何模式一样,它们很少是绝对的好或坏。它们对你的价值,取决于你所处的具体情况。当你遇到某种分支策略时,无论是像 Git-flow 或基于主干的开发这样广为人知的策略,还是开发组织内部自行发展出来的策略,我希望理解其中的模式,能够帮助你判断它们是否适合你的具体情况,以及可以融合哪些其他模式。
归根结底,好的源代码分支管理策略,不是选择看起来最复杂、最流行的流程,而是让团队能够更频繁地集成、更稳定地发布、更低成本地维护代码库健康。无论你采用 Git-flow、特性分支、持续集成还是基于主干的开发,都应该回到同一个判断标准:它是否减少了合并风险,缩短了反馈周期,并帮助团队更快、更安全地把软件交付到生产环境。
文章包含AI辅助创作,作者:guo,如若转载,请注明出处:https://docs.pingcode.com/baike/5247465