複数のServerlessのパッケージを1つのモノレポにまとめるまでの旅路(あるいはyarn v2の話)

8 min read読了の目安(約7600字

はじめに

お仕事でServerless Frameworkで作ったAPIパッケージを複数扱っているのですが、eslintやjest、tsconfigの似たような設定がいろんなところに散らばってたり、ロジックが似通っている部分がある状況でした。そんな状況を改善するために複数のServerlessパッケージを1つのモノレポにまとめることを行いました。

モノレポ化への旅路

モノレポの作成先を用意する

モノレポを作成する場合、

  1. 新しくリポジトリを作る
  2. 既存のどれかのリポジトリに他のリポジトリを合流させる

の2つの選択肢があると思います。今回の場合は、中心となるリポジトリがあったのでそこに他のリポジトリを合流させる方法を取りました。

合流先のリポジトリをモノレポ化する

まずリポジトリのルートにpackages/<パッケージ名>ディレクトリを用意し、リポジトリの内容をそこにすべて移動させます。
その後、ルートでyarn init -yを行いモノレポパッケージを作成します。

lernaを使って他のリポジトリをimportする

lernaというモノレポ用のツールがあるのですが、これが他のリポジトリをモノレポのリポジトリに持ってくる(importする)機能があり、これが非常に便利なので使います。

npx lerna import <他のリポジトリへのパス> --flatten

マージコンフリクトを解消したコミットがあるとimportに失敗するので、--flattenオプションを指定します。詳細は下記のサイボウズのブログが詳しいです。

https://blog.cybozu.io/entry/2020/04/21/080000

yarn workspaceを設定する

yarnはworkspaceというモノレポをやるにはうってつけの機能を持っています。package.jsonにworkspacesの設定を行うことでworkspace機能を有効化することができます。

{
  ...
  "workspaces": {
    "packages": [
      "packages/*"
    ]
  }
}

packages配下にすべてのリポジトリを持ってくる場合は上記の指定で大丈夫です。もう少し込み入ったリポジトリ構成にする場合は個別に指定を書きます。
yarn workspaceを設定したら一度yarn installを行います。そうすると、モノレポのルートにすべての依存関係が記述されたyarn.lockファイルが生成され、各リポジトリにあるyarn.lockファイルは無視されるようになります。(なので各リポジトリのyarn.lockファイルは消します)

yarn v2の導入

yarn v1のworkspace機能も十分な機能を持っているのですが、yarn v2のほうがよりworkspaceの機能が強化されているので、そちらを使います。

yarn v2の有効化

yarn v1の最新版が入っている状況で、下記のコマンドでyarn v2を有効化することが出来ます。

yarn set version berry

そして、このままだと下記のbugがあるので、yarn set version from sources --branch 2262で少し新しめのyarn v2を入れておきます。

https://github.com/yarnpkg/berry/issues/2232

ちなみに、yarn set version from sourcesで最新の入れてもよいのですが、どうも安定していない感じがするので個人的には2262がオススメです。

yarn v2の設定

yarn v2はyarn v1に比べて設定ファイル(.yarnrc.yml)とディレクトリ(.yarn)が増えています。

https://yarnpkg.com/configuration/yarnrc

yarn v2は本来はPlug'n'Playというnode_modulesを生成しないで使うモードがデフォルトなのですが、それだと色々不都合があったり、またyarn v1からのマイグレーションという点ではnode_modulesの方が楽なので、node_modulesを生成するモードで使います。

.yarnrc.ymlに下記の設定を足します。

nodeLinker: node-modules

hoistの設定

yarnはパッケージのhoist機能を持っているのですが、これを適切に設定しないとパッケージが勝手にルートに巻き上げられてしまいます。

簡単に説明します。パッケージROOT(パス/ROOT)はパッケージA(パス/ROOT/packages/A)をworkspaceとして保持していて、パッケージAはライブラリXを依存として持っています。またXYを依存として持っていて、YZを依存として持っています。hoist無しの状態でインストールすると、下記のようにファイルが展開されます。

/ROOT/packages/A/node_modules/X/node_modules/Y/node_modules/Z

※実際は他の依存との兼ね合いもあって、もっと複雑な感じになりますが、ここで簡略化して説明してます

ここから、yarn v2のnmHoistingLimitsの設定に応じて下記のように変化します。

none

nmHoistingLimitsのデフォルト値です。出来るだけhoistしようとします。なので下記のようになります。

/ROOT/node_modules/X
/ROOT/node_modules/Y
/ROOT/node_modules/Z
/ROOT/packages/A

workspaces

hoistする上限が、workspaceになる設定です。今回のモノレポ化ではこれを使っています。

/ROOT/packages/A/node_modules/X
/ROOT/packages/A/node_modules/Y
/ROOT/packages/A/node_modules/Z

workspaceレベルでhoistが行われるので結構使いやすい設定だと思います。

dependencies

workspaceの直接の依存を超えてhoistしない設定です。

/ROOT/packages/A/node_modules/X/node_modules/Y
/ROOT/packages/A/node_modules/X/node_modules/Z

このような感じになる気配がしてます。ちゃんと使い込んでないので不正確かもしれません

オフラインキャッシュの設定

yarn v2はオフラインキャッシュというリポジトリに依存関係を保持させ、インターネット接続がない場合でもyarn installが可能な機能を持っています。

オフラインキャッシュの場所

yarn v2が設定された状態でyarn installを行うと、.yarn/cache以下に自動でオフラインキャッシュが作成されます。このオフラインキャッシュはyarn v2がnpmjs.comから取ってきた圧縮ファイルを展開し、再度zipで圧縮しなおしたファイルになってます。

オフラインキャッシュのメリット

  • インターネット接続が厳しい環境でも比較的戦えるようになる(っぽい)

僕の普段の開発環境では特にインターネット接続での問題はないのですが、たとえばプロキシーが強制されてyarn installが辛い、という環境ではオフラインキャッシュを使うことでだいぶ開発の負担が軽減される気がします。

オフラインキャッシュのデメリット

  • リポジトリにオフラインキャッシュをコミットすると、結構な容量になる

依存関係が全て入るので、serverlessを使ったAPIのパッケージの場合最低でも100MBぐらいは行くのではないかと思います。Githubのリポジトリの最大は1GBだったと思うので、少し怖いですね。

ただ、当たり前ですがライブラリをアップデートすると古いバージョンのオフラインキャッシュは消えますし、使うライブラリも常に増えていくという訳ではないので、そんなに問題になることはないのかなーと。

  • オフラインキャッシュを生成した環境によって、どうもchecksumが変わる問題があるっぽい

https://github.com/yarnpkg/berry/issues/2399

上記のissueの通りなのですが、オフラインキャッシュを生成した環境によってキャッシュのchecksumが変わるかもしれない問題があり(実際遭遇しました)、これを回避するにはリポジトリにオフラインキャッシュを入れるのが手取り早い解決策になってしまいます。そのため、オフラインキャッシュをリポジトリに含めざるを得なくなることがままあるだろうな、というのがデメリットですね。

ignoreの設定

yarn v2は.yarn以下に色々ファイルを生成するので、何をignoreするのかが結構難しいと思います。

https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored

ここに公式の設定が有るので利用すると良いと思います。(node_modulesを使う場合はオフラインキャッシュをignoreするような設定になってますが、ignoreしなくても動作に影響は無いです)

補足

yarn v2でモノレポをやるに当たって、実運用するなら知っておいたほうが良いことを補足します。

yarn v2でルートのnode_modules/.binを参照する

yarn v2では各workspaceからルートのnode_modules/.binを参照することは出来ません。(意図した仕様のようです)

そのため、例えばルートにjest/eslint/prettierを依存として追加し、各workspaceからはそれをlint: "eslint src ..."みたいにnpm scriptsで参照するというのは少しトリックが必要になります。

https://github.com/yarnpkg/berry/issues/2416

上記のissueにある通りにやれば大丈夫なのですが、例としてルートのeslintを各workspaceから参照させる設定方法を書きます。

  1. ルートのpackage.jsonにg:eslintというnpm scriptsを追加する。
...
  "scripts": {
    "g:eslint": "cd $INIT_CWD && eslint"
  }
...
  1. 各workspaceでは、g:eslintを使ってeslintを起動する
...
  "scripts": {
    "lint": "yarn g:eslint ..."
  }
...

これで良い感じにルートのeslintを使うことが出来るようになります。(jestもprettierなども同様の手順で実行可能です)

至極簡単にこのトリックの説明をしておくと、ルートのnode_modules/.binは各workspaceからは見えないが、ルートのnpm scriptsは各workspaceから見えるという仕組みを使ってます。($INIT_CWDは組み込みの変数で、実行時のカレントディレクトリを保持しています。つまり各workspaceのディレクトリを保持しているわけですね)

依存ライブラリの自動更新

Githubならdependabotで依存ライブラリの自動更新を行っている人が多いと思うのですが、dependabotはyarn v2に対応していません。(2021年5月16日時点)

https://github.com/dependabot/dependabot-core/issues/1297

なので、代わりにRenovateを使います。

https://github.com/marketplace/renovate

Github Appsとして導入でき、かつ無料なのでこちらで問題ない場合が多いのではないかと思います。

リポジトリの設定の共通化

eslint

eslintはルートの設定に対して、個々のリポジトリの設定がディープマージされるという仕様になっているのでモノレポと非常に相性が良いです。ルートに共通設定をまとめて差分だけを個々のパッケージに置きましょう。

jest

ルートに共通の設定を生成する関数を置いて、個々のパッケージではそれをrequireで読み込んで使うというやり方を取ってます。

  • ルート: jest.config.base.js
module.exports = (...) => {
  return {
    ...
  };
};
  • 個々のパッケージ: jest.config.js
const config = require('../../jest.config.base.js');

module.exports = config(...);

webpack

jestと基本的に同じ。

tsconfig

tsconfigには継承という機能があるので、モノレポのルートに共通tsconfig設定を定義して、個々のリポジトリでそれを継承します。

  • ルート: tsconfig.base.json
{
  ...
}
  • 個々のパッケージ: tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  ...
}

prettier

prettierの設定はもともと各リポジトリで共通なので、ルートに1つ用意して終わりにしました。各パッケージで差分がある場合でも、jest/webpackのような方法をとれば自由自在に設定できると思います。

VSCode

VSCodeにはワークスペース機能というのものがあり(yarn workspaceとは別物)、これを使うと非常にyarn workspaceとかみ合った運用を行うことができます。

ルートに<任意の名称>.code-workspaceというファイルを作成します。

{
  // ワークスペースのフォルダとして扱うディレクトリを指定する
  // nameはなんでもよく、サイドバーにそのまま表示されるのでわかりやすい名前を書くといいかも
  "folders": [
    {
      "name": "project-root",
      "path": "."
    },
    {
      "name": "API / HogeHoge",
      "path": "packages/hogehoge"
    }
    ...
  ],
  // 各フォルダで適用される共通設定
  "settings": {
    "typescript.tsdk": "node_modules/typescript/lib",
    "typescript.enablePromptUseWorkspaceTsdk": true,
    ...
  },
  // .vscode/extentions.jsonと同じことを書ける場所
  "extensions": {
    "recommendations": [
      ...
    ]
  }
}

このファイル、通常は.vscode/以下に作成するファイルの集合体みたいな存在になっており、ワークスペース内の各フォルダーの指定や共通設定、お勧め拡張機能の指定などができます。

まとめ

書いてみて思ったのですがほぼほぼyarn v2の設定の話に費やしてしまいました。実際Node.jsでモノレポをやる場合は、このパッケージマネージャーをどう選ぶか(npm/yarn/pnpm)、どう設定するかという問題が一番大きいのかなと思います。

今回npm workspaceやpnpm workspaceも試したのですが、serverless offlineが謎のout of memoryエラーで落ちたりして、最終的にyarn v1でnoHoist: "*"をするか、yarn v2を使うかという状況になっていました。最初yarn v2はやはりPlug'n'Playの印象が強くあまり乗り気でなかったのですが、nodeLinker: node-modulesの設定を知りこれなら行けるかもと思っていざ導入してみると、オフラインキャッシュは非常に快適でyarn v1の手触りのまま諸々が改善された状態を得ることが出来たと思ってます(yarn v1はもともとinstallが早かったですが、yarn v2はそれをさらに上回ってる感じがします)

プロジェクトによって最適なパッケージマネージャーの選択は変わると思いますが、yarn v2でモノレポを組み立てようとしてる方に参考になれば幸いです。