关于monorepo:从-Turborepo-看-Monorepo-工具的任务编排能力

78次阅读

共计 6171 个字符,预计需要花费 16 分钟才能阅读完成。

本文大部分图片来自互联网

前言

2021 年 12 月 9 号,Vercel 的官网博客上公布了一篇名为 Vercel acquires Turborepo to accelerate build speed and improve developer experience 的博文,正如其题目所说,Vercel 收买了 Turborepo,以减速构建速度以及进步开发体验。

Turborepo 是一个用于 JavaScript 和 TypeScript 代码库的高性能构建零碎。通过增量构建、智能近程缓存和优化的任务调度,Turborepo 能够将构建速度进步 85% 或更多,使各种规模的团队都可能保护一个疾速无效的构建零碎,该零碎能够随着代码库和团队的成长而扩大。

博文中曾经简明扼要的突出了 Turborepo 的劣势,本文则会从现有的理论场景登程,谈谈大型代码仓库(Monorepo)可能会遇到的一些问题,再联合业界现有的解决方案,看看 Turborepo 在工作编排方面做出了哪些翻新与冲破。

一个合格 Monorepo 的自我涵养

随着业务的倒退和团队的变动,业务型 Monorepo 中的我的项目会逐步减少,极其一点的例子就是 Google 将整个公司的代码都放到一个仓库中,仓库的大小达到了 80TB。

业务型 Monorepo:不同于 lib 型 Monorepo(React、Vue3、Next.js 以及 Babel 等狭义上的 packages), 业务型 Monorepo 将多个业务利用 App 及其依赖的专用组件库或工具库组织到了一个仓库中。——《Eden Monorepo 系列:浅析 Eden Monorepo 工程化建设》

我的项目数量的减少意味着在享受 Monorepo 劣势的同时,也带来了微小的挑战,优良的 Monorepo 工具能够让开发者毫无累赘的享受 Monorepo 的劣势,而不好用的 Monorepo 工具能够让开发者痛不欲生,甚至让人狐疑 Monorepo 存在的意义。

列举笔者遇到的一些理论场景:

  1. 依赖版本抵触

    1. 新建一个我的项目,该我的项目因为依赖问题无奈启动
    2. 新建一个我的项目,其余我的项目因为依赖问题无奈启动
  2. 依赖装置速度慢

    1. 初始化装置依赖 20min+
    2. 新增一个依赖 3min+
  3. build/test/lint 等工作执行慢

笔者先前有过 Rush 的落地教训,在实际过程中,发现除了最根本的代码共享能力外,还该当至多具备三种能力,即:

  1. 依赖治理能力。随着依赖数量的减少,仍旧可能放弃依赖构造的正确性、稳定性以及装置效率。
  2. 工作编排能力。可能以最大的效率以及正确的程序执行 Monorepo 内我的项目的工作(能够广义了解为 npm scripts,如 build、test 以及 lint 等),且复杂度不会随着 Monorepo 内我的项目增多而减少。
  3. 版本公布能力。可能基于改变的我的项目,联合我的项目依赖关系,正确地进行版本号变更、CHANGELOG 生成以及我的项目公布。

一些风行工具的反对能力如下表所示:

依赖治理 工作编排 版本治理
Pnpm Workspace
Rush ✅(by Pnpm)
Lage
Turborepo
Lerna
  1. Pnpm:Pnpm 具备肯定的工作编排能力(--filter 参数),故此处也将其列入,同时作为 Package Manager,其本身更是大型 Monorepo 不可或缺的一部分。
  2. Rush:由微软开源的可扩大 Monorepo 治理计划,内置 PNPM 以及类 Changesets 发包计划,其插件机制是一大亮点,使得利用 Rush 内置能力实现自定义性能变得极为不便,迈出了 Rush 插件生态圈的第一步。
  3. Lage:同样由微软开源,集体认为 是 Turborepo 的前身,Turborepo 是 Lage 的 Go 语言版本。Lage 自称为 “Monorepo Task Runner”,相较于 Turborepo 的 “High-Performance Build System” 内敛许多,Star 数也相差了一个数量级(Lage 300+,而 Turborepo 5k+),更多可查看该 PR。在后文中 Lage 等同于 Turborepo。
  4. Lerna:曾经进行保护,故后续探讨不会将其纳入。

依赖治理过于底层,版本控制较为简单且已成熟,将这两项能力再做冲破是比拟艰难的,实际中根本都是联合 Pnpm 以及 Changesets 补全整体能力,甚至就罗唆专精于一点,即工作编排,也就是 Lage 以及 Turborepo 的发力点。

如何抉择适合本人的 Monorepo 工具链?

  1. Pnpm Workspace + Changesets:成本低,满足大多数场景
  2. Pnpm Workspace + Changesets + Turborepo/Lage:在 1 的根底上加强工作编排能力
  3. Rush:思考全面,扩展性强

工作编排能够划分为三个步骤,各工具反对如下:

范畴界定 并行执行 云端缓存
Pnpm
Rush
Turborepo/Lage

范畴界定:按需执行子集工作

该能力在日常开发中具备丰盛的应用场景。

例如第一次拉取仓库,启动我的项目 app1 须要构建 Monorepo 内 app1 的前置依赖 package1 以及 package2。

而在 SCM 上打包我的项目 app1 时,须要构建 app1 本身以及 Monorepo 内 app1 的前置依赖 package1 以及 package2。

此时则应该依据须要筛选出须要构建的我的项目,而不应该引入与以后用意无关的我的项目构建。

在不同的 Monorepo 工具中,这一行为有着不同的称说:

  1. Rush 中称之为 Selecting subsets of projects,抉择我的项目子集,在本示例中该当应用如下命令:
// 本地启动 app1 开发模式,app1 为依赖图的顶端,但不须要构建 app1 本身
$ rush build --to-except @monorepo/app1

// SCM 打包 app1,app1 为依赖图的顶端,且须要构建 @monorepo/app1 本身
$ rush build --to @monorepo/app1
  1. Pnpm 中称之为 Filtering,即过滤,将命令限度于包的特定子集,在本示例中该当应用如下命令:
// 本地启动 app1 开发模式,app1 为依赖图的顶端,但不须要构建 app1 本身
$ pnpm build --filter @monorepo/app1^...

// SCM 打包 app1,app1 为依赖图的顶端,且须要构建 @monorepo/app1 本身
$ pnpm build --filter @monorepo/app1...
  1. Turborepo/Lage 中称之为 Scoped Tasks,但目前(2022/02/13)这一能力过于局限,Vercel 团队正在设计一套与 Pnpm 基本一致的 filter 语法,详情参见 RFC: New Task Filtering Syntax

范畴界定保障了执行工作的数量不会随着 Monorepo 内无关我的项目的减少而减少,丰盛的参数可能帮忙咱们在各种场景(package 发包、app 构建以及 CI 工作)去进行 selecting/filtering/scoping。

比方批改了 package5,在 Merge Request 的 CI 环境须要保障 package5 以及依赖 package5 的我的项目不会因为本次批改而构建失败,则能够应用以下命令:

// 应用 Rush
$ rush build --to @monorepo/package5 --from @monorepo/package5

// 应用 Pnpm
$ pnpm build --filter ...@monorepo/package5...

在本示例中最终会挑选出 package5 以及 app3 进行构建,从而在 CI 上达到了合入代码的最低要求——不影响其余我的项目构建。

基于工作区所有我的项目的 package.json 文件,能够不便地失去我的项目之间的具体依赖关系,每一个我的项目 Project 都通晓其上游我的项目 Dependents 以及其上游依赖 Dependencies,配合开发者传入的参数,从而不便地进行子集项目选择。

并行执行:充沛开释机器性能

假如挑选出了 20 个子集工作,应该如何执行这 20 个工作来保障正确性以及效率呢?

Project 之间存在依赖关系,那么工作之间也存在依赖关系,以 build 工作为例,只有前置依赖构建结束,才可构建以后我的项目。

网上有一道比拟风行的管制最大并发数面试题,大抵题意是:给定 m 个 url,每次最大并行申请数为 n,请实现代码保障最大申请数。

这道题的思路其实与工作编排中的工作并行执行大同小异,只不过面试题中的 url 不存在依赖关系,而工作之间存在拓扑序,差异仅此而已。

那么工作的执行思路也就跃然纸上了:

  1. 初始可执行的工作肯定是不存在任何前置工作的工作

    • 其 Dependencies 数量为 0
  2. 一个工作执行实现后,从工作队列中查找下一个可执行的工作,并立即执行

    • 一个工作执行实现后,须要更新其 Dependents 的 Dependencies 数量,从其内移除当前任务(Dependencies 数量 -1)
    • 一个工作是否可执行,取决于其 Dependencies 是否全副执行结束(Dependencies 数量为 0)

本文不作代码层面解说,具体实现可见 Monorepo 中的任务调度机制 一文,在代码层面上实现了工作的拓扑序并行执行。

突破工作边界

本图来自 Turborepo: Pipelining Package Tasks

之前谈到工作执行时,都是在同一种工作下,比方 build、lint 或是 test,在并行执行 build 工作时,不会去思考 lint 或是 test 工作。如上图 Lerna 区域所示,顺次执行四种工作,每一种工作都被前一种工作阻塞住了,即便外部是并行执行的,但不同工作之间仍旧存在了资源节约。

Lage/Turborepo 为开发者提供了一套明确任务关系的办法(见 turbo.json),基于该关系,Lage/Turborepo 能够去进行不同品种工作间的调度和优化。

相较于一次只能执行一种工作,重叠瀑布式的工作执行效率当然要高得多。

turbo.json

{
  "$schema": "https://turborepo.org/schema.json",
  "pipeline": {
    "build": {
      // 其依赖项构建命令实现后,进行构建
      "dependsOn": ["^build"]
    },
    "test": {
      // 本身的构建命令实现后,进行测试(故上图存在谬误)"dependsOn": ["build"]
    },
    "deploy": {
      // 本身 lint 构建测试命令实现后,进行部署
      "dependsOn": ["build", "test", "lint"]
    },
    // 随时能够开始 lint
    "lint": {}}
}

正确编排程序

Rush 在 20 年 3 月以及 10 月也进行过相干设计的探讨,并于 21 年年底反对了相似的性能个性,具体 PR 可查阅 [[rush] Add support for phased commands. #3113](https://github.com/microsoft/…)

  • Turborepo: Pipelining Package Tasks
  • How does lage work?
  • [[rush] Design proposal: “phased” custom commands #2300](https://github.com/microsoft/…)

云端缓存:跨多环境复用缓存

Rush 具备增量构建的个性,使 rush build 可能跳过自上次构建以来 输出文件(input files)没有变动的我的项目,配合第三方存储服务,能够达到跨多环境复用缓存的成果。

Rush 在 5.57.0 版本引入了插件机制,进而反对了第三方远端缓存能力(在此之前仅反对 azure 与 amazon),赋予了开发者实现基于企业外部服务的构建缓存计划的能力。

落地到日常开发场景中,本地开发、CI 以及 SCM 各开发环节都能从中受害。

上文有提到,在 CI 环节构建改变我的项目及其上下游我的项目能够肯定水平上保障 Merge Request 的品质。

如上图所示,存在场景批改了 package0 的代码,为了保障其上下游构建不被影响,则在 CI Build Changed Projects 阶段,会执行以下命令:

$ rush build --to package0 --from package0

基于 git diff 挑选出源文件改变的 projects,此处为 package0

通过范畴界定,package0 及其上游 app1 会被纳入构建流程,因为 app1 须要构建,作为其前置依赖,package1 至 package5 也须要被构建,但这 5 个 package 实际上与 package0 并不存在依赖关系,也不存在变更,仅为了实现 app1 的构建筹备工作。

若依赖关系简单起来,比方某个根底包被多个利用援用,那么相似于 package1-package5 的筹备构建工作就会大大增多,导致这一阶段 CI 非常迟缓。

理论构建项目数 = 改变我的项目的上游项目数 + 改变我的项目的上游项目数 + 改变我的项目的上游我的项目前置依赖数 + 改变我的项目上游我的项目的前置依赖数

因为 package1-package5 等 5 个我的项目与 package0 不存在间接或间接的依赖关系,且输出文件没有扭转,故可能命中缓存(如有),跳过构建行为。

如此便将构建范畴由 7 个 project 降至 2 个 project。

理论构建项目数 = 改变我的项目的上游项目数 + 改变我的项目的上游项目数

如何判断是否命中缓存?

在云端,每一个我的项目构建后果的缓存压缩包与其输出文件 input files 计算出来的 cacheId 造成映射,输出文件未发生变化,则计算出来的 cacheId 值就不会变动(内容哈希),就能命中对应的云端缓存。

输出文件蕴含以下内容:

  1. 我的项目代码源文件
  2. 我的项目 NPM 依赖
  3. 我的项目依赖的其余 Monorepo 外部我的项目的 cacheId

若对实现感兴趣,能够查看 @rushstack/package-deps-hash。

结语

在编写本文过程中笔者也想起了 @sorrycc 在 GMTC 上分享的《前端构建提速的体系化思路》中提到的构建提速三大法宝:

  1. 提早解决。基于申请的按需编译、提早编译 sourcemap
  2. 缓存。Vite Optmize、Webpack5 物理缓存、Babel 缓存
  3. Native Code。SWC、ESBuild

作为工作编排工具来讲,Native Code 的劣势并不显著(尽管 Turborepo 应用 Go 语言编写,但 Lage 作者认为在现有规模下,工作编排的效率瓶颈并不在编排工具自身),但提早解决与缓存是有殊途同归之妙的。

最初应用精简且求实的 Lage 官网副标题作为本文主题「工作编排」的结尾:

Run all your npm scripts in topological order incrementally with cloud cache – @microsoft/lage

配合云端缓存,按照拓扑排序增量运行你所有的 npm scripts。

参考

  • monorepo.tools: Your defacto #guide on #monorepos.
  • Rush: a scalable monorepo manager for the web
  • Lage: A Beautiful JS Monorepo Task Runner
  • JS Monorepo Workspace Tools
  • Pnpm-Filtering

正文完
 0