monorepo とは
複数の npm package を 単一の git repository で管理すること
- 例えば Babel では、 100 以上の npm package が単一の git repository で管理されている
package 毎に repository を作る場合と比較した Pros & Cons
Babel の repository のドキュメントから抜粋
Pros:
- lint, build, test, release のプロセスを共通化できる
- package をまたがった修正が容易になる
- issue 管理を一元化できる
- 開発環境の構築が簡単になる
- テストも package をまたいで実行でき、複数 package が絡む不具合の検知が容易になる
Cons:
- コードベースがより威圧的に見える (※訳注: 2 と同じような意味?)
- repository が大きくなる
- ??? (※訳注: 原文ママ)
Lerna とは
Babel 開発者の @sebmck (github では kittens になってる) が monorepo を簡単に実現できるよう開発したツール
使い方
bootstrap
$ lerna bootstrap
- repository を作ったら、最初に実行しておく
-
VERSION
ファイルと、packages
ディレクトリが作られる -
VERSION
ファイルは Lerna がバージョン管理のために使うものなので、手で編集することはしない -
packages
ディレクトリの下に、この repository で管理する npm package を作っていく
-
repository_root
└ packages
| └ package-a
| | └ index.js
| | └ package.json
| | ...
| |
| └ package-b
| | └ index.js
| | └ package.json
| | ...
| ...
└ VERSION
- その後は、 package 間の依存性を解決するためにこのコマンドを使用する
-
package.json
のdependencies
,devDependencies
に記載された package のうち、同一 repository のpackages
下にあり、かつ version 指定が該当するものについては、node_modules
下にリンク (※) が作られる - 当然ながら、複数 package にまたがる変更を加えるような場合、依存する package を npm から取ってくるよりも、 local のものを直接参照したほうがやりやすい
- それ以外の package については、普通に
npm install
により install する
-
※ このリンクの実体は、以下のような index.js
と package.json
のペア
- index.js
module.exports = require("/path/to/repository_root/packages/package-a");
- package.json
{
"name": "package-a",
"version": "1.0.0"
}
publish
$ lerna publish
- 実際に npm に package を公開するタイミングで実行する
- 実行時に公開されるのは、 repository の最新の tag に対して git diff したときに差分が存在する package のみ
- Lerna を使う場合、 tag は後述の通り Lerna が勝手に切ってくれるので、原則手動で切るべきではないが、最初の一回だけは手動で切っておく必要がある
- repository を作ったら速い段階で最初の tag を切っておくとよさそう (最新の tag に対して差分がないと公開対象にならないので、いざ公開というタイミングで tag がまだ切られていないことに気づくと少し焦る)
- 実行すると、 version の上げ方について問われるので選択する (
patch
,minor
ormajor
. default はpatch
) -
VERSION
ファイルで管理されている現在の version から、上記で指定した通りに version が上がり、各ファイルに記載の version が更新される-
lerna bootstrap
で作られたVERSION
ファイル - 公開対象の package の
package.json
のversion
- 公開対象の package に依存する package の
package.json
に記載されている、dependencies
ordevDependencies
の version
-
- version は
VERSION
ファイルで一元管理されているので、全 package で足並みをそろえる形になる- たとえば、 v1.0.0 で package-a のみに差分がある状態で
lerna publish
で patch の version を上げた場合、 package-a は v1.0.1 になり、 package-b は v1.0.0 のまま - その後、 package-b を更新して再度
lerna publish
で patch の version を上げると、 package-a は v1.0.1, package-b は v1.0.2 になる
- たとえば、 v1.0.0 で package-a のみに差分がある状態で
- 公開対象の package が
npm publish
されるとともに、 git の tag が npm の version と同じ名前で切られる
試してみた経緯と、感想・気づいたことなど
経緯
- 仕事で、社内の色々なシステムで使う共通機能を実装した一連のライブラリ群の開発を始めた
- 新しくライブラリを作り始めるとき、既存のライブラリから
package.json
やgulpfile.js
をコピーするところから始める、というプロセスを何度か繰り返すうち、何だかすごく非効率なことをしている気持ちになる - 少し調べてみて、 monorepo という考え方があることを知る -> 試してみよう
- なので最初の動機としては、 Pros に上がっている項目のうちの 1, 4 がメイン
- ただ 2, 5 も当然すごくいいことだと思う
- 3 に関しては、今の仕事では github 上で issue の管理はしないのでわりとどうでもいい
- 上記の Pros には上がっていないけど、 repository が分かれていると build や test に必要な package をそれぞれで
npm install
することになるので、ディスク容量を無駄に消費するのも地味につらい
感想・気づいたことなど
- 実際に試してみたものはこちら
- 以前この記事に書いた、 TypeScript で Flux/React した repository を元に、各 React component や action, dispatcher, store を package に分割してみた
- README に書きましたが、
npm run release
すると本当に npm に公開してしまうので、試される場合は sinopia などを利用して private な npm registry を用意してください
- まだ業務で使い込んでいるわけではないので、感想というほどのこともないが、「やっぱりこうあるべきだよね」というのは強く感じた
- ただし、残念ながら
lerna bootstrap
は私の用途には使えなかった- 試してみた repository でも、業務でも、私は TypeScript を使っている
- 上の説明で書いた通り、
lerna bootstrap
はnode_modules
下にmodule.exports = require("/path/to/repository_root/packages/package-a");
と記述したindex.js
を配置することで、 local の package を参照する - TypeScript では、依存先 package の型情報を取得するのに、
package.json
のtypings
に記載された型定義ファイル (記載がない場合はindex.d.ts
) を見に行くが、上記のようなリンクの仕方ではこの型定義ファイルが参照できない - そもそも、ある package でコンパイルを実行するためには、依存先の package のコンパイルが完了していなければならないが、依存関係に応じた build 順序の制御といった機能は Lerna には存在しない
- そこで、 gulp で
package.json
の記載を元に依存先 <- 依存元
の順にソートした package の配列を作成し、この配列の先頭からnpm link
していくことで local package の参照を解決するとともに、 build もこの配列の順番通りに行うようにした
// gulpfile.js の中で、依存性に基づいて package をソートする様子
// 詳細は実際の gulpfile.js を参照してください: https://github.com/kimamula/monorepo-ts-react/blob/master/gulpfile.js
function getPackagesSortedByDependency(packageDirNames) {
// get packages
var packages = packageDirNames.map(function (package) {
var pkg = require([packagesLoc, package, 'package.json'].join('/'));
return {
dir: package,
dependencies: Object.assign({}, pkg.dependencies, pkg.devDependencies),
name: pkg.name
};
});
// sort packages according to dependencies
function compare(a, b) {
return depends(a, b) ? 1 : (depends(b, a) ? -1 : 0);
}
function depends(depender, dependee) {
return Object.keys(depender.dependencies)
.some(function(dependency) {
if (dependency === dependee.name) {
return true;
}
var dependencyPackage = packages.find(function(package) {
return package.name === dependency;
});
if (!dependencyPackage) {
return false;
}
return depends(dependencyPackage, dependee);
});
}
return packages.sort(compare);
}
-
lerna publish
は特に問題なく使えた
終わりに
今回調査した内容を元に、今後業務でも monorepo を採用していけたらと考えています。
lerna bootstrap
が使えない問題については、 TypeScript のような状況がどれくらいありうるのか分かりませんが、同じような問題にぶちあたった方の参考になれば幸いです。