Better

Ethan的博客,欢迎访问交流

monorepo road map

关于 monorepo 这个话题其实已经探讨过多次,之前公司业务线扩充后,进行前端技术选型改造,考虑到之前单个 repo 代码量过大,编译部署巨慢的痛苦,索性就发展成了 multi-repo 的方式,每个业务线维护好自己的 repo,然后通过简单的微服务实现服务之间的连通,但与此而来新的问题也就出现了,因此希望寄托 monorepo 的方式解决掉开发和管理上的痛点。

multi-repo

没有绝对的劣势,先谈下 multi-repo 的优势

  • 代码物理隔离,各个团队管理好自己的 repo 即可,仓库体积小,模块划分清晰
  • 更好的伸缩性:小仓库可以更好的被管理,更少的合并地狱,团队不需要与其他团队协调,从而更快地执行
  • 独立部署,提高单个服务的构建速度(相对传统的 single-repo 方案)

使用 multi-repo 模式,时间一长,最先摆在我们面前的问题就是,多项目之间如何共享代码,常用方式有

  • Git Submodule
  • Git Subtree
  • NPM

这几种方式,都有各自的问题,比如 NPM 侧重于包的依赖管理,但没办法双向同步,更适用于子项目比较稳定的情形。Git Submodule 和 Git Subtree 都是官方支持的功能,但不具有依赖管理的功能

共享代码只是一个导火索,multi-repo 还带来了更多深层次的问题(其实很多问题在我们团队已经暴露出来,而且变得很尖锐)

  • 代码分散在不同的 repo 中,容易导致大量重复的内容,且团队间不容易迭代同步
    • dependencies 依赖被大量重复安装(忘记被 node_modules 支配的恐惧了吗)
    • configuration
      • environments
      • build configurations
      • test configuration
      • eslint
      • prettier
      • pull request templates
      • CI/CD
  • 公共模块不敢轻易迭代
    • 现象:A、B 都依赖 C,某个 B 开发者更新的 C 的代码,B + C 是可以正常工作,但 A + C 可能工作异常了
    • 解决办法:理论上需要检查一遍所有可能影响到的模块,检查方式是跑单元测试、编译,但分属不同仓库的时候,这个事情做起来就比较麻烦(我们团队很好的将这个矛盾转移给了 QA 团队)
  • 代码复用很麻烦
    • 如果 A 的里面一部分代码需要给 B 使用,那么就需要把这部分代码拆分出来,新建一个​ C ​的仓库,然后再往​ npm​ 发布一个版本(或 submodules)
    • 操作步骤比较繁琐,而且很多时候并不需要发布 npm 版本,因为可能是临时的
  • 三方依赖版本可能不一致(同一个团队,我们还是尽量希望一致的)
  • 实施代码标准化是一项挑战
  • 导致代码评审困难,因为缺失上下文

转向 monorepo 的最大好处是我们没有放弃微服务架构的任何优势,这是另一个话题啦。

monorepo

于是乎,社区针对这些问题,就想出了一个 monorepo 的解决方案。简单来说,monorepo 就是 all in one 的意思。

社区对于 monorepo 的使用

  • Babel, Jest, and Create React App adopt Monorepo using Lerna 采用 lerna 使用 monorepo
  • React 同样使用一个单一的 repo 管理多个 packages,虽然没有采用 lerna

monorepo 的优势

  • 单个配置共享:项目相关的配置,比如 lint、test、build、environments、tsconfig 等(工程统一标准化
  • 统一的地方处理 issue、merge request
  • 代码重构便捷:毕竟都在同一个 repo 里,解决了公共模块不敢轻易迭代的问题
  • 代码复用便捷
    • 所有的项目代码都集中于一个代码仓库,我们将很容易抽离出各个项目共用的业务组件或工具,如果有些代码需要复用,直接新增一个平级的 shared 目录,把代码迁移过去,然后修改 a、b、c 相关的逻辑,直接依赖 shared 这个模块
    • 愿景:packages 下面的模块越来越多,功能越来越独立。业务就是组合这些模块搭建起来
  • 公共三方依赖版本保持一致(共同依赖可以提取至 root,版本控制更加容易,依赖管理会变的方便)

当然了,有优势就有劣势

  • 单个 repo 的体积变得很大
  • 权限管理的粒度会比较大
  • CI 测试运行时间也会变长

monorepo core

搜索引擎搜索 monorepo 相关的实践,都必然会提到 yarn workspaces 和 lerna,为什么需要这两个东西呢,它们一定解决了某个环节中的痛点而生。因为如果只是简单看 monorepo 的概念而言,它只是代码的一种组织方式,不引入新的概念也是完全可以落地的。

我们从只使用 npm 的角度出发,我们应该怎么做呢。我们也着照猫画虎创建一个 packages 文件夹。新建 utils 和 ui package,并通过 npm init 进行初始化。然后 root package 中加入 jest 依赖,ui package 加入 axios 依赖,utils 中加入 lodash 依赖。直接根目录下运行 npm install 进行依赖安装。

最终结果为:仅仅安装 jest,lodash 和 axios 并没有被安装,如果 package 的依赖需要被安装,则需要手动 cd 进入特定目录,进行依赖的安装,也就是说 npm install 只认了同级别目录下的 package.json 文件。

Yarn

很明显这很繁琐,这时候需要 workspaces 来帮助我们了,开启 workspaces 的很简单。直接在 root package.json 下添加 workspaces 字段指定为具体目录即可。

{
    "private": "true", // required
    "workspaces": [
        "packages/*"
    ]
}

然后指定如下命令,注意这里是 yarn,而不是 npm(使用 npm 的话需要升级到 npm7)

yarn install

最终的结果为:yarn 把 root 以及 packages 下声明的依赖全部安装到了 root node_modules 下

现在 utils 包也需要依赖 axios,但是一个比较旧的版本,因此这里把 axios 加入到 utils/package.json 中,这时候再次执行 yarn install。

依赖安装结果为

  • utils 中声明的 axios 依赖提升到了 root 级别
  • ui 中有自己的 axios 包

总结一下 yarn 在依赖安装时帮助了我们什么

  • yarn 会尽量把依赖拍平装在根目录下,减少重复下载。存在版本不同情况的时候会把使用最多的版本安装在根目录下,其它的就装在各自目录里
  • 带来的问题:比如多个 package 都依赖了 React,但是它们版本并不都相同。此时 node_modules 里可能就会存在这种情况:根目录下存在这个 React 的一个版本,包的目录中又存在另一个依赖的版本。依赖拍平的解决方案带来了不同版本依赖冲突的问题。因为 node 寻找包的时候都是从最近目录开始寻找的,此时在开发的过程中可能就会出现多个 React 实例的问题。遇到这种情况的时候,我们就得用 resolutions 去解决问题。resolutions 用于指定使用哪个版本。出现多个版本的问题,通常是因为安装依赖的依赖造成的多版本问题。这种依赖的依赖术语称之为「幽灵依赖

经实践,yarn resolutions 在 monorepo 项目中,只有在 root package.json 中才生效。

依赖安装的问题解决了后,package 一定是会进行互相引用的,因为拆分 package 的首要目的就是为了要复用。这就要谈到 yarn 做的第二件事,就是软链(link),自己查看根目录下的 node_modules,你会发现你声明的 packages 内容,比如我的

yarn-link.jpg

总结一下 yarn 在 link 帮助我们做了什么

  • yarn 在安装依赖的时候会帮助我们将各个 package 软链到根目录中,这样每个 package 就能找到另外的 package 以及依赖了
  • 新问题:因为各个 package 都能访问到拍平在根目录中的依赖了,因此此时其实我们无需在 package.json 中声明 dependencies 就能使用别人的依赖了。这种情况很可能会造成我们最终忘了加上 dependencies,一旦部署上线项目就运行不起来了。这个问题可以通过 eslint 规则进行解决

进行到这里,好像我的问题已经被解决了

  • 针对想要复用的模块,我们可以新建一个 package 进行代码编写
  • 由于 yarn 会帮我们进行 link,则此时需要使用该 package 时,直接引用即可

注意:包名指的是 package.json 下的 name 字段,而不是文件夹名。

yarn 还支持什么

  • yarn workspace <workspace_name>:可以使用命令行在每个 package 中运行相同的 npm scripts
  • yarn.lock:所有 packages 的依赖版本管理,而不是每个 package 中都创建 lock 文件

Yarn 工作空间使得每个 package 可以共享 root 目录的依赖,这对于 devDependencies 来说非常有用,比如 TypeScript、ESLint、Jest 等,它不需要在每个 package.json 中进行声明,降低了在每个包目录中管理这些NPM包的成本

其实仔细思考一番,你会发现问题好像解决了,但有好像没解决。你思考这样一种情况

  • 你需要迭代一个 common package 包,但你引入了破坏性更新,这个包同时被 A 和 B 两个包用到,如果 A 和 B 都是由你项目组进行维护,哪很好解决,你直接进行兼容更改即可
  • 而组织架构复杂后,通常情况你是 A 组,你需要这次更新,但 B 可以不需要,这时候你去修改 B 组代码并不合适,你去要求 B 组去改,哪只会更不合适,毕竟他们有手头的事情

那这个问题要怎么解决呢。其实这种关系就构成了一个小型的社区开源,开源项目通常是采用版本的方式,版本号会有严格的管理,比如是大版本还是小版本,针对更新内容出一份详尽的 CHANGELOG,开发者需要更新时,自行根据文档进行更近,当然,做到好的开发项目,甚至会提供工具帮助一键自动修改。

这时候我们就要涉及到版本管理问题

介绍版本管理之前,先了解一下 yarn 提供的版本控制符,workspace range(workspace:)

  • 有些情况,你不想使用远程仓库包,即使版本并不匹配。比如你的项目并不准备发布,但是你想使用工作空间更好的间隔代码
  • 当你使用 workspace 时,yarn 将拒绝解析到本地工作空间之外的任何东西,有两个特性
    • 如果是 semver 范围,它将选择与指定版本匹配的工作区
    • 如果是项目相对路径,它将选择与此路径匹配的工作区(实验性的),推荐直接使用 workspace:*

Lerna

Lerna 介绍

  • Lerna 是 babel 维护自己的 monorepo,并开源出的一个项目
  • Lerna and Yarn:使用 Lerna 发布,使用 yarn workspaces 管理包的依赖

公共依赖提升好处

  • 所有包使用相同的依赖版本
  • 保持根依赖实时更新,使用自动化工具如 Snyk
  • 减少依赖安装时间
  • 更少的存储

注意:被 npm scripts 使用二进制可执行文件,仍然需要在每个包中单独安装,如 cross-env

Lerna 的出现就是为了解决构建以及发布问题的工具,我们先进行初始化

lerna init # 在项目根目录下执行该命令,初始化一个 lerna 项目,会创建 package.json、lerna.json、packages 文件夹

命令执行完后生成的默认 lerna.json 如下

{
  "packages": [
    "packages/*"
  ],
  "version": "0.0.0"
}

接下来直接执行第一常用的命令

lerna bootstrap

执行后结果如下

  • 为每个 package 安装外部依赖
  • 为互相依赖的 package 建立 symlink

其实 bootstrap 默认还做了一些重要的事就是

  • 对所有的 package 执行 npm prepublish(如果有的话),这是为了解决 package 需要执行一次打包才可用的问题
  • 对所有的 package 执行 npm prepare

但需要注意的时,root package 声明的依赖(jest)并没有安装。对了 bootstrap 还支持很多参数,其中一个就是 --hoist 用于控制是否需要进行依赖的提升。试试看

lerna bootstrap --hoist

神奇的事发生了,使用了 hoist 参数后

  • 依赖被提升到 root 级别,如果存在多个不同版本,处理规则和 yarn 类似
  • root package 声明的依赖也被安装了

这不由的让我 hoist 做了什么事情感到好奇,具体见:hoist,但并没有提到 root package 依赖的问题,姑且认为这是它实现提升带来的副作用吧。

咋一看 lerna 把 yarn workspaces 的工作给做了,但 lerna 提供对于开启 workspaces 的控制,开启方式如下(package.json 中对于 workspaces 的配置依旧需要

"npmClient": "npm",
"useWorkspaces": true

这样依赖 yarn 的能力就被托管给了 lerna,lerna 即使不开启 hoist 参数也会实现依赖提升,那开启 hoist 和使用 workspaces 有什么区别呢,大致看了一下,没有发现太多区别,目前发现的

  • hoist 提取公共的依赖到 root node_modules 中,其余依赖安装到 package/node_modules 中,可执行文件必须安装在 package/node_modules 中,workspaces 所有依赖全部在跟目录的 node_modules,除了可执行文件
  • 网上有说法是:lerna 提供了一个 --hoist 参数将子项目的依赖包提升到最顶层的方式,但这种方式会有一个问题,不同的版本号只会保留使用最多的版本,当项目中有些功能需要依赖老版本时,就会出现问题。但实测没有这个问题
  • 如果你没使用 yarn,当你 clone 项目时,你必须使用 lerna bootstrap 命令,但如果你使用了 yarn,则使用 yarn install 同样可以

注意:若开启了 workspace 功能,则 lerna 会将 package.json 中 workspaces 中所设置的项目路径作为 lerna packages 的路径,而不会使用 lerna.json 中的 packages 值。

接下来执行第二常用的命令

lerna publish

lerna publish 流程

  • 找出上次发布后有变更的包
  • 生成对应的版本号
  • 更新对应包 package.json 中的版本号
  • 在 Git 中打入对应标签
  • 发布到 npm

如果中途包发布失败,运行 lerna publish 的时候,因为 tag 已经打上去了,所以不会再重新发布包到 npm

  • 运行 lerna publish from-git,会把当前标签中涉及的 npm 包再发布一次,不会再更新 package.json,只是执行 npm publish
  • 运行 lerna publish from-package,会把当前所有本地包中的 package.json 和远端 npm 比对,如果是 npm 上不存在的包版本,都执行一次 npm publish

lerna 版本管理模式

  • fixed:任何包中的重大更改都会导致所有包都有一个新的主版本。
  • independent:每个 package 的版本都是独立的

版本号变更如果采用 fix 模式,publish 流程会有些许差别

  • 找出上次发布后有变更的包
  • 根据当前 lerna.json 中的版本生成新的版本
  • 更新所有包 package.json 中的版本号
  • 更新 lerna.json 中的版本号
  • 在 Git 中打入对应标签
  • 发布所有包到 npm

由于我自定义的包名含有前缀,发布时提示 E402 You must sign up for private packages。需要在各自包 package 中添加相关配置

{
    "publishConfig": {
        "access": "public"
    },
}

添加后继续 publish,此时提示 E403 Forbidden,但并没有更详尽的提示,测试了很多次发现是报名的问题,将 @banana/ui 改成 try-banana-ui 即发布成功了。吐了,找了一下午才找到原因,因为我发现把 @banana 改成 @banana2 后,报错变成了 404,我发现 npm 中有一个 Organizations 概念,于似乎新建 @banana2,再次 publish 即成功,至于 banana 报错 403 应该是被人用了,但没有发布什么包,好坑,导致我以为没有被用

lerna 通用参数

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

lerna 其他命令

  • lerna updated:检查哪些包有更新
  • lerna import path/to/existing-repository:从外部导入一个已存在的包,包括 git 历史,该命令仅支持导入本地项目,并且不支持导入项目的分支和标签
  • lerna clean:移除所有 packages 的 node_modules
  • lerna diff:和上一次版本做比较
  • lerna run:在所有子项目中执行 npm script 脚本,并且,它会非常智能的识别依赖关系,并从根依赖开始执行命令
  • lerna exec:像 lerna run 一样,会按照依赖顺序执行命令,不同的是,它可以执行任何命令,例如 shell 脚本
  • lerna create package_name:新增 package
  • lerna add:将本地或远程的包作为依赖添加至当前的 monorepo 仓库中,Lerna 可以识别并追踪包之间的依赖关系
  • lerna list:列出所有的 packages
  • lerna info:相关软件的版本和位置信息
  • lerna link:将本地相互依赖的 package 相互连接. –force-local 无论本地 package 是否满足版本需求,都链接本地的

bootstrap & publish

下面重点看看 bootstrap 和 publish 支持的常用参数

bootstrap 命令:启动指定的 packages

  • ignore:忽略哪些 package 的启动
  • scope:限定启动哪些 package
  • npmClientArgs:透传 npm 支持的参数,比如 --no-package-lock
  • hoist:是否开启依赖提升
  • ignore-prepublish:prepublish 生命将要过期,因此该参数将来会被移除
  • ignore-scripts:忽略所有 lifecycle scripts 执行
  • force-local:无论版本范围是否匹配,强制本地同级链接
    • 如果 lerna1 依赖 lerna2,且版本刚好为本地版本,那么会在 node_modules 中链接本地项目,如果版本不满足,需按正常依赖安装

publish 命令:发布代码有变动的 package

  • 前提:在使用 Lerna 前使用 git commit 命令提交代码,好让 Lerna 有一个 baseline
  • 原则:不会发布 private 为 true 的包
  • ignoreChanges:忽略哪些文件的更新,比如 md 文件
  • ignore-scripts
  • ignore-prepublish
  • message:当版本发布完成后的自定义消息
  • registry:设置自定义 npm 仓库,比如你要发布到内网
  • no-push:不会自动将修改提交到仓库
  • conventional-commits:生成 CHANGELOG

总结

Lerna 无感知的解决了你很多痛点

  • 增量构建 & 测试
    • 因为所有包都存在一个仓库中了,如果每次执行 CI 的时候把所有包都构建一遍,则会非常浪费时间
    • 手动追踪依赖关系非常麻烦:存在包与包之间有依赖的场景时,需要寻找出各个包之间的依赖关系,然后根据这个关系去构建。比如说 A 包依赖了 D 包,当我们在构建 A 包之前得先去构建 D 包才成
    • 通过 lerna 执行命令,本身就会去进行拓扑排序,所以包之间存在依赖时的构建问题也就被解决了
  • 部署
    • 部署单个 package,依赖关系会自动通过拓扑排序解决
    • package 部署时版本自动计算和更新

Comparison

npm、yarn 以及 lerna 的意义和区别就很清晰了,依次而言是一种能力的增强,在各自的环节进行发力。

lerna.png

扮演的具体角色

  • Npm:负责包安装
  • Yarn:负责依赖提升、依赖优化以及 package link
  • Lerna:负责构建、测试、发包
    • 自动提交和版本号修改
    • 交互式commit message
    • 自动生成日志
    • ……

monorepo around

有了 package 后,其实还有很多同样重要的工作值得去做

  • 文档管理:package 要想真正在团队间得到复用,就必须要相应的文档,不然很难落地
    • ui:可以采用 storybook 生成在线文档
    • module:可以采用 jsdoc 生成文档说明
  • Verdaccio:npm 包本地发布
  • commitlint or commitizen:检查提交的 commit 信息,它强制约束我们的 commit 信息必须在开头附加指定类型,用于标示本次提交的大致意图,支持的类型关键字有 feat、chore、fix、refactor、style
  • 合并配置
    • TypeScript、ESLint、Jest、Babel、Prettier、StyleLint、editorconfig、Rollup
    • .gitattributes、.gitignore 等
  • 统一命令脚本 scripty
    • 允许您将脚本命令定义在文件中,并在 package.json 文件中直接通过文件名来引用
    • 子项目间复用脚本命令
    • 像写代码一样编写脚本命令,无论它有多复杂,而在调用时,像调用函数一样调用
  • lerna-changelog 自动生成 CHANGELOG

由于会统一命令脚本,为 npm-scripts 定义命名规则是管理多个包的良好实践。

  • build
  • start
  • clean
  • lint
  • test
  • test:ci (a test script for CI environment)
  • prerelease (run scripts that have to run before publishing a package)

结构参考

看看 jest、babel、cra 以及 ant-design 是如何组织的

# jest
├── docs
├── examples
├── packages
    ├── jest-core
        ├── package.json # no script
        ├── tsconfig.json # extends root tsconfig.json
├── scripts # node 执行脚本,统一 submodule 的脚本执行
├── .editorconfig
├── .eslintignore
├── .prettierignore
├── babel.config.js
├── jest.config.ci.js
├── jest.config.js
├── lerna.json
├── package.json
    ├── build script
    ├── lint script
    ├── test script
    ├── watch script
├── tsconfig.json

# babel
├── docs
├── packages
    ├── babel-parser
        ├── typings
        ├── package.json
        ├── tsconfig.json # extends root tsconfig.base.json
├── scripts
├── .editorconfig
├── .eslintignore
├── .eslintrc.cjs
├── .gitattributes
├── .gitignore
├── .prettierignore
├── .prettierrc
├── babel.config.js
├── jest.config.js
├── package.json
├── tsconfig.base.json # 基本的 ts 开关配置
├── tsconfig.json # 手动配置 include 所有 package 下的 ts 文件,以及对应的 paths 别名,unbelievable

# create-react-app
├── docusaurus # 类比 docs,应用了 facebook 推出的静态文档站点
├── packages
    ├── react-script
        ├── package.json
├── tasks # 类比 scripts
├── .eslintignore
├── .eslintrc.json
├── .gitattributes
├── .gitignore
├── .prettierrc
├── lerna.json
├── package.json
    ├── changelog script # 依赖 lerna-changelog

# ant-design
├── .husky
├── components
├── docs
├── scripts
├── tests
├── typings
├── .editorconfig
├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── .prettierignore
├── .prettierrc
├── package.json
    ├── files # dist、lib、es
├── tsconfig.json
├── webpack.config.js

没那么重要的东西

看到一句话角色很喜欢:代码复用归根到底是一个关于人际沟通的文化问题,最重要的是不忘模块化这一初心

纯文件方式引用

曾经有个错误的想法,就是针对 package 不引入额外的打包机制,而是直接放到 web 项目里去一同打包,比如 CRA 创建的项目中。结论就是可以是可以,但是很麻烦,且不通用。这里只是记录下步骤和坑

  • 禁用 ModuleScopePlugin,将 webpack 的工作范围不在只是 src 目录
  • 扩展 typescript 的 paths 字段,进行别名扩展
  • 扩展 typescript 的 include 字段
  • 修改 webpack 配置,将对应的纯文件目录加入 babelInclude 列表
  • 修改 webpack 配置,增加对应纯文件目录的 alias 配置
  • TypeScript 设置别名导致顶级别名下,必须写完 index,否则提示找不到
    • 这是由于 paths 的语法导致的,因为别名使用的是 @config/* 导致匹配不到 @config
    • 但这个好坑,真要解决的话,好像只能写两个,也就是再写一个 @config 专门用于匹配它

reference



留言