注意时间!
这是一篇 2022年的文章,补档用,里面的某些内容可能已经过时。
前言
我们团队在过去的几个月中一直在进行大仓基本能力建设,经过若干个月的时间和应用以后,在此想分享一下存在坑和理念。
值得注意的是,我 着重声明的一点 就是:单仓的挑战是 极其巨大的,在整个团队还没有做好 转型
和 预先的准备
情况下,贸然的切换成单仓,除了使得整个开发流程变得更混乱以外,没有任何好处。
对于我们而言:单仓 + 主干开发 从来不是一个 孤立 的 工程实践,而更应该被视为一个 工程实践的放大器。简单的说就是这一套流程将在会放大其流程、工具的收益的同时,还会 等效的 放大错误 和异常。任何项目中的错误实践都有可能以指数递增的方式给整个项目带来极大的麻烦。
而单仓的实现通常没有完全一样的落地实践,对于不同的企业公司文化而言,会有截然不同的实践落地方式,故而单仓的实践只能更多的是基于方法论的指导思想,而非实际的落地方案,因为对于不同文化背景、环境、基础设施的情况下,往往实践落地的方案是大相径庭的。
概念
单仓/大仓 到底是什么?
单仓和大仓是一种相同的概念,这在向上追溯可以一直到 2000 年左右,当时兴起的 mono repo 就是一种较为成熟的单仓策略,用较为传统的定义来描述就是 共享仓库,大白话就是所有的代码放一起。
之所以会区分大仓和单仓,主要在于规模的不同。不过一般而言,整个公司 很难
将所有代码归属到一个仓库进行管理。如果能够一起管理,那么这种情况下其规模足够大,那么依照我的认识,这属于大仓(譬如谷歌)。当然,目前我们也不可能达到这种规模的(甚至千万级的代码量、几十G的中央代码仓库都很难达到)。
那么我们的仓库多数情况下就是单仓策略 —— 指仅仅将多个项目放在同个仓库中的版本控制策略(也就是通俗意义上的单条业务线)。
单仓的目的是什么?
单仓的设计核心起源于 单一版本 的哲学思想,用来控制复杂的内部版本管理。在前端中,我们最早看到这种复杂的 monorepo 管理方案是在 babel 上,他们的版本管理极其复杂且互相联动。通过单一仓库控制版本就可以非常方便对整个代码进行管理。
单仓的需要什么、带来了什么?
那么现在就需要详细讲一下 单仓需要什么 、带来了什么了 。
在介绍之前,必须要 重新声明一点,单仓是一个 复杂度极高、依赖条件苛刻 的管理方案,单仓想要达成预期效果需要 工具
、流程
和文化
三方面的准备,以及长期大量持续维护
,在没有下定决心前。贸然迁移到大仓 不但不会带来收益,反而会导致项目和代码管理彻底混乱。我会在后文的 挑战 里面详细描述单仓所来的问题和难题。
规模化效应
在软件工程上,我们总是期望 “Don't repeat yourself”
(DRY),而单仓正是这一原则在项目管理上的落实者。
我们肯定是乐于希望任何的一个良好的工程实践,都能以 自动化、规模化 的手段 几乎零成本
地推广到所有的团队及代码的,这样可以 减轻 开发人员的心智负担,使之将更多的精力放在 创造性的工作 上。
比如如下常见的场景,他们在预期上应当是一致的:
- 静态检查(eslint、stylelint、prettier 只需要在顶层添加)
- 自动化测试(统一的自动化配置,例如 vitest、cypress)
- 持续集成(ci 配置标准化,各个项目的 ci 构建基本保持一致)
- 开发流程标准
- 通用工具(部署工具、构建工具(vite、tsc))
那么基于这一点,在经过完整实践、应用过后的单仓可以比较完美的将这些内容 一劳永逸 的集成进去。简单的对比一下就是我们只需要维护一份通用的配置即可,并且继续开发后续的工程时,不需要重复配置。
单一版本
软件工程上有一个很有意思的概念是——SSoT(Single source of truth)单可信源。
警告
💡 这一概念是指开发人员在任意时间可以确定代码仓库内的哪个分支是唯一可信依赖源。譬如 在我们开发时绝大部分情况下使用 master 或者 develop 作为新分支的起点。
而衍进单一版本后,我们有了一个更严格的标准也就是说:
在任意时间,代码库内的每一份组件、每一个依赖只有一个版本
那么这里要分两个部分来看:
- 对内部库而言,我们的版本控制将只能使用主干开发,并且 必须 在主干 HEAD (并不限定名字,达成共识即可)上依赖。这是一个非常强的约束——除了终端制品,任何一个 内部被依赖 的库都不能通过分支发布,而必须保持自己在单仓的主干上一直是 发布状态。
- 对外部依赖而言,同一个第三方库在单仓中 有且只有 一个版本
这些内容简化了我们的依赖管理难题,并且可以从根本上屏蔽 菱形依赖、依赖地狱 的问题。
主干开发
在单一版本的基底下,我们别无选择,只能使用 主干开发 。在主干开发的情况下每位开发者都将自己的 作业分成小批量
,然后以至少 每天一次
(可能多次)的频率将作业合并到主干中。
对比一下:
- 分支开发: 一位开发者或一组开发者通常通过主干(也称作主实例或主线)创建分支,然后单独处理该分支,直到完成他们要构建的功能。当团队认为 该功能准备就绪 时,他们会将功能分支合并回主干。
- 主干开发:每位开发者都将自己的作业分成 小批量,然后以至少 每天一次(可能多次) 的频率将作业合并到主干中。
他们两者的主要区别在于分支存活时间:
- 保持主干
始终健康
,将所有的 commits 尽快小批量合入
的是主干开发 - 以 feature 为单位,当 feature
完成之后再重新合入
的是分支开发
在主干开发的模式下,我们必须 大面积
增加测试,并且要尽可能的让测试的速度够快(至少是分钟级别的)这样才能均摊每次合并的风险,此外就是通过 code review 来 进一步 减少可能的错误。
自动化测试、code review 以及持续集成
自动化测试
由于应用了 主干开发
的模式,我们受迫于每天需要合并代码,所以我们必须对所有公用的代码 提供足够的代码测试,这就 强制要求 的所有的代码都 必须要覆盖测试用例
。而事实上高强度的 自动化测试 覆盖会进一步提高代码的复用能力,因为只要测试能通过,绝大部分情况下可以无障碍的对整个代码进行升级,而不用担心损毁其他业务的工作。
code review
反倒是 code review 的要求有所降低,因为大面积的分支修改情况被移除了,我们在 code review 时所面临的问题更小。
因为通常而言,提交修改量 同 CR 难度的 相关性增长通常是 高于线性增长
的。而强制小批量合入主干会使得每一次 CR 的规模下降,总体是降低 CR 成本的。
此外由于 强制小批量合入主干的提交 可以将 CR 的责任从分支所有者转移到代码所有者,这样的做法可以均摊代码管理责任、鼓励相互之间更强的交流。
持续集成
主干开发从另一个侧面强制要求了我们的 CI/CD 水平需要足够高,否则缓慢的 ci 编译速度将拖累整个流程的速度。
更容易的代码复用、更透明的交流环境
更容易复用的代码
从代码复用角度而言,任何复用方式都没有代码放一起更方便复用,而单仓则可以把整个优势充分利用起来,而结合 code review、自动化测试,使得团队所有人都 有信心 修改整个基础组件和基础代码,并且快捷的找出所有使用代码的地方,并通过大规模修改一次性的解决所有问题。这进一步增强的所有代码的复用能力。
而单仓一旦落实,所有的维护者都会从主观上更专注于复用性,在编写代码的时候会相比较小仓模式更容易写出能够复用的代码,因为通常而言,一个公共库、框架的诞生往往通过两种途径:
- 自顶向下
- 自下向上
我们分开来讲:
一般而然在没有单仓前,我们通常会要求大家预先想一些公共抽象的东西,这些东西可能会在其他仓库中使用,那么这种行为就是自顶向下的,它具有滞后性 —— 在工作中,我们已经无数的碰到一个很好用的组件到另外一处使用,最大的可能是 copy 一份,这 显然不是一个好的处理手段。
那么相反的是,自下向上 + 单仓可以有效的解决这个问题。
自下而上的开发模式指通过 逐步聚合抽象 公共需求和组件。例如一个顶目的开发过程中,基于实践抽象出自己项目的公共的部分。随着时间的推移,那么这个公共库会逐渐与其他的公共库产生重叠,这个时候只需要将这部分交集进行整理和泛化,就产生了通用的公共代码库。
而抽离公共组件在 单仓 中是 极为容易实现的,因为规模化效应,我们可以 非常简单
的将一个新包的内容同其他小的工具库一样扩展 独立构建,并 推广
到 其他平行的项目组 去。对比 小仓,小仓的构建是分离的,你很难在业务进行到某一个步骤的时候就完善的抽离出去,他既 没有基础设施的复用,也 没有提供良好的复用环境。
更透明的交流环境
从我个人的观点来看,现代软件开发关键在于团队合作。因此一个高透明度、高层面合作的开发模式应当是能够 显著提升 代码开发效率,降低成本的。譬如鼎鼎有名的 康威定律
设计系统的架构受制于产生这些设计的组织的沟通结构。 — 马尔文·康威
显而易见的是,当我们拥有一个单仓的时候,单仓可以 显著的改善 整个团队的交流成本,使得内部的 共享更加开放。在过去的实践中,有些人他们不太愿意在代码未完成时公开代码。而且主干模式和单仓的双重攻势下,使得代码透明变成一个理所应当事情,你需要时刻公开你的变更和修改。最终的效果就是大部分人都会选择允许别人修改,自己由 CR 做最终决定,这反倒是更为开放和有效的合作模式。
挑战
上面是项目使用者的角度思考,那么回过头来,所有挑战都是基于项目维护者角度的思考了,因为单仓对于使用者而言通常是只有 好处
,没有坏处的
,毕竟只是换了一个地方写代码而已,但是对于项目的维护者而言,里面有巨大的挑战。
权限控制
单仓的权限控制向来是比较薄弱的,由于 git 的特性仓库级别的权限控制不足以支持多个团队、多个项目在单仓内作业的,换而言之,我们不能只依靠仓库权限来做控制,我们需要更小粒度的权限单元 —— 也就是说 “项目”级别的权限控制是必须的。
这个时候 codeowner 机制是一个非常棒的选择 ,我们通过它可以实现目录作为 最小颗粒的权限控制。
同样的我很不赞同照搬小仓的提交权限机制,即:
只有项目的 owner(更准确的说是 member,因为私仓的 member 通常都是主要维护者) 才能在该目录下提交
在我看来既然施行单仓,那么我们必须要以 更加开放 的前提面向其他开发者,所以我认为提交权限只需要在 CR 层面解决 —— 即 :
只有有权限的人作为作者或者作为评审者同意提交,才能提交
这一 CR 机制几乎和 gitlab 的默认机制不谋而合,所以在我们这边只需要简单的开启 gitlab 的 approve 即可,并通过 codeowner 设置目录的所有者。
那么这里稍稍展开讲一下,我们将一个单仓的不同项目展开来讲,事实上,我们并不认为某个目录的 owner 一定是 commit 提交者,我们对 owner 的定义是这样的:
- owner 并不是文件的所有者,也不一定是文件的提交者,他只为代码质量负责
- owner 的权限为目录树。但是 owner 也可以为单文件独立设置 owner
- 在 owner 作用域下的文件应当享有最高的自治权,在没有和全项目底线原则冲突的情况下, owner 有权要求 提交者风格 同 owner 所在文件下的风格保持一致
扩容性
在单仓中,你遇到的最大的挑战往往是构建相关的,在单仓中,由于构建依赖的复杂度会导致整体构建复杂度成倍增加,同样的,你的构建时长也会成倍增加。
值得注意!
💡 如果我们的工具是以仓库为操作单元,那么其构建时间的增随开发人员数量的增长往往不是线性的,而是以 最少二次方 的速度增长的。
举例, 考虑每次持续集成都编译整个仓库,则如果人员增长 10 倍,可以粗略假设仓库大小增长 10 倍(事实上人效通常和人员增加的关系并不是线性的),提交频率增长 10 倍,则整个编译量会增加 100 倍。
所以这里最大的难题就是《如何保证构建总时长是随仓库的规模上升线性增长》,这个是维护者碰到的最大难题,对此目前我们主要的解决方案如下:
💡 尽可能的将包拆散,并分步构建,同时依赖缓存策略对制品进行缓存。这里常用的 monorepo 工具都已经自带的缓存能力,这里不多赘述。这样除了预热以外,我们可以将整体编译次数削减为线性。
版本控制
版本控制分为三块,一块是版本控制工具,一块是部署版本,另外一块则是版本功能
版本控制工具
我们用的 版本控制 工具都是 git,所以首要的是减少项目内大文件的数量,如果存在大文件一定要开启 git lfs。
其次是严格使用 rebase,rebase 会保证你的项目历史是 线性的,可阅读,可整理的。同时通过 rebase 后将全绿的 MR 合并进主线时,虽然不能保证 100% 构建安全,但是 90% 的情况下要比 merge 更容易保证 commit 全绿。
最后由于 rebase 的频率将会非常高,这里对于 rebase 的性能有所要求,这里变相的回到了 扩容性
中的问题。
部署版本
在部署版本上,我们的版本发布总是同时发布的,换而言之,你没有机会单独的发布单一的包,即主线永远在演进。但是你的部署版本的发布不能是自动部署的。
所以这里最常用的手段是:版本会自动打要不要发布通过 git 的 tag 来控制,当然更好的做法是在 打版本的同时自动打 tag,但是 tag 不会自动运行部署和打包。这块依赖于 bot 目前还没有能力去实现。
版本功能
版本功能控制则是另外一个难题:在主干模式下,你不可能将一个功能分支保留超过一周以上,也就是说你在发布的时候大概率是需要在没有完成某一块情况下就需要合并到主线。
这种情况下,需要我们提供 功能特性开关 来保证我们携带的一部分功能通过 “暗发布” 的方式携带到线上。
依赖管理
在 单仓 的场景需要认真治理所有的第三方依赖。在 单仓里如果不进行第三方依赖的治理,大仓/主干开发的优势就会慢慢消散。
可信依赖
可信依赖的问题在于指所有的第三方包应该是可靠的,不应该随意 的引入三方包,引入三方包应该经过公开的评审、实践才正式的在所有项目中推广,这一行为在去年的前端规范中已经有所约束了。
依赖发散
一个第三方库可能有同时有多个版本、一个版本的多个拷贝 或 一个版本的不同生成产物,同时使用。依赖发散可能会导致 依赖地狱(指通过版本号来进行包管理时,由于依赖关系过于复杂,导致能够满足所有包之间依赖关系的版本配置不存在)
一个理想的单仓是自包含的,也就是说理论上所有的外部依赖都以源码的形式被引入了单仓(譬如 taptap 服务端做的 proto 引入编译的行为)。 但事实上,这几乎是无法做到,因为我们还是迫不得已的需要使用第三方包,那么这些三方包首要的原则是保证依赖版本一致。
反向依赖
单仓往往难以被外部仓库源码依赖。 如果大仓内的库需要被反向依赖,首先需要保证单仓中对外包关联的包都需要对外(主要通过制品依赖)。
结构管理
对于单仓的目录管理需要因地制宜,不太具有可靠的统一方案,通常而言,原则上我们倾向于对包的等级做出区别,尽可能将不同依赖复杂度的包区分开来,譬如一个纯粹的工具包和一个依赖复杂的业务包不应当归属到一个目录底下。
静态检查
静态检查是可以提升代码质量,并减少代码评审的工作量。整个项目的静态检查必须保持一致且使用相同的静态检查方案。
后备的回迁手段
迁移总是困难的, 尤其是与日常工作息息相关的大规模的仓库迁移,如果你的仓库将会和我们一样是从小仓库迁移到大仓库,那么必须要有后备的回迁手段以临时性的回退。除此以外,在迁移中你还会碰到如下问题:
项目代码所在目录会因迁移改变,从而可能导致代码需要改变
以下解决方案均非完美:
- 直接平移代码:可能会导致代码不符合组织规范,导致无法编译/运行时错误
- 对代码进行自动化修改,可能导致无法编译/运行时错误
- 代码所有人自行维护: 相当于把风险委任给代码所有人,但是若要长期保持迁出与迁入仓的同步,需要大量的努力。
仓库间的依赖关系会极大增加复杂度
- 小仓 A 依赖小仓 B,两者均需要迁移:
- 将小仓 A 迁移至大仓 A,仍然依赖小仓 B。归档小仓 A
- 将小仓 B 复制到大仓 B
- 将大仓 A 的依赖转移至大仓 B
- 此时方可归档小仓 B
- 如果一个小仓被单仓外的仓库依赖,您需要长期保证这个小仓依然可以存在并被依赖
团队还没有做好迁入准备
开发团队可能需要准备一个从分支开发到主干开发的全过程,并且确保主干开发有足够的 自动化测试 和 CI。在 CI 尚未准备好且开发团队经验尚浅的情况下,风险会很高。
流水线、工具迁移困难
项目的流水线需要迁移。将小仓的流水线迁移到大仓,需要保证大仓上迁移过去的流水线可以工作。其它工具也同流水线一样,可能需要适配。
这里有一个不完美但是可以接受的方案处理:
- 原始的旧项目只做归档,仍然可以访问以保证提供紧急回迁方案
- 先将小仓迁移至大仓之后再进行标准化(工具、流水线适配)
- 预先建立项目内的开发规范,保证所有项目的开发规范是标准化的
本文标题:mono 项目最佳实践指南
永久链接:https://iceprosurface.com/2024/monorepo-best-practices/
作者授权:本文由 icepro 原创编译并授权刊载发布。
版权声明:本文使用「署名-非商业性使用-相同方式共享 4.0 国际」创作共享协议,转载或使用请遵守署名协议。