フューチャー技術ブログ

JSパッケージ管理ツールpnpmの概要と内部構造を眺める

画像はpnpm Logosより引用しました。


2022年に入社したTIGコアテクノロジーユニット所属の齋藤と申します。

Vercelに触れてみようVercel CLIのインストール方法を調べていたところ、パッケージ管理ツールを用いたインストール方法としてnpm, Yarnと並んでpnpm1が書かれていました。npmとYarnは利用したことがある一方で、pnpmは馴染みがなく、触れたことがありませんでした。

pnpmではどのようにパッケージを管理しているかなどの特徴を調べました。

pnpmの概要

pnpmはJavaScript系のパッケージ管理ツールです。pnpmはperformant npmの略であり2、その名の通りパフォーマンス性を重視した設計になっています。

package.jsonを利用するほか、npmリポジトリにあるほとんどのパッケージが利用可能であるなど、npmとは互換性を持っています。

pnpm公式ドキュメントによると、pnpmは以下の特徴を有しています。

  • 高速: 他のツールより最大2倍高速に動作する
  • 高効率: node_modules配下にあるファイルはコンテンツのアドレス指定可能な単一のストレージ(contents-addressable storage)にあるファイルの複製、または、ハードリンクになっている
  • モノレポのサポート: 1つのリポジトリにパッケージを複数配置する構成をビルトインでサポートしている(workspace機能)
  • 厳格: デフォルトではnon-flat node_modulesを採用しており、node_modules配下にある任意のパッケージにはアクセスできないようになっている(dependeciesにないパッケージにアクセスできない)

npmやYarnでも対応している「モノレポのサポート」以外について、どのようにして、上記特徴を実現しているのかを後ほど見ていきます。

pnpmの初歩的な利用方法

pnpmでインストールしたパッケージの管理方法を見る前に、ごく簡単に利用方法を見ていきます。

以降Linux環境で用いることを想定してコマンド等を記載します。

pnpmのインストール方法はpnpm Installationを参照してください。

パッケージの追加/削除

コマンド自体は他のツールと大差ありません。expressを追加する場合を例とします。

# expressを追加する
$ pnpm add express

$ ls
node_modules package.json pnpm-lock.yaml

# package.jsonを確認する
$ cat package.json
{
"dependencies": {
"express": "^4.18.2"
}
}

# Expressを削除する
$ pnpm remove express

pnpm addを実行するとpackage.json, pnpm-lock.ymlが生成され、node_modules/配下にパッケージがインストールされます。package.jsonのフォーマットはnpmと同様です。また、pnpm-lock.yamlはnpmのpackage-lock.jsonと同様にインストールしたパッケージのバージョン等の情報が記録されています。

既にあるpackage.jsonから依存パッケージをインストールする場合は、pnpm installを実行すればよいです。

スクリプトの実行

こちらもnpm run <script_name>と同様のコマンドで実行できます。pnpm startpnpm testなどの短縮形でstartスクリプトやtestスクリプトを実行できることも同様にできます。

$ cat package.json
{
"scriptss": {
"hello": "echo hello"
}
}

$ pnpm run hello
> @ hello /path/to/preject
> echo hello

hello

内部構成を見てみる

基本的な利用方法はnpmと大差ないpnpmですが、インストールした依存パッケージの管理方法は大きく異なります。pnpmがどのように管理しているかを見ていきます。

以下のpnpm, npmのバージョンで確認しています。

$ pnpm --version
8.4.0
$ npm --version
9.5.1

また、今回は特にオプション等を設定していない場合の構造について説明します(設定によってはnpmと同様のディレクトリ構造にすることなども可能です)

node_modules

まずは、node_modules/配下を見てみます。依存パッケージにexpress@4.18.2のみを指定したpackage.jsonpnpm installを実行します。実行後のnode_modules/配下は以下の通りです。

# pnpm
$ ls node_modules/
express

$ tree -a node_modules/
node_modules/
├── express -> .pnpm/express@4.18.2/node_modules/express
├── .modules.yaml
└── .pnpm
├── accepts@1.3.8
│ └── node_modules
│ ├── accepts
│ │ ├── HISTORY.md
│ │ ├── index.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ └── README.md
│ ├── mime-types -> ../../mime-types@2.1.35/node_modules/mime-types
│ └── negotiator -> ../../negotiator@0.6.3/node_modules/negotiator
...
├── express@4.18.2
│ └── node_modules
│ ├── accepts -> ../../accepts@1.3.8/node_modules/accepts
│ ├── array-flatten -> ../../array-flatten@1.1.1/node_modules/array-flatten
│ ...
│ ├── express
│ │ ├── History.md
│ │ ├── index.js
...
├── node_modules
│ ├── accepts -> ../accepts@1.3.8/node_modules/accepts
│ ├── array-flatten -> ../array-flatten@1.1.1/node_modules/array-flatten
...

同様に、npmでnpm installした場合のnode_modules配下を確認します。

# npm
$ ls node_modules/
accepts array-flatten ... express ...

$ tree -a node_modules/
node_modules/
├── accepts
│ ├── HISTORY.md
│ ├── index.js
│ ├── LICENSE
│ ├── package.json
│ └── README.md
...
├── express
│ ├── History.md
│ ├── index.js
│ ├── lib
│ │ ├── application.js
│ │ ├── express.js
│ │ ...
│ │ └── view.js
│ ├── LICENSE
│ ├── package.json
│ └── Readme.md
...

pnpmの場合、/node_modules/直下には隠しファイルを除くとdependenciesに記述したexpressのみ存在し、expressはシンボリックリンクになっています。リンク先と同階層にはexpressが依存するパッケージへのシンボリックリンクが張られています。リンクの関係を下の図で示しました。

Node.jsでは実際に存在する場所(リンク先)から依存パッケージを解決するため、このようにシンボリックリンクを用いても依存パッケージを利用できます。

image.png

一方で、npm(V3以降)では基本的に/node_modules/直下にdependenciesの書かれたパッケージ以外にも、それらが再帰的に依存する全パッケージが配置されています。

npmでは、V2以前はある依存パッケージが依存するパッケージは依存元のパッケージ内にあるnode_modules配下に置かれる階層構造になっていましたが、V3以降はフラットな構造になっています。(ただし、依存パッケージfoo@1.0, bar@1.0がそれぞれbaz@1.x, baz@1.yに依存している場合、/node_modules/foo/node_modules/bazbaz@1.xパッケージが置かれる可能性があるなど、階層が生じる場合もあります3)

しかし、フラットな構造にしたことでpackage.jsondependenciesなどで指定していないパッケージをアプリで利用できるようになりました。例えば、expressのみをdependenciesに指定した状態で、以下のスクリプトをnodeで実行しようとします。

// index.js
const express = require('express')
const cookie = require('cookie')

npmでパッケージをインストールして、実行した場合エラーになりません。cookieexpressに依存されているため、node_modules直下にあり、nodeは解決できるからです。

dependenciesで指定していないパッケージを利用することは問題が起きる可能性があります。例えば、expressのメジャーバージョンを固定していたとします。cookieでメジャーアップデートが行われ、expressが対応した場合、expressに破壊的な変更がなければマイナーアップデートで更新されます。しかし、この状態でnpm updateを実行するとcookieがバージョンアップされるため、意図しない不具合が生じる可能性があります。

pnpmでは/node_modules/直下にはdependenciesなどで指定したパッケージしかないため、このような問題はおきません。上記スクリプトを実行するとエラーになります。

$ node index.js
node:internal/modules/cjs/loader:1078
throw err;
^

Error: Cannot find module 'cookie'
...

contents-addressable storage

pnpmでは、各プロジェクトのnode_modulesにパッケージを追加したとき、必ずディスク上にファイルの中身が配置されるとは限りません。pnpmを用いてダウンロードしたファイルなどはグローバルなストレージに一元管理することで、ディスク使用容量の削減や、高速化を図っています。

リポジトリからダウンロードしてきたファイルはグローバルなストレージに保存され、各プロジェクトのパッケージからハードリンクが張られます。

ハードリンクはファイルの属性情報を保持しているinodeに対して、リンクが張られます。シンボリックリンクとは異なり、ファイルに対するリンクではないため、最初に作成したファイルが削除されてもリンクおよびファイルの中身は維持され、あるinodeを参照するファイルがなくなったときファイルが削除されます。

※上では「グローバルなストレージに保存され…」と書きましたが、システム的にはこれもハードリンクの1つになります。

pnpmでは$PNPM_HOME/storeがグローバルなストレージとして用いられます。Linux環境のデフォルトでは$HOME/.local/share/pnpm/storeです。中身を見てみます。

$ tree store/
store/
└── v3
└── files
├── 00
│ ├── ae061b93bd3f7143a55922083f16ae281852332e5d1cee867417fc1b1189400def1e6700fb03ef304d0899e31c1e23f1d38cfc6c6efa14a9466958650359a7
│ └── dbd6ec9969ea9d859a9fd30339a5dd4fc70f2c18d1b49a9a298389a4473a8e7f5a6fa8d2a820053643c143d7202dfdba59236e19ac28b5c19225d2df52f386
├── 02
│ └── 07cf364e3eac974cae61ec68fe3975fd1f1eb6150f51293ce67f62dbb0f27a3d9c193101ef282dcd099fc653ca73cd3c875c18e5e266964038e3334697b5b4
...

$ cat store/V3/file/00/ae061b... | head 5
1.2.0 / 2022-03-22
==================

* Remove set content headers that break response
* deps: on-finished@2.4.1

$ sha512sum store/V3/file/00/ae061b...
00ae061b...

このように、ファイルの中身のsha512とファイルの中身のkey-valueストアになっていることがわかります。ファイルのsha512が一致するファイルがstoreに存在すればそれをハードリンクし、なければstoreに作成してからハードリンクするという仕組みになっています。

また、ファイルの中身以外にもパッケージのあるディレクトリに含まれるファイル情報を管理するindexファイルも存在し、Gitのblobとtreeオブジェクトの管理方法に似ていると思いました。

例えば、あるパッケージの複数のバージョンが依存パッケージとして必要なとき。2つのバージョン間では少数のファイルのみが変更され、多数のファイルには変更がないことも多いです。pnpmでは差分があるファイルのみ、新たにストレージに保存されるため、効率的に保存できます。

なお、ハードリンクでは先述の通りinodeの被参照数が0になったときファイルが削除されますが、storeにあるファイルが参照しているため、どのプロジェクトでも使われなくなったファイルもstoreに残り続けます。これらのファイルはpnpm store pruneコマンドを実行することで、削除できます。

pnpmを利用するか

ここまでpnpmの概要や、内部ではどのように管理することで高効率になっているかなどを見てきましたが、pnpmを趣味や、仕事の開発で使ってみたいか考えます。

まず、学習や趣味の開発などの個人利用ではpnpmの恩恵を受けやすく、利用しやすいと思います。以下の理由で恩恵を受けやすいと考えます。

  • 学習や、趣味の開発では様々なリポジトリからクローンするなどして、多数のnpmプロジェクトがローカルに保存されることになりやすいため、ディスク使用容量削減効果は大きい
  • 色々なパッケージを試す機会が多く、パッケージをインストールする頻度が高くなるため、インストール高速化の恩恵を受けやすい

一方、業務利用の場合でもpnpmは利用できそうですが、いくつか懸念点があります。

  • pnpmのネットでの情報量が少ない
    • 例えば、<ツール名> errorでのGoogle検索件数を見ると、npmが4,200万件、yarnが4,000万件であるのに対して、pnpmは70万件です。トラブルで開発が止まった時に多大な損失が生じる業務での開発では情報量の少なさは問題になりやすいでしょう
  • 他のツールから移行する場合、メンバーのキャッチアップする必要があったり、作業自動化に用いるスクリプトを改修をしなければいけなくなったりして、移行コストがかかる(pnpmだけではなく、移行全体にいえる問題ですが)

そして、pnpmを用いることで早くなるのはパッケージのインストールにかかる時間であり、アプリのパフォーマンスが向上するわけではありません。CIでテストを実行するときなどで、セットアップに多くの時間がかかっている場合はpnpmを使う効果が高いですが、そうではないことも多いでしょう。

pnpmを導入する手間に対して、pnpmを使うことによるメリットが見合っているかは考える必要があります。

まとめ

pnpmの概要と、内部でどのように管理しているのかを眺めました。

まだpnpmをそこまで本格的には利用してはいませんが、使っていて特に問題なければ、少なくとも個人開発では利用したいと考えています。

次は森さんのWebAssemblyとEmscriptenに入門した です。


  1. 1.pnpmが正確な表記です。ちなみに、公式ドキュメントトップページのタイトルではpnpmの各文字の大文字小文字がランダムに決まる(20秒ごとに再生成される)仕掛けになっており、筆者が最初に表記を確認したときはPnpmだったため、これが正確な表記と暫く勘違いしていました。
  2. 2.https://pnpm.io/faq#what-does-pnpm-stand-for
  3. 3.詳細はnpmのHow npm3 Worksを参照してください