Turborepo: 是时候给你的monorepo仓库上上对抗了

·  阅读 4352
Turborepo: 是时候给你的monorepo仓库上上对抗了

前言

最近 Vercel 收购了 Turborepo ,目的是为了加快 Next.js 的构建速度,并且 Turborepo 的作者也加入了 Vercel。

monorepo

什么是monorepo?

在通用的开发场景中,人们希望各个项目之间能够足够的独立,各自的开发和发布不会产生太多的耦合,现在很多的项目也是出于这种考虑去拆成一个一个独立的子项目,在单独的代码仓库中进行管理,这就是我们常见的单代码仓库的开发模式。 但是上面的模式,在某些场景下就会显得低效和繁琐。比如一个仓库的代码被很多其他相关的仓库引用,那么只要这个仓库进行发版,所有依赖了这个代码的仓库也要跟着进行依赖升级和发版。如果把所有有依赖关系的代码都放到一个仓库中进行统一维护,当一个库变动时,其它的代码能自动的进行依赖升级,那么就能精简开发流程、提高开发效率。这种多包的代码仓库管,就是 monorepo。 其实monorepo在前端中非常常见,Babel、Reac、Vue等开源项目都是使用这种方式在管理代码,其中 Babel 官方开源的多包管理工具 Lerna 也被广泛的使用。

Turborepo

Turborepo 是一个适用于 JavaScript 和 Typescript monorepo 的高性能构建工具,它不是一个侵入式的工具,你可以在项目中渐进的引入和使用它,它通过足够的封装度,使用一些简单的配置来达到高性能的项目构建。 和esbuild一样,Turborepo也是基于go实现的工具,在语言层面上就具有一定的性能优势。

优势

  • 增量构建:缓存构建内容,并跳过已经计算过的内容,通过增量构建来提高构建速度
  • 内容hash:通过文件内容计算出来的hash来判断文件是否需要进行构建
  • 云缓存:可以和团队成员共享CI/CD的云构建缓存,来实现更快的构建
  • 并行执行:在不浪费空闲 CPU 的情况下,以最大并行数量来进行构建
  • 任务管道:通过定义任务之间的关系,让 Turborepo 优化构建的内容和时间
  • 约定式配置:通过约定来降低配置的复杂度,只需要几行简单的 JSON 就能完成配置

开始使用

对于一个新的项目,可以运行下面的命令来生成全新的代码仓库

npx create-turbo@latest

对于一个已经存在的 monorepo 项目,可以通过下面的步骤来接入 turborepo

安装Turborepo

将 Turborepo 添加到项目最外层的devDependecies

npm install turbo -D
or
yarn add turbo --dev

增加配置

在 package.json 中增加 Turborepo 的配置项

// package.json
{
     "turbo": {    
     }
}

Turborepo 所有相关的配置,都放入turbo这个配置项中

创建任务管道

package.jsonturbo中,将想要"涡轮增压"的命令添加到管道中 管道定义了 npm 包中 scripts 的依赖关系,并且为这些命令开启了缓存。这些命令的依赖关系和缓存设置会应用到 monorepo 中的各个包中

{
    "turbo": {
        "pipeline": {
            "build": {
                "dependsOn": ["^build"],        
                "outputs": [".next/**"]            
            },
            "test": {
                "dependsOn": ["^build"],
                "outputs": []                            
            },
            "lint": {
                "outputs": []
            },
            "dev": {
                "cache": false            
            } 
        }    
    }
}

上面的示例中,buildtest这两个任务具有依赖性,必须要等他们的依赖项对应的任务完成后才能执行,所以这里用^来表示。 对于每个包中 package.json 中的 script 命令,如果没有配置覆盖项,那么Turborepo将缓存默认输出到 dist/** build/**文件夹中。可以通过outputs数组来设置缓存的输出目录,示例中将缓存保存到.next/**文件夹中。Turborep会自动将没个script的控制台log缓存到.turbo/turbo-<script>.log目录中,不需要自己手动去指定。 dev这个任务通过cache设置为false来禁用这个命令的缓存功能。

pipeline

从上面的 turbo 的配置中可以看出来,管道(pipeline)是一个核心的概念,Turborepo也是通过管道来处理各个任务和他们的依赖关系的。

在传统的monorepo仓库中,比如使用了lerna或者yarn的workspace进行管理,每个npm包的script(如build或者test),都是依赖执行或者独立并行的执行。如果一个命令存在包的依赖关系,那么在执行的时候,CPU的核心可能会被闲置,这样会导致计算性能和时间上的浪费。

Turborepo提供了一种声明式的方法来指定各个任务之间的关系,这种方式能够更容易理解各个任务之间的关系,并且Turborepo也能通过这种显式的声明来优化任务的执行并充分调度CPU的多核心性能。

download.png

上图是Turborepo和Lerna的执行流水线对比,可以看出Turborepo能够高效的执行任务,而Lerna一次只能执行一个任务。

配置pipeline

pipeline中每个键名都可以通过运行turbo run来执行,并且可以使用dependsOn来执行当前管道的依赖项。

上图的执行流程,可以配置成如下的格式

{
    "turbo": {
        "pipeline": {
            "build": {
                "dependsOn": ["^build"],           
            },
            "test": {
                "dependsOn": ["build"],
                "outputs": []                            
            },
            "lint": {
                "outputs": []
            },
            "deploy": {
                "dependsOn": ["build", "test", "lint"]           
            } 
        }    
    }
}

通过dependsOn的配置,可以看出各个命令的执行顺序:

  • 因为A和C依赖于B,所以包的构建存在依赖关系,根据build的dependsOn配置,会先执行依赖项的build命令,依赖项执行完后才会执行自己的build命令。从上面的瀑布流中也可以看出,B的build先执行,执行完以后A和C的build会并行执行
  • 对于test,只依赖自己的build命令,只要自己的build命令完成了,就立即执行test
  • lint没有任何依赖,在任何时间都可以执行
  • 自己完成build、test、lint后,再执行deploy命令

所有的pipeline可以通过下面的命令执行:

npx turbo run build test lint deploy

常规依赖

如果一个任务的执行,只依赖自己包其他的任务,那么可以把依赖的任务放在dependsOn数组里

{
    "turbo": {
        "pipeline": {
            "deploy": {
                "dependsOn": ["build", "test", "lint"]           
            } 
        }    
    }
}

拓扑依赖

可以通过^符号来显式声明该任务具有拓扑依赖性,需要依赖的包执行完相应的任务后才能开始执行自己的任务

{
    "turbo": {
        "pipeline": {
            "build": {
                "dependsOn": ["^build"],           
            }
        }    
    }
}

空依赖

如果一个任务的dependsOn为undefined或者[],那么表明这个任务可以在任意时间被执行

{
    "turbo": {
        "pipeline": {
            "lint": {
                "outputs": []
            }, 
        }    
    }
}

特定依赖

在一些场景下,一个任务可能会依赖某个包的特定的任务,这时候我们需要去手动指定依赖关系。

{
    "turbo": {
        "pipeline": {
            "build": {
                "dependsOn": ["^build"],           
            },
            "test": {
                "dependsOn": ["build"],
                "outputs": []                            
            },
            "lint": {
                "outputs": []
            },
            "deploy": {
                "dependsOn": ["build", "test", "lint"]           
            },
            "frontend#deploy": {
                "dependsOn": ["ui#test", "backend#deploy"]            
            }
        }    
    }
}

在上面的例子中,增加了一个前端的部署任务。这个部署任务,依赖于一个UI组件库和对应的后端项目,只有这个UI组件库通过单测,然后后端项目部署成功,才会进行部署。对于指定包的依赖,使用<package>#<task>语法。

with Lerna

Lerna是现在常用的monorepo构建工具,它不仅能支持包任务的运行,也能很好的进行包的依赖和版本管理。

和Lerna比较,Turborepo有更好的任务调度机制,并且Lerna运行任务的时候是不会进行缓存的,所以在缓存方面Turborepo也有很大的优势。

对于包的publish以及version的更新,Turborepo还没有进行实现,所以在现阶段可以一起使用Lerna和Turborepo,让他们各司其职。

安装Turborepo,并对package.json进行如下的修改:

{
  "scripts": {
-   "dev": "lerna run dev --stream --parallel",
+   "dev": "turbo run dev --parallel --no-cache",
-   "test": "lerna run test",
+   "test": "turbo run test",
-   "build": "lerna run build",
+   "build": "turbo run build",
    "prepublish": "lerna run prepublish",
    "publish-canary": "lerna version prerelease --preid canary --force-publish",
    "publish-stable": "lerna version --force-publish && release && node ./scripts/release-notes.js"
  },
  "devDependencies": {
    "lerna": "^3.19.0",
+   "turbo": "*"
  },
+"turbo": {
+   "pipeline": {
+     "build": {
+       "dependsOn": ["^build"],
+       "outputs": ["dist/**"]
+     },
+     "test": {
+       "outputs": []
+     },
+     "dev": {
+       "cache": false
+     }
+   }
  }
}

对比

我自己也在维护一个monorepo的项目,但是涉及的组件比较少,不能很直观的看出Turborepo带来的提升,这里就找了 reakit 这个库来进行比较。

安装完依赖后,对package.json做如下的修改:

download (1).png

这个项目中各个包的构建是没有依赖关系的,所有build和lint任务一样,都没有依赖项的配置。

run build

下面以build命令为例进行对比。

为了提高lerna的构建速度,对 lerna-build 开启--parallel配置项,让它能够并行执行

第一次执行lerna-build,最终的耗时如下图所示

download (2).png

因为lerna没有缓存,所以后面多次运行的结果,基本上都维持在25秒左右

第一次执行yarn turbo-build,控制台输出为

download (3).png

可以看出第一次执行的时候,消耗的时间和lerna开启并行执行所需要的时间差不多,当第二次运行的时候,因为缓存生效了,最终的输出如下图

download (4).png

因为缓存的存在,所以第二次执行的时候,只花了0.5s

文件变动

修改其中一个包的代码,lerna-build的时间没有变,还基本上是25秒左右。

而运行turbo-build的时候,最后的时间输出如下图

download (5).png

可以看出对于没有修改的包的代码,缓存还在生效,所以执行时间还有大幅的缩减。

复杂场景

我们手动创造一个部署的场景,对应的命令和配置如下

{
    scripts: {
          "turbo-deploy": "turbo run deploy",
          "lerna-deploy": "npm run lerna-lint && npm run lerna-build"  
    },
    turbo: {
         "deploy": {
            "dependsOn": ["lint", "build"]
          }   
    }
}

lerna-deploy 需要45秒左右的时间才能执行完,而 turbo-deploy 只需要20秒左右,并且后面还有缓存的加持。

Remote cache

当多人开个一个项目的时候,团队的成员可以共享构建的缓存,从而加快项目的构建速度。

当一个成员把某个分支构建的缓存文件推送到远程的git仓库是,另一个成员如果在同一个分支上进行开发,那么Turborepo 可以支持你去选择某个成员的构建缓存,并在运行相关的构建任务时,从远端拉去缓存文件到本地,加快构建的速度

运行 npx turbo link,进行登录后,就可以选择要使用的缓存

download (6).png

选择完成后,就可以使用对应的缓存了

download (7).png

在作者的视频demo中,项目在没有使用缓存的情况下,需要花费27秒的时间来完成所有任务的构建

download (8).png

而在使用远端缓存的情况下,只需要3.5秒左右就能完成任务的构建

download (9).png

所以在多人开发的项目中,remote cache是一个杀手锏级别的功能,能够大幅提高构建任务的执行速度

总结

从上面的例子可以看出,执行的任务数量越多,并且依赖越复杂的情况,Turborepo在利用CPU多核心方面的优势就越明显。并且由于缓存的存在,在某些场景下,比如前后端依赖部署,只修改了前端的代码,那么后端代码的构建缓存就能被直接使用,这种情况下可以大大缩减构建时间,提高构建的效率。

所以在现阶段使用Turboreop来代替Lerna进行构建,对于复杂的monorepo项目来说,可以大大减少构建的时间,提高开发体验,具有相当可观的收益。

分类:
前端
标签:
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改