PHP
monorepo
MonorepoBuilder

MonorepoBuilderでPHPのモノレポを作るチュートリアル

本稿はMonorepoBuilderを使った、PHPのモノレポの作り方を解説するチュートリアルです。


モノレポとは

モノレポ(monorepo)とは「一枚岩のリポジトリ(monolithic repository)」のことで、複数のパッケージをひとつのGitリポジトリで管理するGit戦略のこと。パッケージごとにGitリポジトリを作るメニーレポ(manyrepo)の戦略をとるのが普通だが、パッケージ数が増えてくると次のような問題が顕著になる:


  • パッケージごとに、自動テストやCIを設定いちいちしないとならない。

  • 統一したテストや静的解析がやりずらい。

  • 報告されたIssueやCIの結果は、あちこち見に行かないといけない。

  • パッケージを横断した変更がやりずらい。

これらの問題点は、モノレポを採用することで解決することができる。


モノレポ戦略をとった場合に起こる変化


  • 開発がシンプルになる


    • コミットはひとつのリポジトリにするだけでよくなる

    • テストやCIは統一的にできるようになる

    • マージリクエストやIssue報告はひとつのリポジトリを見ておけば良くなる

    • テスト、CI、リリース、アクセス権設定はひとつのリポジトリで完結するようになる



ちなみに、モノレポはPHPに限った戦略ではないが、PHP界隈ではSymfonyLaravelもこのモノレポ戦略を採用している。

モノレポについての詳細は以下の記事を参照。


MonorepoBuilderとは

モノレポはメニーレポにまさる利点があるが、PHPでは各パッケージのcomposer.jsonをマージして開発用のcomposer.jsonを作る必要が出てくる。また、リリースは結局別々のGitHubリポジトリにしなければPackagistで配布できないので、パッケージごとにタグを切ったりgit pushしなければならない。

これらを手作業で行うのは手間だが、それを代わりにやってくれるのがSymplify/MonorepoBuilderである。


MonorepoBuilderでモノレポするおおまかな流れ


  1. モノレポを作る (モノレポにMonorepoBuilderをインストールしておく)

  2. パッケージをモノレポに入れる

  3. モノレポのcomposer.jsonを自動生成する (各パッケージのcomposer.jsonが解析されマージされる)

  4. モノレポ上で開発する

  5. パッケージを一括リリースする (コマンド一発でGitHubにリリースを作れる)


MonorepoBuilderの構成

MonorepoBuilderを使った場合、モノレポの構成は次のようになる:

.

├── .git ... モノレポのGit (例: https://github.com/monorepo-example/monorepo)
├── .gitignore
├── monorepo-builder.yml ... 各パッケージのcomposer.jsonを、モノレポのcomposer.jsonにマージする設定ファイル
├── composer.json ... 上の設定ファイルから自動生成される
├── packages ... パッケージ置き場
│   ├── package1 ... リリース先はhttps://github.com/monorepo-example/package1
│   │   ├── composer.json
│   │   └── src
│   ├── package2 ... リリース先はhttps://github.com/monorepo-example/package2
│   │   ├── composer.json
│   │   └── src
│   └── package3 ... リリース先はhttps://github.com/monorepo-example/package3
│   ├── composer.json
│   └── src
└── vendor ... 各パッケージが依存するライブラリをここに集まる


今回作成するモノレポのデモ

今回作成するモノレポのデモはGitHubで公開している: https://github.com/monorepo-example


モノレポを作る

モノレポはすべてのパッケージをひとつのGitリポジトリで管理する。なので、まずはモノレポをひとつ作ることから始める。

Gitでモノレポを作る:

mkdir monorepo

cd monorepo
git init
git commit --allow-empty --message='モノレポを初期化した'

Githubにもモノレポのリモートリポジトリを作っておく:

hub create monorepo-example/monorepo # hubコマンドがない場合は、GitHubの画面から作る

git push -u origin master

モノレポにcomposer.jsonを作る:

composer init --no-interaction --name='monorepo-example/monorepo'

MonorepoBuilderをインストールする:

composer require symplify/monorepo-builder --dev

すると、composer.jsonの中身は次のようになっているはずだ:


composer.json

{

"name": "monorepo-example/monorepo",
"require": {},
"require-dev": {
"symplify/monorepo-builder": "^5.0"
}
}

MonorepoBuilderがインストールされた状態でcomposer.jsonをGitにチェックインする:

{ echo /vendor; echo /composer.lock } > .gitignore

git add .gitignore composer.json
git commit --message="MonorepoBuilderをインストールした"

下記の内容でモノレポ設定ファイルmonorepo-builder.ymlを作る。なお、このとき上でインストールしたsymplify/monorepo-builderのバージョンを合わせておく。

parameters:

# 各パッケージのcomposer.jsonから、モノレポのcomposer.jsonにマージするものを指定する。
merge_sections:
- require
- require-dev
- autoload
- autoload-dev

# 各パッケージが配置されるディレクトリを指定する。
package_directories:
- packages

# 各パッケージのcomposer.jsonから、モノレポのcomposer.jsonにマージするときに、モノレポの
# composer.jsonに追記するものを設定する。
data_to_append:
require-dev:
symplify/monorepo-builder: ^4.7 # モノレポcomposer.jsonを再生成すると消えてしまうため必要

monorepo-builder.ymlをGitにチェックインする:

git add monorepo-builder.yml

git commit --message='モノレポの設定ファイルYAMLを追加した'

以上でモノレポの基本的な設定は完了だ。


パッケージをモノレポに入れる


packagesディレクトリにパッケージのディレクトリを作る

モノレポで管理するパッケージを作る。今回は3つパッケージを作ってみよう。

mkdir -p packages/{package1,package2,package3}


そのパッケージにcomposer.jsonを作る

それぞれにcomposer.jsonを作る:

composer init --name='monorepo-example/package1' -d='packages/package1' --no-interaction

composer init --name='monorepo-example/package2' -d='packages/package2' --no-interaction
composer init --name='monorepo-example/package3' -d='packages/package3' --no-interaction

各パッケージにcomposer.jsonが作られているはずだ:

$ tree packages

packages
├── package1
│   └── composer.json
├── package2
│   └── composer.json
└── package3
└── composer.json

3 directories, 3 files

その中身:

$ tail -n+1 packages/*/composer.json

==> packages/package1/composer.json <==
{
"name": "monorepo-example/package1",
"require": {}
}

==> packages/package2/composer.json <==
{
"name": "monorepo-example/package2",
"require": {}
}

==> packages/package3/composer.json <==
{
"name": "monorepo-example/package3",
"require": {}
}

作成したcomposer.jsonをGitにチェックインする:

git add packages

git commit --message='各パッケージにcomposer.jsonを作成した'

各パッケージにそれぞれ依存するパッケージを追加する。例として、次のパッケージを追加してみる:


  • すべてのパッケージ


    • phpunit/phpunitに依存する



  • 各パッケージ別々に依存するパッケージを追加する


    • package1

    • symfony/finderに依存する

    • package2

    • guzzlehttp/guzzleに依存する

    • package3

    • monolog/monologに依存する




すべてのパッケージにphpunit/phpunitを追加する

composer require --dev phpunit/phpunit -d packages/package1

composer require --dev phpunit/phpunit -d packages/package2
composer require --dev phpunit/phpunit -d packages/package3

composer.jsonphpunit/phpunitが追加されているはずだ:

$ tail -n+1 packages/*/composer.json

==> packages/package1/composer.json <==
{
"name": "monorepo-example/package1",
"require": {},
"require-dev": {
"phpunit/phpunit": "^7.3"
}
}

==> packages/package2/composer.json <==
{
"name": "monorepo-example/package2",
"require": {},
"require-dev": {
"phpunit/phpunit": "^7.3"
}
}

==> packages/package3/composer.json <==
{
"name": "monorepo-example/package3",
"require": {},
"require-dev": {
"phpunit/phpunit": "^7.3"
}
}

Gitにチェックインする:

git add packages/*/composer.json

git commit --message='各パッケージにPHPUnitを追加した'


各パッケージ別々に依存するパッケージを追加する

composer require symfony/finder    -d packages/package1

composer require guzzlehttp/guzzle -d packages/package2
composer require monolog/monolog -d packages/package3

composer.jsonrequireにそれぞれ別のパッケージが追加されているはずだ:

$ tail -n+1 packages/*/composer.json

==> packages/package1/composer.json <==
{
"name": "monorepo-example/package1",
"require": {
"symfony/finder": "^4.1"
},
"require-dev": {
"phpunit/phpunit": "^7.3"
}
}

==> packages/package2/composer.json <==
{
"name": "monorepo-example/package2",
"require": {
"guzzlehttp/guzzle": "^6.3"
},
"require-dev": {
"phpunit/phpunit": "^7.3"
}
}

==> packages/package3/composer.json <==
{
"name": "monorepo-example/package3",
"require": {
"monolog/monolog": "^1.23"
},
"require-dev": {
"phpunit/phpunit": "^7.3"
}
}

ここまで行うと各パッケージにvendorcomposer.lockが作られるが、モノレポではバージョン管理しないので.gitignoreに追加しておく:

echo '/packages/*/vendor' >> .gitignore

echo '/packages/*/composer.lock' >> .gitignore

Gitにチェックインする:

git add .gitignore packages/*/composer.json

git commit --message='各々のパッケージが依存するパッケージを追加した'

最後に、vendorなどは不要なので削除しておく:

git clean -xfd packages


各パッケージのautoloadを設定する

各パッケージのcomposer.jsonを変更して、autoload.psr-4の設定を追加する。ここではディレクトリと名前空間の対応は次のようにする:


  • packages/package1/src → MonorepoExample\Package1

  • packages/package2/src → Suin\Package2

  • packages/package3/src → Suin\Package3

cat packages/package1/composer.json | jq --indent 4 '.autoload."psr-4"."MonorepoExample\\Package1\\" =  "src"' | tee packages/package1/composer.json

cat packages/package2/composer.json | jq --indent 4 '.autoload."psr-4"."MonorepoExample\\Package2\\" = "src"' | tee packages/package2/composer.json
cat packages/package3/composer.json | jq --indent 4 '.autoload."psr-4"."MonorepoExample\\Package3\\" = "src"' | tee packages/package3/composer.json

composer.jsonには、次のようautoloadを追加しておく:

$ tail -n+1 packages/*/composer.json

==> packages/package1/composer.json <==
{
"name": "monorepo-example/package1",
"require": {
"symfony/finder": "^4.1"
},
"require-dev": {
"phpunit/phpunit": "^7.3"
},
"autoload": {
"psr-4": {
"MonorepoExample\\Package1\\": "src"
}
}
}

==> packages/package2/composer.json <==
{
"name": "monorepo-example/package2",
"require": {
"guzzlehttp/guzzle": "^6.3"
},
"require-dev": {
"phpunit/phpunit": "^7.3"
},
"autoload": {
"psr-4": {
"MonorepoExample\\Package2\\": "src"
}
}
}

==> packages/package3/composer.json <==
{
"name": "monorepo-example/package3",
"require": {
"monolog/monolog": "^1.23"
},
"require-dev": {
"phpunit/phpunit": "^7.3"
},
"autoload": {
"psr-4": {
"MonorepoExample\\Package3\\": "src"
}
}
}

srcディレクトリも作っておく:

mkdir -p packages/{package1,package2,package3}/src

また、適当に各パッケージにクラスを作っておく:

tee packages/package1/src/Package1Class.php <<< "<?php

namespace MonorepoExample
\Package1;
class Package1Class {}"

tee packages/package2/src/Package2Class.php <<< "<?php
namespace MonorepoExample
\Package2;
class Package2Class {}"

tee packages/package3/src/Package3Class.php <<< "<?php
namespace MonorepoExample
\Package3;
class Package3Class {}"

ディレクトリ構成は次のようになっているはずだ:

$ tree packages

packages
├── package1
│   ├── composer.json
│   └── src
│   └── Package1Class.php
├── package2
│   ├── composer.json
│   └── src
│   └── Package2Class.php
└── package3
├── composer.json
└── src
└── Package3Class.php

変更したcomposer.jsonと作ったクラスをGitにチェックインしておく:

git add packages

git commit --message='各パッケージにPSR4オートロードを設定した'


モノレポのcomposer.jsonを自動生成する

各パッケージを作り終わったら、モノレポのcomopser.jsonに各パッケージのcomposer.jsonの内容をマージする。これにより、開発時はモノレポのcomposer.jsonだけを扱えば良くなる。モノレポのcomposer.jsonは常に自動生成されるので直接編集してはならない。


各パッケージのvendorを削除する

各パッケージにvendorが残っているとうまくマージできないため、各パッケージのvendorディレクトリを削除しておく必要がある。(先程の手順でvendorを消しているので、今回は次のような出力は出ないが、モノレポのcomposer.jsonを自動生成するときは必ず消しておく必要がある。)

$ git clean -xfd packages

Removing packages/package1/composer.lock
Removing packages/package1/vendor/
Removing packages/package2/composer.lock
Removing packages/package2/vendor/
Removing packages/package3/composer.lock
Removing packages/package3/vendor/


各パッケージのcomposer.jsonをモノレポのcomposer.jsonにマージする

次のコマンドを実行して、各パッケージのcomposer.jsonrequireなどをマージしたモノレポのcomposer.jsonを生成する:

./vendor/bin/monorepo-builder merge

するとcomposer.jsonの中身は次のように、各パッケージのrequirerequire-dev、そしてautoloadがマージされているはずだ:

$ cat composer.json

{
"name": "monorepo-example/monorepo",
"require": {
"symfony/finder": "^4.1",
"monolog/monolog": "^1.23",
"guzzlehttp/guzzle": "^6.3"
},
"require-dev": {
"phpunit/phpunit": "^7.3",
"symplify/monorepo-builder": "^4.7"
},
"autoload": {
"psr-4": {
"MonorepoExample\\Package1\\": "packages/package1/src",
"MonorepoExample\\Package2\\": "packages/package2/src",
"MonorepoExample\\Package3\\": "packages/package3/src"
}
}
}

Gitにチェックインしておく:

git add composer.json

git commit --message='各パッケージのcomposer.jsonをモノレポのcomposer.jsonにマージした'


モノレポ上で開発する

各パッケージが依存するライブラリはモノレポのcomposer.jsonに集まっているので、モノレポ上ですべてのパッケージを開発することができる。


composer updateで各パッケージが依存するライブラリを追加する

各パッケージが依存するライブラリを追加するには、モノレポのcomposer.jsonに対してcomposer updateする:

composer update

ちなみに、composer show -itすると、モノレポに各パッケージが依存するライブラリがインストールされたことが確認できる。composer show --selfでオートロードの設定も確認できる。


各パッケージを開発する

いつものようにコードを書いて、テストして、コミットする。


パッケージを一括リリースする

モノレポなのですべてのパッケージを一括してリリースできる。ここではリリースの手順を説明する。


パッケージのGitHubリポジトリを作る

パッケージをPackagistで配布するには、各パッケージごとにGitHubリポジトリが必要になるため、おのおののGitHubリポジトリを作ることになる。今回は次の3つのリポジトリをGitHubに作る:


  • package1: git@github.com:monorepo-example/package1.git

  • package2: git@github.com:monorepo-example/package2.git

  • package3: git@github.com:monorepo-example/package3.git

GitHubでいつものように空っぽのリポジトリを作成する。リポジトリにコミットを作っておく必要はない。

hub create -c -d '[READ-ONLY] Package1' monorepo-example/package1

hub create -c -d '[READ-ONLY] Package2' monorepo-example/package2
hub create -c -d '[READ-ONLY] Package3' monorepo-example/package3

モノレポ運用では、バグ報告やPull Requestはモノレポに対して行うため、GitHubのSettings→Options→Features→Issuesからチェックを外し、Issueを登録できないようにしておく。また、あとでリポジトリの説明の頭に[READ-ONLY]と書いておく。


GitHubリポジトリのURLをmonorepo-builder.ymlに設定する

GitHubのリポジトリのURLとパッケージを対応付ける設定をmonorepo-builder.ymlに追加する:


yamlmonorepo-builder.yml

parameters:

...
# 各パッケージのリリース先リポジトリの設定
directories_to_repositories:
packages/package1: git@github.com:monorepo-example/package1.git
packages/package2: git@github.com:monorepo-example/package2.git
packages/package3: git@github.com:monorepo-example/package3.git

この変更をGitにチェックインする:

git add monorepo-builder.yml

git commit --message='GitHubリポジトリのURLをmonorepo-builder.ymlに設定した'


リリースバージョンのGitタグを付ける

リリースのためにリリースバージョンをGitタグでつける。このときvX.Y.Zのようにvはじまりのセマンティックバージョンにする。

git tag v1.0.0

git push && git push --tags # リモートのモノレポに変更を反映しておく(必須ではないが)


各GitHubリポジトリにgit pushする

次のコマンドを実行することで、パッケージごとにコミットログが分解されて、各GitHubリポジトリにgit pushされる。また、リモートブランチには上でつけたリリースバージョンのタグもpushされる。

vendor/bin/monorepo-builder split