什么是monorepo?
Monorepo 模式中,所有相关的项目和组件都被存储在一个统一的代码仓库中,而不是分散在多个独立的代码仓库中。
为什么会出现monorepo?
发展历史
1、单一代码仓库
将所有的功能和模块打包在一起,形成一个单一的代码库和部署单元。这种单一的代码库包含了应用程序的所有部分,从前端界面到后端逻辑,甚至包括数据库模式和配置文件等。
├── client/
│ ├── src/
│ │ ├── components/
│ │ ├── pages/
│ │ ├── services/
│ │ ├── utils/
│ │ ├── App.js
│ │ └── index.js
├── server/
│ ├── controllers/
│ ├── models/
│ ├── routes/
│ ├── config/
│ └── app.js
├── tests/
│ ├── client/
│ └── server/
├── package.json
├── README.md
└── ...
优势
代码管理成本低,发布简单,代码清晰
存在问题
随着迭代业务复杂度的提升,项目代码会变得越来越多,越来越复杂,大量代码构建效率也会降低
2、多代码仓库
将不同的功能模块、组件或服务等分别存放在独立的仓库中,可以单独进行版本控制、构建、部署和发布,使得不同的团队或开发者可以独立地开发、测试和维护各自的模块,更容易实现并行开发和团队协作。
// Repository - module1
├── node_modules/
├── src/
│ ├── components/
│ ├── pages/
│ ├── utils/
│ ├── App.js
│ └── index.js
├── styles/
│ ├── main.css
├── public/
│ ├── index.html
├── package.json
└── ...
// Repository - module2
├── node_modules/
├── src/
│ ├── components/
│ ├── pages/
│ ├── utils/
│ ├── App.js
│ └── index.js
├── styles/
│ ├── main.css
├── public/
│ ├── index.html
├── package.json
└── ...
// Repository - lib
├── node_modules/
├── package.json
├── src
│ ├── ...
├── README.md
// 共享代码
- lib 进行发包,比如包名为 @my-scope/lib
- 进入project1 或 project2 进行npm install
- 在代码中引入import {method} from '@my-scope/lib';
优势
每个服务都有属于自己的仓库,职责单一;代码量和复杂性受控,不同的服务可以让不同的团队管理;单个服务方便测试和部署以及进行拓展,不需要集中协调和管理。
存在问题:
MultiRepo这种方式虽然从业务上解耦了,但增加了项目工程管理的难度,随着模块仓库达到一定数量级,会跨仓库代码难共享;分散在单仓库的模块依赖管理复杂(底层模块升级后,其他上层依赖需要及时更新,否则有问题);增加了构建耗时;项目件冗余代码多。
3、单仓库应用
多个项目集成到一个仓库下,共享工程配置,同时又快捷地共享模块代码,有助于简化代码共享、版本控制、构建和部署等方面的复杂性,并提供更好的可重用性和协作性。
.
├── node_modules
├── package.json
├── packages
│ ├── package1
│ ├── package2
│ └── package3
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── readme.md
└── tsconfig.json
优势
所有的源都在一个仓库内,分支管理简单;公共依赖清晰,方便统一管理公共模块;统一配置和构建。
常见问题
-
幽灵依赖问题:npm/yarn 安装依赖时,存在依赖提升,某个项目使用的依赖,并没有在其 package.json 中声明,也可以直接使用,这种现象称之为 “幽灵依赖”;随着项目迭代,这个依赖不再被其他项目使用,不再被安装,使用幽灵依赖的项目,会因为无法找到依赖而报错。
使用pnpm来解决
-
依赖安装耗时长,MonoRepo 中每个项目都有自己的 package.json 依赖列表,随着 MonoRepo 中依赖总数的增长,每次
install时,耗时会较长相同版本依赖提升到 Monorepo 根目录下,减少冗余依赖安装;使用 pnpm 按需安装及依赖缓存。
-
构建打包耗时长
增量构建替代全量构建。
怎么使用单仓库模式?
构建工具出现的目的:解决大仓库 Monorepo 构建效率低的问题
Pnpm Workspace 构建
pnpm的优势:安装速度快(无扁平算法)、通过hard-link的方式节省空间
-
使用
初始化项目:
pnpm init新建pnpm-workspace.yaml文件用来指定子项目的位置
packages:
# all packages in direct subdirs of packages/
- "packages/*"
安装/删除全局依赖:`pnpm add/rm typescript -D -w`
安装子包依赖:`pnpm --filter package_name add react`
安装本地依赖: `pnpm --filter package_name add local_name`
-
优点
- 天然支持monorepo
- 解决幽灵依赖
-
缺点
- 手动提升公共的依赖
- 不支持自动版本控制
- 任务不支持并行执行,影响构建速度
- 不支持缓存
Lerna
Lerna 是一个用于管理多包存储库的工具。它是一个用于管理 Monorepo的工具,特别适用于大型项目,有助于简化跨多个包的协作和版本控制。
-
使用
- 初始化:
npx lerna@latest init - 可视区:
npx nx graph - 新增包:
lerna create <page_name> - 新增依赖:
lerna add chalklerna add chalk —scope <page_name> - 构建:
npx lerna run buildnpx lerna run build --scope=<package_name> - 发布:
lerna publish - 开启缓存:
npx lerna add-caching
为某个 package 安装的包被放到了这个 package 目录下的 node_modules 目录下。这样对于多个 package 都依赖的包,会被多个 package 安装多次,并且每个 package 下都维护 node_modules ,也不清爽。使用 --hoist 来把每个 package 下的依赖包都提升到工程根目录,来降低安装以及管理的成本。
lerna bootstrap --hoistlerna clean - 初始化:
-
优点
- 依赖自动提升
- 支持缓存、内部依赖分析
- 带版本控制(能分析出 private:false 的包,引导版本号提升)
-
缺点
- 默认使用npm,只能安装根目录的依赖
Turborepo
Turborepo是一个智能的构建系统,针对 JavaScript 和 TypeScript 的项目进行了优化。Turborepo 利用缓存来加速本地环境和 CI 环境。
-
增量构建:Turborepo 会记住你之前构建的结果并跳过已经计算过的内容。
-
内容hash: Turborepo 通过文件的内容,而不是时间戳来确定需要构建的内容。
-
并行处理: 不浪费任何闲置 cpu 性能,以每个核心最大的并行度来执行构建。
-
远程缓存 : 与团队成员、CI/CD 共享远程构建缓存,以实现更快的构建。
-
零运行时开销: Turborepo 不会影响您的运行时代码或 sourcemap。
-
任务管道: 定义任务之间的关系,然后让 Turborepo 优化构建内容和时间。
-
渐进式设计:可以在几分钟内快速集成到项目中
使用:
-
创建一个turborepo项目:
npx create-turbo@latest -
将turborepo继承到现有项目中:
- 在项目根目录下安装依赖:
pnpm i turbo --save-dev
- 在项目根目录下安装依赖:
-
{
"pipeline": {
"build": {
"outputs": ["dist/**"]
}
}
}
- 配置turbo.json
- 构建:`pnpm turbo build`
并行处理:
使用缓存:第二次构建时,turbo直接使用了之前构建过的缓存,大大缩短了构建时间。
changeset
Changesets 是一个用于 Monorepo 项目下版本以及 Changelog 文件管理的工具。changeset主要关心 monorepo 项目下子项目版本的更新、changelog 文件生成、包的发布。一个 changeset 是个包含了在某个分支或者 commit 上改动信息的 md 文件。
安装changeset:pnpm install @changesets/cli -w --save-dev
初始化changeset: pnpm changeset init 生成对应配置文件
生成变更集:pnpm changeset
发布:pnpm changeset publish
单仓库模式的优缺点总结
MultiRepo Vs MonoRepo
| 场景 | MultiRepo | MonoRepo |
|---|---|---|
| 代码可见性 | ✅ 代码隔离,研发者只需关注自己负责的仓库 ❌ 包管理按照各自owner划分,当出现问题时,需要到依赖包中进行判断并解决。 | ✅看到整个代码库的变化趋势,更好的团队协作。 ❌ 增加了非owner改动代码的风险 |
| 依赖管理 | ❌ 多个仓库都有自己的 node_modules,存在依赖重复安装情况,占用磁盘空间大。 | ✅ 多项目代码都在一个仓库中,相同版本依赖提升到顶层只安装一次,节省磁盘空间 |
| 代码权限 | ✅ 各项目单独仓库,不会出现代码被误改的情况,单个项目出现问题不会影响其他项目。 | ❌ 多个项目代码都在一个仓库中,没有项目粒度的权限管控,一个项目出问题,可能影响所有项目。 |
| 开发迭代 | ✅ 仓库体积小,模块划分清晰,可维护性强。❌ 多仓库来回切换,项目多的话效率很低。多仓库见存在依赖时,需要手动 npm link,操作繁琐。❌ 依赖管理不便,多个依赖可能在多个仓库中存在不同版本,重复安装。 | ✅ 可看到相关项目全貌,编码非常方便。✅ 代码复用高,方便进行代码重构。❌ 多项目在一个仓库中,代码体积多大几个 G,git clone时间较长。✅ 依赖调试方便,依赖包迭代场景下,借助工具自动 npm link,直接使用最新版本依赖,简化了操作流程。 |
| 工程配置 | ❌ 各项目构建、打包、代码校验都各自维护,不一致时会导致代码差异或构建差异。 | ✅ 多项目在一个仓库,工程配置一致,代码质量标准及风格也很容易一致。 |
| 构建部署 | ❌ 多个项目间存在依赖,部署时需要手动到不同的仓库根据先后顺序去修改版本及进行部署,操作繁琐效率低。 | ✅ 构建性 Monorepo 工具可以配置依赖项目的构建优先级,可以实现一次命令完成所有的部署。 |
优点
1. 更快的搭建并运行新项目
-
自定义代码生成模板
通过模板可以生成项目特定的样板代码,具有高度的定制化特点。例如在nx中设定完毕后可以直接创建带有身份验证和主题切换的项目。尽管不使用monorepo也能做到这一点,但是monorepo能够更好的管理内部依赖项,使它们保持最新状态。
-
使用现有的 CICD 设置
在 Monorepo 中开发时,不需要设置自己的 CICD pipeline,也不需要自己去配置 yaml 文件。项目生成完毕之后,CICD 的 pipeline 就已经设置完毕了。
2. 在生产环境中更少出错
由于所有代码都在一个地方,所以当有 breaking changes 的时候很容易得到反馈。
假设有一个很多项目都依赖的身份验证库,新开发了一个feature,经过本地测试可以正常运行。但是如果该feature会使得某个项目崩溃,关于这段代码的 merge 就会被 CICD 里的 build pipeline 自动阻止,并且开发人员也可以查看是哪一行命令阻止了构建,在本地运行并复现错误。
3. 带来更好的团队合作
mutirepo带来更多的项目自主权,因为组内成员可以选择他们自己更加习惯的,或者认为更好用的工具或者库,并且他们也可以决定生产环境中的变化在什么时候发生。
由于这些原因,很难从全局去总览这些不同的项目。不同的组可能不知道哪些服务是共享的,也可能某些共享的服务或者库不包含某个项目组需要的 feature,带来了很多项目之外的问题。大量的代码在被重复的复制和粘贴。
在 Monorepo 里面很容易找到相关的共享服务,Nx 还有一个画出相关依赖项直接的结构图的功能。因此共享的服务或者组件就不再是一个黑盒,项目集成变得更加简单。
4. 更好的内部依赖管理
在 mutirepo 里,如果两个内部依赖项依赖两个版本之间不兼容的包,有时就会显示 Can't resolve dependencies 使得项目无法成功运行。
如果所有的依赖包都在一个 repo 里面,那么任何使用它们的项目都会使用一个相同的版本,这就不会用兼容性上的问题了。
5. 更容易做出改动
假如要对所有项目内的 Logo 都进行替换,对于 Mutirepo 来说,通常需要以下的步骤:
-
更改 logo 仓库中的代码,比如更换 SVG
-
Pull request & Merge
-
等待打包完成然后发布
-
让所有项目组去更新他们的项目依赖版本
-
对于每个项目组来说,等到手上的迭代开发完了有时间的时候,他们又需要以下的步骤
- 安装新的 logo 依赖版本
- 检查是否有什么冲突
- Pull request & Merge
- 等待项目部署完毕
要想完成所有项目的logo替换,所需要的时间显然是无法准确估计的。
在 Monorepo 里,我们在第一步更改代码的时候就会知道有哪些项目依赖更改的这个更改的库,在Pull request,Merge,并且发布完成后,CICD pipeline会把改动应用到所有受影响的项目上。
缺点
1. main分支出问题时危害更大
尽管这种情况在 Monorepo中的出现几率会更低,但在主分支上或者上线的分支上出现问题时通常需要及时回滚,项目本身会有一段时间不可用。由于在Monorepo 中, main分支的改动会带来统一的部署,因此所有与之相关的项目都有出现问题的可能。如果发生了这种情况,需要对所有项目进行回滚,然后取消现有的 pipeline 防止bug 被重新部署上去,再回滚出现问题的库的 commit,最后再把 pipe重新启动。
2. 打包构建需要专门优化,否则会出现打包时间过长
Monorepo需要处理大量的文件,这会造成 lint, test, build 这些环节的速度变慢。类似 Nx 的工具实际上能够只对受影响的项目进行改动,并且通过使用远程缓存的方式提高了速度。
3. 性能问题
git 操作(git clone, git pull…), 在处理过于庞大的 Monorepo 时会变得很慢,谷歌甚至开发了他们自己的版本控制系统,但是对于目前的大部分 Monorepo 的规模来说,慢的程度应该还不会让人能够明显注意到。
4. 项目组不知道项目中已经出现了Bug
当对核心库进行更新时,所有的相关项目都会被重新构建部署,如果该操作的通知不够明显,项目拥有者可能不知道项目中的部分内容已经被更改并且出现了 bug。当然,这种情况下如果 bug 又被修复了,项目人员可能会以为什么都没发生。要想避免出现问题,最好准备好覆盖全面的测试用例。
5. 特定开发工具和框架限制,灵活性差。
使用 Monorepo 会迫使所有项目组使用特定的工具链,例如所有项目都需要使用 Nx,Typescript,和React。并且,项目部署也不是完全独立的。在某些情况下,如果只是想对自己的项目做一些很小的改动,又不想被别人的代码带来的改动打扰,这种做法似乎看起来就显得没必要。不过什么事都是相对的,Monorepo是拿自由度与标准化/规模化做了交换。