こんにちは、フロントエンドエキスパートチームの小林(@koba04)です。
本記事では、Lerna と Yarn Workspaces を使った Monorepo 管理について解説します。
Monorepoとは
本記事では、単一のリポジトリで複数のモジュールやパッケージ(今回の場合は npm パッケージ)を管理する手法を Monorepo と呼んでいます。
有名なところだと、Babel や Jest、Create React App などが後述する Lerna を使い複数パッケージを単一のリポジトリで管理しています。 他にも React も Lerna は使っていませんが単一リポジトリで複数パッケージを管理しています。
また、上記のようなライブラリ以外にも企業で利用している npm パッケージを Monorepo として管理している例もあります。下記は Shopify の例です。
packages/
ディレクトリ以下を見ると、ASTの utility から React 関連のライブラリまで色々あることがわかります。
https://github.com/Shopify/quilt/tree/master/packages
単一のリポジトリで複数パッケージを管理するメリット、デメリットには下記のような点があります。
メリット
- 依存関係のある複数パッケージの開発が簡単
- 都度パッケージをリリースしたり、
npm link
をする必要がないため
- 都度パッケージをリリースしたり、
- 依存パッケージの管理をまとめてできる
- Renovate などを使い依存パッケージのバージョンを管理している場合には特に楽
デメリット
- Issue や PR をすべて 1 つのリポジトリで管理する必要がある
- Monorepo での開発ワークフローを理解している必要がある
サイボウズでは、すでに kintone のプラグインやカスタマイズ開発を行うための npm パッケージをいくつも提供しており、これらは現状個別のリポジトリで管理されています。 しかしながら、Renovate による依存パッケージの更新対応コストや、お互いに依存関係のあるパッケージの開発など、リポジトリが分かれていることによるデメリットがありました。
現在、新しく kintone のプラグインやカスタマイズの開発体験を向上させるためのツール開発を行っており、これを機会に Monorepo に移行することにしました。 開発体験を向上させる取り組みについては、別記事として紹介したいと考えています。
実際に適用しているリポジトリは下記より確認できます。 現在は 2 パッケージのみですが、今後どんどん増えていく予定です。
https://github.com/kintone/js-sdk
まず最初に、Lerna と Yarn Workspaces について簡単に紹介します。
Lerna
Lerna は ”A tool for managing JavaScript projects with multiple packages.” と公式サイトにある通り、複数パッケージを管理するためのツールです。
Lerna は、一例として下記のような機能を提供します。
- 複数 npm パッケージを単一リポジトリで一元管理
lerna bootstrap
コマンドを使った Monorepo すべての依存パッケージを一括インストール- 重複した依存パッケージの hoisting(巻き上げ)
- hoisting とは、パッケージ間で重複している依存パッケージをそれぞれ別にインストールするのではなく、親となるディレクトリにインストールして共有することです
lerna publish
コマンドを使った、変更があるパッケージの一括 npm publish- すべての npm パッケージのバージョンを同一にするか、個別に管理するか(independent mode)を選択可能
lerna run
コマンドを使った Monorepo で管理しているパッケージが持っている npm-scripts の一括実行- 既存の git リポジトリから Monorepo へのインポート
このように、Lerna は複数パッケージをまとめて扱うための機能を提供しています。
Yarn Workspaces
https://classic.yarnpkg.com/lang/en/
Yarn は JavaScript のパッケージマネージャです。 Node.js が標準で提供している npm と同様に、npm パッケージの管理ができます。 パッケージマネージャとしての Yarn と npmの違いについては本記事では言及しないので、下記を確認ください。
https://classic.yarnpkg.com/en/docs/migrating-from-npm
※本記事では、Yarn v1 を使用します。
Yarnは Workspaces として、複数の npm パッケージを管理する機能を持っています。 これは npm にはない機能です。
https://classic.yarnpkg.com/en/docs/workspaces
注:npm は v7 で Monorepo サポートを計画しています。下記は RFC です。
https://github.com/npm/rfcs/pull/103
Yarn Workspaces を使うことで下記のようなことが可能です。
- 複数 npm パッケージを単一リポジトリで一元管理
yarn install
コマンドを使った Monorepo すべての依存パッケージを一括インストール- 依存パッケージの hoisting
yarn workspaces
コマンドを使った Monorepo で管理しているパッケージが持っている npm-scripts の一括実行- 単一の
yarn.lock
ファイルですべての依存関係を管理
上記の通り、Yarn Workspaces と Lerna は同じような機能を持っています。
その違いとして、
Yarn’s workspaces are the low-level primitives that tools like Lerna can (and do!) use
とある通り、Yarn Workspaces の方がより低レベルなパッケージマネージャとして機能を提供しています。
LernaとYarn Workspacesを一緒に使う
Lerna は Yarn Workspaces と一緒に使うためのオプションを提供しています。
Lerna の設定ファイルである、lerna.json
に "npmClient": "yarn"
と指定することで、内部的に npm の代わりに Yarn を使用できます。
実際、多くの Lerna を使っているプロジェクトでは Yarn Workspaces と組み合わせて使っていることが多いです。 ただ、ここで一つ疑問が湧きます。
「 Lerna で Yarn Workspaces が提供している機能はカバーできるが、Yarn を使うメリットはあるのか?」
実際のところ、Lerna と npm だけでも多くの場合はやりたいことを実現できます。
ただ、Yarn はパッケージマネージャ本体で Monorepo をサポートしていることもあり、Monorepo であることを利用者にあまり意識させない形での Monorepo 管理を可能にしてくれます。
例えば下記のような点です。
パッケージのインストール
全パッケージの依存パッケージのインストールはリポジトリのルートで yarn install
するだけであり、単一パッケージのリポジトリと同様です。個別のパッケージで依存パッケージをインストールする場合も、対象パッケージのディレクトリで yarn add {package-name}
コマンドを実行するだけです。
単一の yarn.lock
で管理できる
Yarn Workspaces では yarn.lock
ファイルはリポジトリのルートにのみ作成され、それぞれのパッケージ毎に作られません。そのため依存パッケージのアップデートの際に発生する差分は最小限になります。
使い方
ここでは、Lerna と Yarn Workspaces を使った Monorepo 運用のための手順を記載します。
セットアップ
リポジトリのルートにある package.json
に "private": true
を設定します。また Yarn Workspaces のための設定をします。
workspaces
には、パッケージ用のディレクトリだけでなく examples
のようなサンプルのためのディレクトリも対象に含めることも可能です。
kintone/js-sdk
では、デモやサンプルを配置するために examples
ディレクトリを使用しています。
// package.json // workspacesのフィールドを追加 { : "private": true, "workspaces": [ "packages/*", "examples/*" ], : }
Lerna をインストールして lerna init
を実行します。
% yarn add --dev lerna % yarn lerna init --independent lerna notice cli v3.19.0 lerna info Updating package.json lerna info Creating lerna.json lerna info Creating packages directory lerna success Initialized Lerna files ✨ Done in 0.98s.
今回はそれぞれの npm パッケージ単位で個別にバージョンを管理したいので、--independent
を指定します。
設定しない場合、すべての npm パッケージのバージョンが同一バージョンになります。
生成された Lerna の設定ファイルである lerna.json
に Yarn Workspaces のための設定をします。
{ "npmClient": "yarn", // 追加 "useWorkspaces": true, "version": "independent" }
Lerna は useWorkSpaces というオプションを持っています。 これを利用することで Yarn Workspaces のワークスペース設定をそのまま Lerna でも利用できます。
もちろん Lerna と Yarn Workspaces で対象パッケージを分けることも可能です。
この方法は Jest でも採用されており、Jest では Yarn Workspaces で packages
、website
、examples
として管理しており、 Lerna では packages
のみ管理しています。
- https://github.com/facebook/jest/blob/44a960de28035e7590b21c25de44af5f0f1796df/package.json#L114-L120
- https://github.com/facebook/jest/blob/44a960de28035e7590b21c25de44af5f0f1796df/lerna.json#L4-L6
今回は、lerna
コマンドで examples
ディレクトリも統一的に扱いたいため、useWorkSpaces
オプションを使用します。
新規パッケージの追加
新しく開発するパッケージを Monorepo に追加する際は、 lerna create
コマンドを使います。
% npx lerna create @kintone/rest-api-client
npm パッケージのインストール
npm パッケージをインストールする場合は、インストールしたいパッケージのディレクトリで通常通り yarn add package-name
するか、yarn workspace
コマンドを使ってインストールします。
% cd packages/rest-api-client % yarn add form-data # or % yarn workspace @kintone/rest-api-client add form-data
各パッケージで同一の依存ライブラリをインストールした場合、package.json
はインストールしたパッケージのものが更新されますが、yarn.lock
はルートにあるものが更新されて、インストールもルートの node_modules/
に対して行われます。
共通 npm パッケージのインストール
TypeScript などすべてのパッケージで使うような devDependencies については、ルートで入れておくと個別にインストールする手間を省くことができます(依存関係が各 package.json
からわからなくなるというデメリットはありますが)。
ルートに共通で使うパッケージとしてインストールする場合には、インストールのオプションに明示的に -W
オプションを付ける必要があります。
% yarn add -W --dev typescript prettier eslint
パブリッシュ
パッケージのパブリッシュは lerna publish
で行います。
lerna publish
はデフォルトでは CLI 上でリリースするバージョンを指定しますが、Conventional Commits の形式で commit している場合には、--conventional-commits
オプションを利用することで commit log からバージョンを決定できます。この際、CHANGELOG.md
も自動生成されます。
% npx lerna publish --conventional-commits lerna notice cli v3.19.0 lerna info versioning independent lerna info Looking for changed packages since @kintone/rest-api-client@1.0.0 lerna info getChangelogConfig Successfully resolved preset "conventional-changelog-angular" Changes: - @kintone/rest-api-client: 1.0.0 => 1.1.0 - @kintone/cutomize-uploader: 3.0.1 => 3.0.2 ? Are you sure you want to publish these packages? (ynH) : Successfully published: - @kintone/rest-api-client@1.1.0 - @kintone/customize-uploader@3.0.2 lerna success published 2 packages
新しくパッケージを npm に publish する場合は下記コマンドで publish できます。
% npx lerna publish from-package --conventional-commits
examples
以下にあるパッケージのように npm にパブリッシュしたくないパッケージには、package.json
に "private": true
を指定します。
Clone後のセットアップ
git clone した後のセットアップは Lerna 単体の場合、独自の lerna bootstrap
コマンドを利用する必要がありますが、Yarn Workspaces を使う場合には通常のリポジトリと同様にリポジトリのルートで yarn install
を行うだけです。
% yarn install
まとめて npm-scripts を実行する
lerna run
というコマンドを使うことで、指定した npm-scripts を Lerna で管理しているすべてのパッケージに対して一括で実行できます。
例えば、すべてのパッケージに test
の npm-scripts があることを想定して一括で実行したい場合には、リポジトリのルートで lerna run test
を実行します。
--stream
オプションをつけることで、それぞれの npm-scripts の出力を表示できます。
kintone/js-sdk
で実行すると下記の通りです。
% npx lerna run test --stream lerna notice cli v3.20.2 lerna info versioning independent lerna info Executing command in 2 packages: "yarn run test" @kintone/customize-uploader: yarn run v1.22.4 @kintone/customize-uploader: $ jest --rootDir src @kintone/customize-uploader: PASS src/__tests__/init.test.ts @kintone/customize-uploader: PASS src/__tests__/util.test.ts @kintone/customize-uploader: PASS src/__tests__/import.test.ts @kintone/customize-uploader: Test Suites: 4 passed, 4 total @kintone/customize-uploader: Tests: 7 passed, 7 total @kintone/customize-uploader: Snapshots: 0 total @kintone/customize-uploader: Time: 1.948s, estimated 2s @kintone/customize-uploader: Ran all test suites. @kintone/customize-uploader: Done in 2.95s. @kintone/rest-api-client: yarn run v1.22.4 @kintone/rest-api-client: $ jest --rootDir src @kintone/rest-api-client: PASS src/client/__tests__/BulkRequestClient.test.ts @kintone/rest-api-client: PASS src/__tests__/KintoneAllRecordsError.test.ts @kintone/rest-api-client: PASS src/__tests__/url.test.ts @kintone/rest-api-client: PASS src/client/__tests__/File.test.ts @kintone/rest-api-client: PASS src/__tests__/KintoneRestAPIError.test.ts @kintone/rest-api-client: PASS src/__tests__/KintoneRestAPIClient.test.ts @kintone/rest-api-client: PASS src/__tests__/KintoneRequestConfigBuilder.test.ts @kintone/rest-api-client: PASS src/client/__tests__/RecordClient.test.ts @kintone/rest-api-client: PASS src/client/__tests__/AppClient.test.ts @kintone/rest-api-client: Test Suites: 9 passed, 9 total @kintone/rest-api-client: Tests: 243 passed, 243 total @kintone/rest-api-client: Snapshots: 0 total @kintone/rest-api-client: Time: 3.37s @kintone/rest-api-client: Ran all test suites. @kintone/rest-api-client: Done in 3.86s. lerna success run Ran npm script 'test' in 2 packages in 7.5s: lerna success - @kintone/customize-uploader lerna success - @kintone/rest-api-client
npm-scripts の一括実行は、yarn workspaces run test
のように Yarn Workspaces が提供するコマンドでも可能です。
それぞれのパッケージが test
や lint
など決まった名前で npm-scripts を用意しておくことで、コマンド 1 つで確認が可能になるため、複数パッケージのメンテナンスコストを下げることができます。
kintone/js-sdk
では、下記のような npm-scripts を定義することをルールとしています。
https://github.com/kintone/js-sdk/blob/master/CONTRIBUTING.md#create-a-new-package
- build … TypeScript のコンパイルなどの成果物を生成する処理
- lint … ESLint などの Lint 処理
- test … テスト
- test:ci … CI 上で実行するテスト
- prerelease … パッケージのリリース前に行いたい処理
上記はルールだけだと漏れてしまうことが容易に想像できるため、上記の npm-scripts をすべて実装しているかを下記のテストでチェックしています。
https://github.com/kintone/js-sdk/blob/a603caabadc695a34f3202eb45699e505b58eb80/tests/npmScripts.test.ts
既存リポジトリからの移行
途中で Monorepo 運用に変える場合、既存のリポジトリを Monorepo での管理に移行する必要があります。Lerna は import コマンドという既存のリポジトリを Monorepo に移行するためのコマンドを提供しています。これを利用することで、元のリポジトリの commit log を残したまま Monorepo に移行できます。
実行方法は、lerna import path/to/target
と元のリポジトリを指定するだけです。
ただし Troubleshooting にもある通り、Merge conflict commits があるリポジトリの場合は import に失敗してしまいます。
これは Troubleshooting に従って、--flatten
オプションをつけることで import 可能ですが、この場合はMerge commit 単位で commit がまとめられてしまいます。
Merge commit 単位でまとめて欲しくない場合は、一度リポジトリの commit をすべて rebase した状態にすることで import 可能な状態にできます。
@kintone/rest-api-client
も kintone/js-sdk
への移管のタイミングで上記の方法で import しました。
パッケージを import した後は、最後にリリースした commit に対して package-name@version
の形式で GitHub に tag を push する必要があります。
これにより、 Lerna は次回のリリース時にこの tag を起点として差分を検出します。
ts-nodeを使ったサンプルパッケージの実行
現在 examples
のディレクトリには、rest-api-client
のサンプルスクリプトを管理するパッケージがあります。
このパッケージは元々 rest-api-client
本体に含まれていたものを切り出したものです。
以前は ts-node
で直接実行していたのですが別パッケージとして切り出した結果、スクリプトの実行に rest-api-client
のコンパイルが都度必要になってしまいました。これまで通りの体験でスクリプトを実行するために、TypeScript の Compiler Options である paths
を使いリンクすることで、別パッケージに切り出した後も引き続き ts-node
コマンドでコンパイルせずに実行できるよう対応しました。
"baseUrl": "../../", "paths": { "@kintone/rest-api-client": ["packages/rest-api-client/src"] },
ts-node
で paths
オプションを有効にするために tsconfig-paths を使用しています。
おわりに
今回は、Lerna と Yarn Workspaces を組み合わせて効率的に複数の npm パッケージを管理する方法を紹介しました。
今回は紹介できませんでしたが、今後パッケージが増えてお互いに依存関係を持つような状態に備えて、TypeScript の Project References の導入も検討しています。
サイボウズでは、Web アプリケーションのフロントエンドだけでなく、プラットフォームのためのツール作りを OSS でやりたいというエンジニアも絶賛募集中です!
https://cybozu.co.jp/company/job/recruitment/list/front_end_expert.html
※ 2020/07/15 更新:typeRoots の指定はデフォルトでは不要であるため削除