😸

node_modulesの問題点とその歴史 npm, yarnとpnpm

2023/06/23に公開
2

皆さんnpmパッケージのバージョンを上げるときにハマって依存地獄から抜けられなかったことはありませんか?

私はあります。

複雑怪奇な依存関係を調べてみようとnode_modulesを覗いてみて、そのカオスっぷりに臭いものに蓋をしたことはありませんか?

私はあります。

そこでnode_modules以下について調べてみたのですが、node_modulesにどんな問題点があって、npmやyarn, pnpmは何を目指していたのか時系列順に紐解いた方がわかりやすいことに気づきました。

ここでは初期のnpmが抱えていた問題から今に至るまでを順を追って説明します。
するとnode_modulesの仕組みの他に、各パッケージマネージャの方針の違いが見えてくるはずです。

初期の頃のnpm (~2015年以前)

この頃はシンプルで、依存関係はそのままnode_modulesのディレクトリ構造に反映されていました。
例えば以下のような依存のpackage.jsonがあるとします。

"dependencies": {
    "mod-a": "^1.0.0",
    "mod-c": "^1.0.0"
}

また、mod-amod-b^v1.0.0に依存していて、mod-cmod-b^v2.0.0に依存しているとします。図に表すと、下図のようになります。

図1: 単純な依存ツリー
図1: 単純な依存ツリー

このとき、npm v2でnpm installすると、node_modules以下のディレクトリは

node_modules/
|-- mod-a
|   `-- node_modules
|       `-- mod-b
`-- mod-c
    `-- node_modules
        `-- mod-b

となります。わかりやすいですね。node_modules直下のディレクトリには、プロジェクトに直接依存しているパッケージのみが配置されます。この時点ではなんの疑問もないと思います。

参考:
npm v2 Dependency Resolution: https://web.archive.org/web/20160315070134/https://docs.npmjs.com/how-npm-works/npm2

npm v3 (2015-06-25)

初期のnpmの依存解決はシンプルでわかりやすかったのですが、大きな問題がありました。

  • 依存関係が深くなると、そのままディレクトリ構造が深くなっていくこと
    • 特にWindowsではpathの文字列が256文字の制限に引っかかる
  • 同じパッケージが複数回現れること
    • ディスク容量の圧迫
    • コピーする分のインストール時間の増加

などです。こうした問題に対処するためにnpmはv3から重複削除(deduplication, 略してdedupe)を導入します。先の例を発展させて次の例を考えましょう。
図2: 複雑な依存ツリー
図2: 複雑な依存ツリー

プロジェクトに新たにmod-dを追加しました。mod-dmod-b^2.0.0を依存に持ちます。このプロジェクトで、npm v3を使ってインストールをすると、以下のようなnode_modulesとなります。

node_modules/
|-- mod-a
|   `-- node_modules
|       `-- mod-b
|-- mod-b
|-- mod-c
`-- mod-d

ここで、mod-bのv2.0.0は依存関係の中で重複しているので、親ディレクトリに配置されます。ちなみにこのことをnpm界隈では巻き上げ(hoisting)と言います。また, mod-bのv1.0.0はそのままmod-a以下のnode_modulesに置かれています。これはメジャーバージョンが異なるので、dedupeがされなかったわけです。こうしたnode_modulesのフラット化の努力により、上記の問題を解決することができました。
そしてこの仕様は今のnpmまで続いています。しかしnode_modulesにまつわる戦いはまだまだ続きます。

参考:
npm v3 release note: https://github.com/npm/npm/releases/tag/v3.0.0
npm v3 Dependency Resolution: https://web.archive.org/web/20160304023442/https://docs.npmjs.com/how-npm-works/npm3
npm3 Duplication and Deduplication: https://web.archive.org/web/20160315070510/https://docs.npmjs.com/how-npm-works/npm3-dupe
npm v3′s approach to node_modules: https://blog.izs.me/2015/07/npm-v3s-approach-to-nodemodules/

yarnの登場 (2016-10-11)

npm v3の登場である種の問題は解決されました。しかしdedupeは新たな問題を生み出しました。

  • 直接依存していないモジュールをrequireできる
    (hoistingによって、dependenciesに書かれていないモジュールがnode_modules以下に現れます。つまり、dependenciesに書かれていないモジュールまでrequireできてしまいます)
  • dedupeする分npm installのパフォーマンスの低下
  • npm installをする順番で、node_modules以下のディレクトリ構造が変わる

npm v3の新機構によって、こうした問題が出てきました。とくに3つ目は重大で、npm installをする順でどのversionがhoistingされるかが変わってしまいます。
例えば図1の例で、npm install mod-aの後にnpm install mod-cとすると以下のようなツリーになります(npm v3.8.6)。

node_modules/
|-- mod-a
|-- mod-b <--- v1.0.0
`-- mod-c
    `-- node_modules
        `-- mod-b <--- v2.0.0

一方で、npm install mod-cの後にnpm install mod-aとすると以下のようなツリーになります。

node_modules/
|-- mod-a
|   `-- node_modules
|       `-- mod-b <--- v1.0.0
|-- mod-b <--- v2.0.0
`-- mod-c

これは大規模開発や複数人による開発に致命的で、npm installしてもnode_modules以下の構造が人によって異なり動かないといった問題が多発しました。

こうした問題に対して、Facebookがyarnを引っ提げて現れます。yarnは二つの点で革新的でした。

  • 木構造が不変のアルゴリズム
  • ロックファイル(yarn.lock)によるバージョニング

yarnを使えば必ず同じツリー構造が再現できるというのは、当時では非常に便利で革新的なものだったはずです(余談ながら、私はまだこの時点ではWeb開発とは無縁で、当時の状況を汲み取ることしかできません)。

またロックファイルを導入したことも革新的でした。package.jsonだけでは依存モジュールのバージョンは確定しません(この点が理解していない方はSemantic Versioningなどでググって他の記事を参照してください)。こうしたこともnpm installしたが動かないという原因の一つでした。yarnはこうした改善により、依存ライブラリの再現性を担保しました。

余談ですが、厳密にはnpmもv1.xからnpm-shrinkwrap.jsonというロックファイルがありました。
shrinkwrapは今のpackage-lock.jsonとほぼ同等の機能を持っていましたが、主に公開パッケージのための機能でした。yarnのロックファイルのようにgit管理して開発サイクルの中で使うということはあまり想定されていませんでした。

参考:
Yarn: A new package manager for JavaScript: https://engineering.fb.com/2016/10/11/web/yarn-a-new-package-manager-for-javascript/
npm3 Non-determinism: https://web.archive.org/web/20160304015820/https://docs.npmjs.com/how-npm-works/npm3-nondet
v5.0.0 release note: https://github.com/npm/npm/releases/tag/v5.0.0
spec: Describe npm-shrinkwrap.json and package-lock.json: https://github.com/npm/npm/pull/16441
add "npm shrinkwrap": https://github.com/npm/npm/commit/d54ce3154dfe5283fcfeffc13d4e003bbade6370

npm v5 (2017-05-26)

yarnはとても革新的でしたが、まだ多少の問題はありました。
まず、図1の例でyarnを実行した時のyarn.lockを覗いてみましょう。

# yarn lockfile v1


mod-a@^1.0.0:
  version "1.0.0"
  resolved "https://registry.yarnpkg.com/mod-a/-/mod-a-1.0.0.tgz#d27217e16777d7c0c14b2d49e365119de3bf4da0"
  integrity sha512-LHSY3BAvHk8CV3O2J2zraDq10+VI1QT1yCTildRW12JSWwFvsnzwLhdOdrJG2gaHHIya7N4GndK+ZFh1bTBjFw==
  dependencies:
    mod-b "^1.0.0"

mod-b@^1.0.0:
  version "1.0.0"
  resolved "https://registry.yarnpkg.com/mod-b/-/mod-b-1.0.0.tgz#0d6e560f07d533708a39693b5de7188db74b66b8"
  integrity sha512-w3+jMEBzh6ap32RoJkmkFSIi6EmBYArDviaA9mAri/zfhu5pKcIFhyiGdtt9Ce9Wz6aF7wkkL9hMd3F4XWgjsA==

mod-b@^2.0.0:
  version "2.0.0"
  resolved "https://registry.yarnpkg.com/mod-b/-/mod-b-2.0.0.tgz#d3c10b5815b31689a51b7c7d84341825353a2382"
  integrity sha512-F1mbrVGqDeid+VoEdswLYsznXnTG/k8xf5aYRTX7ifhzWk9yzwQJPq5wHikqx+/eLzwEaj9tjVQSLO2prdRZew==

mod-c@^1.0.0:
  version "1.0.0"
  resolved "https://registry.yarnpkg.com/mod-c/-/mod-c-1.0.0.tgz#849adb050fcb7f5dd463b105dbf23771a3bd9df0"
  integrity sha512-aUhu8lL4T+UYGNi9qd+DqBfCuDaZxkBJ0gDC5lS9WhQmLusTncROjXL0W8JvVe3mvwrbJCTTbyJ8SJpm1pd9Og==
  dependencies:
    mod-b "^2.0.0"

yarn.lockはパッケージの詳細が記載されているのみで、node_modules以下の木構造の情報は含みません。node_modules以下の木構造はyarnのアルゴリズム、すなはちyarnのバージョンや設定によって変わってしまうということで、今度はyarnのバージョン管理が必要となりました。

そこでnpmはv5からロックファイル(package-lock.json)を導入しました。以下が図1の例でnpm v5を使って生成したpackage-lock.jsonです。(lockfileVersionがv1なのに注意。現在はv2になっています。)

{
  "name": "node",
  "version": "1.0.0",
  "lockfileVersion": 1,
  "requires": true,
  "dependencies": {
    "mod-a": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/mod-a/-/mod-a-1.0.0.tgz",
      "integrity": "sha512-LHSY3BAvHk8CV3O2J2zraDq10+VI1QT1yCTildRW12JSWwFvsnzwLhdOdrJG2gaHHIya7N4GndK+ZFh1bTBjFw==",
      "requires": {
        "mod-b": "1.0.0"
      },
      "dependencies": {
        "mod-b": {
          "version": "1.0.0",
          "resolved": "https://registry.npmjs.org/mod-b/-/mod-b-1.0.0.tgz",
          "integrity": "sha512-w3+jMEBzh6ap32RoJkmkFSIi6EmBYArDviaA9mAri/zfhu5pKcIFhyiGdtt9Ce9Wz6aF7wkkL9hMd3F4XWgjsA=="
        }
      }
    },
    "mod-b": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/mod-b/-/mod-b-2.0.0.tgz",
      "integrity": "sha512-F1mbrVGqDeid+VoEdswLYsznXnTG/k8xf5aYRTX7ifhzWk9yzwQJPq5wHikqx+/eLzwEaj9tjVQSLO2prdRZew=="
    },
    "mod-c": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/mod-c/-/mod-c-1.0.0.tgz",
      "integrity": "sha512-aUhu8lL4T+UYGNi9qd+DqBfCuDaZxkBJ0gDC5lS9WhQmLusTncROjXL0W8JvVe3mvwrbJCTTbyJ8SJpm1pd9Og==",
      "requires": {
        "mod-b": "2.0.0"
      }
    }
  }
}

package-lock.jsonは木構造を含むので、npmのバージョンに依存することなく、node_modulesの木構造を再現することができます。
こうしてnpmはyarnの問題点を克服しました。ただしnpmは、この時点でもまだ決定論的に木構造は決まらず、それはnpm v7を待つことになります(npm installの順番で木構造が変わるという話)。

参考:
npm v5 release note: https://github.com/npm/npm/releases/tag/v5.0.0

pnpmの登場と新たなアプローチ (2017-06-28)

ここで全く別のアプローチをするパッケージマネージャーが現れます。pnpmはnpm v3のdedupeとhoistingの仕組みを否定し、シンボリックリンクを利用したnode_modulesを提案します。
図1の例でpnpmを実行すると、node_modulesは以下のようになります(一部ファイルは省略)

node_modules/
|-- .pnpm
|   |-- mod-a@1.0.0
|   |   `-- node_modules
|   |       |-- mod-a
|   |       `-- mod-b -> ../../mod-b@1.0.0/node_modules/mod-b
|   |-- mod-b@1.0.0
|   |   `-- node_modules
|   |       `-- mod-b
|   |-- mod-b@2.0.0
|   |   `-- node_modules
|   |       `-- mod-b
|   |-- mod-c@1.0.0
|   |   `-- node_modules
|   |       |-- mod-b -> ../../mod-b@2.0.0/node_modules/mod-b
|   |       `-- mod-c
|   `-- node_modules
|       `-- mod-b -> ../mod-b@1.0.0/node_modules/mod-b
|-- mod-a -> .pnpm/mod-a@1.0.0/node_modules/mod-a
`-- mod-c -> .pnpm/mod-c@1.0.0/node_modules/mod-c

この構造はよくみると、シンボリックリンクを多用しているだけで、初期の頃のnode_modulesの構造と同一です。この構造も非常にシンプルでわかりやすいと思います。一方でモジュールが重複してしまう問題をシンボリックリンクで解決しています。
(また、Windowsの256文字問題もJunctionを利用して解決しています。このあたりは詳しくないので、他の文献にあたってください。 参考: does it work on windows?: https://pnpm.io/faq#does-it-work-on-windows)

余談: なぜnpm v3でシンボリックリンク方式を導入しなかったのか

私にはpnpmのシンボリックリンクのアプローチが最も妥当に思えました。dedupeには複雑なアルゴリズムを実装する必要がありますし、ディレクトリ構造も複雑になります。色々と調べているとnpmの開発者であるisaacs氏のこんなコメントを見つけました。

訳: シンボリックリンクのネストは、よほど慎重な計画を立てないと私はやりたくない。npm 0.xを覚えている人なら、私が慎重な理由を理解してくれるだろう。

symlink nest is not something I'm eager to do without a lot of very careful planning. Anyone who remembers npm 0.x will understand why I am cautious.

引用元: https://github.com/nodejs/node-v0.x-archive/issues/6960#issuecomment-46704998

このコメントから、npm v3の際にシンボリックリンクを用いたpnpmのような方式を検討したことがうかがえます。しかし、npm v0.xの際に何があったかまではわかりませんでした。そこで色々インターネットに潜っていると、以下のIssueを見つけました。

訳: ずいぶん前のある日、モジュール・ディレクトリのシンボリックリンクをサポートするとうたったNodeのバージョンがリリースされた。その実装には欠陥があった。「サポート」をオフにする方法がなかったのだ。こうしてエコシステムは、シンボリックリンクは「非常に悪いもの」であり、実際には避けるのが最善であると宣言し、Nodeを実際に改善することはおろか、うまく動作する可能性もないと信じることにした。

One day long ago, a version of node was released that purported to support symlinking of module directories. Its implementation was flawed. It offered no way to turn the "support" off. And thus the ecosystem proclaimed symlinks to be "a very bad thing", best avoided in practice, choosing to believe they could never possibly work well with, let alone actually improve node.

引用元: https://github.com/pnpm/pnpm/issues/496

こうした事情からnpmも同様にこのバグを避けるためにフラット化のアプローチをとったことが推察できます(しかし、この辺りの詳細は、初期のフォーラムまで遡っても見つけることはできませんでしたので真相はわかりません)。そしてそれは2016年後半になってようやく解決されました。https://github.com/nodejs/node/pull/9719 このような事情からシンボリックリンクを使ったpnpmが登場したというのが経緯のようです。またnpm側もpnpmのような方式を検討はしているようで、今後変化があるかもしれません(追記: 後述しますがnpm v9.4.0でexperimentalですがシンボリックリンク方式が利用できるようになりました)。

訳: 私たちはnpm v8で、2019年にKat Marchánが書いた概念実証であるTinkをモデルにした仮想ファイルシステムのアプローチを模索するつもりです。また、pnpmのレイアウト構造のようなものへの移行についても話していますが、これはある意味でTinkよりもさらに大きな破壊的変更になります。

We intend to explore a virtual file system approach in npm v8, modeled on Tink, the proof of concept Kat Marchán wrote in 2019. We’ve also talked about migrating to something like pnpm’s layout structure, though this is in some ways an even bigger breaking change than Tink would be.

引用元: https://blog.npmjs.org/post/621733939456933888/npm-v7-series-why-keep-package-lockjson

参考:
Why should we use pnpm?: https://www.kochan.io/nodejs/why-should-we-use-pnpm.html
does it work on windows?: https://pnpm.io/faq#does-it-work-on-windows

yarn PnP (2018-09-xx)

ここでyarnはPlug'n'Play(PnP)という機能を公表しました。yarnは、node_modulesの問題点について以下のように指摘しました。

  • Nodeはrequireを呼び出す際、単純にファイルシステム内で一致するものが見つかるまで探索し、それを利用します。これは特にNodeの起動時に大量のファイルシステムI/Oを発生させます。これがNodeの起動が遅い大きな原因です。
  • パッケージマネージャはnode_modulesを作成する際、キャッシュから大量のファイルをコピーします。これがnpm installの最も大きなボトルネックです。

そこでyarn PnPはそもそもnode_modulesを作らないというアプローチを採用します。代わりにyarn PnPは.pnp.cjsというファイルを作成します。このファイルはnodeのrequireをオーバーライドします。.pnp.cjsは全てのモジュールへのパスが含まれており、require時にnode_modulesを探索する必要がありません。確かにこれは実行時の動作が早くなることが容易に想像できます。しかし、この方式は当然ながらNodeの標準のrequireを置き換えるので、対応していないパッケージも多くあります。こうしたyarn PnPはyarn v2の機能として、2020年初頭にリリースされました。

しかしながら、そのアグレッシブな方式から、まだあまり採用されていないようです。またyarn自身もそれを認識しているのか、PnPと従来のnode_modulesを生成するモードが切り替えれるようになっています。

追記

requireをオーバーライドすると書きましたが、今の実装ではModule._loadをオーバーライドしていると書いてありました。

The current implementation overrides Module._load, but Node 10 recently released a new API that we plan to use to register into the resolver.
引用元: https://github.com/yarnpkg/rfcs/blob/master/accepted/0000-plug-an-play.md

参考:
Plug'n'Play: https://yarnpkg.com/features/pnp
Plug'n'Play Whitepaper: https://github.com/yarnpkg/rfcs/blob/master/accepted/0000-plug-an-play.md

npm v7 (2020-10-13)

npm v7ではnpm installの順番によらず、決定論的にnode_modulesの木構造が決まるようになりました。ここに来てようやくnpmもyarn v1と同等の機能を持つことになりました。またpackage-lock.jsonがv2となり、パフォーマンスの改善が施されました。

参考:
npm v7 release note: https://github.com/npm/cli/releases/tag/v7.0.0
Presenting v7.0.0 of the npm CLI: https://github.blog/2020-10-13-presenting-v7-0-0-of-the-npm-cli/
npm v7 Series - Why Keep package-lock.json?: https://blog.npmjs.org/post/621733939456933888/npm-v7-series-why-keep-package-lockjson

npm v9.4.0 (2023-01-25)

npmに--install-strategy=linkedオプションが追加され、シンボリックリンク方式がnpmでも利用可能になりました(ただし2023-06-23現在ではexperimentalです)。図1の例でnpm install --install-strategy=linkedを試してみるとディレクトリは以下のようになります。

|-- .store
|   |-- mod-a@1.0.0-JLAJKScwD0n7X1X9XwbV7g
|   |   `-- node_modules
|   |       |-- mod-a
|   |       `-- mod-b -> ../../mod-b@1.0.0-zk-UsSOKv0TQtzcw9jp6dw/node_modules/mod-b
|   |-- mod-b@1.0.0-zk-UsSOKv0TQtzcw9jp6dw
|   |   `-- node_modules
|   |       `-- mod-b
|   |-- mod-b@2.0.0-_cHnd1clcmzVpQqwPsnTQg
|   |   `-- node_modules
|   |       `-- mod-b
|   `-- mod-c@1.0.0-BvKdLXHRNfrMG-mNfxZeZA
|       `-- node_modules
|           |-- mod-b -> ../../mod-b@2.0.0-_cHnd1clcmzVpQqwPsnTQg/node_modules/mod-b
|           `-- mod-c
|-- mod-a -> .store/mod-a@1.0.0-JLAJKScwD0n7X1X9XwbV7g/node_modules/mod-a
`-- mod-c -> .store/mod-c@1.0.0-BvKdLXHRNfrMG-mNfxZeZA/node_modules/mod-c

全くpnpmと同じディレクトリ構成ですね!npmは柔軟に他のパッケージマネージャの利点を取り入れようとしているのが伝わります。

参考:
npm-install: https://docs.npmjs.com/cli/v9/commands/npm-install#install-strategy
npm v9.4.0 change log: https://docs.npmjs.com/cli/v9/using-npm/changelog#940-2023-01-25
isolated mode: https://github.com/npm/rfcs/blob/main/accepted/0042-isolated-mode.md
謝辞: ご指摘ありがとうございます @ybiquitous: https://twitter.com/ybiquitous/status/1672245597758443522?s=20

終わりに

ここではnode_modulesが抱える問題点と、各パッケージマネージャの取り組みを時系列に沿って説明しました。ただNodeのパッケージマネージャの戦いはnpmの進歩により一応の決着はついたと思います。特にnpm v7以降はあまり大きな話題がなさそうです。かくいう私もnpm v7以降、大きな不満もなく使っています。また今回の調査で私はpnpmの実装が一番妥当だと感じましたし、またyarn PnPも一目置く必要があると感じます。requireを上書きするやり方はやりすぎ感が否めないにしても、そもそもnodeのrequireのアルゴリズム原因でパフォーマンスの劣化を招いているという指摘は妥当性を感じます。ともあれnodeのエコシステムがどう進歩していくのか楽しみです。

参考:
node require algorithm: https://nodejs.org/api/modules.html#all-together

更新履歴

2023-06-23 引用の翻訳を追加, yarn PnPについて加筆, 各種修正
2023-06-24 npm v9.4.0を追加
2023-06-25 shrinkwrapについて追加
2023-06-28 Directoryの誤りを訂正, shrinkwrapについて修正

Discussion

フシハラフシハラ

pythonとか他の言語だと、DLした外部のパッケージは共通部分に配置するって方法もあるよな。
というか、色んな言語をやってるとそっちの方が多くて
プロジェクトフォルダの中に外部パッケージ専用のディレクトリを作る方式は結構レアケースだと思う

元がjsだからそれに合わせたんだろうけど…。

SaggggoSaggggo

確かに他の言語だとあまり聞いたことがない問題ですよね

どちらかというとNodeのrequireのアルゴリズムに合わせるために各パッケージマネージャが苦労しているといった感じで、じゃあそのNodeはというとモジュールの仕組みはCommonJSの仕様で決まってるし後方互換性のために簡単には変えられないといった感じじゃないかなぁと思ってます(あまり裏はとってないです)。