从Turborepo的Monorepo工具的任务调度能力来看,正如其标题所说,Vercel收购了Turborepo以加速构建并改善开发体验。Turborepo是一个用于JavaScript和TypeScript代码库的高性能构建系统。通过增量构建、智能远程缓存和优化的任务调度,Turborepo可以将构建速度提高85%或更多,使各种规模的团队都能维护一个快速高效的构建系统,该系统可以随着代码库和团队的成长和扩展而增长。博文简要强调了Turborepo的优势。本文将从现有的实际场景出发,谈谈在大型代码仓库(Monorepo)中可能遇到的一些问题。结合业界已有的解决方案,看看Turborepo在任务调度上有哪些创新和突破?一个合格的Monorepo的自我培养随着业务的发展和团队的更替,业务Monorepo中的项目会逐渐增多。一个极端的例子是谷歌把整个公司的代码放在一个仓库里,仓库的大小达到了80TB。业务型Monorepo:与lib型Monorepo(React、Vue3、Next.js、Babel等广义的包)不同,业务型Monorepo将多个业务应用app及其依赖的公共组件库或工具库组织成一个仓库。——项目数量的增加,意味着在享受Monorepo优势的同时,也带来了巨大的挑战。优秀的Monorepo工具可以让开发者无负担的享受Monorepo的优势,而不能使用的Monorepo工具则会让开发者苦不堪言,甚至让人怀疑Monorepo存在的意义。列举笔者遇到的一些实际场景:依赖版本冲突新建项目,项目因依赖问题无法启动新项目,其他项目因依赖问题无法启动依赖安装速度慢初始安装依赖20min+添加一个dependency3min+build/test/lint等任务执行缓慢。笔者之前有过Rush的经验。在实践过程中,我发现除了最基本的代码共享能力外,至少应该具备三种能力,即:依赖管理能力。随着依赖数量的增加,仍然可以保持依赖结构的正确性、稳定性和安装效率。任务编排能力。Monorepo中项目的任务可以以最大的效率和正确的顺序执行(可以狭义地理解为npm脚本,如build、test和lint等),复杂度不会随着数量的增加而增加Monorepo中的项目。释放能力。基于变更后的项目,结合项目依赖,可以正确进行版本号变更、CHANGELOG生成和项目发布。一些流行工具的支持能力如下表:某些任务的Orchestration能力(--filter参数),所以也包含在这里,并且作为PackageManager,本身就是大型Monorepo中不可或缺的一部分。Rush:微软开源的可扩展的Monorepo管理方案,内置PNPM和类Changesets的合约方案,其插件机制是一大亮点,使用Rush内置的能力实现自定义功能极其方便,并率先迈出了Rush插件生态的第一步。Lage:也是微软开源的,个人认为是Turborepo的前身,Turborepo是Lage的Go语言版本。Lage称自己为“MonorepoTaskRunner”。与Turborepo的“高性能构建系统”相比,它要内敛很多,Star数也相差一个数量级(Lage300+,而Turborepo5k+),更多可以在这个PR中找到。以下Lage等同于Turborepo。Lerna:已经停产了,所以不会纳入后续的讨论。依赖管理太底层,版本控制相对简单成熟。这两项能力很难有突破。在实践中,基本上是结合Pnpm和Changesets来完成整体的能力,甚至只是单纯专注于一个点,就是tasksArrangement,这是Lage和Turborepo的重点。如何为您选择合适的Monorepo工具链?pnpmWorkspace+Changesets:成本低,满足大部分场景pnpmWorkspace+Changesets+Turborepo/Lage:在1Rush的基础上增强了任务编排能力:综合考虑,扩展性强任务编排分为三步,各工具支持如下:ScopingParallelExecutionofCloudCachePnpm???Rush???Turborepo/Lage???Scoping:按需执行子任务该能力在日常开发中有丰富的使用场景。比如第一次拉取仓库时,启动项目app1需要在Monorepo中构建app1的前置依赖package1和package2。在SCM上打包项目app1时,需要在Monorepo中构建app1自身以及app1的前置依赖package1和package2。这时候要根据需要过滤掉需要建设的项目,不要引入与当前意向无关的项目建设。在不同的Monorepo工具中,这个行为有不同的名字:Rush称之为Selectingsubsetsofprojects,选择项目的子集,在这个例子中应该使用如下命令://startapp1developmentmodelocally,app1isdependentthetopofthe依赖关系图,但不需要自己构建app1$rushbuild--to-except@monorepo/app1//SCM打包app1,app1是依赖图的顶部,需要自己构建@monorepo/app1$rushbuild--to@在monorepo/app1Pnpm中叫做Filtering,即过滤,将命令限制在特定的包子集。在这个例子中,应该使用以下命令://在本地启动app1开发模式,app1是依赖图的顶部,但不需要自己构建app1$pnpmbuild--filter@monorepo/app1^...//SCM打包app1,app1是依赖图的最顶端,需要自己构建@monorepo/app1$pnpmbuild--filter@monorepo/app1...在Turborepo/Lage中叫做ScopedTasks,但是目前(2022/02/13)这个能力太有限了,Vercel团队正在设计一个与Pnpm基本一致的filter语法。详见RFC:NewTaskFilteringSyntaxScopingGuarantee为了保证执行任务的数量不会随着Monorepo中不相关项目的增加而增加,丰富的参数可以帮助我们在各种场景下进行选择/过滤/范围界定(包分发、应用程序构建和CI任务)。比如修改了package5,在MergeRequest的CI环境下,需要保证package5和依赖package5的项目不会因为这次修改而构建失败,可以使用如下命令://UseRush$rushbuild--to@monorepo/package5--from@monorepo/package5//使用pnpm$pnpmbuild--filter...@monorepo/package5...本例中会选择package5和app3进行构建,因此实现CI最低要求的集成代码-不影响其他项目构建。根据工作空间中所有项目的package.json文件,可以轻松获取项目之间的具体依赖关系。每个项目都知道自己的上游项目Dependents和下游依赖的Dependencies,并配合开发者传入的参数,方便进行子项选择。并行执行:充分释放机器性能假设选择了20个子任务,这20个任务应该如何执行才能保证正确性和效率?如果项目之间存在依赖关系,那么任务之间也存在依赖关系。以构建任务为例,只有构建前置依赖后,才能构建当前项目。网上有一个比较流行的面试题,就是控制最大并发数。题意大致是:给定m个url,每次最大并行请求数为n,请实现代码保证最大请求数。这道题的思路其实和任务编排中的任务并行执行类似,只是面试题中的url没有依赖关系,任务之间有拓扑顺序,区别仅此而已。那么任务的执行思路就出来了:初始的可执行任务一定是没有任何前驱任务的任务,且Dependencies数为0。一个任务执行完毕后,下一个可执行任务为从任务队列中搜索,并立即执行一个任务后,需要更新其Dependencies的Dependencies数量,并从中移除当前任务(Dependencies-1的数量)。一个task能否执行取决于它的所有Dependencies是否都执行完毕(Dependencies个数为0)。本文不对代码层面进行讲解,具体实现见Monorepo中的任务调度机制一文,在代码层面实现了任务的拓扑顺序并行执行。打破任务边界这张图来自Turborepo:PipeliningPackageTasks之前讲任务执行的时候,都是在同一个任务下,比如build,lint或者test。并行执行构建任务时,不会考虑lint或测试任务。如上图Lerna区域所示,依次执行4个任务,每个任务都被前一个任务阻塞。即使内部任务并行执行,不同任务之间仍然存在资源浪费。Lage/Turborepo为开发者提供了一套明确任务关系的方法(参见turbo.json)。基于这种关系,Lage/Turborepo可以调度和优化不同类型的任务。与一次只执行一种任务相比,重叠瀑布的任务执行效率当然要高很多。turbo.json{"$schema":"https://turborepo.org/schema.json","pipeline":{"build":{//在依赖构建命令完成后构建"dependsOn":["^build"]},"test":{//自己的build命令完成后,进行测试(所以上图有错误)"dependsOn":["build"]},"deploy":{//selflintbuildtest命令完成后,部署"dependsOn":["build","test","lint"]},//可以随时启动lint"lint":{}}}正确顺序Rush在2020年3月和10月也进行了相关的设计讨论,并在21年底支持了类似的功能特性。具体的PR可以参考[[rush]添加对阶段性命令的支持。#3113](https://github.com/microsoft/...)Turborepo:流水线包任务滞后如何工作?[[rush]设计建议:“分阶段”自定义命令#2300](https://github.com/microsoft/...)Cloudcache:跨多环境缓存复用Rush具有增量构建的特性,可以让rushbuild跳过自上次构建以来输入文件没有变化的项目,配合第三方存储服务达到跨多个环境复用缓存的效果。Rush在5.57.0版本引入了插件机制,进而支持第三方远程缓存能力(之前只支持azure和amazon),让开发者能够基于企业内部服务构建缓存方案。在日常开发场景中实现时,本地开发、CI、SCM开发环节都可以从中受益。如前所述,在CI环节构建修改项目及其上下游项目,可以在一定程度上保证MergeRequest的质量。如上图所示,有一个场景是修改了package0的代码。为了保证其上下游构建不受影响,在CIBuildChangedProjects阶段会执行如下命令:$rushbuild--topackage0--frompackage0basedongitdiff选择源文件为改变了,这里package0是作用域的,package0及其上游app1会被包含在构建过??程中,因为app1需要构建,作为它的前置依赖,package1到package5也需要构建,但这5个package其实并不依赖package0上,也没有变化,只是完成app1的构建准备。如果依赖变得复杂,比如一个基础包被多个应用引用,那么package1-package5的准备工作就会大大增加,导致这个阶段的CI非常慢。实际构建项目数=修改项目下游项目数+修改项目上游项目数+变更项目下游项目前端依赖数+前端依赖数更改项目的上游项目。由于package1-package5和其他5个项目不存在与package0的直接或间接依赖关系,并且输入文件没有更改,因此它可以命中缓存(如果有),跳过构建操作。这将构建范围从7个项目减少到2个项目。实际构建项目数=修改项目+修改项目的上游项目数如何判断是否命中缓存?在云端,每个项目构建结果的缓存压缩包映射到输入文件inputfiles计算的cacheId,输入文件没有变化。那么计算出来的cacheId值不会改变(contenthash),可以命中对应的云缓存。输入文件包含以下内容:项目代码源文件项目NPM依赖项目项目依赖的其他monorepo内部项目的cacheId如果对实现感兴趣,可以查看@rushstack/package-deps-hash。结束语在写这篇文章的过程中,笔者也想起了@sorrycc在GMTC上分享的《前端构建提速的体系化思路》中提到的加速构建的三大法宝:延迟处理。按需编译,根据请求延迟编译sourcemap缓存。ViteOptimize、Webpack5物理缓存、Babel缓存NativeCode。对于SWC和ESBuild作为任务编排工具,NativeCode的优势并不明显(虽然Turborepo是用Go语言编写的,但Lage作者认为在现有规模下,任务编排的效率瓶颈不在编排工具本身),但是延迟处理类似于缓存。最后,用简洁务实的Lage官网副标题作为本文主题“任务安排”的结尾:Runallyournpmscriptsintopologicalorderinincrementallywithcloudcache-@microsoft/lageWithcloudcache,runallyournpm按拓扑顺序递增脚本的脚本。请参阅monorepo.tools:关于#monorepos.Rush的事实上的#guide:用于webLage的可扩展的monorepo管理器:一个漂亮的JSMonorepoTaskRunnerJSMonorepoWorkspaceToolsPnpm-Filtering
