前言
五月份分享了 应用级 Monorepo 优化方案 ,主要阐述了之前 monorepo (Yarn + Lerna)存在的问题以及解决方案,但在该分享里,并没有涉及到 pacakge 发布相关的内容(在那段时期主要是以应用 app 开发为主),偶有 pacakge 开发也是依赖关系较为简单的场景(单包开发/发布),使用 npm publish
就能搞定。
随着后续的发展(主要是团队内另一个仓库的迁入),package 开发场景占了相当重的比例(仓库代码行数达到了百万级,项目数超过 100),但多包发布体验并不是很好,主要集中在以下 3 个方面:
- 发布方式与 Lerna 差异较大,而 Rush 相关命令文档较为简陋(太简陋了,参数试了很多遍),无法迅速上手;
- 发布流程不够规范,基本靠手敲命令行;
- 缺少标准的开发工作流。
本次分享便是为了解决以上问题,在实际中摸索出 Monorepo 多包发布场景的较佳实践。
Workspace protocol (workspace:)
在进行讨论之前,先要了解 Workspace protocol (workspace:),这里以 pnpm 为例,下述例子摘自 Workspace | pnpm
默认情况下,如果工作区中可用的包版本与声明的范围相匹配,pnpm 将从工作区链接包。比如 monorepo 中存在 [email protected]
,而 monorepo 内另一个项目 bar 依赖 "foo: ^1.0.0"
,那么 bar 就会使用工作区中的 foo,倘若 bar 依赖 "foo: 2.0.0"
,那么 pnpm 将会从远端下载 [email protected]
供 bar 使用,这就引入了一些不确定性。
使用 workspace 协议时,pnpm 将拒绝解析为本地工作区包以外的任何内容。因此,如果设置 "foo": "workspace:2.0.0"
,这次安装将失败,因为工作区中不存在 "[email protected]"
。
多包发布
基础操作
相较于传统单仓库单包一个一个的去手动进行发布行为,monorepo 的优势之一就是可以方便地进行多包发布。
rush change
在 Rush monorepo 中,rush change
是发包流程的起点,其产物
(后文用 changefile.json 代替)会被 rush version
以及 rush publish
消费。
changefile.json 生成流程如下:
- 检测当前分支与目标分支(通常是 master)的差异,筛选出存在变更的项目(基于
git diff
命令); - 针对筛选出来的每一个项目通过交互式命令行询问一些信息(如版本更新策略以及更新的内容简要描述);
- 基于上述信息在
common/changes
目录下生成对应 package 的 changefile.json。
注意:截图中的变更类型( type 字段)为 none,不是 major/minor/patch 中的任何一种, none 意味着 "将这些变更滚入下一个补丁(patch)、次要变更(minor)或主要变更(major)",因此理论上,如果一个项目只存在类型为 "none "的变更文件,它既不会消耗文件,也不会提升版本。
type: none
的特性使得我们可以将已开发完毕但不需要跟随下一次发布周期的 package 提前合入 master,直到该 pacakge 出现 type 不为 none 的 changefile.json。
rush version 与 rush publish
rush version
或 rush publish --apply
则会基于生成的 changefile.json 进行版本号的更新(即 bump version,遵循 semver 规范,被发布 package 的上层 package 的版本号可能会被更新,下一小节会详细描述)。
rush publish --publish
则会基于 changefile.json 进行对应 package 的发布。
Rush 的发布流程与另一个流行的 Monorepo 场景发包工具 Changesets 基本一致 ,遇到单纯的 PNPM Monorepo 也许可以基于 Changesets 复用本文方案 。
- A way to manage your versioning and changelogs with a focus on monorepos
- Changesets: 流行的 Monorepo 场景发包工具
级联发布
前面有提到在更新版本号时,除了更新当前需要被发布的 package 的版本号,也可能更新其上层 package 的版本号,这取决于上层 package 在 package.json 中如何引用当前 package 的。
如下所示,@modern-js/plugin-tailwindcss
(上层 package) 通过 "workspace:^1.0.0"
的形式引入 @modern-js/utils
(底层 package)。
package.json(@modern-js/plugin-tailwindcss)
{
"name": "@modern-js/plugin-tailwindcss",
"version": "1.0.0",
"dependencies": {
"@modern-js/utils": "workspace:^1.0.0"
}
}
package.json(@modern-js/utils)
{
"name": "@modern-js/utils",
"version": "1.0.0"
}
- 若
@modern-js/utils
更新至1.0.1
,Rush 在更新版本号时不会更新@modern-js/plugin-tailwindcss
的版本号。因为^1.0.0
兼容1.0.1
,从语义的角度出发,@modern-js/plugin-tailwindcss
不需要更新版本号,直接安装@modern-js/[email protected]
是可以获取到@modern-js/[email protected]
的 - 若
@modern-js/utils
更新至2.0.0
,Rush 在更新版本号时会更新@modern-js/plugin-tailwindcss
的版本号至1.0.1
。因为^1.0.0
不兼容2.0.0
,更新@modern-js/plugin-tailwindcss
版本至1.0.1
才可引用到最新的@modern-js/[email protected]
,此时@modern-js/plugin-tailwindcss
的 package.json 内容如下:
{
"name": "@modern-js/plugin-tailwindcss",
"version": "1.0.1",
"dependencies": {
// 引用版本号也发生了变化
"@modern-js/utils": "workspace:^2.0.0"
}
}
更新了版本号,还需要发布至 npm。此时需要 rush publish
增加 --include-all
参数,配置该参数后 rush publish
检查到仓库中存在 shouldPublish: true
的 package 的版本新于 npm 版本时,会将该 package 发布。
这样就完成了基于语义化的级联发布。
意料之外的发布
最开始基于 Rush 改造项目时,monorepo 内的项目之间始终使用 "workspace: *"
互相引用,即使用 monorepo 内最新版本。
这就导致了两个问题:
- app 上线时,可能会带上开发过程中的 package 上线(基于 Trunk Based Development 的开发分支模型,master 为 trunk 分支)
- 发包时会带上意料之外的发布,因为使用了
"workspace: *"
,所以底层包更新,上层包必然发布(为了保证*
的语义)
所以,monorepo 内项目之间的引用需要遵循以下规范。
引用规范
- 判断是否需要使用
workspace:
引用 monorepo 内的最新版本 - 如果需要使用
workspace:
,那么请使用"workspace: ^x.x.x"
代替"workspace: *"
,避免无意义的发布(当然也要考虑实际依赖关系,如果 packageA 与 packageB 始终需要一起发布,就应当使用"workspace: *"
)
随着 monorepo 内项目数日益增长,项目之间若全部使用 workspace:
引用,那么一个 package 更新时,需要其所有内部接入方被动进行回归测试
通过 CI 提高 master 分支准入标准当然没问题,但是业务场景往往过于复杂,还是需要测试同学的介入,除非团队成员代码质量以及测试质量极高。
或者使用 feature flag 机制进行控制,但人工控制往往成本较大,需要成熟的基建方案配合。
而对于业务关联并不紧密的项目,他们仅仅是在物理层面存在于同一个 monorepo 内罢了,并不需要关注对方最新版本,并不需要使用 workspace:
。
举个例子:把 babel/react/modernjs 等套件放到同一个 monorepo 管理,各套件内部使用 workspace:
享受 monorepo 工程优势合情合理,项目之间依赖则直接使用 npm 稳定版本更合适。
当然开源项目的业务边界都很明显,而具体到我们的业务仓库(一个团队一个仓库),里面可能放着许多八竿子打不着的模块,这个时候使用 workspace:
便是自讨苦吃了。
那么如何判断是否需要使用 workspace:
?
举个例子,假设我是包 bar 的 owner,现在要引用一个包 foo,需要通过以下判断:
foo 更新,bar 一定会更新并进行测试且迭代上线节奏一致,那么使用 workspace:
,否则一律使用 npm 远端版本。
工作流
需要注意的是:
- 开发阶段功能点阶段性合入 trunk 分支(master 分支)时,生成的是
type: none
的 changefile.json,这是为了避免其他 package 发布时带上处于开发过程中的包 - 因为需要生成
type: major/minor/patch
的 changefile.json 在测试分支进行测试包发布,所以测试阶段则不进行合入,待验收完毕后合入进行正式版本发布。
流水线详解
测试版本
- 基于 changefile.json 获取本次需要发布的 package
按需安装目标 package 的依赖
- rush install -t package1 -t package2
按需构建目标 package
- rush build -t package1 -t package2
rush publish 读取 changefile.json 进行版本号更新
- rush publish --prerelease-name [canary.x] --apply
rush publish 发布版本号变更的 package
- rush publish --publish --tag canary --include-all --set-access-level public
- 通过机器人将发布信息同步至相关通知群
正式版本
- 基于 changefile.json 获取本次需要发布的 package
按需安装目标 package 的依赖
- rush install -t package1 -t package2
按需构建目标 package
- rush build -t package1 -t package2
- 拉取一个目标分支用于承载发布流程中产生的 commits(可以将该分支理解为 release 分支)
rush version 在上一步拉取的目标分支上消费 changefile.json 更新版本号并生成 CHANGELOG.md
- rush version --bump --target-branch [source-branch] --ignore-git-hooks
- 在目标分支上执行 rush update 更新 lockfile,避免 package.json 与 lockfile 不一致
rush publish 发布 package 至 npm
- rush publish --apply --publish --include-all --target-branch [source-branch] --add-commit-details --set-access-level public
生成一个将目标分支合并至 master 分支的 Merge Request
- Deleting change files and updating change logs for package updates.
- Applying package updates.
- rush update.
- 通过机器人将发布信息同步至相关通知群(包含 Merge Request 信息,需要及时合入)
发布加速
可以看到,发布流程中的前三个步骤都是一致的:
- 基于 changefile.json 获取本次需要发布的 package
按需安装目标 package 的依赖
- rush install -t package1 -t package2
按需构建目标 package
- rush build -t package1 -t package2
但这套方案刚刚落地时,使用的是「全量安装 monorepo 依赖并全量构建 packages 」这种简单粗暴的方式。
monorepo 需要解决的是规模性问题:项目越来越大,依赖安装越来越慢,构建越来越慢,跑测试用例越来越慢。
「按需」就成为了关键词,pnpm 作为包管理器已经非常优秀,甚至可以按需安装依赖,但对于大型 monorepo 需要的能力还是有所欠缺的,所以我们引入了 Rush 解决 monorepo 下的工程化问题。
所以目标很明确:在 monorepo 规模越来越大的情况下,整个项目的复杂度始终维持在一个稳定的水准。 —— 应用级 Monorepo 优化方案
在优化之前,发布一次接近 12min,哪怕只要发布一个包,并且这个包里只有一句 console.log("hello world")
,而且随着项目的增多,12min 可能只是起点。所以「按需」又回到了我们的视线。
Rush 在发布流程中会改变需要发布的项目的版本号,只要将这个过程提前,预先获取改变了版本号的项目,就能得到 install 与 build 命令的目标参数。
于是通过翻阅 @microsoft/rush-lib
的 rush version
相关源码,得到了以下代码:
function getVersionUpdatedPackages(params: {
rushConfiguration: RushConfiguration;
prereleaseName?: string;
}) {
const { prereleaseName, rushConfiguration } = params;
const changeManager: ChangeManager = new ChangeManager(rushConfiguration);
if (prereleaseName) {
const prereleaseToken = new PrereleaseToken(prereleaseName);
changeManager.load(rushConfiguration.changesFolder, prereleaseToken);
} else {
changeManager.load(rushConfiguration.changesFolder);
}
// 改变 package.json 版本号(内存中,实际文件不做改动)
changeManager.apply(false);
return rushConfiguration.projects.reduce((accu, project) => {
const packagePath: string = path.join(
project.projectFolder,
FileConstants.PackageJson,
);
// 实际 package.json 的版本号
const oldVersion = (JsonFile.load(packagePath) as IPackageJson).version;
// 内存中 package.json 的版本号
const newVersion = project.packageJsonEditor.version;
// 不一致则为我们的目标项目
if (oldVersion !== newVersion) {
accu.push({ name: project.packageName, oldVersion, newVersion });
}
return accu;
}, [] as UpdatedPackage[]);
}
辅助命令
rush change-extra
源自接入方 lockfile 造成的困扰。该命令可以为未变更的 package 生成 changefile.json,使其可以被发布。
rush change
命令默认会比对当前分支与 master 分支的差异,找出产生变更的项目,通过交互式命令行让开发者生成对应的 changefile.json 文件。
前面「级联发布」中有提到,Rush 可以根据 semver 规范更新相关包的版本并进行发布,在 "workspace: ^x.x.x"
的引用方式下,除非底层包进行 major 更新,否则上层包是不会更新发布的。
问题就在于此,上层包没有被发布,底层包又被接入方的 lockfile 锁住了,我们(被迫)需要一种方案能够发布实际上不需要发布的包(这里是 @jupiter/block-tools),这就是 rush change-extra 诞生的原因。
更需要一种能够深度更新指定依赖的方式,但目前没有找到包管理器维度的解决方案
结语
本文通过 Rush 基本的发包操作入手,介绍了在实际开发过程中会遇到的一些问题并给出了整体落地的方案,同时基于「按需」的思路优化线上发布速度。