スケーラブルで保守性の高いモジュラーディレクトリ構成へのフロントエンドリポジトリ移行

Merpay Advent Calendar 2022 の9日目は、メルペイ フロントエンドエンジニアの @tokuda109 がお届けします。

この記事は、メルペイ フロントエンドチームで保守しているリポジトリを複数のディレクトリに分割し、スケーラブルで保守性の高いコードベースに再構築する方法を紹介します。
記事の内容は『Merpay Tech Talk 〜Monorepo開発におけるツール選定〜』のイベントで一度紹介したものになりますが、この記事ではさらに詳しく説明します。
フロントエンドチームは今後 Nuxt 3 への移行作業を控えていて、今回紹介するリポジトリ移行が Nuxt 3 への移行に与える効果についても紹介します。

抱えていた課題

フロントエンドチームは、メルペイのサービスリリース時からカスタマーサポート業務を行う社内向け管理画面や加盟店さま向け管理画面など、様々なマイクロサービスを開発し、保守しています。
サービスリリースが2019年2月なので、4年近く保守し続けていて、徐々に負債が溜まってきています。

依存パッケージの更新作業は顕著で、脆弱性が発見された依存パッケージは優先的に解決していますが、何かしらの問題が発生し、解決に必要な時間が増えてきています。
具体的には次のような課題を抱えています。

  • 何のためにインストールしているのか理解できない依存パッケージがたくさんある
  • 依存パッケージのバージョンを上げると、全然関係なさそうなところで何故かビルドが失敗する
  • 複数のバージョンの依存があり、全てを満たせないため、古い方に合わせるしかなく、最新バージョンを使えない

依存が複雑で依存パッケージの更新に問題がある状態を説明するのに適切な図があるため、以下の図を見ていただきたいです。この図が示すとおり、node_modules にダウンロードされる依存パッケージの更新作業は困難な作業になります。(図1)

node_modulesの依存の複雑さの比較

図1(引用: Heaviest objects in the universe : r/node

依存の複雑さ以外にも npm-scripts が単一の簡単な処理しか記述できないため、スクリプト間の依存関係がわからないという問題があります。(スクリプトの実行順序を知るために、全てのスクリプトの処理内容を理解する必要があります)

これらの課題は package.json ファイルの dependenciesdevDependenciesscirpts フィールドで起こりうる一般的な問題になります。

依存パッケージ間の複雑な依存関係

メルペイは、マイクロサービスアーキテクチャによって、サービスドメインや開発チームごとに分割された複数のマイクロサービスが協調することによってサービスを提供しています。

クーポン機能を例にすると、バックエンドとフロントエンドの2つのマイクロサービスが存在し、それぞれ1つ(計2つ)のリポジトリで管理されています。
クーポン機能のフロントエンドのリポジトリには、42個の依存パッケージが dependencies としてインストールされ、さらに60個の依存パッケージが devDependencies としてインストールされています。

例として、インストールされている依存パッケージには axiosvuex のような目的が明確なものがあれば、core-js のように不明確なものもあります。
Nuxt 2 のアプリケーションの実行に必要だからインストールされているのか、テスト等の他の処理実行に必要なものなのか今となっては不明です。core-js を外してみて、ビルドが通ったとしても本番環境で何かが壊れるかもしれないと思うと、怖くて外せません。

こういった理由からクーポン機能のフロントエンドリポジトリには core-js の2系の古いバージョン指定が未だに残っています。

不明確な npm-scripts の実行順序

package.jsonscripts フィールドに記述される npm-scripts の数も同様に増えてきます。
npm-scripts は単一の処理しか書けないため、複数の処理を順番に実行する必要がある場合は、ドキュメンテーションがないと理解が難しくなります。

ここでもクーポン機能のフロントエンドリポジトリを例にすると、全部で26個の npm-scripts が定義されています。リグレッションテストを実行するための Cypress 関連の npm-scripts だけで簡略化すると次のようになります。

{
  "scripts": {
    "build:cypress": "NODE_ENV=development IS_CYPRESS=true nuxt generate",
    "build:dev": "NODE_ENV=development nuxt build",
    "cypress:open": "cypress open",
    "cypress:run": "cypress run --spec './cypress/integration/*.test.ts'",
    "cypress:start": "NODE_ENV=development IS_CYPRESS=true nuxt start",
    "cypress:dev": "IS_CYPRESS=true npm run dev",
    "cypress:build": "IS_CYPRESS=true npm run build:cypress",
    "cypress:local": "start-server-and-test cypress:dev http://localhost:3000 cypress:open",
    "cypress:ci": "start-server-and-test cypress:start http://localhost:3000 cypress:run",
    "cypress:a11y:local": "start-server-and-test cypress:dev http://localhost:3000 cypress:a11y:open",
    "cypress:a11y:open": "cypress open --config-file cypress.a11y.json",
    "cypress:a11y:ci": "start-server-and-test cypress:start http://localhost:3000 cypress:a11y:run",
    "cypress:a11y:run": "cypress run --config-file cypress.a11y.json"
  }
}

そして、Cypress のテストをローカルで実行するためには以下の手順で npm-scripts を実行する必要があります。

  1. npm run cypress:buildnuxt generate を実行して、テスト可能なスタティックビルドされた成果物を生成する
  2. npm run cypress:cistart-server-and-test を使って nuxt start を起動しつつ、cypress run を実行して Cypress のテストを実行する

この手順通りに実行しないと正しくテストが実行できず、cypress:build を忘れると変更前の成果物に対してテストすることになります。cypress:build は別の npm-scripts を呼び出していて、最終的に何が実行されるのかを追う必要があります。

モジュール性とコンポーザビリティ

お客さま体験を支えるコードは複雑化し、フロントエンド技術も日々進化しているため、フロントエンドのエンジニアリングには様々なツールや何かしらの設計原則が必要になります。
リポジトリのコードベースにこれらを導入するたびに、ツールを実行するための npm-scripts が追加されていきます。

フロントエンドチームが直面している課題を解決するには、リポジトリのコードベースを大まかな処理の単位ごとにモジュールに分割し、これを組み合わせて一連のワークフローとして実行することが可能なコンポーザブルなシステムとして再構成するといいのではないかと考えました。

  • モジュール性 – 処理の単位で分割することで変更による影響範囲がより狭いコードベースとして保守できるようにする
  • コンポーザビリティ – モジュール同士を組み合わせて、モジュール単体では実現できない、より複雑なシステムを構成したり、処理を実行したりできるようになる

これらは、スケーラブルで保守性の高いシステムを作る上で効果的な設計原則で、直面している課題に対して次のような改善が見込めるのではないかと考えました。

  • リポジトリで1つの package.json に全ての依存パッケージが定義されている状態から、package.json をモジュール単位とした複数のモジュールに分割することができる。分割された package.json は、依存パッケージが少なくなるため、依存パッケージの更新は比較的容易になる。
  • コンポーザブルなシステムが、分割されたモジュール間の依存を解決した上で一連のワークフローを手順通りに実行することを担保するため、実行順序を間違うことなく、再現性の高い実行ができる。

このアイデアは、毎週フロントエンドチームで行っている Web Wednesday というウェブ技術に関するトピックや雑談を行うための場で出されました。2021年の年末だったと記憶しているので、丁度1年くらい前のことです。
その場で効果的かもしれないという反応をもらったことで調査を開始することになりました。

このアイデアを実現するためには、モジュール分割とコンポーザブルなシステムを可能にする新しいツールチェーンが必要となります。

モジュールの定義

ツールチェーンの紹介の前にモジュールの定義について説明します。

フロントエンドチームで、次の基準に1つ以上当てはまるものをモジュールとして定義しました。

  • プロダクトのコードである
  • 単一の package.json を持ったディレクトリである
  • フロントエンドチームの品質指標に該当する(アクセシビリティ、パフォーマンス、テスト、セキュリティ)
  • 特定のプログラミングパラダイムとして分類可能である

この基準に則り、フロントエンドチームで現在保守しているリポジトリをモジュールに分割すると次のように分割することができました。

  • a11yapp モジュールに対してアクセシビリティ検査を実行
  • app – Nuxt 2、Vue 2で実装されたプロダクトアプリケーション
  • e2e – ローカルの app モジュールやステージング環境に対して Cypress によるリグレッションテストを実行
  • integration – ローカルの app モジュールに対して Cypress による結合テストを実行
  • perf – ステージング環境に対して Lighthouse によるパフォーマンス検査を実行
  • styleguideapp モジュールのコンポーネントをインポートしてスタイルガイドを生成
  • unitapp モジュールに対して Jest による単体テストを実行

また、app モジュールからUIコンポーネントを追加モジュールとして切り出して、共通化するといったことも難しくないでしょう。

新しいツールチェーン

Node.js プロジェクトにおけるツールチェーンは、パッケージマネージャとビルドツールの2つに分けることができます。

パッケージマネージャ

パッケージマネージャには、pnpm を使うことにしました。
pnpm にはワークスペース機能があり、モジュールごとに package.json ファイルを分割することができます。

実装ファイルから参照しない依存パッケージは dependenciesdevDependencies には記述せず、package.json ファイルに次のように記述します。

{
  "pnpm": {
    "packageExtensions": {
      "app": {
        "dependencies": {
          "consola": "^2.15.3",
          "ufo": "^0.7.9",
          "vue-client-only": "^2.1.0",
          "vue-meta": "^2.4.0",
          "vue-no-ssr": "^1.1.1",
          "vue-router": "^3.5.3",
          "webpack": "^4.46.0"
        }
      },
      "e2e": {
        "dependencies": {
          "@babel/preset-env": "^7.0.0",
          "babel-loader": "^8.0.2"
        }
      }
    }
  }
}

pnpm.packageExtensions.app.dependencies に指定されている依存パッケージは nuxt バージョン2系でランタイムに必要とされる依存パッケージで、app モジュールでしか使われないことが明確です。

ビルドツール

ビルドツールには、Turborepo を使うことにしました。
このツールによって、分割されたモジュール間の依存を解決しつつ、一連のワークフローを実行することができます。

npm-scripts の依存関係は turbo.json ファイルに次のように定義することができます。

{
  "pipeline": {
    "app#generate": {
      "outputs": ["dist"]
    },
    "integration#run": {
      "dependsOn": ["app#generate"]
    }
  }
}

turbo run integration#run を実行すると、app#generate (nuxt generate) を実行して成果物を生成した上で Cypress のテストが実行されます。1回の実行で複数の処理を順に実行できるため、簡単で再現性のある実行が可能となります。

また、Turborepo はキャッシュ機構も備えているため、nuxt generate した成果物に差分がない場合は、nuxt generate の処理はスキップされ、テスト実行のみ行われます。(キャッシュされた出力結果が表示されます)

最後に Turborepo で最も気に入っている点を紹介します。
それは、Turborepo が Go で開発されていることです。Turborepo をインストールするために他の依存パッケージをインストールする必要はなく、依存ツリーを複雑にしません。このビルドツールの選択は、課題である複雑な依存関係を解消するという方向性に合致します。

共通の設定と固有の設定

pnpm と Turborepo を使ったツールチェーンに移行することで、アプリケーションやテスト等の設定を全ての処理で共有されるべき設定と、モジュール内の処理でのみ指定が必要な設定に分割することができるようになります。

移行前のコードベースは、リポジトリ内の全ての実装が一枚岩として構成されています。どのような処理を行うにしても全ての実装が対象となるため、設定が複雑になります。
例として、jest.config.json ファイルは単体テストをするためのテストコードとテスト対象となるアプリケーションコードが設定として記述されるべきですが、Cypress の実装ファイルを除外するための設定が必要となります。本来このような設定は必要ではありません。(図2)

分割前の一枚岩構成によって、小さな設定変更が全体に影響を与える

図2: 分割前は一枚岩で構成されるため、全ての実装を対象とした設定が記述される

一方、今回紹介する分割方法は全モジュールで共有する設定と、モジュール固有で必要となる設定を分けて管理することができます。

jest.config.json ファイルは単体テストを実行するために必要な設定なので、unit モジュール内に置くことで、不要な外部モジュールの影響を受けずに設定を記述することができます。(図3)

共有設定と個別設定に分割できる

図3: 共有の設定とモジュール固有の設定に分割

ディレクトリ構成

ここまでの話を踏まえて、最終的なディレクトリ構成は次のようになります。

├─ dist/
├─ modules/
│   ├── a11y/
│   │   └── package.json
│   ├── app/
│   │   ├── .nuxt/
│   │   ├── nuxt.config.ts
│   │   └── package.json
│   ├── e2e/
│   │   ├── cypress/
│   │   └── package.json
│   ├── integration/
│   │   ├── cypress/
│   │   └── package.json
│   ├── perf/
│   │   └── package.json
│   ├── styleguide/
│   │   └── package.json
│   └── unit/
│       └── package.json
├── package.json
└── turbo.json

dist ディレクトリは、app モジュールの Nuxt 2 アプリケーションに対して nuxt generate を実行した際に成果物を出力するためのディレクトリです。modules/app ディレクトリ内に出力しない理由は、a11ye2e モジュールの npm-scripts の実行対象として参照するためにアクセスしやすい場所に置くためです。

参考までに、modules/integration/package.json がどのようになったかを紹介します。

{
  "name": "integration",
  "private": true,
  "scripts": {
    "start": "serve ../../dist",
    "open": "start-server-and-test 3000 'cypress open'",
    "run": "start-server-and-test 3000 'cypress run'",
    "lint": "eslint --config ./.eslintrc --ext .ts cypress"
  },
  "devDependencies": {
    "@cypress/code-coverage": "3.10.0",
    "@cypress/webpack-preprocessor": "5.15.5",
    "@testing-library/cypress": "7.0.7",
    "cypress": "7.7.0",
    "serve": "13.0.4",
    "start-server-and-test": "1.14.0",
    "tsconfig-paths": "3.14.1",
    "tsconfig-paths-webpack-plugin": "3.5.2",
    "ts-loader": "^8.3.0"
  }
}

先程たくさんの cypress:xxx 系の npm-scripts を見てもらいましたが、このファイルでは、依存パッケージが何のためにインストールされ、何を実行できるのかが一目見て理解できるのではないかと思います。

どのようなプロジェクトに適用しやすいか

今回紹介した方法は万能ではなく、効果が得やすい適切なアプリケーションの規模があると考えています。

新規でリリースをして、機能開発をしたり、テストを追加して少し複雑になってきたと感じる規模から導入できるのではないかと思います。そのような状況の時には、クーポン機能と同じくらいの依存パッケージがインストールされ、更新作業が煩わしく感じると思います。

これよりもさらに規模が大きくなる場合、マイクロフロントエンドで提案されているような画面コンポーネントや機能単位で分割したほうが効果的だと思います。

フロントエンドチームで保守しているマイクロサービスは、そこまでは大きい規模ではないため、今回紹介した分割方法が最適だと移行してみて感じました。

途中経過と Nuxt 3 への移行

今回紹介した分割方法への移行は、現在稼働中のアプリケーションに少なからず影響があるため、プロダクト開発の合間を縫って行っています。規模が小さかったり、開発が比較的落ちついているリポジトリから随時移行していっています。

移行ペースは、四半期に1サービスずつ移行完了できていて、移行するべきリポジトリはまだ残っていますが、少しずつでも移行していく予定です。
移行済みのものを保守した感想としては、モジュールに閉じた影響範囲で更新できるため、依存パッケージは更新しやすいです。(逆に更新に失敗する場合は、後方互換性がなく呼び出しに失敗しているといった類のもので、外部要因の影響から失敗しているものはありません)

そして2022年11月16日に、遂に Nuxt のバージョン3がリリースされました。今回紹介したモジュラーディレクトリへの移行は、Nuxt のバージョン3移行にとても意味があると思っています。
モジュール分割されていることで、Nuxt のバージョン3への移行の影響を極力 app モジュール内だけに限定できるのではないかと予想しています。
例として、Nuxt のバージョン3にあげることでビルドシステムも変更される(webpack 4系からwebpack 5系やvite)ため、結合テストや単体テストも対応する必要があり、リポジトリ全体の大きな変更になりますが、app モジュール内のコード変更だけで済むかもしれません。
(※ これはやってみるまで分からないので、個人の希望的観測に過ぎませんが作業後ブログとして結果報告したいと思っています)

最後に

メルペイ フロントエンドチームとしては、今後 Nuxt のバージョン3への移行がメインの取り組みになってくる予定です。技術的な課題としては凄く挑戦的で、実際のプロジェクトで Nuxt 3 に触れる機会はタイミング的にもまだ多くないと思います。
メルペイではフロントエンドエンジニアを募集しているので、Nuxt 3 と Vue 3 を使った開発に興味があれば、ご応募お待ちしております。

採用説明会の書き起こし記事もあるため、こちらも是非ご覧になってください。

明日の記事は @hyrrot さんです。引き続きお楽しみください。

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加