Monorepoを扱う時に注意しなければならないこと 著者: tkow

Monorepoを扱う際に環境構築でハマりやすいところを書いていきます。

Mochiの新規プロジェクトでは、再利用可能なライブラリの依存関係を解決しやすいようにモノレポを採用しています。

基本的なmonorepoの設定

lernaとyarnを共存する形で設定を行なっています。

lerna.json
{
  "npmClient": "yarn",
  "useWorkspaces": true,
  "version": "independent"
}

以上はyarn workspacesを有効にする設定になります。"version": "independent:の指定は、それぞれのpackagesのversionを独立して管理するようにする設定です。

また、package.jsonにworkspacesというfield項目を追加します。

package.json
  "workspaces": {
    "packages": [
      "packages/admin-cli",
      "packages/admin-utils",
      "packages/admin-web",
      "packages/backend",
      "packages/cross-platform",
      "packages/core",
      "packages/web"
    ],
    "nohoist": [
      "**/ts-jest",
      "**/@prisma",
      "**/@prisma/client"
    ]
  },

ここで設定されているpackagesがそれぞれ依存関係のあるパッケージとして管理され、相互に設定したscope内のパッケージ名でパッケージを参照することができるようになります。

例えば、このパッケージのスコープ名を@mochicorpとした場合、それぞれのpackageのpackage.jsonのname fieldには@mochicorp/というprefixを定義しましょう。例えばpackages/coreでは

packages/corepackage.json
{
  "name": "@mochicorp/core",
  "version": "1.0.0"
}

と設定されます。packages/webからこのpackages/coreが提供するライブラリにアクセスしたい場合、packages/core/package.jsonのmainフィールドにエントリポイントとなるファイルパスを指定し、packages/web/package.jsonで

packages/web/package.json
{
  "dependencies": {
    "@mochicorp/core": "^1.0.0",
  }
}

を設定します。これで、@mochicorp/coreを通常のnpmライブラリと同じように利用することができます。

typescript等のbuild設定

ここで問題となるのはこのエントリポイントがjsで利用可能なモジュールを指定していれば、これで設定が終了なのですが、実際は、エントリポイントのjsをimport可能なmoduleフォーマットにbuildして生成することが多いと思います。

モノレポでは、このパッケージの変更に応じて毎回buildして依存先に変更を伝播させる仕組みを自前で作らなければならず、ここが大きな鬼門になります。毎回手動でbuildするでも構わないのですが、作業を忘れて変更を反映されないことに頭を悩ませたり手間も大きいので、なるべく自動化した方が良いでしょう。

そのため、各共通利用されるモジュールのbuild方法はシンプル、かつ、あまり複雑な仕組みを導入しない方がよいです。幸い、typescriptには、このモノレポで依存関係にあるパッケージを連動して一括にbuildできるような機能がサポートされているのでこちらを今回は紹介します。

typescriptのbuild設定

tsc --build

コマンドはコマンド実行したパスに存在するtsconfig.jsonの以下のようにreferencesパラメータのpath keyを設定したオブジェクトに指定されているパスのモジュールのtsconfig.jsonを参照してbuildを開始します。

tsconfig.json
"references": [
    { "path": "packages/admin-cli" },
    { "path": "packages/backend" },
    { "path": "packages/web" }
]

今回の例ではpublishしたいパッケージのみを同時にbuildしたいため、admin-cli、backend、webというパッケージを指定しました。

また、この"references""path"フィールドはディレクトリを指定するとそのdirectoryのtsconfig.jsonを探してbuildしてくれる他、直接tsconfigとして扱うjsonファイルのパスを指定することで、参照先のプロジェクトで利用するtsconfigファイルを直接指定することができます。 例えば、package/webのtsconfig内で依存しているpackages/coreがtsconfig.build.jsonという設定ファイルがリリース用のコンフィグとして利用している場合、これをreferencesに登録する場合は、

packages/web/tsconfig.json
"references": [
    {
      "path": "../core/tsconfig.build.json"
    }
  ]

と指定することができます。このreferencesの設定は、buildを実行したtsconfigから遡っていき、referencesの指定が途絶えるまでbuildを連鎖させることができます。これは、例えば、rootからtsc --buildを開始し以下の依存関係ばあるような場合、

depencey-graph-1
packages/core -> packages/web
packages/core -> packages/admin-utils -> packages/backend
packages/core -> packages/backend
packages/core -> packages/admin-utils -> packages/admin-cli 

この通りにbuildが走ります。ここで依存関係が重複しているものはbuildのキャッシュが利用されるようです。これを実行場所をpackages/webだけにすると

depencey-graph-2
packages/core -> packages/web

だけのbuildが実行されるようになります。また、他のパッケージから依存されているパッケージは、型定義の読み込みのために、

tsconfig.json
"composite": true,
"declaration": true

を設定しなければいけません。詳しくはwww.typescriptlang.orgのproject-referencesを参照してください。また、注意事項として、declarationを定義することによって型定義をパッケージごとに設定する影響で型名などを厳密に要求されるようになるため、型推論などで型付けされたAPIをexportする場合は型定義が必要になることがあります。 ここでは、coreやadmin-utilsにはこの設定が含まれています。これで連鎖的にビルドが利用できるようになりました。

あとは、compositeを設定したパッケージの変更をwatchしてこのパッケージに依存しているパッケージ全てをreferencesで指定したtsconfig.jsonを用意してあげて、これをもとにtsc --buildを行う、あるいは、tsc --buildの引数には、複数のtsconfig.jsonを指定できるため、パッケージに依存した全てのprojectを引数で指定して、変更に応じてpackage全てが再ビルドするようにしたらよいでしょう。

deployの設定

deployにおいては非常に悩ましいことが起きます。yarn workspacesではパッケージの依存をルートにまとめてしまうことで、管理がしやすくなる仕組みなのですがdeploy後にホスティング先でpackage.jsonを利用したパッケージのインストールを行う時はこの仕組みがミスマッチしてしまい、package.jsonとyarn.lockの欠落を補完するようにbuildを行うなどの対処が必要になります。

この場合、packagesからこのプロジェクトを外してしまうのも一つの手でしょう。これができるプロジェクトであれば、これが1番の得策です。しかし、実際は、他のパッケージにも依存してしまうことが多いため、これ以外の方法も検討する必要があります。その手段としては

  1. webpackで依存パッケージをまるごとまとめてbuildする。
  2. デプロイ用のpackage.jsonとyarn.lockを別に用意する。
  3. モノレポごとデプロイを行い、ビルドフローをおこなう。

などがあります。他にも方法が考えられると思いますが、Mochiのプロジェクトで採用しているのは以上の方法です。

1.はfirebaseのprivateリポジトリで管理しているパッケージのinstallを避けられること、commonjs形式のexportを必要としているので、やり方にマッチしていました。

2.はapp engineではpackage.jsonとyarn.lockを使用するため、また、directoryを階層的に探索するため依存ファイルを減らすために別にpackage.jsonとyarn.lockを管理するようにしました。これで部分的な依存を含めてyarn installで実行できるようにします。

3.はCIなどでデプロイする方法で、モノレポのビルドフローを踏襲してから生成物をデプロイする方法です。この方針が取れるのであれば最も望ましいかと思います。

また、通常はモノレポは依存関係にあるパッケージを全部インストールするため、パッケージが多くなるにつれてCIなどで使うマシンの負荷や走らせないといけないテストのパイプラインがリポジトリ内で増えていくため、 npmではなくprojectをリリースする場合のパッケージはトレードオフを意識して使うと良いでしょう。