pnpmについて調べたのでメモ
はじめに
pnpmについて調べたのでその備忘録です。
特徴
- ディスク容量の節約
- インストールしたパッケージのファイルはディスク上の1つの場所に保存される。たとえば新しくプロジェクトを作成して、既に他のプロジェクトでインストール済みのパッケージをインストールした場合、重複してインストールされるのではなく、グローバルなストアに保存されているファイルへのハードリンクが作成されるため、ディスク領域を消費せずに済む。
- インストール速度の向上
- インストールはResolving, Fetching, Linking、の3段階で行われる。従来のインストールでは全てのパッケージが前のステージを完了しないと次のステージが実行されなかったが、パッケージごとにステージを進めるようになったため時間が短縮される
- 厳格さ
- node_modules直下には直接インストールしたパッケージのシンボリックリンクしか置かれないため、直接使用しているパッケージが明確かつ、それ以外のパッケージのimportができないようになっている。
- Monorepoに対応
- リポジトリ内マルチパッケージをサポート
ストアの仕組みを調べてみる
プロジェクトの用意
pnpmのインストール方法はインストール | pnpm
から。
下記のようなプロジェクトを作りました。
hello-pnpm1
というディレクトリを作ってpnpmでexpressのみインストールしたものです。
hello-pnpm1
┣ node_modules
┃ ┣ .pnpm
┃ ┣ express
┃ ┗ .modules.yaml
┣ package.json
┗ pnpm-lock.yaml
直接インストールしたものだけがnode_modules
配下にパッケージのシンボリックが作成されます。プロジェクトで使われているすべてのパッケージの依存関係はnode_modules/.pnpm
配下に.pnpm/<name>@<version>/node_modules/<name>
という命名規則で置かれています。
このディレクトリを仮想ストア
と呼ぶそうです。(実態となるファイルはグローバルなストアに保存されていて、node_modules/.pnpm
配下は依存関係を示すためのシンボリックリンクとグローバルなストアへのハードリンクで構成されているだけなのでそう呼んでいるんじゃないかなと)
(※)フラットな node_modules が唯一の方法ではありません | pnpm
保存先のストアはどこにあるのか?
hello-pnpm1/node_modules/.modules.yaml
を見てみると、storeDir
というキーに保存先のパスが書かれていました。
storeDir: /Users/ユーザー名/Library/pnpm/store/v3
実際に見てみるとfiles
というディレクトリがあり、その中にハッシュ化された名前のファイルが格納されています。
$ cd /Users/ユーザー名/Library/pnpm/store/v3
$ ls -1
files
github.com+vercel+pkg@7e4fef9f0828b11c7a9655b95425f32094f2daa2
tmp
試しにファイルを一つ覗いてみると何かしらの構成ファイルの中身だということがわかります。
$ cd files/00
$ cat 023cd7f571ac2aff67ed087e213b41824bd653816f82eb5e24e14532dd2ecfbccb9b328529e4aaa5bab5eec0a7d461e541b4614fb7745938c4cf21f26ed577
{
"sideEffects": false,
"module": "../esm/Modal/index.js",
"typings": "./index.d.ts"
}%
どうやってプロジェクトのファイルとストアのファイルを紐づけているのか?
hello-pnpm1/node_modules/.pnpm/lock.yaml
の中を覗くと、integrity
というキーにsha512のハッシュ値が書かれています。
...
/express@4.18.2:
resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==}
engines: {node: '>= 0.10.0'}
...
pnpmのコードを読むとcreateCafsStore
関数の中で、storeDir
にfiles
という文字列でパスを繋げている箇所があります。
ちなみにcafsとはcontent-addressable filesystem
の略のようです。
読み進めると、ここでファイルのマップを作っています。
getFilePathByModeInCafs
を見るとディレクトリ名にパスを繋げています。
contentPathFromIntegrity
を見てみるとssriでBase64データの16進数に変換した文字列を作って、contentPathFromHex
関数に渡しています。
contentPathFromHex
関数では受け取った16進数の最初の2桁をディレクトリ名、それ以降の文字列をファイル名とした文字列を作っています。
例えばindexタイプのファイルだとこんな感じになるわけですね。
e7f3ec2fa8863dd7d0fe528cd54ba27a5620bf7054a097f3d5a53053dbc767e27b832bf07505c510120421ac5e19fd0621cade013372044c6d6a58ac0dbb8ca9
↓
e7/f3ec2fa8863dd7d0fe528cd54ba27a5620bf7054a097f3d5a53053dbc767e27b832bf07505c510120421ac5e19fd0621cade013372044c6d6a58ac0dbb8ca9-index.json
ということはhello-pnpm1/node_modules/.pnpm/lock.yaml
のパッケージごとのintegrity
のハッシュ値からストアのファイル名が割り出せそうです。
実際に紐付けを確認してみる
とりあえず適当にハッシュ値をパースするためのコードを用意します。
import ssri from 'ssri'
const shaValue = process.argv[2]
const integrity = ssri.parse(shaValue)
const hex = integrity.hexDigest()
console.log("hex: ", hex)
https://github.com/t-shiratori/ssri-parser
hello-pnpm1/node_modules/.pnpm/lock.yaml
にあるexpress@4.18.2
パッケージのintegrity
のハッシュ値をパースしてみます。
$ node index.js sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==
hex: e7f3ec2fa8863dd7d0fe528cd54ba27a5620bf7054a097f3d5a53053dbc767e27b832bf07505c510120421ac5e19fd0621cade013372044c6d6a58ac0dbb8ca9
/Users/ユーザー名/Library/pnpm/store/v3/
配下のfiles/e7/f3ec2fa8863dd7d0fe528cd54ba27a5620bf7054a097f3d5a53053dbc767e27b832bf07505c510120421ac5e19fd0621cade013372044c6d6a58ac0dbb8ca9-index.json
を見てみます。
expressパッケージの情報が書かれいます。
{
"name": "express",
"version": "4.18.2",
"files": {
"LICENSE": {
"checkedAt": 1682147597317,
"integrity": "sha512-XggvFK90CGus/6a6u1h0Hs5AgOJqhMkVFp6F7JXtYHz/PDAgiK1yJad47jTiTl2DfiKwIWHNMLO2JyvsH9ItVg==",
"mode": 420,
"size": 1249
},
"lib/application.js": {
"checkedAt": 1682147597339,
"integrity": "sha512-wGMnelm4Pf/AhRFnaUdexczhxHwWe5vSJG6L2gTw68J3O18G47RPxe0FfgQ/bTPnd0HzTRXiJUITTjhlV0opvg==",
"mode": 420,
"size": 14593
},
"lib/express.js": {
"checkedAt": 1682147597339,
"integrity": "sha512-Kt1mtPLozkY0ScqPLqwZNjhEtqsVmkG0IWMCjFfwekJF6+/nWab5DoaFtb0jnJaf6ZNm7/iTeMuLkrinA9rNYQ==",
"mode": 420,
"size": 2409
},
"index.js": {
"checkedAt": 1682147597339,
"integrity": "sha512-dm0uIC3V5SCsIn4o48NZzKGDYFxStOTJXGmCXJKTVs6ncnI6mvSRo2YtPCb3IJ6JzDp69291FlwQRJLcZyiszA==",
"mode": 420,
"size": 224
},
"lib/router/index.js": {
"checkedAt": 1682147597342,
"integrity": "sha512-9jGAy0cYdKzFRLZ0b6voC3OU/VUYBn4UvW6euskIr2RfwWHrIAkkLYshUFtFYfH6FrwdWJ8JjQOoXF3K7xbcTQ==",
"mode": 420,
"size": 15124
},
"lib/middleware/init.js": {
"checkedAt": 1682147597342,
"integrity": "sha512-Z+p9u1yM83tSSyRv7ag5oyioJMTN/Zv5/5f3nyjEUEiz8hPjY7ifK+ODeBXEGQP2LTR527zFqVReiuvyFHRmig==",
"mode": 420,
"size": 853
},
"lib/router/layer.js": {
"checkedAt": 1682147597342,
"integrity": "sha512-fdyrQpqciFe9fpFwyS6/0+PFnOUjIwIbU+JLp3aro6dXCYrp8yZFheT+XzwzHnRzBzATRNwZ6uk0XmhimlxEDA==",
"mode": 420,
"size": 3296
},
"lib/middleware/query.js": {
"checkedAt": 1682147597342,
"integrity": "sha512-ANvW7Jlp6p2Fmp/TAzml3U/HDywY0bSamimDiaRHOo5/Wm+o0qggBTZDwUPXIC39ulkjbhmsKLXBkiXS31Lzhg==",
"mode": 420,
"size": 885
},
"lib/request.js": {
"checkedAt": 1682147597347,
"integrity": "sha512-98zTB35qTR4qKwCLsxVtqI93BbkghrVnVcfslczfH3lYOuQA9Rk5yd5trwPp9mQTcXIBhvGTZsgRHnRxNRYhsQ==",
"mode": 420,
"size": 12505
},
"lib/response.js": {
"checkedAt": 1682147597410,
"integrity": "sha512-8KB9ZAPj9KDCSjOqQ96MAZNU6f3owIRp2de2I8ZIbbxzm8Paumd4oMKnK6rhoC9WQgdGisqmB8pcP0EzHgPS3g==",
"mode": 420,
"size": 28014
},
"lib/router/route.js": {
"checkedAt": 1682147597378,
"integrity": "sha512-kQWGBSz0KAc6lZlqI315ke2o7nYCeF0qxNeNzFrjhq/EMK3vx0cxNK362f6GvB7PtbeGkdpBGozhS5xNMJDM1g==",
"mode": 420,
"size": 4281
},
"lib/utils.js": {
"checkedAt": 1682147597378,
"integrity": "sha512-FOD2s+3Z6l1kyYEnOK5V+MdhNNlmT/G/hNq9VNoPWHhaEcg1zUMLyS/E/9h1diee/aJiBgsMGGYtBkZP0bf6QQ==",
"mode": 420,
"size": 5955
},
"lib/view.js": {
"checkedAt": 1682147597419,
"integrity": "sha512-CqnvnwdpLQwlTN7d6AY7XR3U9agZafUpO3S8GLIWkEGJKhZOPB+9y+Bsiq+r1MKOkHLHVPtqi4M+YVPhw1mIbw==",
"mode": 420,
"size": 3325
},
"package.json": {
"checkedAt": 1682147597419,
"integrity": "sha512-DEjlVbIrdOe+uEMGy1vt6WG4pPIMkJbX9/ENcbQDrwL6vqYRtaf9JIUv329ae8JKTPRZ2j6vE5GpWAfKW7mVCA==",
"mode": 420,
"size": 2624
},
"History.md": {
"checkedAt": 1682147597483,
"integrity": "sha512-yceKA05lnmBVci2j1bzaTVh2RbfnJEAxirUW/3y15ETVNsAhaZl4SKW4obwK5E6dgFaKK2Crzp5reNutCqc8SQ==",
"mode": 420,
"size": 113107
},
"Readme.md": {
"checkedAt": 1682147597483,
"integrity": "sha512-ibR4XKDpueOD/qJ7Ta+XFS4DUjxVVyhNk7aHE6jfG7atULJUnALvtqyyw9unLpJSalyararMv1by0mWCMG1qNA==",
"mode": 420,
"size": 5419
}
}
}
この中のindex.js
のハッシュ値をさらに調べてみます。
{
"name": "express",
"version": "4.18.2",
"files": {
...
"index.js": {
"checkedAt": 1682147597339,
"integrity": "sha512-dm0uIC3V5SCsIn4o48NZzKGDYFxStOTJXGmCXJKTVs6ncnI6mvSRo2YtPCb3IJ6JzDp69291FlwQRJLcZyiszA==",
"mode": 420,
"size": 224
}
...
}
}
先ほどのパース処理を使います。
node index.js sha512-dm0uIC3V5SCsIn4o48NZzKGDYFxStOTJXGmCXJKTVs6ncnI6mvSRo2YtPCb3IJ6JzDp69291FlwQRJLcZyiszA==
hex: 766d2e202dd5e520ac227e28e3c359cca183605c52b4e4c95c69825c929356cea772723a9af491a3662d3c26f7209e89cc3a7af76f75165c104492dc6728accc
ファイルの中身を見てます。ちゃんとexpressのコードが書かれていました。
$ cd /Users/ユーザー名/Library/pnpm/store/v3/files/76
$ cat 6d2e202dd5e520ac227e28e3c359cca183605c52b4e4c95c69825c929356cea772723a9af491a3662d3c26f7209e89cc3a7af76f75165c104492dc6728accc
/*!
* express
* Copyright(c) 2009-2013 TJ Holowaychuk
* Copyright(c) 2013 Roman Shtylman
* Copyright(c) 2014-2015 Douglas Christopher Wilson
* MIT Licensed
*/
'use strict';
module.exports = require('./lib/express');
ちなみにこのファイルのinode番号を確認してみます。
$ cd /Users/ユーザー名/Library/pnpm/store/v3/files/76
$ ls -i1
...
49618440 6d2e202dd5e520ac227e28e3c359cca183605c52b4e4c95c69825c929356cea772723a9af491a3662d3c26f7209e89cc3a7af76f75165c104492dc6728accc
...
49618440
でした。
hello-pnpm1
プロジェクトにインストールしたexpressのハードリンクのinode番号を確認してみます。
$ cd /Users/ユーザー名/Documents/study/pnpm/hello-pnpm1/node_modules/.pnpm/express@4.18.2/node_modules/express
$ ls -i1
49618530 History.md
49618435 LICENSE
49618531 Readme.md
49618440 index.js
49619151 lib
49618494 package.json
49618440 index.js
グローバルストアと同じinode番号です。
試しに、hello-pnpm1
と同じ内容のhello-pnpm2
プロジェクトを作成してみます。
同様にハードリンクのinode番号を確認してみます。
$ cd /Users/ユーザー名/Documents/study/pnpm/hello-pnpm2/node_modules/.pnpm/express@4.18.2/node_modules/express
$ ls -i1
49618530 History.md
49618435 LICENSE
49618531 Readme.md
49618440 index.js
54405782 lib
49618494 package.json
49618440 index.js
こちらもグローバルストアと同じinode番号です。
これで新規のプロジェクトのパッケージもグローバルストアに保存されたファイルのハードリンクが使われていることが確認できました。つまり全て同じファイルデータを参照しているということがわかります。
最後に
個人的には、node_modules配下が実際に使ってるパッケージのシンボリックリンクだけになるのはわかりやすくていいなと思います。それからgitでworktreeを使うことが多いので容量節約になるのがとても助かります。
あとはターミナルのインストール結果表示も見やすい気がします。
「pnpm i」したターミナルの表示結果
$ pnpm i
Packages: +154
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Packages are hard linked from the content-addressable store to the virtual store.
Content-addressable store is at: /Users/shiratoritakashi/Library/pnpm/store/v3
Virtual store is at: node_modules/.pnpm
Downloading registry.npmjs.org/@swc/core-darwin-arm64/1.3.62: 12.1 MB/12.1 MB, done
Progress: resolved 184, reused 139, downloaded 15, added 154, done
node_modules/.pnpm/@swc+core@1.3.62/node_modules/@swc/core: Running postinstall script, done in 847ms
node_modules/.pnpm/esbuild@0.17.19/node_modules/esbuild: Running postinstall script, done in 936ms
dependencies:
+ react 18.2.0
+ react-dom 18.2.0
devDependencies:
+ @types/react 18.0.28 (18.0.38 is available)
+ @types/react-dom 18.0.11
+ @typescript-eslint/eslint-plugin 5.57.1 (5.59.0 is available)
+ @typescript-eslint/parser 5.57.1 (5.59.0 is available)
+ @vitejs/plugin-react-swc 3.0.0 (3.3.0 is available)
+ eslint 8.38.0
+ eslint-plugin-react-hooks 4.6.0
+ eslint-plugin-react-refresh 0.3.4
+ typescript 5.0.2 (5.0.4 is available)
+ vite 4.3.0 (4.3.1 is available)
Done in 8.4s
Discussion