LINE株式会社は、2023年10月1日にLINEヤフー株式会社になりました。LINEヤフー株式会社の新しいブログはこちらです。 LINEヤフー Tech Blog

Blog


Git submoduleを使ってマルチリポジトリなMonorepoを管理する

こんにちは、LINEフロントエンド開発センターの玉田です。新春を名乗るにはすこし遅いですが、昨年開催した「UIT新春Tech blog」を今年も開催します! 本日から5日間4日間、フロントエンド開発に携わるUITのエンジニアが毎日持ち回りで記事を公開します。ぜひ最後まで見に来てください。

Monorepo

フロントエンドにおけるMonorepo toolは、大規模なフロントエンド開発を効率よく管理する手段として、ここ数年で普及が進みました。LernaやNx、npmやyarnのWorkspace機能など、すでに皆さんも使っている例があるかもしれません。

Monorepoを実現するツールはいくつかありますが、どのツールも共通して以下のような特徴を持っています。

  • package.jsonを持つnpm packageを一単位として、複数のパッケージが一つのGitリポジトリに存在する[^1]
  • 各パッケージの依存関係を記述することで、Monorepo toolが内部で依存グラフを作成して効率よくビルドを実行する
  • 一部のMonorepo toolは、パッケージのバージョニングなどリリースのためのツールも用意している

[^1]: この記事ではフロントエンド開発の文脈におけるMonorepoを紹介していますが、BazelやNxのIntegrated reposなど、package.jsonの存在にもとづかないmonorepo toolも存在します。

とはいえ、新規開発のプロジェクトならともかく、すぐに既存のプロジェクトにMonorepoを導入できるとは限りません。今回はその中の一つの例として、私たちが開発するLINE公式アカウントの管理画面(LINE Official Account Manager)にMonorepo toolを導入する上でのハードルや、今取り組んでいることについて紹介します。

マルチリポジトリなMonorepoとは?

まずは、現在のプロジェクトの構成について紹介します。

このプロジェクトの特徴として、複数のGitリポジトリ(マルチリポジトリ)で構成している点があります。baseと呼ばれるGitリポジトリを基点として、「一斉メッセージを配信する機能」「クーポンを作成する機能」「統計を見る機能」など、機能・ページ単位で分割されたおよそ30個のGitリポジトリを持つ構成になっています。そして、各Gitリポジトリのルートディレクトリにpackage.jsonが存在しています。Monorepo的な観点で見ると、各パッケージがGitリポジトリとして独立したmonorepoと見なすことができます。

それぞれのパッケージを連携させる方法は、基本的にはnpm installの依存解決に基づいています。あるパッケージで新しい機能がリリースされると、CI上で自動的にpackage.jsonのversionがインクリメントされ、さらにCIがbaseパッケージのpackage.jsonを書き換えて新しいパッケージをインストールする、という流れでリリースされています。

各機能がGitリポジトリごとに分割されていることは、一見それぞれの機能が分離されて疎結合になっているように見えますが、実際にはそうではありませんでした。コードを共有するための依存関係により、複数のGitリポジトリでリリース作業が発生したり、手動での依存関係の解決などの手間が発生しており、開発効率が悪化していたことは大きな問題でした。

この構成が、今回紹介する以下のような構成に変わります。

まず、新たにMonorepoとして管理するためのGitリポジトリを(以下root repo)作成します。このリポジトリがbaseも含めた各Gitリポジトリ(以下feature repo)をGit submoduleとしてリンクさせることで、これまでnpmの依存グラフでのみ紐付いていた複数のGitリポジトリを管理しています。そして、Monorepo toolの一種であるTurborepoを導入して、各Git submoduleを一つのパッケージと見なすことで、形式上は一つのMonorepoプロジェクトとして扱っています。マルチリポジトリ(Polyrepo)なMonorepoという、言葉で表すと矛盾した変な仕組みです。

Polyrepoを維持する理由

Monorepoの目指す理念の根底は、分散したGitリポジトリを一つにまとめること[^2]なので、その意味では今回提案する構成は意味のないものと言えるかもしれません。その上で、最終的にリポジトリを集約する決定をしなかった理由は以下のようなものです。

  • 既存ドキュメントの維持: それぞれのGitリポジトリに対する(GitHubの)URLは、すでにコード上のコメントや社内Wikiなどの形で多数残されています。リポジトリを集約する上で、それらのドキュメントを維持するためにすべてのURLを書き換えて回ることは現実的ではありませんでした。
  • セキュリティ上の観点: Gitリポジトリの分割単位はコードの分割以外にも、コードに対するアクセス制御も理由の一つとなっています。各国、各プロジェクトの開発チームが適切な範囲の権限を持つように制御できることも、セキュリティ上重要な要件の一つです。
  • 機能単位でのリリース・ロールバックの容易さ: 他にPolyrepo方式のポジティブな面は、各コンポーネントの変更をcommit hashで抽象化できる点です。複数のチームが並行して開発するケースでは、ある機能を先行してリリースしたり、逆にロールバックしたりするケースが頻繁に起きます。機能の変更がコンポーネント内で完結している場合、コンポーネントのcommit hashを差し替えるだけで対応でき、mergeやrevertのリスクが低減できます。

[^2]: https://monorepo.tools/

このやり方は、おそらくMonorepoが本来目指す理想的な開発とは異なったものです。私たちは複数の開発拠点のチームが一つの大規模アプリケーションを開発しているため、Monorepoの完全な導入というドラスティックな移行は当初は困難と考えていました。それでも、Git submoudleで代用したプロジェクト全体の一括管理、依存グラフにもとづくキャッシュなどMonorepoのコンセプトの一部は実現できていると思っています。

PolyrepoをよりMonorepoらしく

Git submoduleを使うことでひとまずMonorepo化を達成できましたが、これだけではMonorepoの利点を十分に享受できていません。よりMonorepoらしい開発体験のために、他にもいくつかの取り組みがあります。

設定の共通化

Monorepoの利点の一つは、プロジェクト全体でモジュールを共通化し、Monorepo内で新しいパッケージを気軽に作成できる点です。Lint tool、CIの設定、webpack(これも今後別のビルドツールに変わるかもしれません)のconfig fileなどは一箇所に集約する必要があります。Monorepoの設計パターンの一つとして、こういった設定に関する機能だけを提供するパッケージを作成して各パッケージで共有する例があります。本プロジェクトでは、新たにcommon-libという名前のパッケージをroot repoの中に作成して、config関連のファイルを集約しています。

// tsconfig.jsonの参照例
{
  "extends": "@org/common-lib/tsconfig"
}
// eslintrc.jsの参照例
module.exports = require('@org/common-lib/.eslintrc');

tsconfig.jsonなど、他のnpm packageを参照できる設定はこの対応で問題ありません。しかし、CIに関する設定(本プロジェクトではCircleCI) は簡単には他のリポジトリの設定内容を参照できません。この問題に対しては、完全な解決策とは言えないものの、CI上で実行するステップをroot repoのスクリプトに集約することで対応しています。たとえば、以下のようなCIの設定をfeature repoに用意します。

  some-task:
    steps:
      - checkout
      - run: npx zx@7.1.1 https://my-git-object-hosts.com/org/root/some/script.md

そして、root repoのsome/script.mdにzxで記述したスクリプトを配置します(zxはこういった簡単なスクリプトを書く用途にとても向きます)。これにより、すべてのfeature repoに同じようなCIの処理を記述しなくても良くなりますし、CIに依存した処理を減らすことで他のCIツール(たとえばGitHub Actionなど)への将来的な移行も容易になります。

コミット履歴の集約

もう一つのMonorepoの利点は、複数のパッケージのコミットを一つのGitリポジトリで管理できる点です。ただ、今回の例ではリポジトリが複数に分散しているため、別の方法で複数のパッケージのコミットを集約することを考えます。

各パッケージはgit submoduleで管理されているため、それぞれのcommit hashを同期させることで常に最新の状態を保つことができるようになります。そこで、以下の2つのタスクをCIに設定することで実現しました。

  • feature repoからroot repoへの反映(図上の点線の矢印): feature repoのmain、develop、root/* branchにプッシュされたら、root repoに同名のbranchを作成して、対象のgit submoduleをその時点でのcommit hashに更新します。この例では、一つのfeature repoの更新だけで完結する機能(fix/A)は直接develop branchにマージしており、この結果root repoのdevelop branch上でマージ後のcommit hashに更新されています。また、複数のfeature repoにまたがる機能(root/feat-A)は直接develop branchにマージしておらず、root/feat-Aというbranchにマージすることでroot repoに同名のbranchを作成しています。これにより、root/feat-Aの機能が完成した後にroot repo上でマージできるようになり、複数の開発が並行するケースに対応します。

  • root repoからfeature repoへの反映(図上の黄色の矢印): root repoのmain、develop branchにプッシュされたら、各feature repoの同名のbranchをチェックアウトし、commit hashが最新のものと一致するかを確認します。一致しない場合、そのcommit hashの内容をmain、develop branchにマージします。root repo側でマージをしてしまうと、feature repoの元のcommit hashが上書きされてしまうため、このタスクによって改めてmain、developの内容を適用しています。

上記のCIのタスクは一見うまくいきそうなものの、CI上の複数タスクのリードタイム(あるfeature repoがマージされてCIが走っている間に他のfeature repoがマージされたらどうするか?)の問題や、root repoに集約されているpackage-lock.jsonの管理の問題などが残っています。このあたりは、実際にこのワークフローを運用しながら解決する予定です。

今後について

以上が、PolyrepoとMonorepoのハイブリッド(?)開発手法の詳細です。本来であれば、今回紹介した手法で上手く運用できています! というところまで言いたかったのですが、残念ながらこの記事を公開する時点ではまだプロダクションリリースまでは達していません。実際に運用していく上で、まだ想定していない課題が見つかることも考えられます。上手く妥協点を見つけつつ、Monorepoの利点を最大限取り入れられるような構成を模索していきたいです。

UIT 新春 Tech blog 2023記事一覧

1. Git submoduleを使ってマルチリポジトリなMonorepoを管理する
2. LINEドクターフロントエンド開発の流れ
3. 静的リソースをCDN配信する社内サービス『Abyss』のフロントエンドを作り直した話
4. LINE NEWS フロントエンドの自動テストの改善