monorepo 是什么

一个产品会有多个项目,每个项目之间会存在版本同步的问题,如何在其中一个项目发布上线后,保证每个项目版本升级后的版本同步问题,提出的解决方案就是 monorepo 策略。

monorepo 是一种将多个项目代码存储在一个仓库里的软件开发策略(mono 意为单一,repo 意为 仓库)。与之相对的是另一种流行的代码管理方式 MultiRepo,即每个项目对应一个单独的仓库来分散管理。

多项目版本管理:monorepo 策略

无论是比如 Google、Facebook,还是社区内部知名的开源项目 Babel、Vue3都使用了 monorepo 方案来管理他们的代码。

多项目版本管理:monorepo 策略

通过 monorepo 策略管理代码的代码仓库的目录结构会是这样:

多项目版本管理:monorepo 策略

不过,monorepo 不只是将不同子项目的目录汇集到一个目录之下,实际上操作起来所要考虑的事情复杂得多。

monorepo 的优劣

monorepo 不是银弹,有优亦有劣:

在实际场景来落地项目级别的 Monorepo,需要一套完整的工程体系来进行支撑,因为基于 Monorepo 的项目管理,不只是代码放到一起,还需要考虑项目间依赖分析、依赖安装、构建流程、测试流程、CI 及发布流程等诸多工程环节,同时还要考虑项目规模到达一定程度后的性能问题,比如项目构建/测试时间过长需要进行增量构建/测试、按需执行 CI 等等,在实现全面工程化能力的同时,也需要兼顾到性能问题。

Monorepo 如何落地

1 锁定环境:volta or nvm

除了使用 Docker 和显式的在文档中声明 node 和 npm(yarn)的版本之外,我们需要一个锁定环境的强力工具。相比使用 nvm,volta 支持当项目 CLI 工具与全局不兼容时,自动切换为项目指定的版本。

Volta 是用 Rust 构建的 JavaScript 工具管理器,它可以让我们轻松地在项目中锁定 node,npm(yarn) 的版本。只需在安装完 Volta 后,在项目的根目录中执行 volta pin 命令,那么无论您当前使用的 node 或 npm(yarn)版本是什么,volta 都会自动切换为您指定的版本。

volta pin node@12.20
volta pin yarn@1.19
/**
"volta": {
  "node": "12.20.2",
  "yarn": "1.19.2"
}
*/

2 利用 workspace 特性复用 package

workspace 特性使得:

  1. 避免重复安装包,减少了磁盘空间的占用,并降低了构建时间;
  2. 内部代码可以彼此相互引用;

考虑到支持 workspace 的 npm 版本目前不是 LTS,所以选择 yarn 作为包管理工具,而且需要完成如下工作:

        1. 调整目录结构,将相互关联的项目放置在同一个目录,推荐命名为 packages

请注意对子项目的命名统一以 @<repo_name>/ 开头,这是一种社区最佳实践,更容易让其他开发者了解整个应用的架构和在项目中找到所需的子项目。

多项目版本管理:monorepo 策略

        2. 在项目根目录里的 package.json 文件中,设置 workspaces 属性,属性值为之前创建的目录(packages);

比如在 babel 中:

 "workspaces": [
    "codemods/*",
    "eslint/*",
    "packages/*",
    "test/esm",
    "test/runtime-integration/*",
    "benchmark"
  ],

        3. 为了避免我们误操作将仓库发布,在 package.json 文件中,设置 private 属性为 true。

然后,在项目根目录中执行 npm installyarn install 后,项目根目录中生成由所有子项目共用的 npm 包和我们自己的子项目共同构成的 node_modules 目录,正因如此,才使得可以像引入一般的 npm 模块一样彼此相互引用。

3 统一 ESlint / Typescript / babel 配置

可以在 项目根目录设置通用的 tsconfig.base.json / .eslintrc / .babelrc 配置,然后在子项目的对应配置文件中声明继承 extend 属性即可:

// babel
{
  "extends": "../../.babelrc"
}
// eslint
{
  "extends": "../../.eslintrc",
  "parserOptions": {
    "project": "tsconfig.json"
  }
}
// typescript
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true, // 用于帮助 TypeScript 快速确定引用工程的输出文件位置
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

项目目录大致如下所示:

多项目版本管理:monorepo 策略

4 统一脚本 script 配置

每个子项目的 package.json 文件中的 scripts 属性都大同小异,我们可以使用 scripty 管理脚本命令提高复用性,简单来说,scripty 允许将脚本命令定义在文件中,并在 package.json 文件中直接通过文件名来引用。这使我们可以实现如下目的:

同时,将脚本分为两类 package 类与 workspace 类,分别放在两个文件夹内:

多项目版本管理:monorepo 策略

如此既可以在项目根目录执行全局脚本,也可以针对单个项目执行特定的脚本。

注意:需要使用 chmod -R u+x scripts 命令使所有的 shell 脚本具备可执行权限,可以将其备注在 README.md 中。

子项目的 package.json 文件中的 scripts 属性简化为:

多项目版本管理:monorepo 策略

5 利用 lerna 统一包管理 

lerna 可以帮助解决 monorepo 因为多个子项目放在一个代码仓库,并且子项目之间又相互依赖时带来的两个棘手问题:

首先,在项目根目录使用 npx lerna init 初始化,根目录会生成一个 lerna.json 文件,稍作改动:

{
  "packages": ["packages/*"],
  "npmClient": "yarn", // 显式声明包客户端为 yarn
  "version": "independent",  // 将每个子项目的版本号看作是相互独立的
  "useWorkspaces": true, // 开启 workspaces 特性:子项目引用和通用包提升
}

当某个子项目代码更新后,运行 lerna publish 时,Lerna 将监听到代码变化的子项目并以交互式 CLI 方式让开发者决定需要升级的版本号,关联的子项目版本号不会自动升级,反之,当我们填入固定的版本号时,则任一子项目的代码变动,都会导致所有子项目的版本号基于当前指定的版本号升级。

Lerna 有很多有用的 CLI 命令:

  • lerna bootstrap:等同于 lerna link + yarn install,用于创建符合链接并安装依赖包;
  • lerna run:会像执行一个 for 循环一样,在所有子项目中执行 npm script 脚本,并且,它会非常智能的识别依赖关系,并从根依赖开始执行命令;
  • lerna exec:像 lerna run 一样,会按照依赖顺序执行命令,不同的是,它可以执行任何命令,例如 shell 脚本;
  • lerna publish:发布代码有变动的 package,因此首先需要在使用 Lerna 前使用 git commit 命令提交代码,好让 Lerna 有一个 baseline;
  • lerna add:将本地或远程的包作为依赖添加至当前的 monorepo 仓库中,该命令让 Lerna 可以识别并追踪包之间的依赖关系,因此非常重要;比如# 向 @mono/project2 和 @mono/project3 中添加 @mono/project1:
lerna add @mono/project1 '@mono/project{2,3}'

lerna 中有用的参数:

  • --concurrency <number>:参数可以使 Lerna 利用计算机上的多个核心,并发运行,从而提升构建速度;
  • --scope '@mono/{pkg1,pkg2}'--scope 参数可以指定 Lerna 命令的运行环境,通过使用该参数,Lerna 将不再是一把梭的在所有仓库中执行命令,而是可以精准地在我们所指定的仓库中执行命令,并且还支持示例中的模版语法;
  • --stream:该参数可使我们查看 Lerna 运行时的命令执行信息

结合 verdanccio 在本地创建 npm 代理仓库,先发布体验和验证,全局安装 npm install --global verdaccio,在项目根目录创建 .npmrc 文件,并在文件中将 npm 仓库地址改写为本地代理地址 registry="http://localhost:4873/",执行 shell 命令 verdaccio 后,访问  localhost:4837即可,这样,每当执行 lerna publish 时,子项目所构建成的 package 将会先发布在本地 npm 仓库中,只有执行 lerna bootstrap 时,verdaccio 才发布到远程 npm。

6  commitlint:约束 commit  信息

commitlint 可以帮助我们检查提交的 commit 信息,它强制约束我们的 commit 信息必须在开头附加指定类型,用于标示本次提交的大致意图,支持的类型关键字有:

因为 monorepo 仓库可能被不同的开发者提交不同子项目的代码,规范化的 commit 信息在故障排查或版本回滚时是很有必要的。

可以通过下面的命令安装 commitlint 以及周边依赖,其中 husky 使得能在提交 commit 信息时自动运行 commitlint 进行检查:

多项目版本管理:monorepo 策略

项目根目录下的 package.json 文件中 husky 配置为: 

多项目版本管理:monorepo 策略

在项目根目录中增加 commitlint.config.js 文件使得 commitlint 能感知到子项目名称,并设置文件内容为:

module.exports = {
  extends: [
    "@commitlint/config-conventional",
    "@commitlint/config-lerna-scopes",
  ],
};

除此之外,commitlint 还支持显示指定本次提交所对应的子项目名称。比如,针对名为@mono/project1 的子项目提交的 commit 信息可以写为:

多项目版本管理:monorepo 策略而且,可以通过在命令行执行 echo "build(project1): change something" | npx commitlint 命令即可验证 commit 信息是否通过 commitlint 的检查。

总结:利用 tomono 基于已有的项目转化为 monorepo 项目

lerna import 命令用来将已有的包导入到 monorepo 仓库,并且还会保留该仓库的所有 commit 信息,但是该命令仅支持导入本地项目,并且不支持远程仓库、以及导入项目的分支和标签。

所以可以使用 tomono 导入远程仓库:

//格式为: Git仓库地址 子项目名称 迁移后的路径
git@github.com/backend.git @mono/backend packages/backend
git@github.com/frontend.git @mono/frontend packages/frontend
git@github.com/mobile.git @mono/mobile packages/mobile
cat repos.txt | ~/tomono/tomono.sh

除了上述的基于 Lerna 负责发布和版本控制,而使用 Yarn Workspaces 来管理多个应用程序之间的依赖的偏底层 monorepo 方案。也有一些集成的 Monorepo 方案,比如 nx 、rushstack,提供从初始化、开发、构建、测试到部署的全流程能力,有一套比较完整的 Monorepo 基础设施,适合直接拿来进行业务项目的开发。

而且基于 lerna 进行构建的 monorepo 项目,如果构建多个应用程序依赖,耗时很长,可以探索诸如 TurboRepo 的方案解决。

发表回复