LernaとYarn WorkspacesでMonorepo管理

こんにちは、フロントエンドエキスパートチームの小林(@koba04)です。

本記事では、Lerna と Yarn Workspaces を使った Monorepo 管理について解説します。

Monorepoとは

本記事では、単一のリポジトリで複数のモジュールやパッケージ(今回の場合は npm パッケージ)を管理する手法を Monorepo と呼んでいます。

有名なところだと、BabelJestCreate 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

https://lerna.js.org/

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 で packageswebsiteexamples として管理しており、 Lerna では packages のみ管理しています。

今回は、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 が提供するコマンドでも可能です。

それぞれのパッケージが testlint など決まった名前で 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-clientkintone/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-nodepaths オプションを有効にするために 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 の指定はデフォルトでは不要であるため削除