👨‍👩‍👧‍👦

Webサービス開発でのmonorepo環境(Turborepo, nx)

2022/07/25に公開

Webサービス開発の文脈で、モノレポ環境を作る方法について整理しました。
※バックエンドもフロントエンドも全てTypeScriptで開発している前提

この記事を書いた背景

  • 世間で言われる「モノレポ」がどのように達成されているのか知らなかった
  • 参考記事を読んでると実現方法が異なっていたり、運用のイメージが見えなかったり、ベストプラクティスが分かりづらかった
  • Turborepoのようなモノレポツールを導入すれば解決しそうだが、このツールが何をしてくれるのか理解できなかった
  • モノレポ何も分からない………になった

この記事ではモノレポに関連する仕組みを1つ1つ丁寧に把握し、最終的に理想的なモノレポ環境を実現することを目指します。

モノレポとは?

まずは用語の整理から。
モノレポを素直に訳せば「1つのリポジトリ」という意味でしかなく、1つのリポジトリで開発していればそれは「モノレポ」と呼べそうです。しかし、期待する定義はもう少し違ったものでしょう。

こちらの記事の説明がわかりやすかったので引用します。

https://monorepo.tools/

A monorepo is a single repository containing multiple distinct projects, with well-defined relationships.」

この記事では「関係性が明確な複数の個別のプロジェクトからなる、1つのリポジトリ」のことをモノレポと定義しています。また、「単なるコードの集まりはモノレポではない」とも書いてますね。

Image from Gyazo

Image from Gyazo

※上記記事より引用

モノレポで何を達成したいのか?

より具体的に、自分がモノレポに求めるものを整理すると、以下となりました。

  • WebバックエンドとWebフロントエンド、その他のTypeScriptコードを1つのリポジトリで管理すること
  • バックエンドとフロントエンドで、共通のモジュール(プロジェクト)を使いまわせること
  • プロジェクト間の依存関係が定義できること
  • lintやformatterの設定を1つにまとめること(可能であれば)
  • これらが簡潔な形で行えること
    • 開発体験を損ねて良いのであれば、これらはポリレポで達成可能なので。

特に、共通モジュールのリファクタリングをVSCodeで行う際に、依存しているバックエンド/フロントエンドのコードが一括で変更されるような開発体験の達成が、自分の1番の目的となります。

また、モノレポにより損なわれたくない開発体験も整理してみます。

  • エディタの各種のサポート(特に自動インポート、リファクタリング、インテリセンスなど)が通常通り使えること
  • ビルドやデプロイがこれまでのように、シンプルで簡単な方法で行えること
  • node-dev next dev のような開発体験が達成されること

また、この記事ではあまり触れないものの、世間的にモノレポに求められるものも整理しておきます。

  • パフォーマンス、キャッシング
  • タスクランナー
  • 依存パッケージの一括管理

モノレポの構築

Turborepoなどの便利ツールを使うのは後にして、まずは自分でモノレポ環境の構築を目指します。

特に、「プロジェクト間の依存関係をどう定義するか(=他プロジェクトをどう参照しているか)」に注意して構築していきます。

Yarn Workspaceによるモノレポ

Yarn Workspaces(or npm workspace)を使うのが直近でのお手軽なモノレポ達成方法でしょう。
Yarn Workspacesが何をしてくれるのか、機能を絞って整理すると、

  • 全てのプロジェクトの依存パッケージが、ルートディレクトリの node_modules 配下にインストールされる
  • 各プロジェクトへのリンクが node_modules に作成される
    • これを利用して、他プロジェクトをローカルのnpmパッケージとして参照できる
// package.jsonの例
{
  "dependencies": {
    // ローカルのnpmパッケージとして参照
    "@monorepo-sample/common": "*",
    "next": "12.2.3",
    // ...
  },
}

これを使って構成したサンプルが以下となります。

https://github.com/nullnull/monorepo-sample/tree/master/yarn-workspace

構成の要点

  • ルートディレクトリではworkspacesを定義するための package.json を配置。 tsconfig.json は無い
  • 各プロジェクトが、それぞれ package.json, tsconfig.json を持つ
    • 「他プロジェクトをローカルnpmパッケージとして参照している」という点以外で、プロジェクト同士は独立している
  • TypeScriptのビルド結果は、各プロジェクトのディレクトリの dist に出力される。node実行時は、このdist以下のjsファイルを実行する。
    • package.jsonmain でも dist/index.js を指定しており、他プロジェクトを参照する際は、 node_modules 配下のリンクを経由してこれが参照される。
  • 他プロジェクトを参照した場合、TypeScriptのレイヤーでは ./dist ではなく普通にプロジェクトのルートディレクトリが参照されるため、 ./index.ts が参照される
    • そのため、vscodeのリファクタリングなどが正しく機能する
    • 「開発時はtsファイル、実行時はjsファイルが参照される」 というのを頭にいれておく
  • Next.jsのプロジェクト(=Webpackによるバンドル)でも、 jsファイルをimportする分には挙動は同じ。他プロジェクトのjsxをimportしたい場合、エラーになる
    • エラー文を読む限り、なぜかpackage.jsonのmainで指定している dist 配下を参照しておらず、tsファイルを読んでいそう(こんなものなのか…?webpack分からない)
    • turborepoのサンプルではnext-transpile-modulesを入れることで解決していたので、これで解決した。
      • tsxファイルを直接読んでいることになるので、 next dev した際もHMRされる
      • webpackのレイヤーでは独自のコンパイルを挟むことになる

注意点

さて、Yarn Workspaceだけで十分にモノレポ環境が作れたように見えますが、実際に運用するには厳しい点がいくつかあります。

  • プロジェクトの依存関係に応じたbuildを自前で行う必要がある
    • (例) common -> backend の順番で常にbuildが行われなければならず、逆になったり、commonの後にbackendがbuildされなかったりすると、実行時エラーになりうる
  • 開発サーバーを立てる際は工夫が必要
    • ts-node-dev index.ts とすると、プロジェクト内のtsファイルの変更は検知できるが、プロジェクト外の変更は検知できない。
      • 自プロジェクトは ts-node-dev がTypeScriptプロジェクトとしてコンパイルし、依存プロジェクトはnpmパッケージとしてbuild済みのjsファイルが参照される
    • node-dev dist/index.js として、tsファイルの変更検知と tsc --watch 相当を各プロジェクトで行えば、全ての変更を検知できる。ただし、前述の通りbuild順が正しくなるように工夫が必要
  • ルートの node_modules 配下に全てのプロジェクトのリンクが生成されるため、 package.json で依存を指定していないプロジェクトに対しても、実際はimportできてしまう
    • これは、Nodejs/TypeScriptのパス解決時はpackage.jsonは参照されず、node_modules配下にファイルがあればrequireできてしまうため
    • これはeslintのimport/no-extraneous-dependenciesで解決可能。またvscodeの自動importはpackage.jsonを考慮してくれるので、自前でimport文を書いたりしない限りは問題なし

※ちなみに「Yarn Workspaceによるモノレポ」というタイトルで、他プロジェクトへの参照をTypeScriptのレイヤーのpathsts-loader で解決している記事が散見されましたが、ややミスリードのように思っています。Yarn Workspaceの他の機能を使ったモノレポという意味で間違っていないのですが、個人的に混乱したので一応コメント。

TypeScriptのProject Referencesによるモノレポ

Project Referencesを使うと、TypeScriptの仕組みのみでモノレポがおおよそ達成できます。
他のプロジェクトへの参照は、単純にTypeScriptの相対パスの解決で行われるだけ、前述のビルド順のような問題もtsc側が解決してくれます。

https://zenn.dev/katsumanarisawa/articles/58103deb4f12b4

複雑な設定をせずにモノレポが達成可能なので個人的には気に入っていますが、残念ながら以下の制約や残念ポイントがあります。

  • webpackがproject referencesに対応していない? -> 対応していそうなことに気づいたので、後ほど調査したい…
  • eslintやts-nodeなどの周辺ツールがproject referencesに対応しておらず、tsconfigを別で用意するなどの工夫が必要
  • 各プロジェクトから外部のプロジェクトにexportする関数は選べない。(package.jsonのmain相当がない)
  • Phantom Dependencies(この文脈では A -> B -> C の依存があるときに、 A から C のプロジェクトをimportできてしまうこと、と定義します)が避けられない
  • paths を指定しないと相対パスになるので、やや分かりづらいかも

Yarn Workspace + TypeScriptのinclude,paths,referencesなどの合わせ技によるモノレポ

ここまでの説明の組み合わせ、またincludeやpathsなどの指定でもモノレポを作れます。

しかし、自分が試したケースだと、node/tsc/webpack/node-dev/eslint/vscodeなどの周辺ツールがそれぞれ微妙に異なった挙動になりがちで、結構混乱しました。ビルドは通るけどVSCodeでの自動インポートが効かず使いづらくなってしまうなど…。

それぞれのツールの挙動、またtsconfigなどの設定について十分な知識があれば恐らく問題ないですが、個人的には避けた方が良いように思います。

Turborepoによるモノレポ

TurborepoはVercelが開発したモノレポ環境用のビルドツールです。
便利な機能が様々にあるものの、基本的な理解としては「Yarn Worksapces(ないしnpm, pnpm)の仕組みをベースに、これらをラップしてビルド順を保証してくれるもの」と認識するのが良さそうです。

Yarn Workspaceによるモノレポ環境に、Turborepoを追加してみましょう。

公式はこちらです。

https://turborepo.org/docs/getting-started#install-turbo

$ yarn add turbo -DW
$ vi turbo.json
// turbo.json
{
  "$schema": "https://turborepo.org/schema.json",
  "pipeline": {
    "build": {}
  }
}

この設定で、それぞれのプロジェクトの build コマンドを一度に実行できるようになります。(yarn workspaceのforeach相当)

$ yarn run turbo run build
# 並列でbuildが実行される

更に、以下の記述をすると、Turborepoがプロジェクトごとの依存順を自動で解決し、依存順にbuildを実行することができます。

{
  "pipeline": {
    "build": {
      // dependsOnで、このタスクを実行する前に実行すべきタスクを指定
      "dependsOn": [
        // ^buildで、「依存しているプロジェクトのbuildタスク」を指定
        "^build"
      ]
    }
  }
}

これにより、Yarn Workspaceによるモノレポで問題になっていたビルドの依存順の問題が解決できました 👏

残る問題

しかしまだ問題は残っており、特に node-dev など開発中のファイル監視・リロード周りの解決策が用意されていません。
turborepoの公式サンプルだと next dev が上手く動いているように見えますが、これは前述の通りwebpackのレイヤでビルドを行なっているためです。webpackを使わないバックエンド側のアプリを追加したりすると問題となるので注意してください。

公式でもissueがあがっています。
https://github.com/vercel/turborepo/issues/986

暫定的な解決策としては、以下となりそうです。

  • onchange '**/*.ts' '**/*.tsx' -- yarn build 相当で、「ファイルが変更される度にTurborepoのビルドを実行する」という形(やや重い)
  • 開発中はビルドせずにTypeScriptとして扱う。そのための tsconfig.json を別途用意する?(これ可能なのか分からなかった。誰か教えて欲しい)

追記:開発中のファイル監視の解決策について

上記の問題について、手元の環境では以下のように解決しました。

  • package.jsonでは"main": "index.ts"として、tsファイルを参照する
    • これにより、ts-nodeなどの監視が全ての依存パッケージに対して有効に機能する
    • 開発環境ではTypeScriptのみを扱う形にする
  • 本番環境では、ビルド後に全てのpackage.json"main": "dist/index.js"と書き換えてからnodeで実行する

これ以外の方法があればぜひ教えてください🙇‍♂️

Turborepoの便利機能

せっかくなので、Turborepoの便利機能についても簡単に触れておきます。

  • 複雑なパイプラインの構築: 上記ではシンプルに build タスクを実行する設定を紹介しましたが、より複雑なパイプラインも当然構築できます
  • インクリメンタルビルド: 上記パイプラインで定義したタスクは、各プロジェクト以下に変更あった時のみ実行され、変更がなければキャッシュから取得される
  • リモートキャッシュ: キャッシュをリモートに保存できるので、CI/CDでのキャッシュ管理に便利そう(まだ使ってない)

その他、こちらの記事も参考にどうぞ。

https://zenn.dev/mizchi/articles/introduce-turborepo

nxによるモノレポ

他のモノレポツールとして、nxのサンプルも触ってみました。使用したレシピは nx-react-express です。

構成の要点を整理すると、

  • package.json はルートに1つ(=workspaceの機能は利用していない)
  • tsconfig.json はルートに存在せず、各プロジェクトに存在している
    • ルートに tsconfig.base.json があり、各プロジェクトはこれを読み込んでいる。また、ここで paths により各プロジェクトへのエイリアスを指定している。

ということで、 paths を利用したモノレポに見えます。
nxを無視してこの構成でビルドするのであれば、依存の頂点にあるアプリのプロジェクトをビルドする際は、個々のプロジェクトのビルド結果を使うような形ではなく、1つの大きなTypeScriptプロジェクトとしてビルドされそうな構成です。
おそらくnxがこの辺を上手くキャッシュしたりしてくれると思うのですが、ビルドの挙動はnxにラップされており、またサンプルの設定ファイルも初見では難しそうだったので、具体的に何が起きているのか把握するのを諦めました(そもそもTurborepoで満足してしまったので、触る気力がでなかった)。

また、ビルドは通るものの、vscode上での自動インポートが効かないなどの問題が発生しました。 paths 指定しているので問題ない認識なのですが…。

nxはscaffoldのプリセットが充実していたり機能が豊富そうだったりするのが良さそうでしたが、Turborepoが「モダンでSimpleなモノレポ構築方法」という印象なのに比べて、nxは「やや古くEasyなモノレポ構築方法」という印象を受けました。

nxとTurborepoの比較はこちらをどうぞ。

https://nx.dev/guides/turbo-and-nx

おわりに

当初の目的通り、一通り触って把握できてよかったです。
モノレポで便利になる面はありつつ、まだ世の中に知見が少ない印象はあり、Webサービス開発で取り入れるのは少し大変かなという感想。

Discussion