腾讯 PCG 工程效能平台部自 2020 年开始进行大仓基本能力建设,并在 2021 年与工蜂合作成立了代码大仓研效联合项目组。在此, 我们想分享大仓/单仓踩过的坑。我们认为这些坑是真实存在且很难避免的,不是小马过河。
单仓并不简单。成功的单仓所带来的效果绝不止简单的代码聚合,但成本是大量的工具支持以及工程实践。单仓像放大镜,可以将优秀的工程实践以极低的成本推广,但同时也会将错误迅速放大。同时,向单仓迁移的过程也有相当程度的风险。本文会详细讨论单仓的益处、挑战,以及我们对挑战的应对之道,以供参考。
单仓是我们的目的,而大仓则是(终极)单仓的必然结果。
单仓的设计核心起源于"One Version"的哲学,并内化了规模化的思想。
在软件工程中,我们希望“解决一次,解决全部”。即,任何的一个良好的工程实践,都以自动化、规模化的手段几乎零成本地推广到所有的团队及代码。这样可以减轻开发人员的心智负担,使之将更多的精力放在创造性的工作上。典型的例子包括代码静态检查、自动化测试及持续集成、开发流程标准化、工具统一化等等。
单仓极大地简化了规模化:当所有代码都放置在一处,并且高度统一,则所有的工具都可以规模化地在单仓上作业。
Single source of truth(SSoT)原则,是指开发人员在任意时间可以确定代码仓库内的哪个分支是唯一可信依赖源(SSoT)。在 CVS 中,单一来源是核心原则;在 DVS 中,如 git,在现代的业界实践中也采取了该原则,即要求永远都只有一条主干,且所有的分支(除了发布分支)最终都会被收拢回主干里。
单一版本(One Version)则更进一步,是指在任意时间,代码库内的每一份组件、每一个依赖只有一个版本。
不强制 SSoT/One Version 的版本控制策略往往通过制品/版本分支发布。这意味着在整个依赖关系图之上还有一个版本的维度。这也是在开源社区/SDK 发布商通常采用的策略,其根本原因在于并非完全掌握下游用户的情况。这样除了导致较高的维护成本,还会导致依赖关系难以满足(Dependency hell, 依赖地狱)。
基于版本控制的协作模式一般分两种:
两者的主要区别在于分支存活时间:保持主干始终健康,将所有的 commits 尽快小批量合入的是主干开发;以 feature 为单位,当 feature 完成之后再重新合入的是分支开发。
虽然听上去差异微小,但从分支开发迁移到主干开发对研发模式的影响深远。请务必确保您的团队深入理解前置需求(挑战)和长期影响(益处)。下文将分别阐述。
考虑以下常见场景:一个公共库的作者如何 deprecate 旧的 API 并提供新的 API 作为替代品?
小仓场景:这个公共库按版本分支或按制品发布。API 可以在下一个版本直接更新,并强制 API 调用方对 API 进行更新。这样的好处是 API 提供方的责任较简单,坏处是每一个 API 调用方都需要自行更新代码,并且 API 提供方无法保证自己的新版 API 已经被使用。
单仓/主干场景:公共库只提供源码依赖,并不按版本发布。这样,我们需要保证公共库在主干上始终是正确的。API deprecation 时,API 提供方可以查找所有 API 调用方,并且发动大规模自动修改,原子性地将所有的旧 API 调用更新至新的 API。这样的好处是 API 调用方的责任更简单,但是这种修改只有在能够查找 API 的所有用户、并且整个持续集成水平较高时才有可能。
从上面的例子可见,在单仓/主干的场景下,很多代码的维护工作可以左移并规模化。减少重复劳动、大规模的工业化,是我们启用单仓的原动力。
以下的益处基于开发者视角和代码维护者视角。
再次提醒, 单仓想要达成预期效果需要工具、流程和文化三方面的准备,以及长期大量持续维护。贸然迁移到大仓不但不会带来收益,反而会导致项目和代码管理彻底混乱。请确保您对挑战部分有所准备。
主干开发是为了进行持续集成。频繁地、小批量地构建/测试是持续集成的关键。通过主干开发可以:
在大仓下,所有的代码会变得更公开透明。这便于我们抽象出共同的需求,建立公共库与公共框架,也便于我们学习更优秀的代码,以及寻求常见问题的解决方案。另外,我们对公共代码的调用可以变得更简单:直接从代码层面依赖,而非必须使用包管理/制品/跨仓库的依赖。
例子:查询 API 的使用方法
需要一个调用非常复杂(例如一个有几百个 fields 的 protobuf)的 API。如何才能正常调用?Stackoverflow 里不存在答案,而码客似乎也不适合提问,难道只能找 API 的原作者询问用法?
如果在大仓中,可以查找该 API 已有的在生产环境中的调用作为极好的参考。相反,在纷乱的小仓,缺少好的代码检索工具,很难确定 API 到底在哪里被调用。
需要:
大仓模式下会带来/诱发合作模式的改变。我们认为现代软件开发关键在于团队合作,因此一个能够增加透明度、允许更高层面合作的开发模式能够促成更透明更亲密的合作。更进一步,根据Conway's law,一个所有人的代码都公开透明、亲密协作的组织能够产生更加公开透明、结构亲密协作的产品(注意:这里协作不代表耦合)。
在大仓模式和主干开发的模式下, 我们可以更方便地主动去为其他组的项目提交代码,例如修复缺陷或实现我们所需的新功能。这个组可能是姐妹组,也可能是相隔甚远的项目组。
我们认为作者对代码有太强的归属感,即有强烈的领地意识,对开放协作文化有害。隐藏代码或不允许别人触碰自己编写的代码是反模式。允许别人修改,自己由 CR 做最终决定,是更为开放和有效的合作模式。
例子:公共库/框架
一个公共库/框架的诞生往往通过两种途径 -- 自顶向下和自下向上。自下向上指通过逐步聚合抽象公共需求和组件。例如一个顶目的开发过程中,基于实践抽象出自己项目的公共库;慢慢地,该公共库与其它项目的公共库的共同部分会产生相当程度的重叠,我们便将这部分交集进行整理和泛化,升级为更高级别(如产品线)的公共库。经过更进一步的抽取和整合,最终形成公司级别的公共库。如果没有足够的透明度和协作,这种聚合几乎是天方夜谭。
需要:
原子修改指我们可以在一个提交中修改在单仓中的多个项目的代码并同时生效。这对仓库始终保持在一致状态至关重要。与此相反,在小仓场景下,我们需要在每个需要修改的仓库发送提交,这意味着原子性不能保证,即我们无法保证这些提交同时生效。
一个典型的例子是同时修改 API 接口、实现以及所有的 API 调用者。如此,我们就可以避免同时维护同一接口的两个版本。
注意:原子修改也无法解决 C/S 不统一的问题。即对 API 的更改,即使对实现和调用的更新和 API 更新一起是原子提交,也无法解决 C/S 面对的接口不一致的问题。因为 C/S 的发布周期不会因为原子提交而实现原子化。
由于开发人员可以访问所有项目,因此我们可以批量地进行大规模的自动化的简单修改,例如 API 更新、自动清理长期未被引用代码等,以维护代码质量。这种由工具团队发起的大规模的修改在大仓开发模式下十分常见。
需要:
代码单一的存放位置使得代码作业更加简单。
例子:代码扫描
我们希望建立知识库,因此希望扫描所有相关仓库内的 markdown 工程文档。小仓场景下,我们需要维护所有相关仓库的列表,克隆并扫描每个仓库。单仓场景下,只扫描一个仓库即可。
在大仓里,第二方和第三方依赖可以直接依源码导入,并且只有一个版本。这样可以极大地简化我们对依赖的治理。而且也可以减少已经标准化的项目对引入第三方依赖的成本,因为这些引入的依赖需要标准化,如 Bazel 化。
注意:第三方依赖的治理本身是需要大量工作的。详见[挑战·依赖管理]。
以下挑战基于单仓维护者视角。
这些挑战是我们在以往大仓开发/大仓迁移过程中真实踩过的坑,而非臆想。我们鼓励所有考虑迁移单仓的团队在迁移前认真检查自己是否对以下挑战有所了解并做好准备。另外请注意,以下尚未穷举所有挑战,您需要随时准备迎接新的挑战。
单仓不是银弹。事实上,单仓/主干开发不应该做为一个孤立的工程实践,而更应该被视为一个工程实践的放大器 -- 除了固有的挑战,它可以实现工业化,放大其它的流程/工具的收益;但是如果没有足够有效的工具和严格的流程及实践控制,大仓几乎就是灾难的代名词。下文中“单仓所需的工程实践”并非严格的限制,但这正是陷阱所在 -- 不满足这些前置的单仓还不如小仓。小仓虽然也需要这些实践,但是至少可能不会有这么强的扩散效应。如果妥协,即在单仓内完全采取和小仓一样的严格隔离项目并采取分支发布的策略,则迁移单仓的意义基本被消解。
我们将挑战分为两类:
所有大仓引起的挑战在下文有特殊标明。如果您的单仓规模较小可暂时忽略,但请做好仓库规模增长的准备。
代码迁移到单仓会引入对权限控制的新要求。
目前我们的提交权限以 Git 仓库为最低颗粒度。在单仓中,仓库级别的权限控制不足以支持多个团队、多个项目在单仓内作业。因此“项目”级别的权限控制是必须的 -- 在单仓场景下,这需要目录作为最小颗粒的权限控制。
同时,我们不建议照搬小仓的提交权限机制,即“只有目录的 owner 才能在该目录下提交”。我们认为提交权限需要在 CR 层面解决,即并非“只有有权限的人才能提交”,而是“只有有权限的人作为作者,或者作为评审者同意提交,才能提交”。
我们的实践:
引入类似于Chromium 的 OWNERS机制。机制的关键在于:
我们与此同时将设立一个工具以自动为 commit 自动分配 reviewer,因为在大仓下寻找正确的 reviewer 会相对更困难。
一个不太常见的、与大仓的设计目的相反的需求是隐藏代码。有一些代码是必须需要维持私密性的,例如与权限相关的代码或者具体高商业价值的代码。所以这些代码需要只对一部分人可见,并需依赖大仓其它代码进行编译。这里有几个选项:
我们的实践:暂时未遇到,视情况解决。
一个更加少见的需求是清除一个提交。例如,一个提交泄露了高商业价值信息,需要及时清除。如何从仓库历史里以及从开发者本地的克隆里及时清除是一件困难的事情。在小仓场景下,我们可以简单地将提交所在仓库归档为私有,将该提交之前的所有历史复制到另一个新的仓库,将新的仓库公开并删除旧的仓库。在单仓场景下,其影响面会比原来大。
我们的实践:暂时未遇到,视情况解决。
大仓需要的存储空间可能会非常大。以 Google 为例,2015 年时已经有 20 亿行代码,仓库超过 86T。这个大小对版本控制造成了非常大的挑战,尤其是默认 Git 会下载整个仓库。这对代码托管方、开发、代码分析方(如 CI/数据分析/...)等原本可以单机承载单仓容量的系统都会造成更大的压力。
我们的实践:我们与代码托管方,即工蜂,合作成立了代码大仓研效联合项目组,以解决扩容问题。
(该部分为大仓引起的挑战。如果您的单仓规模较小可暂时忽略,但请做好仓库规模增长的准备)
可扩容性的核心挑战在于,如果工具是以仓库为操作单元,则其随开发人员数量的增长往往不是线性的,而是以不低于二次方增长的。举例, 考虑每次持续集成都编译整个仓库,则如果人员增长 10 倍,可以粗略假设仓库大小增长 10 倍,提交频率增长 10 倍,则整个编译量会增加 100 倍。
我们认为所有的所有开发工具和流程都应该优化,使之随仓库的规模上升线性增长。如果无法保证,请保证您的单仓较小。
版本控制工具将主要面对两个挑战:
原生 Git 对仓库的最小操作单位是仓库,即不支持部分检出。这有其设计目的和历史原因,因此原生 Git 并不适合大型仓库。为了缓解这个问题,Git 本身做了很多尝试,如支持 Git-Lfs, Git Submodule, Git sparse checkout, Git partial clone 等,但是更像是在打补丁。 2. 主干进展迅速
大仓乃至单仓的提交非常频繁,主干进展很快,因此需要频繁进行 rebase。这对 rebase 的性能有所要求,并且可能需要您提交锁的设计。
如果使用 Git 作为版本控制工具,在迁移到大仓之前,请确保您对仓库大小有所控制,包括:
我们的实践:我们对 Git 进行了改造,开发了基于懒加载的检出方案。基于前文所提到的 Code access API,我们实现了对文件的按需检出。在初次 clone 时,所有的文件都只有元数据被下载,只有当需要获取文件内容时才会真正下载文件。
我们强烈推荐在单仓使用统一的构建系统。至少,每门语言应该使用统一的构建系统。否则,您可能使用的不是一个单仓,而是一个将众多小仓放在一起的空间。
我们认为一个适合大仓的构建系统应该满足以下要求:
一个常见的错误是,把构建速度作为选择构建系统的唯一指标。另外,我们推荐统一每个语言的编译器以及对应版本。
我们的实践:我们采用Bazel作为唯一的构建系统。Bazel 有以下优点:
如果要建立单仓,推荐考虑 Bazel 作为构建系统。但是请注意:
一个能够支持大仓体量的、可伸缩的持续集成系统是高度不平凡的。在设计持续集成系统,请确保已考虑以下可能需要解决的问题:
a.全量测试:朴素地编译整个大仓里的所有项目、执行所有测试可能并不是一个可行的方案,因为耗时可能过长。
b.人工精准测试:人为定义一个“项目”,当项目内的文件被修改时,执行所有项目相关的测试。但是该策略可能难以保证所有被影响的目标都会被测试到,尤其当修改的代码是公共库时。
c.精准测试:从构建系统出发,计算被修改的文件所可能影响到的所有测试。但是这需要构建系统的支持(这也是为什么我们推荐 Bazel 的原因之一)。另外,大型测试,如需要搭起多个服务的系统测试,依然需要一个不基于依赖关系的测试策略,即需要人工精准测试。 2. 自动构建/测试的体量可能超过单机承载量,即使已经采取了精准测试。您可能需要将测试任务分片。 3. 持续集成的构建机需要快速拉取代码进行构建和测试。请注意在大仓的体量巨大时,构建机可能每次构建都重新下载整个仓库的代码可能并不可行。您可能需要在构建机上缓存代码或者采取其它的裁剪策略保证您的持续集成不会因为下载代码花费过长时间。 4. 您可能需要严格执行持续集成的“主干永远是绿的”政策,即,主干上持续集成变红/变黑之后,受影响的团队需要立刻修复问题,或者,无法快速修复时,还原相关修改。
a.您可能希望通过"PreMR 100%成功"保证主干永远是绿的。遗憾的是,这在大仓中无法做到。由于主干进展很快,我们在执行 PreMR 集成测试时的主干 HEAD 与实际合入时的 HEAD 可能相差甚远,这可能导致中间会由于被依赖文件的修改而发生合入之后的测试失败。因此您始终需要 PostMR,且 PostMR 需要更大范围的测试,因为这种情况只有在 PostMR 的自动化测试才能发现。
b.您需要对主干上“失败肇因识别(culprit finding)”准备策略。这是指,PostMR 由 commit A 失败的测试(记为 T)未必是由 A 导致的。如果 T 上次测试通过的 commit 是 B,则所有在(B, A]之间的所有 commit 都可能是肇因。您可能需要对所有测试目标一段时间持续集成执行历史的记录。 5. 并发的执行自动测试的请求可能超过构建机的数量。您可能需要机器锁,或者一个更好的构建队列调度器。当 PreMR 和 PostMR 同时在排队时,您可能会需要给 PreMR 更高的优先度以不阻碍开发人员的工作。
我们的实践:我们设计了新的持续集成服务。
注意从分支开发到主干开发需要大量的学习成本,不可能一蹴而就。未实操过主干开发的开发者很可能难以想像如何采用这种方式工作。我们推荐进行大量的培训,甚至类似于导师制,来培养团队主干开发的习惯和能力。我们强烈不建议使用考核等行政手段作为唯一的推进手段,因为向主干开发的切换并非只是意愿问题。
为达成主干开发,除了开发人员的培训,您需要在单仓中以下工具/实践:
小批量开发。
开关系统。当一个特性尚未开发完成,您需要在主干中通过开关将该特性屏蔽,使用户和其它特性完全不受该特性影响。开关系统分为两类:编译时开关和运行时开关, 区分在于开关可以判断状态的阶段。我们推荐:
通过编译时开关进行主干开发协作;
通过运行时开关进行上线发布以及 A/B 实验。这并不局限于大仓。
高优先度的代码评审:相比于开发,CR 的优先应该更高。保持 CR 流程的畅通是主干开发的必要前置。
保持主干健康。当主干上出现了构建/测试失败,需要开发者停止当前工作并立即修复 CI 问题。
持续集成,见下。
主干开发和持续集成是相辅相成的关系。尽快往主干的提交使得持续集成可以更细地触发,而持续集成是使主干代码始终保持在可发布状态的主要保证。
我们的实践:
代码评审在大仓+主干开发的模式下非常重要。因为大仓的代码的合作程度更高,依赖关系会更加复杂,在大仓内的坏提交可能会比原来在小仓内有数百倍计甚至更高的破坏性。在维护左移与加强协作的情况下,代码的维护者很可能不是代码变更的作者,而是代码变更的评审者。如果没有足够良好的代码评审文化和实践,如果代码评审形同虚设,就无法通过代码评审来保证代码变更质量,则您可能需要考虑先培养代码评审的文化。
高频次的细致的代码评审对代码评审工具有更高的要求。
我们的实践:我们采取了严格的 Code Review 的政策:
大仓的结构管理需要慎重设计 。随着开发人员的增加和仓库规模的增长,需要考虑如何将维持大仓目录结构,使得各团队在合作的同时又不失结构。一个好的大仓的目录结构应该是易于在未知情况下定位项目所在目录的,并且比较稳定。
我们推荐在设计结构目录时考虑以下问题:
从经验上来看,与其说设计结构目录是设计一个最好的,不如说是设计一个最不坏的。
正向依赖:需要认真治理所有的第三方依赖。在大仓里如果不进行第三方依赖的治理,大仓/主干开发的优势就会慢慢消散。第三方依赖主要有两个治理方向:
一个理想的单仓是自包含的,即,所有的外部依赖都以源码的形式被引入了单仓。单仓自身能够完成对所有项目的构建而不需要构建时下载外部代码/制品。与此同时,您可能需要思考第二方库在大仓中的位置。
反向依赖:大仓往往难以被外部仓库源码依赖。如果大仓内的库需要被反向依赖,请确保您有应对策略。通常有两个方案:
以下并非硬性要求,但是我们仍然积极推荐您准备以下实践及工具。另外请注意,我们推荐您制定并维护严格的工程规范,例如命名、分支拉取、发布策略、比腾讯代码规范更详细的语言规范。
静态检查 (static analysis),包括基于规则的代码扫描、基于抽象语法树的静态分析和基于编译插桩的分析工具。静态检查可以提升代码质量,并减少代码评审的工作量。
静态检查的范围可以比现有代码扫描广泛得多,比如:
我们追求的目标是:将尽可能多的CR 意见转化为静态检查。静态检查一般在请求 MR 时触发,若有可能,尽可能将静态检查左移,在开发/编译时触发。
我们的实践:
我们将在持续集成阶段执行标准化的静态检查,提高静态检查报告的强制力,并在代码评审阶段要求开发人员做出反应。同时,我们将提供 IDE 插件以实现静态检查左移。
当我们拥有一个大型仓库,有一个良好的代码浏览器可以减少代码阅读的成本,可以更好地达到代码的复用和分享。我们希望拥有一个代码浏览器,并做到:
但是,现有的浏览代码的方案都不完美:
我们的实践:我们希望有一个带有语义索引的代码浏览器,类似于https://cs.opensource.google。
Web IDE 与代码浏览器一起可以降低代码修改和提交的成本。对于一些小的修改,例如纠正 typo,轻量的工具可以极大地增加提交者进行修改的意愿。
迁移总是困难的, 尤其是与开发者日常工作息息相关的大规模的仓库迁移。当您要制定将众多小仓迁移至单仓的计划,请确保您考虑过以下核心问题:
1.您是否需要提供紧急回迁方案?
提供回迁方案会极大地增加迁移复杂度。如果您的单仓规模较可控(如 < 500 开发),可以考虑通过一些其它手段增加迁移成功的成功率,而不提供回迁方案。
2.您准备先将仓库迁移到单仓再完成治理(即“挑战”中所提到的必须实践),还是先治理再迁移单仓,抑或不进行治理?
同时,请确保您考虑过以下风险:
a.直接平移代码:可能会导致代码不符合组织规范,或导致无法编译/运行时错误
b.对代码进行自动化修改(如 copybara):可能导致无法编译/运行时错误
c.代码所有人自行维护:相当于把风险委任给代码所有人,但是若要长期保持迁出与迁入仓的同步,需要大量的努力。 2. 仓库间的依赖关系会极大增加复杂度。
a.一个简单的例子:小仓 A 依赖小仓 B,两者均需要迁移,则您需要 :
i.将小仓 A 迁移至大仓 A,仍然依赖小仓 B。归档小仓 A
ii.将小仓 B 复制到大仓 B
iii.将大仓 A 的依赖转移至大仓 B
iiii.此时方可归档小仓 B
换言之,被依赖的仓库需要后迁移。这意味着,如果是一次性迁移多个小仓,您需要整理所有的依赖关系图,拓扑 排序使入度为 0(即不被依赖)的仓库先迁移,以此逐阶段迁移。
b.如果一个小仓被单仓外的仓库依赖,您需要长期保证这个小仓依然可以存在并被依赖 3. 开发团队可能需要准备一个从分支开发到主干开发的全过程,并且确保主干开发有足够的测试和 CI。在 CI 尚未准备好且开发团队经验尚浅的情况下,风险会很高。 4. 项目的流水线需要迁移。将小仓的流水线迁移到大仓,需要保证大仓上迁移过去的流水线可以工作。 5. 其它工具也同流水线一样,可能需要适配。
我们的实践:相对效率,我们的迁移方案更强调安全性。
十年磨一剑,霜刃未曾拭。感谢 PCG 两年多的效能改革为如今的单仓提供了可能。
路漫漫其修远兮,大迁移已经启航,我们会前进。
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8