JavaScript
Node.js
monorepo
lerna

Lerna を使って、 Babel や React が採用している monorepo を試してみる

More than 1 year has passed since last update.

monorepo とは

複数の npm package を 単一の git repository で管理すること

package 毎に repository を作る場合と比較した Pros & Cons

Babel の repository のドキュメントから抜粋

Pros:

  1. lint, build, test, release のプロセスを共通化できる
  2. package をまたがった修正が容易になる
  3. issue 管理を一元化できる
  4. 開発環境の構築が簡単になる
  5. テストも package をまたいで実行でき、複数 package が絡む不具合の検知が容易になる

Cons:

  1. コードベースがより威圧的に見える (※訳注: 2 と同じような意味?)
  2. repository が大きくなる
  3. ??? (※訳注: 原文ママ)

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.jsondependencies, devDependencies に記載された package のうち、同一 repository の packages 下にあり、かつ version 指定が該当するものについては、 node_modules 下にリンク (※) が作られる
    • 当然ながら、複数 package にまたがる変更を加えるような場合、依存する package を npm から取ってくるよりも、 local のものを直接参照したほうがやりやすい
    • それ以外の package については、普通に npm install により install する

※ このリンクの実体は、以下のような index.jspackage.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 or major. default は patch)
  • VERSION ファイルで管理されている現在の version から、上記で指定した通りに version が上がり、各ファイルに記載の version が更新される
    • lerna bootstrap で作られた VERSION ファイル
    • 公開対象の package の package.jsonversion
    • 公開対象の package に依存する package の package.json に記載されている、 dependencies or devDependencies の 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 になる
  • 公開対象の package が npm publish されるとともに、 git の tag が npm の version と同じ名前で切られる

試してみた経緯と、感想・気づいたことなど

経緯

  • 仕事で、社内の色々なシステムで使う共通機能を実装した一連のライブラリ群の開発を始めた
  • 新しくライブラリを作り始めるとき、既存のライブラリから package.jsongulpfile.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 bootstrapnode_modules 下に module.exports = require("/path/to/repository_root/packages/package-a"); と記述した index.js を配置することで、 local の package を参照する
    • TypeScript では、依存先 package の型情報を取得するのに、 package.jsontypings に記載された型定義ファイル (記載がない場合は 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 のような状況がどれくらいありうるのか分かりませんが、同じような問題にぶちあたった方の参考になれば幸いです。