基於 Rush 的 Monorepo 多包釋出實踐

海秋發表於2021-11-19

前言

五月份分享了 應用級 Monorepo 優化方案 ,主要闡述了之前 monorepo (Yarn + Lerna)存在的問題以及解決方案,但在該分享裡,並沒有涉及到 pacakge 釋出相關的內容(在那段時期主要是以應用 app 開發為主),偶有 pacakge 開發也是依賴關係較為簡單的場景(單包開發/釋出),使用 npm publish 就能搞定。

隨著後續的發展(主要是團隊內另一個倉庫的遷入),package 開發場景佔了相當重的比例(倉庫程式碼行數達到了百萬級,專案數超過 100),但多包釋出體驗並不是很好,主要集中在以下 3 個方面:

  1. 釋出方式與 Lerna 差異較大,而 Rush 相關命令文件較為簡陋(太簡陋了,引數試了很多遍),無法迅速上手;
  2. 釋出流程不夠規範,基本靠手敲命令列;
  3. 缺少標準的開發工作流。

本次分享便是為了解決以上問題,在實際中摸索出 Monorepo 多包釋出場景的較佳實踐。

Workspace protocol (workspace:)

在進行討論之前,先要了解 Workspace protocol (workspace:),這裡以 pnpm 為例,下述例子摘自 Workspace | pnpm

預設情況下,如果工作區中可用的包版本與宣告的範圍相匹配,pnpm 將從工作區連結包。比如 monorepo 中存在 foo@1.0.0,而 monorepo 內另一個專案 bar 依賴 "foo: ^1.0.0",那麼 bar 就會使用工作區中的 foo,倘若 bar 依賴 "foo: 2.0.0",那麼 pnpm 將會從遠端下載 foo@2.0.0 供 bar 使用,這就引入了一些不確定性。

使用 workspace 協議時,pnpm 將拒絕解析為本地工作區包以外的任何內容。因此,如果設定 "foo": "workspace:2.0.0",這次安裝將失敗,因為工作區中不存在 "foo@2.0.0"

多包釋出

基礎操作

相較於傳統單倉庫單包一個一個的去手動進行釋出行為,monorepo 的優勢之一就是可以方便地進行多包釋出。

rush change

在 Rush monorepo 中,rush change 是發包流程的起點,其產物 <branchname>-<timestamp>.json(後文用 changefile.json 代替)會被 rush version 以及 rush publish 消費。

changefile.json 生成流程如下:

rush-change

  1. 檢測當前分支與目標分支(通常是 master)的差異,篩選出存在變更的專案(基於 git diff 命令);
  2. 針對篩選出來的每一個專案通過互動式命令列詢問一些資訊(如版本更新策略以及更新的內容簡要描述);
  3. 基於上述資訊在 common/changes 目錄下生成對應 package 的 changefile.json。

change-file-sample

注意:截圖中的變更型別( type 欄位)為 none,不是 major/minor/patch 中的任何一種, none 意味著 "將這些變更滾入下一個補丁(patch)、次要變更(minor)或主要變更(major)",因此理論上,如果一個專案只存在型別為 "none "的變更檔案,它既不會消耗檔案,也不會提升版本。

type: none 的特性使得我們可以將已開發完畢但不需要跟隨下一次釋出週期的 package 提前合入 master,直到該 pacakge 出現 type 不為 none 的 changefile.json。

rush version 與 rush publish

rush versionrush publish --apply 則會基於生成的 changefile.json 進行版本號的更新(即 bump version,遵循 semver 規範,被髮布 package 的上層 package 的版本號可能會被更新,下一小節會詳細描述)。

rush publish --publish 則會基於 changefile.json 進行對應 package 的釋出。

rush-publish-package-flow

Rush 的釋出流程與另一個流行的 Monorepo 場景發包工具 Changesets 基本一致 ,遇到單純的 PNPM Monorepo 也許可以基於 Changesets 複用本文方案 ? 。

級聯釋出

前面有提到在更新版本號時,除了更新當前需要被髮布的 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/plugin-tailwindcss@1.0.0 是可以獲取到 @modern-js/utils@1.0.1
  • @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/utils@2.0.0,此時 @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 內最新版本。

這就導致了兩個問題:

  1. app 上線時,可能會帶上開發過程中的 package 上線(基於 Trunk Based Development 的開發分支模型,master 為 trunk 分支)
  2. 發包時會帶上意料之外的釋出,因為使用了 "workspace: *",所以底層包更新,上層包必然釋出(為了保證 * 的語義)

所以,monorepo 內專案之間的引用需要遵循以下規範。

引用規範

  1. 判斷是否需要使用 workspace: 引用 monorepo 內的最新版本
  2. 如果需要使用 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 遠端版本。

工作流

package-development

需要注意的是:

  1. 開發階段功能點階段性合入 trunk 分支(master 分支)時,生成的是 type: none 的 changefile.json,這是為了避免其他 package 釋出時帶上處於開發過程中的包
  2. 因為需要生成 type: major/minor/patch 的 changefile.json 在測試分支進行測試包釋出,所以測試階段則不進行合入,待驗收完畢後合入進行正式版本釋出。

流水線詳解

測試版本

publish-canary

  1. 基於 changefile.json 獲取本次需要釋出的 package
  2. 按需安裝目標 package 的依賴

    • rush install -t package1 -t package2
  3. 按需構建目標 package

    • rush build -t package1 -t package2
  4. rush publish 讀取 changefile.json 進行版本號更新

    • rush publish --prerelease-name [canary.x] --apply
  5. rush publish 釋出版本號變更的 package

    • rush publish --publish --tag canary --include-all --set-access-level public
  6. 通過機器人將釋出資訊同步至相關通知群

正式版本

publish-release-refresh

  1. 基於 changefile.json 獲取本次需要釋出的 package
  2. 按需安裝目標 package 的依賴

    • rush install -t package1 -t package2
  3. 按需構建目標 package

    • rush build -t package1 -t package2
  4. 拉取一個目標分支用於承載釋出流程中產生的 commits(可以將該分支理解為 release 分支)
  5. rush version 在上一步拉取的目標分支上消費 changefile.json 更新版本號並生成 CHANGELOG.md

    • rush version --bump --target-branch [source-branch] --ignore-git-hooks
  6. 在目標分支上執行 rush update 更新 lockfile,避免 package.json 與 lockfile 不一致
  7. rush publish 釋出 package 至 npm

    • rush publish --apply --publish --include-all --target-branch [source-branch] --add-commit-details --set-access-level public
  8. 生成一個將目標分支合併至 master 分支的 Merge Request

    1. Deleting change files and updating change logs for package updates.
    2. Applying package updates.
    3. rush update.
  9. 通過機器人將釋出資訊同步至相關通知群(包含 Merge Request 資訊,需要及時合入)

釋出加速

可以看到,釋出流程中的前三個步驟都是一致的:

  1. 基於 changefile.json 獲取本次需要釋出的 package
  2. 按需安裝目標 package 的依賴

    • rush install -t package1 -t package2
  3. 按需構建目標 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-librush 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-change-extra

結語

本文通過 Rush 基本的發包操作入手,介紹了在實際開發過程中會遇到的一些問題並給出了整體落地的方案,同時基於「按需」的思路優化線上釋出速度。

相關文章