サイトアイコン Capital P

モノレポ入門 – Gutenberg が採用するリポジトリ戦略

WordPress 5 から導入されたブロックエディタの Gutenberg は JavaScript で記述されたフロントエンドのプロジェクトで、 Node.js のパッケージマネージャーの npm で管理されています。Gutenberg は単一のパッケージではなく、多数のパッケージから構成されている巨大プロジェクトです。GitHub のリポジトリを見てみると、./packages というフォルダの中に大量のパッケージが同梱されているのが分かります。

たくさんの npm パッケージが 1 つのリポジトリに同梱されている

このように 1 つのリポジトリの中に複数のパッケージを同梱するリポジトリの運用方法は monorepo (モノレポ・モノリポ)と呼ばれています。この記事では、モノレポのメリットやモノレポを実現するためのツールである Lerna を紹介します。

https://lernajs.io/

モノレポのメリット・デメリット

Babel のドキュメントに記載されている、モノレポのメリット・デメリットを引用します。

Why is Babel a monorepo? | なぜ Babel はモノレポなのか?

メリット

デメリット

その他にも、「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)という地名だそうです。

https://ja.wikipedia.org/wiki/%E3%83%92%E3%83%A5%E3%83%89%E3%83%A9%E3%83%BC
レルネのヒュドラー

前提条件

以降の解説は npm initnpm 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 コマンドを使った通常のパッケージ公開の手順を試しておくと比較しやすいかもしれません。

https://docs.npmjs.com/cli/publish
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 initlerna createlerna addlerna 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 を使うと、複数のパッケージを含むリポジトリの運用を簡単に行うことができるようになります。

プロジェクトが巨大であるほど、モノレポを導入するメリットは大きいものと思われますが、一方でビルド/テスト/リリースなどのワークフローが変化するため、これが無視できないコストとなってしまうこともあり得ます。プロジェクト規模や性質、メンバーの数やスキルなどを考慮した上でモノレポの導入を考えるのが良いかもしれません。

モバイルバージョンを終了