WordPress 5 から導入されたブロックエディタの Gutenberg は JavaScript で記述されたフロントエンドのプロジェクトで、 Node.js のパッケージマネージャーの npm で管理されています。Gutenberg は単一のパッケージではなく、多数のパッケージから構成されている巨大プロジェクトです。GitHub のリポジトリを見てみると、./packages
というフォルダの中に大量のパッケージが同梱されているのが分かります。
このように 1 つのリポジトリの中に複数のパッケージを同梱するリポジトリの運用方法は monorepo (モノレポ・モノリポ)と呼ばれています。この記事では、モノレポのメリットやモノレポを実現するためのツールである Lerna を紹介します。
モノレポのメリット・デメリット
Babel のドキュメントに記載されている、モノレポのメリット・デメリットを引用します。
Why is Babel a monorepo? | なぜ Babel はモノレポなのか?
メリット
- (各パッケージに対して)単一のビルド/テスト/リリースのプロセスを適用できる
- パッケージをまたいだ変更を連動させることが簡単
- Issue の受付場所を 1 つに絞ることができる
- 開発環境のセットアップ簡単になる
- 全てのパッケージを同時にテストできるため、パッケージをまたいで発生するバグの発見が容易になる
デメリット
- コードベースが威圧的になる
- リポジトリのサイズが肥大化する
その他にも、「GitHub のスターが集中しやすくなる」「Issue が集中するのでかえって管理の手間が発生する」などのメリット・デメリットがあると思われます。
Gutenberg プロジェクトでは、ユニットテストや e2e テストなどからなる膨大な量の自動テストが実施されています。Travis CI のログを見ると、最近だと 19 分以上かかっています。モノレポに含まれているパッケージも個々のテストケースを持っていて、また多くのパッケージは互いに参照し合っています。
多くの OSS 開発者と膨大な数のユーザーを世界中に抱えながら、著しいスピードで開発が進められている Gutenberg のようなプロジェクトでは、品質をコントロールするための重厚なテストとともに、素早いフィードバックを実現するための洗練されたワークフローが必要です。
Lint やテストなどのタスクを統一されたワークフローで適用できるモノレポが Gutenberg プロジェクトに採用採用されていることは理に適っていると思われます。
Scoped Packages
モノレポと関連が深いものとして、 npm の Scoped Packages と呼ばれる名前空間付きのパッケージ作成機能があります。Gutenberg プロジェクトの中にも React 関連のユーティリティの @wordpress/compose
、 国際化を実現するためのライブラリ @wordpress/i18n
のような @wordpress
の名前空間付きのライブラリが含まれています(そして、これらはモノレポとして運用されています!)。
名前空間には、自分の npm ユーザー名や Organization 名を使うことができます。Scoped Packages として公開されたパッケージは、例えば以下のようにインストールして使用することができます。
$ npm install @<ユーザー名>/<パッケージ名> --save
// Scoped Package を require する const foo = require('@<ユーザー名>/<パッケージ名>')
npm レジストリのパッケージ名は、現在のところ先に取ったもの勝ちです。Scoped Packages を使うことで、パッケージ作者はパッケージの名称を整理することができます、また限られた名前空間を自ら消費せずに済みます。 Docker イメージを配布するの公式レジストリの Docker Hub や PHP のライブラリ管理ツール Composer ではデフォルトで導入されている機能ですね。
monorepo として管理されているプロジェクトは Scoped Packages を採用していることがあります。例えば Gutenberg 以外では以下のパッケージなどです。
モノレポ管理ツール Lerna
npm モノレポを管理するツールとしてよく使われているものが Lerna です。Lerna はモノレポの作成、ブートストラップ、各種のタスクランニング、そしてパッケージの公開を実行するためのツールです。ここでは、Lerna を使ってシンプルなモノレポを作成し、npm レジストリに複数のパッケージを公開するまでの手順を紹介します。
ちなみに Lerna という名前は、ギリシア神話に登場する水蛇のヒュドラーから来ているようです。ヒュドラーはたくさんの首を持つ怪物で、その様を子パッケージに例えているのでしょう。ヒュドラーが住んでいたとされる場所がギリシャのレルナ(Lerna)という地名だそうです。
前提条件
以降の解説は npm init
や npm install
の実行といった、基本的な npm の操作が経験済みであることを前提にしています。Node.js は LTS 版を用意してください。
パッケージの npm レジストリへの公開を行うために、npmjs.com にサインアップしてアカウントを作成してください。アカウントを作成した後は、ターミナルから npm login
コマンドを実行してログインしておきます。このコマンドを実行することで、ローカルマシンに npm レジストリに対するアクセストークンが保存され、作成したパッケージが公開可能な状態になります。
今回は、 @<ユーザー名>
というスコープを用いてパッケージを公開する体でソースなどを記述していますが、これは皆さんの npmjs.com のユーザー名や、作成した Organization 名と置き換えてください。
$ npm login Username: <ユーザー名> Password: Email: (this IS public) username@example.com Logged in as <ユーザー名> on https://registry.npmjs.org/.
これ以降はモノレポとして複数のパッケージを公開する手順を紹介しますが、その前に npm publish
コマンドを使った通常のパッケージ公開の手順を試しておくと比較しやすいかもしれません。
npm の公開には、GitHub などの Git のホスティングサービスが必要になりますので、これらについても開発環境やアカウントを準備してください。
Lerna のプロジェクトを作成する
早速 Lerna でモノレポを作成してみましょう。
$ mkdir your-project $ cd your-project $ npx lerna init npx: installed 637 in 61.584s lerna notice cli v3.13.0 lerna info Initializing Git repository lerna info Creating package.json lerna info Creating lerna.json lerna info Creating packages directory lerna success Initialized Lerna files
npx
は、npm として公開されている CLI ツールのコマンドを即時インストールして実行するコマンドです。ローカルにインストールされている Lerna がある場合はインストールを実行せずにそちらを使います。 Node.js にはデフォルトでバンドルされています。 npx
コマンドを使うことで、 Lerna をグローバルのコマンドとしてインストールすることなく使用することができます。
これ以降のコマンドは、全てプロジェクトフォルダの your-project
ルートで実行します。
lerna init
の実行で、Git の管理が開始され、以下の 3 つのファイルまたはフォルダが作成されます。
$ tree . . ├── lerna.json ├── package.json └── packages 1 directory, 2 files
erna をローカルにもインストールしておきましょう。 lerna init
の実行により、 Lerna 本体への依存関係が package.json の devDependencies
として記載されています。インストールコマンドで、そのまま Lerna をインストールすることができます。
$ npm install
.gitignore を追加して Git のリポジトリのセットアップを行っておきます。
$ echo 'node_modules' > .gitignore
現時点でのリポジトリ構成は以下の様にになっているはずです。 node_modules
と .git
フォルダは除外しています。
$ tree . -a -I 'node_modules|.git' . ├── .gitignore ├── lerna.json ├── package-lock.json ├── package.json └── packages 1 directory, 4 files
子パッケージを作成する
以降は、モノレポとして管理される複数のパッケージを子パッケージ、Lerna プロジェクトのことを親パッケージと呼ぶことにします。Lerna では、./packages
フォルダの中に子パッケージを格納するようになっています。今回は、hello
というパッケージと、goodbye
というパッケージを追加してみます。パッケージを追加するには、 lerna create
コマンドを実行します。
# @<ユーザー名>/hello というパッケージを、 ./packages/hello のフォルダに作成する $ npx lerna create @<ユーザー名>/hello ./packages/hello # @<ユーザー名>/goodbye というパッケージを、 ./packages/goodbye のフォルダに作成する $ npx lerna create @<ユーザー名>/goodbye ./packages/goodbye
これで名前空間付きのパッケージ名を持つ 2 つの子パッケージが作成されました。この時点でのフォルダ構成は以下の様になります。
$ tree . -a -I 'node_modules|.git' . ├── .gitignore ├── lerna.json ├── package-lock.json ├── package.json └── packages ├── goodbye │ ├── README.md │ ├── __tests__ │ │ └── goodbye.test.js │ ├── lib │ │ └── goodbye.js │ └── package.json └── hello ├── README.md ├── __tests__ │ └── hello.test.js ├── lib │ └── hello.js └── package.json
これらに依存パッケージを追加してみます。例としてコンソール出力に色を付ける chalk というパッケージを追加してみます。lerna add
コマンドは配下の子パッケージ全てに依存関係を追加します。これは、npm install
コマンドに相当します。
$ npx lerna add chalk
lerna add
コマンドは配下の子パッケージ全てに依存関係を追加します。もしも特定の子パッケージだけに依存パッケージを追加したい場合は、 scope
オプションを使います。
# @<ユーザー名>/hello パッケージだけに chalk を追加する $ npx lerna add chalk --scope="@<ユーザー名>/hello"
lerna add
コマンドを実行することで、子パッケージから追加した依存パッケージを参照することができる様になりました。例えば、以下のようなプログラムを作成するとパッケージとして実行することができます。
// packages/hello/lib/hello.js // シアン色の文字で `Hello, <name>!` と出力する。 const chalk = require('chalk') module.exports = name => { console.log('Hello, ' + chalk.cyan(name) + '!') }
// packages/goodbye/lib/goodbye.js // 黄色の文字で `Goodbye, <name>!` と出力する。 const chalk = require('chalk') module.exports = name => { console.log('Goodbye, ' + chalk.yellow(name) + '!') }
これでパッケージが完成しました。パッケージを公開するために、コミットを作成し、リモートリポジトリにプッシュしておきます。
$ git remote add origin <リモートリポジトリのURL> $ git add . $ git commit -m "Initialize monorepo" $ git push origin master
以上で、パッケージ公開の準備が整いました。パッケージを公開する lerna publish
コマンドを実行すると、semver に従って major
.minor
.minor
のどのバージョン番号をインクリメントするのかを問われます。ここでは patch
を選択してみます。
$ npx lerna publish lerna notice cli v3.13.0 lerna info current version 0.0.0 lerna info Assuming all packages changed ? Select a new version (currently 0.0.0) Patch (0.0.1) Changes: - @<ユーザー名>/goodbye: 1.0.0 => 0.0.1 - @<ユーザー名>/hello: 1.0.0 => 0.0.1 (略) lerna success published @<ユーザー名>/hello 0.0.1 (略) lerna success published @<ユーザー名>/goodbye 0.0.1 (略) lerna success published 2 packages
全ての子パッケージのバージョンが更新され、一括して npm レジストリにパッケージが公開されました。公開したパッケージは、npm install @<ユーザー名>/hello
などのコマンドでインストールできるようになっています。
その他の Lerna のワークフロー
lerna init
、 lerna create
、 lerna add
、 lerna publish
の 4 つのサブコマンドを紹介しました。Lerna にはこれ以外にもモノレポの運用に役立つコマンドが存在します。その一部を紹介します。
lerna clean
全ての子パッケージから node_modules フォルダを削除します。
lerna bootstrap
全ての子パッケージで npm install
を実行します。例えばクローンした Git リポジトリをブートストラップするときや、lerna clean
と合わせて依存パッケージの再インストール行うために使われます。
lerna exec
全ての子パッケージで指定したコマンドを実行します。例えば全ての子パッケージのテストを実行したいときは次のコマンドを実行します。
$ npx lerna exec -- npm test
npm test
のような共通のインターフェースを定義しておくことで、親パッケージから単一のコマンドを実行することにより全ての子パッケージで共通の処理を実行することができます。
まとめ
モノレポと呼ばれるリポジトリの運用方法の概要や、それを実現するためのツールである Lerna について紹介しました。Lerna を使うと、複数のパッケージを含むリポジトリの運用を簡単に行うことができるようになります。
プロジェクトが巨大であるほど、モノレポを導入するメリットは大きいものと思われますが、一方でビルド/テスト/リリースなどのワークフローが変化するため、これが無視できないコストとなってしまうこともあり得ます。プロジェクト規模や性質、メンバーの数やスキルなどを考慮した上でモノレポの導入を考えるのが良いかもしれません。