Monorepo 考

簡体字中国語からAIで翻訳しました。原文を読む

以前はよく Monorepo で個人プロジェクトを整理していたけど、コードを書き始めると、心のどこかでいつも漠然とした疑問が引っかかっていた:自分の Monorepo 設定は正しいんだろうか?

最近新しいプロジェクトを始めようとしていて、これを機にいろいろ調べ、いくつかのオープンソースプロジェクトの構成を参考にして、Monorepo のいわゆる「正しい」構成とは何なのかを理解しようとしてみた。

Disclaimer

私は Monorepo の専門家ではないので、以下の内容はよく吟味してください。事実誤認があるかもしれません。指摘歓迎です。冒頭でも言ったとおり、私は Monorepo の専門家ではないので (。^▽^)

ビルド成果物を import するか、ソースを import するか

これは最初に整理しておくべきことだと思う。一般的な Monorepo プロジェクトでは、構成はだいたい次のようになる:

.
├── apps
│   └── web
└── packages
    └── utils

見てのとおり、utils を定義して、複数の App から使えるようにしたい。utils を正しく認識することはとても重要だ——これはバンドルされる必要があり、Registry に公開して他のプロジェクトから参照されることを想定したパッケージなのか? それともソースコードのまま存在し、内部でのみ使うパッケージなのか?

Nx での実践

Nx の Monorepo 実践では、Workspace Libraries と Publishable Libraries / Buildable Libraries を明確に区別している

Workspace Libraries とは「公開やビルドを目的としないパッケージ」のことで、これらのパッケージの TypeScript ソースは App から直接参照され、App の Bundler によって App と一緒にバンドル・ビルドされる。つまり上述の後者、「ソースコードのまま存在し、内部でのみ使うパッケージ」だ。

Nx Integrated Repo では、すべての Project の依存関係は Repo ルートの package.json で定義され、Workspace Libraries は独自の package.json を持たない。App からの参照は、そのディレクトリを Repo ルートの tsconfig.json の paths に追加することで実現するので、App 内で明示的に pnpm add ... する必要はない。pnpm Workspace ベースの Monorepo でも、この実践は非常によくある。

余談:Polyrepo プロジェクトでも、components フォルダに @components/ のような alias を tsconfig.json で設定することがよくあるけど、これも一種の Monorepo と見なせるんだろうか 23333(逃

この設定の一番わかりやすい利点は、dev では App の Dev Server を起動するだけで済むことだ。dist ではなくソースを import しているし、トランスパイルなどは App のツールチェーンで行われるので、パッケージへの変更は App の Dev Server が検知してホットリロードできる。

一方、Registry に公開する必要がある/Build が必要なパッケージには、Nx Integrated Repo が package.json を作成し、その main と exports フィールドをバンドル出力に向けることで、外部プロジェクトが正しくパッケージを import できるようにする。Repo 内の他の App がこのパッケージを import するときは、やはり tsconfig.json 経由でバンドル成果物ではなくソースを import し、App のツールチェーンでバンドルするので、ホットリロードが可能になる。

公開用の成果物と内部 import 用のコードを分けるのは確かにとても便利だけど、明らかな問題が一つある:コードを直接 import する場合、そのパッケージ自身のバンドル設定が効かなくなる。

あるパッケージに getVersion() という関数があり、そのパッケージの vite.config.ts でテキスト置換プラグインを使ってコード内の __VERSION__ を現在のバージョン番号に置き換えているとしよう。公開される成果物ではパッケージ自身の Vite 設定でバンドルされるため、成果物には正しく現在のバージョン番号が出力される。一方、App がソース経由でこのライブラリを import すると、App のツールチェーンでバンドルされることになり、App 側にこのテキスト置換プラグインが設定されていなければ、App は __VERSION__ というプレースホルダーしか得られない。

使う人が知らない場合、これは誤解を招きかねない。Monorepo 進化論 - 本当に公共パッケージを使っていますか? という記事では、この状況が実際かなり一般的であることがわかる。その下にある 张立理 のコメントにはとても刺激を受けた。以下が原文だ:

根っこにあるのは、それを「publish しないパッケージ」と見なすか、「プロジェクト内のソースディレクトリ」と見なすかという違いだ。パッケージとして見なせば、すべての問題は問題ではなくなる:

package.json の問題:パッケージがパッケージ内の内容を参照できるかといえば、もちろんできる。公開されていればね。でも一部のファイルを公開していなければ参照できない。monorepo には「公開」の工程が欠けているが、「公開」の概念は必要だ。普通は公開前に build や bundle を行い、それらを済ませたうえで最終的に参照されるのは entry であるべきで、その entry を package.json で指定するのも同じく有効だ。

tsconfig.json の問題:パッケージは tsconfig.json を外に出すのか? 出したところで効果があるのか? 答えは明らかに明白だ。パッケージとして使うなら、tsconfig の疑問は自然と生じない。もっと正確に言えば、ts は直接参照されるべきではない(他人に使わせるために ts コードを公開するパッケージなんてない)。

phantom dependency の問題:パッケージ内部の依存として当然そちらに従うし、workspace のツールがうまく処理してくれる。理論上、使用側がバージョンを決めるべき依存は peerDependencies として設計すべきだ。(ここには peer + dev が同時に存在すると monorepo で問題になるケースがあり、これは確かに解決不能だ)

monorepo における common は「公開工程を通らないパッケージ」であるべきで、「ソースディレクトリ」ではない。この概念を貫けば、いくつかの問題は最初から問題ではなくなる。

本質的には、まだそれを「パッケージ」として見ておらず(ソースを直接参照し、build は参照側に任せる)、それでいて「パッケージ」の振る舞い(package.json と tsconfig.json が効くこと)を求めてしまっている。これはかなり自己矛盾している。

でも、これを許容できない実践だとは思わない。こうするとホットリロードの実装が本当に楽だからだ (/▽\)。これを「公開工程を通らないパッケージ」として扱う、つまり App がそのバンドル成果物を import する形にすると、パッケージを変更したときに別の Daemon がファイル変更をリアルタイム監視して再バンドルする必要がある。そうして初めてホットリロードが実現できるし、App 側の依存最適化/キャッシュ問題でホットリロードが効かなくなる可能性も予想できる。

pnpm Workspace での実践

Nx Integrated Repo と違い、pnpm Workspace では各サブプロジェクトが package.json を持つべきで、サブプロジェクトの依存関係もそこにインストールされる。Monorepo 全体にインストールするわけではない(これは Nx Package-Based Monorepo に似ている)。

tsconfig.json とバンドラーで alias を設定してプロジェクト内の共有パッケージを import し続けることもできる(個人的にはこの方法はやや Tricky に感じる)し、App の依存関係にパッケージを追加して workspace: プロトコルでリンクする方法も選べる。この場合、また二つの選択肢がある。つまり上述の、バンドル成果物を import するか、コードを import するかだ。

バンドル成果物を import する

この場合、パッケージの package.json にある main と exports を dist ディレクトリに向けるだけで、App はバンドル成果物を import するようになる。パッケージのコードを変更したら再バンドルが必要なので、Repo ルートの package.json に次のような scripts を定義できる:

{
  "scripts": {
    "dev": "pnpm -r --filter=./packages/** --parallel run dev"
    // ...
  }
  // ...
}

Repo ルートで pnpm dev を実行すれば、すべてのパッケージの dev スクリプトを並列実行し、変更を検知したら成果物を再コンパイルできる。これは Slidev のやり方 だ。

コードを import する

同様に、main と exports をコードに向けることもできる。

このパッケージがバンドル可能で単独公開できるなら、publishConfig で main と exports を上書きして dist に向けられる。

{
  "main": "src/index.ts",
  "publishConfig": {
    "main": "dist/index.js",
    "types": "dist/index.d.ts"
  }
  // ...
}

これも BlockSuit のやり方 だ。

単一か複数の package.json か

Nx Integrated Repo はデフォルトで package.json が一つだけだ(Publishable Libraries を作成しない限り)。

Your Monorepo Dependencies Are Asking for Trouble という記事で、著者は Monorepo で複数の package.json を使うときに生じるバージョン不一致の問題を詳しく説明している。Nx Integrated Repo を使えば当然これを簡単に処理できるが、短い試用の後、今の自分にはこれもあまり合わないかもしれないとわかった。

単一の package.json の明らかな問題は、各プロジェクトの依存関係が混ざり合って整理しにくいことだ——nx g rm <App Name> でプロジェクトを削除してみたが、package.json の dependencies は掃除されなかった。小さな例から全体を推し量ると、プロジェクトが大きくなった後、どうやって安全に依存関係を削除すればいいのか想像もつかない。

また、現在多くのフレームワークの scaffold CLI は複数 package.json を前提に設計されていて、新規プロジェクトの依存関係は Repo ルートの package.json にはインストールされない。Nx が提供するプラグイン経由で作成・移行するしかない。もちろん Nx Integrated Repo にも Package-Based Project を追加するガイド はあるけれど、自分はこういう Vendor lock-in っぽい感じが苦手なのかもしれない。だから今はあまり Nx Integrated Repo を使うつもりはない。

複数 package.json を使う場合、pnpm Workspace には catalog: プロトコルがあり、Repo 全体のバージョン番号変数として pnpm-workspace.yaml で設定できる。これもバージョン不一致を解決する方法だ。

Project Reference

次のようなプロジェクトを考えてみる

.
├── apps
│   └── web
│       └── tsconfig.json
├── packages
│   └── utils
│       └── tsconfig.json
└── tsconfig.json

Monorepo では、Repo ルートに tsconfig.json を置き、サブプロジェクトの TypeScript 設定が Repo ルートの tsconfig.json を extends して、そのうえで Override する、というのがよくあるやり方だ。

ルートの tsconfig.json で noUnusedLocals を有効にし、でも utils パッケージで noUnusedLocals を false に上書きしている場合、Web App が Utils パッケージを import した後、tsc でプロジェクトをコンパイルすると何が起きるだろう?

.
├── apps
│   └── web
│       └── tsconfig.json <- "extends": "../../tsconfig.json"
├── packages
│   └── utils
│       └── tsconfig.json <- "extends": "../../tsconfig.json"
│                            "noUnusedLocals": false (Override)
│
└── tsconfig.json         <- "noUnusedLocals": true

成果物を import する場合、import するのはすでに TypeScript でコンパイル済みの JavaScript ファイルと d.ts 型定義ファイルなので、Web App は Utils パッケージのコンパイルに関与しない。

ソースを import する場合、第一節で述べたとおり、import するのは TypeScript ソースなので、コンパイル作業は事実上 Web App に委ねられ、使われるのは Web App の TypeScript 設定になる。だからエラーになる。

../../packages/utils/src/index.ts:2:9 - error TS6133: 'unused' is declared but its value is never read.

2   const unused = 1
          ~~~~~~


Found 1 error in ../../packages/utils/src/index.ts:2

このとき、TypeScript の Project References を使ってこの問題を解決できる。utils パッケージの TypeScript 設定で composite、declaration、declarationMap を有効にし、noEmit を無効にし、outDir を設定する。

そのうえで web の TypeScript 設定に次を追加する:

{
  "references": [
    {
      "path": "../../packages/utils"
    }
  ] // ...
}

最後に web で tsc -b を実行すると、プロジェクトが正常にコンパイルされ、utils の下にコンパイル済みファイルが現れるのがわかる。

tsc -b コマンドは参照項目を一つずつビルドする。Nx でタスクを管理し、適切に設定していれば、Nx が依存プロジェクトのビルドタスクを自動で実行するので、その場合は tsc でも同じ効果になる。

ただ注意すべきなのは、Monorepo の場合 TypeScript のプロジェクト境界チェックが効かなくなり、少し気を抜くと references を追加し忘れる可能性があることだ。Maskbook の tsconfig.json では この状況が説明されていて、開発者は paths でパッケージの alias を作ることでこの状況を解決している。欠点は、サブパッケージが依存関係としてインストールされていなくても import できてしまうことだ。

Turbo の Blog You might not need TypeScript project references では、Project References が不要なこともあると指摘し、TypeScript コードを最終的な利用者(Web App)にコンパイルさせることを提唱している。自分はこの意見にかなり賛成で、Repo ルート全体で自己完結した tsconfig.json を一つ保守する方に傾いている。ほとんどの場合、プロジェクトの TypeScript 設定はそこまでバラバラではないからだ (。・ω・。)

成果物を import する試み

ここまでの章はどちらかというとコードを import する方式に寄っていたけど、個人的には成果物を import する、つまりサブパッケージを公開工程を通らないパッケージとして扱う方式の方が好みだ。こちらの方が見た目がより「標準的」に感じるから ( ̄▽ ̄)。

このやり方の要点は、依存グラフに従って依存するサブパッケージを一つずつコンパイルする必要があることだ。Nx の dependsOn を使えばこれを簡単に実現できる。でもホットリロードが必要な dev / serve 関連のユースケースになると、Nx では少し手に負えないように見える(dependsOn: ["^dev"] を設定すると、依存パッケージのタスクが watch モードで動き続けて終了できず、App の dev タスクをブロックしてしまう)。

この Discussion でもこの問題が言及されているが、2021 年に提起されてから今に至るまで、かなり完全な解決策はない。Nx の run-many で workaround できるけれど、サブパッケージ名を一つずつ指定する必要がある。ありがたいことに、もう一つの Monorepo 管理ツール Turborepo の run コマンドには filter 構文があり(... を使ってパッケージとその依存パッケージを指す)、App とその依存パッケージの dev タスクを同時に実行できる:

{
  "scripts": {
    "dev:web": "turbo run dev -F=@a-monorepo/web..."
  } // ...
}

でもこれでもまだ理想の効果とは差がある:App の dev が動き出す前に、依存パッケージの dev があらかじめ動いて少なくとも一巡のコンパイルを済ませている必要があり、そうでないと App の Bundler がエラーを出す。この問題については本当にこれ以上良い解決方法を見つけられなかったし、Turborepo のドキュメントでもこんなふうに使われている。

この状況で Vite を使うと、依存パッケージの変化によって Vite が Dep Optimization をやり直してページをリロードするため、ページの状態が失われ、HMR にならない。解決方法はあるのかもしれないけど、自分は試していない (/▽\)。

Microsoft が出している Rush Monorepo 管理ツールの あるドキュメントでこの需要が言及されている けれど、現時点ではまだ実験段階で、最後に App の dev を起動するのにいくつかコマンドが必要で少し面倒だ。こんな感じに:

# 构建所有依赖于 D 的项目(但不包括 D 本身),并在无限循环中重复这个操作
$ rush build:watch --to-except D

# 在项目 D 的目录下开启 Webpack 的开发服务器
# (这是示例中的 web 应用)
$ cd apps/D
$ heft start # 或者用自己的 "npm run start"

(Turborepo の watch は filter 構文を組み合わせれば、理論上は上述の Rush ドキュメントの方法でも操作できる)

これが成果物を import する方式についての簡単な試みだが、結論としては現時点でこの方式の DX はソースを import する方式に劣る。Turborepo の Examples も見たが、どれもソースを import する、つまり exports を src に向ける方式を採用していた。

銀の弾丸はない

いろいろな Monorepo の実践を調べて試すのに何日もかけて、最後にこの記事にまとめた。まあ、ここ数週間は確かに効率が上がらなかった (;´д`)ゞ。

冒頭で「『正しい』構成」にわざわざ引用符を付けたのは、オープンソースプロジェクトの Monorepo 構成はそれぞれ違っていても、それが優れたオープンソースプロジェクトであることを妨げないからだ——正しいものはなく、ただ適したものがあるだけ。

Polyrepo であれ Monorepo であれ、どんな方式で組織された Monorepo であれ、今の開発者のニーズに適応し、今のソフトウェア開発環境に堪えられるなら、それは良い Repo だ <( ̄︶ ̄)>(逃