見出し画像

モノレポで JavaScript のローカルパッケージをいい感じにできる『tamashii』というツールを作った話

こんにちは。dinii の whatasoda です。モノレポで JavaScript のローカルパッケージをいい感じにできる『tamashii』というツールを作ったので作るに至った背景や機能についてまとめました。

モノレポとローカルパッケージ

dinii では開発に用いる言語を TypeScript に統一しています(こちらでご紹介いただいています!)が、ほぼ全てのプロダクトのソースコードをモノレポで管理しているという特色もあります。 dinii が提供している MO-POS-CRM サービスは 10 近くのプロダクト群から出来上がっているため、モノレポであることは一人のエンジニアがプロダクトを横断して機能開発を行いやすい環境を実現するために非常に重要な意味を持ちます。ここでは深堀りはしませんが、プロジェクト間でバージョンの管理をする際の手間が省けることで開発がしやすかったり、リリースを安全で高速なものにできたりしています。

プロダクトを横断した開発を進めていくなかで、汎用的な便利関数など復数のプロジェクトで利用したい実装は同じモノレポの中でパッケージ(以後ローカルパッケージと呼びます)として切り出しているのですが、ローカルパッケージの更新がしにくい状態だとせっかくモノレポで簡単にコードを共通化できるのにその旨味を生かすことができません

これまでのアプローチ

モノレポであるかどうかに関係なく、組織内で共通して利用したい JavaScript のパッケージがある場合、パッケージマネージャーでインストールできるようにする方法があると思います。具体的には private な npm レジストリにパッケージを登録したり GitHub の URL を指定するなどです。モノレポでなければそれらのアプローチが最適解かと思いますが、同じことをモノレポでやってしまうと、せっかくプロジェクト間のバージョンの組み合わせを管理する煩雑さから開放されているのにローカルパッケージのせいで同様の煩雑さを持ち込むことになってしまいます。

そこで dinii では npm のローカルファイルからパッケージをインストールする 機能を利用し、そのコミット時点でのローカルパッケージの実装をそのまま適用することを実現していました。これ自体は非常に便利ですが、いくつか課題が存在していました。

条件によっては yarn (classic) が node_modules の中身を更新してくれないときがある

本来 node_modules の中身はパッケージマネージャーによって適切な状態が保たれるものです。通常のパッケージであれば package.json に記載するバージョンが変わることをトリガーにパッケージマネージャーが node_modules の中身を更新することができますが、ローカルパッケージではそのバージョンの記載の変更がされないため適切に node_modules の中身を更新してくれないという事象が発生してしまいます。

この事象が発生していても見た目上気がつくことは難しく、その結果開発体験を悪化させたり、意図しないデプロイ結果を招いたりする可能性があります。

パッケージのビルドを挟みたい

通常の npm パッケージでは一度レジストリへの publish の手順を踏んでおり、その際に一度ビルドを走らせることができるため、ビルド後の必要なファイルだけを公開するパッケージに含めることができます。しかし、ローカルパッケージの場合はそういった手順が存在していないため別の方法を考える必要があります。

一つの方法としてパッケージの利用側でインストールする際にビルドを行うというものがあります。通常は node_modules に配置されたローカルパッケージのディレクトリ内でそれを行わざるを得ないでしょう。ビルドには devDependencies のインストールが必要になりますが、ただパッケージをインストールしたときには devDependencies はインストールされないため、何らかの調整が必要になってしまいます。

ローカルパッケージのソースコードが存在している箇所でビルドを行うスクリプトを走らせるという選択肢もあるでしょう。 dinii では一時期そのようなスクリプトを用意していた時代がありますが、ビルドした複数のファイルをディレクトリのルートに配置したいようなケースではソースコード側のディレクトリが散らかってしまって体験があまり良くありませんでした。また、複数のプロジェクトから利用している場合にはパッケージマネージャーや JavaScript ランタイムのバージョンの違いなどから lock ファイルに差分が生じてしまったりビルドに失敗してしまったりすることがありました。

もう一つの方法としてビルド結果をそのまま git 上で管理するようにしてしまうという方法があります。しかしながらこちらの場合はビルド結果が最新のものであることを担保する必要性が生じたり、そもそもビルド結果を git のファイルツリーに含めるのが気持ちよくないという話もあります。

すべてを利用側で実行するコマンドだけで完結させたい

上記のローカルパッケージのソースコードが存在している箇所でビルドを行う方法の亜種として、ローカルパッケージ側で手動でビルドを走らせた上で利用側のプロジェクトからインストールするという方法もあります。この方法は通常のパッケージのリリースの流れに近く、余計なことをあまり考えないで済む点では良い方法であると言えます。しかしこの方法はチーム開発には向いておらず、新しいメンバーの環境構築の難度が上がってしまったり、ローカルパッケージへの変更が反映されないまま作業をしてしまうメンバーが出てきたりして結果的に開発体験を損なうどころか不具合を生み出すきっかけになることもあります。

そのため、ローカルパッケージにまつわる問題を解決する上で、すべてを利用側で実行するコマンドだけで完結させることを要件としています。

Docker イメージのビルドでパッケージをコピーする手順が面倒

dinii では CloudRun にアプリケーションをデプロイしているため、 Docker イメージのビルドについての考慮もしなければなりません。 Docker でイメージのビルド実行する際にコンテキストと呼ばれるもの(通常はディレクトリ)を指定するのですが、ビルド中はそのコンテキストの外のファイルを参照することができません。必要なファイルが全てコンテキストに含まれるように先祖のディレクトリを指定してしまえば良いのではと思うかもしれませんが、コンテキスト内に含まれるファイルが増えることでビルドの各フェーズで効いてくれるはずのキャッシュが効かなくなってしまったり、 CloudBuild などに送信するファイルのサイズが大きくなってしまうなどの懸念が発生してしまいます。(また、単純に先祖ディレクトリからビルドスクリプトを書くのが気持ち良くないというのもあります…)

上記の事情から、同じ packages 配下にいるローカルパッケージをビルドに含めるためにはパッケージ利用側のプロジェクトに該当のローカルパッケージをコピーしてくる必要があります。このステップに大変なことはあまりないものの、ステップの追加を忘れたりローカルパッケージ毎に cp コマンドを記載するとビルドスクリプトやデプロイスクリプトがごちゃごちゃしてしまうので、何も気にしないで済むようにできたらとても嬉しいです。

バージョン管理が難しい

コミット基準で実装が反映されていくのは便利ですが、一方で共通パッケージの変更が全ての利用箇所に反映されてしまうという悩みもあります。

というわけで tamashii を作りました

これらの課題を一挙に解決すべく、ローカルパッケージをいい感じにできる(しようとしている)CLI ツール、『tamashii』を作りました。tamashii という名前は dinii の Values である『魂を燃やす』から取っていますが、特定の思想のもとローカルパッケージとして共通化された実装はコードの品質に反映されていくため、魂のようなものではないかと思っています。(こじつけ)

tamashii は package.json の lifecycle scripts と一緒に機能します。(そのため、 prepare や preinstall が機能しない yarn 2+ 系では最高の状態にはできていないです… 😭)

以下のように、 preinstall で `tamashii sync` を、 prepare で `tamashii refresh` を実行するように設定することで、パッケージマネージャーのインストール処理の前後でローカルパッケージの適切なハンドリングに必要な処理を実行します。

{
  "name": "mobile-order",
  "private": true,
  "scripts": {
    "preinstall": "npx --yes @dinii-inc/tamashii@v1.4.2 sync",
    "prepare": "npx --yes @dinii-inc/tamashii@v1.4.2 refresh",
    ...
  },
  ...
}

2024/07/03 現在 README には tamashii をグローバルインストールした想定でサンプルを記載していますが、チーム開発をするのであれば npx を用いてグローバルインストール不要かつ tamashii 自体のバージョンコントロールを行うことを推奨します。

プロジェクトでローカルパッケージを新たに利用したい場合には `tamashii link` を実行します。 tamashii はパッケージマネージャーとは別の仕組みでローカルパッケージの場所を管理しているため、専用のコマンドが用意されています。

# ローカルパッケージをリンクする(手動て適宜実行する)
npx @dinii-inc/tamashii@v1.4.3 link ../path-to-local-package

また、バージョン管理への解決策としてその時点でのローカルパッケージを保持できるようにアーカイブを作成する `tamashii zip` というコマンドも用意されています。このコマンドで作成されたアーカイブは `tamashii sync` と組み合わせると自動的に展開してくれるようになっているため、他のローカルパッケージと使い勝手を変えることなく利用できるようになっています。(詳細な機能や用途については後半のセクションにて説明しています。)

# ローカルパッケージのアーカイブを作成する
npx @dinii-inc/tamashii@v1.4.3 zip ./path-to-local-package

次セクションからは各コマンドがどのように機能しているのかについて、詳しく説明していきます。

tamashii の仕組み

tamashii は link ・ sync ・ refresh の3つの段階で、前述のローカルファイルからパッケージをインストールする仕組みを拡張する形で機能します。パッケージ利用側のプロジェクトのルートディレクトリに作成される .tamashii というディレクトリの中でこの3つの操作が行われます。

link では対象パッケージへのパスをシンボリックリンクとして `.tamashii/links` に記録します。そこから sync によって必要なファイルをリンク先から `.tamashii/intermediate` 内にコピーしてビルドしたものを `.tamashii/packages` に配置します。そうして用意されたファイルたちをローカルファイルからパッケージをインストールする仕組みで読み込みます。 refresh ではパッケージマネージャーによるインストール後に適宜ファイルを node_modules 内に直接コピーすることで最新の状態に維持する役割を担います。

Docker イメージのビルドの観点でもこの構成が生きてきます。Cloud Build などに渡す前に sync を実行しておけばファイルのコピーが行われるため Dockerfile にて .tamashii ディレクトリをコピーするようにしておくだけでイメージのビルドを行うことが可能になります。

sync の際に実行されるビルドはローカルパッケージの package.json に `tamashii:pre-sync` というスクリプトを定義することで設定可能です。これらが設定されていない場合にはビルドは行わず、ローカルパッケージのディレクトリそのままをパッケージとして認識します。ビルド処理が設定されている場合には dist ディレクトリ配下をパッケージとして認識するため、ビルド処理ではビルド結果と package.json がコピーされていることが期待されています。 pacakge.json に tamashii.dist というプロパティを追加することで dist 以外のディレクトリを指定することもできます。

{
  "private": true,
  "name": "awesome-local-package",
  "version": "1.0.0",
  "scripts": {
    "build": "tsc && cp package.json dist",
    "tamashii:pre-sync": "yarn --silent build"
  },
  "peerDependencies": {
    ...
  },
  "devDependencies": {
    ...
  },
  "tamashii": {
    "dist": "dist"
  }
}

link は新しいパッケージを足すごとに手動で実行しますが sync と refresh は特定のタイミングで実行することを想定しているため、少しだけセットアップが必要になります。 sync はインストールするパッケージの実体を作成するため npm-scripts の preinstall での実行が、refresh はインストール後の状態に手を加えるため npm-scripts の prepare で実行する必要があります。

このセットアップをしておくことで、ローカルパッケージに変更を加えた際に利用側のプロジェクトで yarn install を実行すれば node_modules の中身を適切な状態に更新することができます。(sync と refresh を個別に実行しても構いません)

{
  "name": "mobile-order",
  "private": true,
  "scripts": {
    "preinstall": "npx --yes @dinii-inc/tamashii@v1.4.2 sync",
    "prepare": "npx --yes @dinii-inc/tamashii@v1.4.2 refresh",
    ...
  },
  ...
}

tamashii zip について

上記 tamashii の基本機能によって `条件によっては yarn が node_modules の中身を更新してくれないときがある` `パッケージのビルドを挟みたい` `すべてを利用側で実行するコマンドだけで完結させたい` `Docker イメージのビルドでパッケージをコピーする手順が面倒` の4つの問題については対処できています。残る5つ目の `バージョン管理が難しい` 問題については `tamashii zip` という機能で対処します。

このコマンドを使うには少しだけ別のセットアップを行う必要があります。

まず、ローカルパッケージをビルドする際に package.json のバージョンに応じて出力先のディレクトリを切り替えるようなセットアップを行います。ここでは例として archives というディレクトリの配下に `{パッケージ名}@{バージョン}` のようなディレクトリを作ることとします。

awesome-package/
├─ archives/
│  ├─ .gitignore
│  ├─ awesome-package@v1.0.0/
│  │  ├─ package.json
│  │  └─ (build result)
│  ├─ awesome-package@v1.1.1/
│  └─ ...
├─ src/
│  └─ ...
├─ package.json
└─ ...

次に、 archives 配下のディレクトリに対して `tamashii zip` を実行するようにします(ビルドスクリプトの最後に配置すると良さそうです)。するとビルド結果が .tamashii.tar.gz というファイルに圧縮されます。このとき、 archives 直下に置いている .gitignore を以下のようにしておくことで Git のファイルツリーには pacakge.json と .tamashii.tar.gz のみが含まれるようになります。

/*/*
!/*/package.json
!/*/.tamashii.tar.gz
!.gitignore
# Git 上で認識されているファイルツリー
awesome-package@v1.0.0/
├─ package.json
└─ .tamashii.tar.gz

この状態のディレクトリを `tamashii link` で指定すると tamashii は sync のビルドステップでビルドの代わりに .tamashii.tar.gz を展開するため、他のローカルパッケージと何ら変わりなく利用することができるようになります。これによりローカルパッケージのバージョニングが可能になり、内部の変更の影響範囲をコントロールすることができます。

この一連の流れを簡単になるようなコマンドを追加予定ですが、現状はバージョンごとのディレクトリの作成などは個別に準備する必要があります。

workspace は使わないの?

dinii におけるモノレポはあくまで個別のプロダクトを1つのリポジトリで管理しているだけなので、パッケージ間の境界が曖昧になってしまいがちな npm の workspace 機能 はあまり適していません。特に dinii の場合は実行環境が異なるプロダクトをまとめて管理しているため、意図しないパッケージやバージョンが含まれることで不都合が生じやすいと言えます。

tamashii の制限

tamashii ではローカルパッケージの dependencies のハンドリングについてはサポートしていません。 package.json の内容の変更による依存関係の更新についてはパッケージマネージャーを通す必要があるため tamashii がそこまで考慮したハンドリングを行うのは一旦難しいと判断したためです。もしかするとちゃんと動いてくれるかもしれませんが、利用する場合には必ず `tamashii zip` と併用して lock ファイルの更新などを明示的に行うようにしてください。

dinii では dependencies の代わりに peerDependencies などで必要なパッケージを記述し、利用側の個別のプロジェクトでそれらを満たすパッケージをインストールするという運用をしています。これはむしろ細かいバージョンを利用側で調整できるので嬉しい側面もあるので、基本的にはこちらの方式を推奨します。

まとめ

以上、モノレポで管理しているマルチプロダクトサービスにおいて JavaScript のローカルパッケージをいい感じにする tamashii のご紹介でした。まだ磨き込みの余地が残ったツールではありますが、モノレポでのコードの再利用性を向上させることができたと感じています。

おわりに

dinii には現在 Feature Team, Platform Team, Data Team, Discovery Team の4つのエンジニアリングチームが存在しています。自分が所属している Platform Team ではインフラや基盤実装の整備や改善の他にもこのように開発体験向上に向けた取り組みも行っています。

dinii のプロダクト開発に興味を持たれた方はぜひこちらからカジュアル面談の申し込みをお願いします!(募集ポジション一覧はこちら

この記事が気に入ったらサポートをしてみませんか?