JavaScriptプロジェクトの規模と運用に応じた分割のアーキテクチャ(モノリス・モノレポ・メニーレポ)の比較

10 min read読了の目安(約9600字

対象読者

  • JavaScript/TypeScript のプロジェクトを運用している
  • モノリスな実装を分割したい
  • モノレポを検討している
  • メニーレポ運用から切り替えたい

背景

現在の JavaScript(以下 JS)は大きく分けてブラウザ上で動くと Node などのランタイムで動く JS の 2 つがあります。さらに JS はその多くが npm パッケージとして提供され、非常に強力なエコシステムによって再利用性が高められています。

同じ実装を繰り返さなくて済む形で抽象化され、開発者に多くの恩恵をもたらしますが、トレードオフもあります。例えば、npmパッケージのバージョン更新を行う工数だったり、その更新のためのビルドやリリース時間だったりします。凝集性の高いアプリケーションが更新頻度の高いパッケージを利用する場合、この作業時間がリリース速度を低下させる原因にも繋がります。

凝集性の高いパッケージ群は一つのリポジトリにまとめ上げ、そのリポジトリ内でバージョン更新を一括で行う、モノレポ(monorepo)というバージョン管理戦略を取ることもできます。これによりバージョン更新にかかる手数を減らすことができます。これでめでたしめでたし、というわけではありません。モノレポの運用のためには、モノレポ用の管理ツールを利用する必要があり、そのツールはモノレポのメンテナーによって保守される必要があります。ゆえに、モノレポで運用する難易度が高い場合、メニーレポ(manyrepo)で運用したほうが結果的に保守コストが安くつく場合もあります。

さて、選択肢が色々とある中で何を選択すると良いのか?元も子もない結論から書くと、それはケースバイケースで、実際のライブラリやリポジトリの責務の設計によって何を選択すべきかは変わってきます。しかしながら、この結論だと本記事を読む意味がないため、モノリス、モノレポ、メニーレポの運用のGood、Badを示して比較していきます。

用語の定義

本記事で利用するアプリケーションライブラリ規模という用語の定義をします。

アプリケーション

  • 具体的な例
    • ブラウザ上で動く Single Page Application(SPA)などを想定
  • 特徴
    • 成果物はビルドを必要とする
      • ビルドツールは webpack や rollup などのバンドラーを想定
    • 成果物は二次利用されることがほとんどない(※1)
    • テストを実施する
      • テストの内容は実装されるアプリケーションに対して利便性の良いものを選択する

※1 "ほとんど"という表現は Server Side Rendering(SSR)などで利用される可能性はありうるため。今回は話がややこしくなるのでSSRに関しては度外視します。

ライブラリ

  • 具体的な例
    • day.jsmaterial-uiReactVueAngularなどの特定の処理に特化した処理の集合
  • 特徴
    • 成果物はビルドが必要な場合もある
      • TypeScript や Babel などのトランスパイラーを通すだけで良い
    • 成果物は二次利用されることを想定している
    • テストを実施する
      • テストの内容は実装されるライブラリに対して利便性の良いものを選択する

規模

ざっくりとした指標で、筆者の感覚で一般的に証明するとかは難しいのですが何で規模感を測定するかを見たときの指標として定義します。開発者 1人あたりの1回あたり(Pull Request 単位と考えても良い)の変更コード行数で規模を定義しておきます。

規模の大きさ 条件
1 人の開発者の 1 回の変更コードの最大値が 1000 行を超えることができる
1 人の開発者の 1 回の変更コードの最大値が 300 行以上、1000 行未満
1 人の開発者の 1 回の変更コードの最大値が 300 行未満

※ 注意以下の変更を除きます

  1. リファクタリング
  2. 自動生成

指標の定義について簡単に解説すると、開発者 1 人あたりの 1 回あたりのコードの変更量と規模は反比例していると考えていて、規模が大きいものほど責務が別れていて変更する量が小さくなり、
規模が小さいものほど多くの責務をまたいで変更を入れないと変更としての価値が出ない状態担っていると考えています。ただし、開発者似スキルが十分にあり、責務の定義やレビューが正常に機能している状況ではあります。

「規模」の定義について諸説あると考えられるため、各々が考えている規模で置換してください。

アプリケーション・ライブラリを分割する方法

用語の対称性と粒度を揃えるためにあえて以下の 3つで表現します。

  • モノリス
    • 単一リポジトリで、内部の責務はディレクトリベースで分割して管理している
  • メニーレポ(Manyrepo)
    • 責務を複数のリポジトリで管理している
  • モノレポ(Monorepo)
    • 単一リポジトリで、責務を内部で複数のパッケージに分割して管理している

比較

アプリケーションとライブラリの区切りの方法を 3 種類示しました。単純に組み合わせ数を出すと、2x3の6パターンあります。ここでは、モノレポ、メニーレポで取り扱う複数のアプリケーション、ライブラリはそれぞれ粒度の整った理想的な状態として比較します。

アプリケーション / モノリス

Good

  1. 作業するリポジトリが 1 箇所で良い
  2. ビルドツールの管理場所が 1 つでよい
  3. デプロイ速度が早い

Bad

  1. 規模が大きくなるに連れて変更で触るコードよりも触らないコードの方が多くなる
  2. 規模が大きくなるに連れて変更に対するテスト(確認)が全体に対して小さくなり、本当はしなくても良いテスト時間も消費する
  3. 変更箇所だけのテストをしようとするとビルドやテストツールに対する学習コストが必要となる
    1. 依存関係があるため実現するのはなかなか難しいときもある

とにかく規模が大きくなると実装全体に対する変更量が小さくなり、テスト(確認)の時間が増える

アプリケーション / メニーレポ

具体的な例だと、たとえばルーティング(ページ)単位でアプリケーションのリポジトリを分割しているような場合。

Good

  1. アプリケーション間の連携は少ないが、ページ間のドメインに大きな差がある場合に管理コストが低くなる
  2. 全体に対する変更量をモノレポと比較して小さくできる
  3. デプロイ時の影響範囲を小さくできる

Bad

  1. 類似のアプリケーション間で利用するための共通のライブラリを作る必要がある
  2. アプリケーション間のビルドツールが分離される
  3. アプリケーション固有のビルド・テスト設定が組み込まれやすくなる(他が追従できなくなる)
  4. アプリケーション間で同時リリースするようなライブラリの変更の取り込みコストがかかる

アプリケーション / モノレポ

具体例

メニーレポの区切りがモノレポで集約されているような状況

Good

  1. アプリケーション間で共通のライブラリの変更に対する追従コストが減る
  2. ビルドツールが統一しやすい

Bad

  1. 変更箇所のみのビルド、デプロイ環境を構築するための実装・保守が必要となる
  2. アプリケーション間で共通のライブラリで不具合が発生した場合に、複数のアプリケーションが同時に不具合の影響を受ける
  3. git のバージョン履歴があらゆる責務に対して行われため、(規格化されていなければ)追跡しにくい
  4. モノレポ内のパッケージの粒度設計・調整が必要
  5. モノレポビルドツールの運用
  6. リポジトリ間でバージョン更新順序が循環する問題(後述)

ライブラリ / モノリス

アプリケーション / モノリスと同様

ライブラリ / メニーレポ

Good

  • 責務がきれいに分割できると更新がほとんどいらない枯れた状態にできる

Bad

  • インターフェースや処理の破壊的変更が活発に入るような運用には向かない
  • ライブラリが依存したライブラリに重要な更新があった場合に、リポジトリ数だけ更新をする必要がある

ライブラリ / モノレポ

Good

  1. モノレポ内のパッケージ間の依存であれば破壊的変更があった場合も吸収しやすい

Bad

  1. モノレポ内のパッケージの粒度設計・調整が必要
  2. モノレポビルドツールの運用

プロジェクトの成長とともにどの分割方法を使うと良いか?

明確な答えはありませんが、規模に応じて開発速度を落とさない順序はある程度予測して書きます。

小規模

項目 取りうる分割方法
アプリケーション モノリス
ライブラリ -

状況

おそらくモノレポから開始するプロジェクトはそうそうないはず。もともと別のプロジェクトからクローンしてきた場合はあり得るかもしれないが、小規模なうちはモノレポで運用は分割の利点を活かしきれない。ライブラリにアプリケーションの一部のコードを切り出すほどではない状況。

小規模 〜 中規模

項目 取りうる分割方法
アプリケーション モノリス
ライブラリ メニーレポ

状況

プロジェクトが成長していくと、サービスのコアとなる実装は他と異なりライブラリとして分離することで、ドメインレイヤーの汚染を防いだり、メンテナンス性を上げられる。逆に、それ以外の部分もある程度パターン化されるとライブラリとして切り出すことで実装がスケールしやすくなる。開発者の人数が増えたとしても、特定の領域に絞って開発ができるようになることで全体の開発速度を維持できる。

中規模

項目 取りうる分割方法
アプリケーション モノリス
ライブラリ メニーレポ

状況

とにかく実装をしていく時期。アプリケーションの実装も、ライブラリの実装も増えていく。実装が増えることにより、アプリケーションの CI 実行時間が増えたり、切り出されたライブラリが依存し合ったりし始め、混沌としていく予兆が見え始める。

中規模 〜 大規模

項目 取りうる分割方法
アプリケーション モノリス、メニーレポ
ライブラリ メニーレポ、モノレポ

状況

変更のコード量が全体のコード量が小さくなっている状態。この状態になるまでに適切に責務を分割していなければ、レビューコストが実装する時間よりも長くなることがある。アプリケーションのビルド方法も適切にチューニングしていかなければ CI の待ち時間が長くなる。モノリスのまま行くか、ライブラリとしていくつか切り出すか、アプリケーションのコードをメニーレポに分割するか、CI パフォーマンスを向上させるか戦略を考える必要がある。いくつかのライブラリは集約してモノレポに変更するとパフォーマンスが上がる可能性もでてくる。1 つの変更がリリースまでにどれくらい時間がかかるか計測し、戦略を立てる時期。

大規模

項目 取りうる分割方法
アプリケーション モノリス、メニーレポ、モノレポ
ライブラリ モノリス、メニーレポ、モノレポ

状況

アプリケーション、ライブラリの運用戦略はどのパターンも考えられる。大規模化する前にある程度手を打っていれば、それぞれのドメインレイヤーの汚染を防ぎ、レビューコストも低くなる。しかしながら徐々にビルドのパフォーマンスが落ちてくるため、定期的に開発体験のパフォーマンス測定をしつつ運用方法を改善していく必要がある。

プロジェクトの分割時に発生する課題

簡単に分割する、という戦略を考えますがそこに必ずメンテナンスコストが発生するため必ずしも良い選択ではない可能性があります。ここではどのような問題に直面するか洗い出しておきます。なお、モノリスに関しては分割していないのでここでは言及しません。

ビルド・テストツールのメンテナンス

単純に分割した数だけビルドとテストツールが増えます。それをメンテナンスする工数を割く必要が出てきます。技巧的な方法で構築すればするほどチーム内で知識の偏りが発生するため、難しいことを「やらない」と決めて運用する必要があります。担当の人が変わった場合にロストテクノロジーになるのを防ぐ必要があります。

CIの設計とメンテナンス

話はビルド・テストツールと同様ですが、モノレポにした場合、変更箇所だけビルドするといった技巧的な方法を実現するためにはCI上の設定をする必要があります。また、粒度が揃っていないようなモノレポの場合もCIの設定は粒度が揃っていない時と比較して難しくなります。

バージョン上げのコストが掛かる

Renovateがあるからよい、ではありません。バージョンを区切るということは取り込む側の追従コストを後回しにすることです。またバージョンを上げる人はライブラリのメンテナーではないことがあります。ライブラリの変更の学習コストも考慮に入れる必要があります。

リポジトリ間でバージョン更新順序が循環する

モノレポ内のパッケージ間の直接的な依存関係は疎ですが、更新する際の手順がうっかり循環してしまうような依存関係を作れてしまいます。以下のような依存関係だと、

[Repo 1] pkgA
[Repo 1] pkgB --> pkgC
[Repo 2] pkgC --> pkgA

pkgAのバージョン更新のためには、

  1. Repo 1でpkgAを更新してpublish
  2. Repo 2pkgCpkgAのバージョンを更新してpublish
  3. Repo 1pkgBのバージョンを更新してpublish

といった手順を踏む必要があります。Repo 1が2回でてきます。この場合、pkgAはRepo 1にもRepo 2にも属さない場所に設置するのが手っ取り早い解決方法です。ただし、更新の手数は変わりません。

※ 循環という表現は正しくない気がしているので、もう少し適切にな表現がよい

モノレポ利用時は分割の粒度についてよく考える必要がある

モノレポにしよう!といってもその旨味を最大限享受するためには分割の粒度を整える必要があります。ここでいう粒度とは主に「ビルド」「テスト」周りのことです。異なるビルド設定、異なるテストツールを利用しているようなモノレポでは異方性のあるパッケージの更新が開発効率に対するボトルネックとなる可能性があります。特にライブラリをモノレポで構築するのであれば分割の粒度について注意深くなる必要があります。

しかしながら、例外も無きにしもあらずでアプリケーションをモノレポにしようものなら異方性も飲み込む決断が必要です。なぜなら、アプリケーションレイヤーは業務ドメインが集中するため、ビルド時間よりも業務フローは分業体制によってモノレポを構築したほうが開発効率や責務分割が良い可能性があります。

たとえばモノレポ内でViewに関するドメインとその振る舞いを実装するドメインでパッケージを分割した場合、それぞれの責務で作業が集中できます。バージョンを区切ることによる追従の先延ばしよりも凝集した状態の作業のほうが影響範囲がわかりやすくなります。

ゆえに、アプリケーションレイヤーのモノレポに関してはよく考えて設計する必要があります。

プロジェクトの分割と運用を支えるツール

dependency-cruiser

ファイルの依存関係に対してテストするツールです。どの規模、どのアーキテクチャを選択してもかなり有用なツールであり、モノリスな状態の時から利用すると分割が圧倒的にしやすくなります。

yarnlock-dedupe-check

yarn.lockpackage-lock.json中に特定のライブラリのバージョンが複数混在していないかテストするツールです。node_modules は複数バージョンインストールすることを許容しますが、逆にそれが不具合を起こすようなケースに遭遇したため、それを未然に防ぐ目的で導入します。

(自分で作ったやつなので手前味噌ですが...)

lerna

モノレポに対する運用ツール。個人的には CHANGELOG や Semantic Version の機能を利用したいのでモノリスな時でも利用しています。

renovate

依存しているライブラリのバージョンを自動で上げるための BOT。更新があれば Pull Request を投げてくれます。モノレポやメニーレポの運用のときに活躍します。

@himenon/performance-report

ファイルサイズの変化、シェルの実行時間を記録するためのツール。GitHub のリポジトリをストレージとして利用して Pull Request などでファイルサイズの変化を観測できるようにしたもの。

(ほんっと手前味噌ですみません)

sort-dependency

node_modules の依存関係を可視化するためのツールです。特定のライブラリの更新を行うにはどのような順序で更新する必要があるかソートしてくれます。単純に package.json の dependencies をトポロジカルソートして、Graphviz で出力しているだけのもの。

(使ってるのも作ってるのも私だけなのですが...)

筆者の個人的な戦略

アプリケーション / ライブラリの構築

いろいろ考えた挙げ句、モノリスにしています。アプリケーション(またはライブラリ)自体が依存するライブラリのバージョン更新や、CI の運用コストを考えたときに圧倒的にモノリスのほうがパフォーマンス高いです。Renovate によるバージョン上げ運用のコストも正直めんどくさいので数を減らしたい理由もあります。

ただ、少しだけ特殊なことをしていて、内部のディレクトリ構造は責務単位で区切り、その単位でライブラリ利用者が利用できるように Proxy Directory という package.json のエイリアス機能を使ったアーキテクチャを気に入って採用しています。JavaScript ライブラリにおける Proxy Directory パターンとライブラリの参照整理という記事を書いてますので、こちらを参照してください。

また、今後 TypeScript がexportsフィールドの対応をしてくれると上記の様なテクニックは使わなくて済む予定です(TypeScript issue#33078)。

まとめ

実装のアーキテクチャについてはさまざまな記事で取り上げられることが多いですが、パッケージの管理をどうやるかという視点に対する考察記事は多くはありません。パッケージ管理というエコシステムは強力ですが便利ですが運用する時は色々と考えなければいけないことが出てきます。この運用を支えるための仕組みは意外と知らないことだらけで、NodeJSの参照解決のためのアルゴリズムやDual Packageのサポートなど知っておくと実は便利だった、といった機能が眠っている可能性があります。

しかしながら忘れ手はいけないのは、どの方法を取るにしても保守する人がいることです。そこのコストは決してゼロではなく、分割の単位が増えれば増えるほど保守に割く時間が増えていくことを忘れてはいけません。

分割戦略(しないも含めて)をうまく立てて、成長するプロジェクトをいかにして開発体験を維持し続けるか、本記事はそれを考える参考になれば幸いです。