实践 Monorepo 模式两年多的一些总结

date
May 18, 2022
slug
monorepo-experience-record
status
Published
tags
Monorepo
type
Post
lang
summary
团队从 19 年末开始从 multirepo 的模式迁移到 monorepo,经历了 2 年多的历程,也有许多感悟。本文主要总结一下自己对 monorepo 这种项目管理模式的粗浅理解。
本文不会对 monorepo 的概念进行介绍,如果你还不了解 monorepo 的概念,可以参考这篇文章:Monorepo 是什么,为什么大家都在用? 或者其他对于 monorepo 的介绍文章,网上有很多,一搜一大把。

为什么使用 monorepo

当初团队从 multirepo 迁移到 monorepo,主要还是因为方便共享和调试工具库,但是除了这点,monorepo 还有其他优势,比如:

便捷共享和调试代码

团队最初使用 multirepo 最主要的问题是多仓库共享代码、调试非常不方便,也就是开发工具库/组件库时调试很麻烦,每次都需要使用 yarn link 的模式来开发,然后开发完成后再进行发版才能供其他项目使用,这就导致了多仓库共享代码成本非常高。
切换成 monorepo 的模式后,对基础库的开发就非常省事,我们可以直接在项目中引用工具库进行开发调试,不需要再使用 yarn link。

重复的基础建设

除了共享代码繁琐,每个工具库还需要配置自己的基础设施、CI/CD 流程、开发环境,同时每个项目都需要专人来维护,这样就很容易导致项目之间的不一致性,而后提升多项目维护成本。
试想一下,如果你需要开发多个项目,而每个项目的开发模式都是不一致的,这种体验是非常糟糕的,也是非常耗费人力成本的。
但是当我们使用 monorepo 时,我们就可以在使用一套基础建设、开发规范等来降低项目维护的成本。且只需要抽出单独的 1-2 个人力去专门维护基础建设,其他项目完全不需要再关心。

简化依赖管理

同时因为不再使用的发包模式来管理公共代码,所以对 monorepo 内的公共依赖都是使用的最新版本,这样就很容易追踪到公共代码的变更会对其他项目的影响,当然这个时候就更需要注重公共代码的自动化测试,因为公共代码只要合并到 master 后就会立即影响所有线上项目。

快速协助其他项目开发

因为使用了完全相同的基础建设、工作流、开发环境,所以在团队之间相互协助的时候,不需要在配置开发环境、如何部署项目等等流程上重复浪费时间,只需要关心业务的开发即可。

实践过程中遇到问题

使用 monorepo 的模式的确解决了多仓库联调、基础建设复用等问题,但同时也带来了一些特有的问题。

幽灵依赖(phantom dependency)

幽灵依赖指的是一个库使用了不属于其 dependencies 里的 package,我相信大部分使用 monorepo 的朋友应该都遇到过或者了解过。

使用未声明的依赖的版本

假设现在 APP1 依赖了 A@1.0.0。突然有一天,APP2 也想用 A@1.0.0 这个依赖,这时,在 APP2 中使用 import X from 'A' 直接跑通了,看起来没什么问题,但其实 APP2 的 dependencies 中并没有添加 A 依赖,但因为有 yarn 的依赖提升,将 A@1.0.0 直接提升到了最外层,让 APP2 也能使用依赖项 A
后来的某天 APP1 不需要依赖 A 了,这个时候它就把依赖项从 dependencies 中移除了,但是这时候,APP2 直接就挂了。
这种 case 还算是比较好的了,毕竟项目在开发或者部署的时候直接就挂了。
最惨的是,APP1 需要升级 A@1.0.0 到 A@2.0.0 升级自己的项目回归过,没问题,代码合并到 master 上线。
APP2 这时候就倒霉了,A@1.0.0 到 A@2.0.0 完全不兼容,而 APP2 完全没有做相关代码的升级,这就导致线上的 APP2 项目直接就挂了。

peerDependencies 错误

看一下这种场景:
  1. APP1 依赖 A@1.0.0
  1. APP2 依赖 B@2.0.0
  1. B@2.0.0 将 A@2.0.0 作为 peerDependencies,故 APP2 也应该安装 A@2.0.0
但如果 APP2 没有安装 A@2.0.0,那么 B@2.0.0 则会使用被变量提升的 A@1.0.0 版本。
如果你运气不错, A@1.0.0 和 A@2.0.0 完全不兼容,那么项目运行就直接挂了。但如果两个版本只是有部分 API 变更,导致你开发的时候并没有遇到问题,但是一上线,直接就挂了。
或者 APP1 升级了依赖到 A@3.0.0 版本,也会导致 APP2 直接挂掉,因为 APP2 会使用 APP1 升级后的依赖。

如何解决

幽灵依赖是一个比较常见的问题,容易导致线上问题是因为比较隐蔽,很难发现,出现问题也很难定位,所以我们需要从根源上杜绝幽灵依赖的问题。
导致问题发生的罪魁祸首就是 ”依赖提升“,所以我们只需要想办法解决掉它就可以了。
我们可以将包管理器换成 pnpm ,直接帮我们解决使用依赖但是不写到 dependencies 中的问题。

编译时间&依赖安装时间变长

这个问题很好理解,1 个项目和 N + 1 个项目,哪个编译/依赖安装时间更短?显而易见的是单独 1 个项目的编译时间更短。
但我们可以通过这两种方式降低构建时间(目前团队正在使用的两种方案):

按需构建减少需要构建的项目

编译时间变长主要是因为需要对所有项目进行构建,但是我们其实只需要构建变更的项目和被变更项目影响到的项目,也就是按需构建
如果项目使用的是 lerna,那么就可以使用 yarn lerna:changed 找出所有需要构建的项目,然后只对这部分项目进行构建。

手动指定编译项目

当我们不需要将整个 monorepo 合并到主干分支前,我们可以只对需要构建的项目进行构建。我们可以在 commit body 中指定需要编译的项目,在 CI 脚本中对 commit body 进行识别,从而只进行单个项目的构建。
当需要合并的主干分支时,再对整个项目进行构建,只有所以项目都完成 CI 构建 & 校验,才能被合并到主干分支。

依赖安装加速

同样,项目变多了之后,依赖项就会非常多,导致安装依赖的时间非常长。
这个解决起来也很容易,如果你使用的是 pnpm 就可以使用 按需安装,也就是使用 pnpm install --filter="XXX" 的方式来进行按需安装,相关文档:Filtering
除了按需安装,还可以通过缓存方案来加速,可以看这篇文章:依赖缓存加速 CI 构建

Git 记录混乱

因为所有人的 commit 在 monorepo 里都在一个线性历史里面,所以很乱。如果团队内没有对 commit 信息进行规范强制校验的话,就会导致项目的 commit 信息直接废弃。想解决这个问题就需要团队定义好 commit 规则,加上 Git Hooks 做好前置校验即可。
不过即使做了规范化的处理,也顶不住异常多的 commit 记录,所以建议使用可视化工具去看 Git 记录。例如笔者使用的就是 IDE 自带的 Git 工具。

项目隐私性和安全性的问题

如果 monorepo 采用单个仓库,那么所有项目的代码对该仓库的所有开发者都是可见的,如果希望对某一个项目施加细粒度的权限控制,采用单仓库是很难实现的。所以如果你的项目需要做保密处理,那么就需要谨慎选择使用 monorepo(当然如果你有别的解决方案也可以告诉我)。

IDE 卡顿

实话实说,使用 monorepo 之后,我的电脑只有四个字:“芜湖起飞”,因为我习惯使用 WebStorm / IDEA 来开发项目,众所周知 JetBrains 家的 IDE 是真的吃性能,而我们的代码仓库又实在太大了,约几十个项目,且每个项目的代码量都不小。
如果你也使用的是 IDEA 可以通过 exclude 不需要关注的项目来减缓这个问题,或者只打开自己需要开发的项目。想要彻底解决这个问题的话,建议直接换成 M1 的 Mac。

总结

如果你还没有使用 monorepo 这种项目管理模式,且可以接受这些痛点的话,非常推荐你进行尝试。尤其是你开发工具库的时候,monorepo 的模式实在是太香了。
如果你想实践的话最简单的方式是直接使用 yarn workspace 去尝试,毕竟 lerna 已经停止维护了。可以参考 Vue 3 的实践方式,照抄一下~
以上总结都是个人的一些浅显理解,希望读者朋友能从笔者的总结有所收获。如果你有不同的理解或者建议欢迎在评论区留言指出,非常感谢。

© CC BY-SAJin 2019 - 2022

Powered by Vercel & Notion