pnpmとdependency-cruiserでWebフロントエンドのMonorepoを支える

目次

こんにちは。株式会社ハイヤールーの@Himenonです。

Hirerooアドベントカレンダー9 日目の記事です!

本記事では、Web フロントエンドのプロジェクトにおけるモノレポを支える技術について紹介します。

monorepo ?

monorepo とはなにか、monorepo に求められるものはなにかについてはmonorepo.toolsを参照してください。その上で、今回弊社のプロジェクトで構築した monorepo について説明します。

HireRoo のプロダクトにおける monorepo に求めるもの

現在ハイヤールーではリアーキテクチャを実施(Web フロントエンドのリアーキテクチャに向けた課題整理と解決の道筋を参照)しています。monrepo にする大義は 2 つあります。

  • 実装の DRY(Don’t repeat yourself)を徹底すること
  • 責務分離を明確にし、パッケージとして切り出せるものは早期に実施すること

これらの 2 つを達成することがモノレポ化の初期フェーズです。

技術選定

その上で、モノレポを達成するために pnpm workspace を利用しました。他にも Turborepo や Lerna、Nx など候補はありますが、前述したとおり、今、求めるものは実装の DRY と責務分離です。ビルド時のキャッシュ戦略などはこの作業が終わった後に考える予定のため今回のリアーキテクチャのスコープには含まれていません。

pnpm workspaceを利用する手順

pnpm はビルトインでモノレポを構成する workspace 機能が提供されています。pnpm-workspace.yamlに以下の記述を行い、packages/[name]/package.jsonを用意すれば workspace 管理化に置かれます

packages:
  - "packages/*"

Storybook を利用している場合、shamefully-hoistを true にする必要があるため、.npmrc にこれを記載します。

always-auth=true

たったこれだけで monorepo の準備が整います。

monorepo の設計

monorepo の全体設計において定めることは、どの粒度で package を分離するかにあります。ボトムアップでどのように構成されているか紹介します。

フロントエンドのコンポーネントの責務分離のための方針とその効果についてで紹介したようにリアーキテクチャの柱として Presentation 層と Container 層を分離を実施しています。また、GraphQL も導入しているため、これら 3 つが主軸となってモノレポを構成していきます。

packages
  app      // Container層
  backend  // BFF層
  ui       // Presentation層

DRY の原則の視点から分離する

BFF 層と Container 層では GraphQL の schema から生成される TypeScript の型定義を共有します。(GraphQl に関するディレクトリ構成はBackend For Frontend と React で GraphQL を導入するための構成を参照)

packages
  app
  backend
  graphql    // Graphqlの型定義, 自動生成された実装
  ui

また、翻訳情報(i18n)は固定の場合は Prentation 層で定義され、動的に変化するようなケースの場合は Container 層で決まる可能性があるため、翻訳情報も切り出します。

packages
  app
  backend
  graphql
  i18n         // 翻訳情報
  ui

ユーザーの入力情報のバリデーションロジックは react-hook-form を含む Presentation 層で利用したり、信頼区間内の BFF 層で Validation することがあるため、これも切り出します。

packages
  app
  backend
  graphql
  i18n
  ui
  validation // zod schemaを定義

ここまで、重複した実装が発生しないために分離してきました。

責務分離の視点から分離する

責務の観点からも分離してきます。まずわかりやすいのは、Storybook 自動生成による DX 改善や、型安全な grpc client のコード生成と GraphQL で実装された BFF の接続で紹介したように、コード生成のための実装がモノレポ内に含まれています。これらを Container 層や Presentatino 層などに同居させるのは責務の観点からして不自然です。したがって、パッケージとして切り出します。

packages
  app
  backend
  graphql
  generator-tools  // UIやContainer層の雛形を生成のためのツール
  i18n
  nodejs-client   // proto-to-clientを用いて生成されたコードを保管する
  proto-to-client // protocol bufferからコードを生成するツール
  ui
  validation

さらに、Container 層は valtio による Store が必要ですが、app の視点からすればどの State 管理ライブラリを利用しているか、には興味がないのでパッケージとして切り出して隠蔽します。また、Presentation 層にあるコンポーネントはサードパーティ製のライブラリを利用することもありますが、Component を読み込むだけでファイルサイズが巨大になるようなライブラリはReact.lazyなどで遅延読み込みの対象とします。明らかなものに関しては最初から分離しておきます。

packages
  app
  app-store           // Stateを管理する場所
  backend
  graphql
  generator-tools
  i18n
  nodejs-client
  proto-to-client
  ui
  validation
  [BIG Size Component] // ファイルサイズの大きなサードパーティ製を切り出す

また、Container 層と GraphQL 間と GraphQL と grpc 間で型情報やデータの変換が発生します。使いまわしする可能性が十分に高いため、これらもパッケージとして切り出します。

その他、アプリケーション固有の時間のフォーマットも切り出しています。このようにパッケージを切り出しています。

構築したモノレポの運用方針

ハイヤールーにおけるモノレポの運用方針について紹介します。

バンドラーツールの alias を使わない

vite や webpack などのバンドラーツールは alias 機能をサポートしています。しかしながら、alias を利用するには alias の設定を知っておく必要があり、プロジェクトが大きくなるにつれて保守コストが高くなります。モノレポのパッケージとして切り出しておけば package の import として扱えるため alias を貼るくらいなら分離するのが望ましいと判断しました。また、多少の相対パス../../は発生しますがそれも許容します。その他のディスカッションについては@azuさんのTypeScript の設定の良し悪し.mdを参照すると良いでしょう。

各パッケージは tsc build せず、型チェックのための tsc –noEmit のみ実行する

「TypeScript のパッケージとして切り出した場合、tsc でビルドしなければ他のライブラリが使えない」と当初は思っていましたが、vite や webpack などのバンドラーツールは node_modules 内に ts ファイルしかない場合でもビルドしてくれます。これを逆手に取り、tsc buildを走らせることをやめました。

これにより。tsc buildし忘れによるパッケージの更新漏れのトラブルが無くなります。また、バンドラーツールの差分検知はとても賢いため手元のローカル開発のパフォーマンスが下がることはありません。

その代わり、CI 上でのビルドはtsc –noEmitを各パッケージで走らせ、型チェックを並列で走るように工夫しています。(noEmit オプションについて

package.json の dependencies が責務境界を表す様になる

モノレポで特徴的なのは 1 つのリポジトリ内のパッケージを別のパッケージから参照できることにあります。これはつまり、依存するパッケージが責務として正しいかどうかをチェックする場所が、実装レベルではなくpackage.jsonを見ればわかる、という状態になります。これによりレビューの際は重要なポイントとして意識することができます。

依存関係のテストをする

特徴で上げたなかでいくつかのことは「やってはいけない」というものが含まれています。この部分は法律みたいなもので、「やろうと思えばできてしまう」部分でもあります。つまり、レビュー漏れによって一度でも破られてしまうと割れ窓となり、秩序が乱される可能性があります。また、アーキテクチャを作成した本人ですら忘れてしまう可能性があるためテストで検知できるようにしておくのが無難です。

依存関係のテストをするにはスコープの異なる 2 つの方法で実現します。

  1. pacakge.json の exports フィールドで、import 時に参照可能なモジュールを制限する
  2. dependency-cruiserを用いてファイル間、パッケージ間の依存関係をテストする

詳解していきます。

package.json の exports フィールドを利用する

Node v12.7.0 より、package.json の exports フィールドが利用できるようになりました。これを利用すると、package が公開するモジュールの subpath を Allow List 形式で指定できます。

{
  “name”: “@hireroo/sample”,
  “exports” {
    “.”: “./index.ts”,       // import “@hireroo/sample” で参照可能
    “./sub”: “./sub/index.ts”// import “@hireroo/sample/sub” で参照可能
  }
}

exports フィールドに記載されていないパスに対して参照が伸びた場合、バンドラーツールがビルドに失敗し、違反を検知することができます。また、TypeScript ファイルを直接指定してもバンドラーツールは解決してくれることは確認できています。

dependency-cruiser で依存関係のテストをする

dependency-cruiser は次のような依存関係をデフォルトで検知してくれます。

  • 循環参照
  • どこからも参照されていない孤立したファイル
  • package.json に記載されていないパッケージへの参照
  • package のスコープ外への直接参照

さらに、自分で依存関係のルールを記述できます。dependency-cruiser を初期化すると、.dependency-cruiser.cjsファイルが生成され、以下のコマンドで実行できます。

depcruise src -v

これをモノレポ内の各パッケージ直下に配置し、実行することで期待している依存関係をテストできます。

例えば、Presentation 層のコンポーネントの区切りとしてpageswidgetがあります。ディレクトリ構造は以下のとおりです。

src/
  pages
  widget

pages は widget に参照できますが、widget から pages に参照できないようにする場合は次のように記述します。

module.exports = {
  forbidden: [
    {
      name: "do-not-depends-from-widget",
      severity: "error",
      comment: "widgetからpagesに依存することは禁止です"
      from: {
        path: "src/widget/.+",
      },
      to: {
        path: ["src/pages/.+"],
      },
    },
   // 省略
  ]
}

例えば、これに違反した場合次のようなエラーが表示されます。

まとめ

Web フロントエンドのリポジトリは pnpm workspace を用いたモノレポで構成されています。パッケージの分割基準として実装の DRY責務分離の観点から実施していることを紹介しました。また、パッケージ間やファイル間の依存関係をテストできるように package.json の exports フィールドを利用したり、dependency-cruiser で違反を検知したりしてルールが明確で持続的なものとなる施策を導入しています。

より価値を速く提供できるようにモノレポの機動力を上げるための施策は残っていますが、これから進化させていきたいと思います。

次は@ryuichi_74 さんの「抽象構文木を用いた機械学習」です!