30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

package.json やロックファイルによるパッケージの依存関係の管理

この記事では、npm installnpm ciを実行したときにどのようにパッケージがインストールされるのか、依存パッケージにバージョンのコンフリクトが発生した際にどのように処理されるのか、などを見ていく。必要に応じて Yarn での挙動にも触れる。

動作確認に使った npm のバージョンは6.14.5。Yarn は1.22.4
特に npm はバージョンによって動作が大きく異なるので、注意する。

package-lock.json によるバージョンの固定

package.jsonだけではインストールするパッケージのバージョンを固定できず、package-lock.json(Yarn の場合はyarn.lock)によってバージョンを固定する。
多くの人が知っている話ではあるが、重要な機能なので改めて触れておく。

package.jsondependenciesdevDependenciesは、デフォルトでは^を使ってバージョンを指定している。
そのため、インストールを行うタイミングによってパッケージのバージョンが変わってしまう。

例として、redux-thunk@2.1.0をインストールしてみる。
普通にインストールするとpackage-lock.jsonが作られてしまうので、--no-package-lockオプションをつけて検証する。こうすると、package-lock.jsonが作られない。
以下のコマンドを実行すると、redux-thunk@2.1.0がインストールされる(iinstallのエイリアス)。

$ npm i redux-thunk@2.1.0 --no-package-lock

npm lsで確認しても、間違いなくインストールされている。

$ npm ls | grep 'redux-thunk'
└── redux-thunk@2.1.0

この状態で、node_modulesを削除する。

$ rm -rf node_modules/

今、node_modulespackage-lock.jsonも存在しない状態になっている。この状態で$ npm iすると、どうなるか。

$ npm i
$ npm ls | grep 'redux-thunk'
└── redux-thunk@2.3.0

redux-thunk@2.3.0がインストールされてしまっている。
$ npm iのタイミングで作成されたpackage-lock.jsondependenciesにも、2.3.0で記録されている。

  "dependencies": {
    "redux-thunk": {
      "version": "2.3.0",
      "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz",
      "integrity": "sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw=="
    }
  }

これは、package.jsondependencies^を使っているため。

  "dependencies": {
    "redux-thunk": "^2.1.0"
  }

^2.1.0は、「2.xの最新バージョン」を意味する。そのため、記事執筆時の最新バージョンである2.3.0がインストールされた。
今後、例えば2.3.12.4.0がインストールされれば、もちろんそれがインストールされる。
つまり、インストールするタイミングによってパッケージのバージョンが異なってしまい、バージョンを固定できない。

最初にredux-thunk@2.1.0をインストールした際に--no-package-lockをつけなければ、その時点で以下の内容のpackage-lock.jsonが作られる。
そうすると、$ npm iした際にこの内容に基づいてインストールを行うため、必ずredux-thunk@2.1.0がインストールされるようになる。

  "dependencies": {
    "redux-thunk": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.1.0.tgz",
      "integrity": "sha1-xyS/7nXb41LaLjupvBQwK63Ympg="
    }
  }

これがpackage-lock.jsonの機能であり、この仕組みによって、全ての開発者に対して同じ環境を提供することが可能になる。

package.json で ^ を使わなくても問題の解決にはならない

だがそもそも、package.json^を使っているのが悪いのであって、2.1.0のように厳密にバージョンを指定すればよいのでは?
そうすればpackage-lock.jsonが無くてもバージョンを固定できるのでは?

そう思う人もいるかもしれないが、結論を言うと、その方法では上手くいかない。これからそれを確認していく。

まず、^をつけずにインストールする方法だが、installコマンドに-E、もしくは--save-exactオプションをつければよい。
そのため、以下のコマンドを実行すると、package.jsondependencies^なしで記録しつつ、package-lock.jsonは作成しない。

$ npm i redux-thunk@2.1.0 -E --no-package-lock
  "dependencies": {
    "redux-thunk": "2.1.0"
  }

この状態でnode_modulesを削除してからnpm installを実行してみる。

$ rm -rf node_modules/
$ npm i

すると、期待した通りredux-thunk@2.1.0がインストールされる。

$ npm ls | grep 'redux-thunk'
└── redux-thunk@2.1.0

つまり、このケースなら上手くいくのである。package-lock.jsonがなくても、インストールするパッケージのバージョンを固定できる。
このような言い方をしていることから分かるとは思うが、上手くいかないケースもあり、次はそのようなケースを見ていく。

一度プロジェクトを綺麗にして、node_modulespackage-lock.jsonが存在しない状態にする。package.jsondependenciesも空にする。

  "dependencies": {}

この状態で今度は、react@16.13.1をインストールしてみる。

$ npm i react@16.13.1 -E --no-package-lock

こうするとreact@16.13.1がインストールされるのだが、$ npm lsで確認すると、他のパッケージもインストールされている。

└─┬ react@16.13.1
  ├─┬ loose-envify@1.4.0
  │ └── js-tokens@4.0.0
  ├── object-assign@4.1.1
  └─┬ prop-types@15.7.2
    ├── loose-envify@1.4.0 deduped
    ├── object-assign@4.1.1 deduped
    └── react-is@16.13.1

node_modulesの中を確認しても、確かに入っている。

$ ls node_modules/
js-tokens   loose-envify    object-assign   prop-types  react       react-is

これは、reactがこれらのパッケージを必要としているため、つまりこれらのパッケージに依存しているためである。

具体的には、node_modules/react/package.jsondependenciesに書かれているパッケージが、reactが依存しているパッケージである。

  "dependencies": {
    "loose-envify": "^1.1.0",
    "object-assign": "^4.1.1",
    "prop-types": "^15.6.2"
  },

redux-thunkのときに他のパッケージがインストールされなかったのは、node_modules/redux-thunk/package.jsonには、dependenciesが何も指定されていなかったからである。
devDependenciesは指定されているが、これらは開発時に必要なパッケージであり、パッケージの動作には必要ない。そのため、ライブラリとしてインストールされる際にはdependenciesに指定されているパッケージのみがインストールされる。

reactのインストールに話を戻すと、必要としている 3 つのパッケージをインストールしたあと、今度はそれらが必要としているパッケージのインストールを開始する。
例えば、loose-envifypackage.jsondependenciesフィールドは、以下のようになっている。

  "dependencies": {
    "js-tokens": "^3.0.0 || ^4.0.0"
  },

そのため、js-tokensnode_modulesにインストールされる。

このように依存パッケージのインストールは連鎖していき、全ての依存パッケージがインストールされるまで繰り返される。

そして、既に気付いているかもしれないが、node_modules以下にある各種package.jsondependenciesは、^付きでバージョンを指定している。
つまり、依存パッケージのバージョンは固定されていない、ということである。

例えば、prop-types
改めてnode_modules/react/package.jsondependenciesを確認してみると、^15.6.2と指定されている。

  "dependencies": {
    "loose-envify": "^1.1.0",
    "object-assign": "^4.1.1",
    "prop-types": "^15.6.2"
  },

これは、15.xの最新バージョンを意味する。
実際にどのバージョンがインストールされているのか確認してみると、15.7.2である。

$ npm ls | grep 'prop-types'
  └─┬ prop-types@15.7.2

これは、記事執筆時点での15.xの最新バージョンが15.7.2だからそうなったに過ぎない。
仮に明日15.8.0がリリースされた場合、それ以降は15.8.0がインストールされるようになる。

このように、-Eオプションを使ったところで、固定できるのは直接インストールしたパッケージ(この例ではreact)だけであり、そのパッケージの依存パッケージのバージョンまでは、固定できないのである。

以上のことから、全ての開発者に同じ環境を用意するためには、素直にpackage-lock.jsonを使うべきという結論になる。
installコマンドではなく後述するciコマンドを使えば、より確実である。

Yarn の場合、バージョンを指定してインストールすると、特にオプションをつけなくても^がつかない。

$ yarn add react@16.13.1
  "dependencies": {
    "react": "16.13.1"
  }

だが、npm の場合と同様、これでバージョンが固定されるのはreactだけであり、依存パッケージのバージョンは固定されない。固定するためにはやはりロックファイル、つまりyarn.lockが必要になる。

ちなみに、ライブラリとして配布する際にpackage-lock.jsonを含めることはできない。そのためnode_modules以下にはpackage-lock.jsonは存在しない。
また、手動でnode_modules/react/package-lock.jsonのようなファイルを用意したとしても、プロジェクトのルートディレクトリ以外の場所にあるpackage-lock.jsonは無視されるため、意味がない。
これは npm の仕様上そうなっている。
Yarn の場合も同様で、プロジェクトのルートディレクトリ以外にあるyarn.lockは無視される。

依存パッケージにバージョンのコンフリクトが発生した場合の挙動

依存パッケージが増えていくと、複数のパッケージが依存しているパッケージ、というものが発生する。

実はreactのときも発生していたので、確認してみる。
今度は余計なオプションをつけずにインストールする。

$ npm i react

以下のグラフは、このときの依存関係を示している。

f:id:numb_86:20200524162505p:plain

例えばloose-envifyが重複している(上のグラフの緑の部分)。
reactが依存しているのだが、同じくreactが依存しているprop-typesも、loose-envifyに依存している。

しかし、npm は可能な限り重複を排除しようとするので、loose-envifynode_modulesに二重にインストールされてしまうことはない。

それぞれが必要としているloose-envifyのバージョンを見ると、react^1.1.0prop-types^1.4.0になっている。
この場合、1.4.0以上の1.x系をインストールすれば、どちらの条件も満たせる。
このような場合は、条件を満たすバージョンのパッケージをnode_modules直下にインストールするのである。

確認してみると、1.4.0がインストールされている。

$ cat node_modules/loose-envify/package.json | grep '"version"'
  "version": "1.4.0"

この仕組みにより、node_modulesは以下のような構造になっている。

f:id:numb_86:20200524163413p:plain

先程のグラフと照らし合わせてみると、全てのパッケージのバージョンが条件を満たしていることを確認できる。

だが、重複を排除できないこともある。
両立できないようなバージョン指定がなされているケースでは、それぞれのバージョンのパッケージをインストールしなければならない。
次はそのようなケースを見てみる。

reactを削除し、babel-loaderをインストールする(ununinstallのエイリアス)。

$ npm un react && npm i babel-loader

記事執筆時点での最新バージョンであるbabel-loader@@8.1.0がインストールされた。
依存の依存、も含めて多くのパッケージがインストールされたが、今回重要なのは、babel-loaderが直接依存しているloader-utilsである。
babel-loader^1.4.0を指定しており、node_modulesのなかを調べてみると、1.4.0がインストールされている。

$ cat node_modules/loader-utils/package.json | grep '"version"'
  "version": "1.4.0"

次に、style-loaderをインストールする。
style-loader@1.2.1がインストールされたが、このパッケージも、依存パッケージを持っている。
そのひとつがloader-utilsなのだが、^2.0.0でバージョン指定されている。node_modules/style-loader/package.jsondependenciesを見れば分かる。

ここで、問題が発生する。loader-utilsは既にbabel-loaderの依存パッケージとしてインストールされているが、そのバージョンは1.4.0である。
style-loaderが欲しいのはloader-utils@^2.0.0なので、条件が合わない。

f:id:numb_86:20200524164157p:plain

この場合、npm はnode_modules/style-loadernode_modulesを作り、そのなかに条件を満たすloader-utilsをインストールするようになっている。

確認してみると、確かにnode_modules/style-loader/node_modules/loader-utils@2.0.0がインストールされている。

$ cat node_modules/style-loader/node_modules/loader-utils/package.json | grep '"version"'
  "version": "2.0.0"

つまり、node_modulesは以下のような構造になっている。

f:id:numb_86:20200524165418p:plain

package-lock.jsonにも、そのように記述されている。

dependencies直下に1.4.0loader-utilsが記述されており、それとは別に、dependencies.style-loader.dependencies2.0.0loader-utilsが記述されている。

f:id:numb_86:20200524165504p:plain

ここまでの説明から分かるように、node_modulespackage-lock.jsonの構造は、パッケージをインストールする順番等によって変化する。

試しに、先程とは順番を逆にして、まずstyle-loaderをインストールする。
そうすると、node_modules直下にloader-utils@2.0.0がインストールされる。

$ cat node_modules/loader-utils/package.json | grep '"version"'
  "version": "2.0.0"

この状態でbabel-loaderをインストールすると、node_modules/babel-loader/直下にnode_modulesが作られ、loader-utils@1.4.0がそこにインストールされる。

$ cat node_modules/babel-loader/node_modules/loader-utils/package.json | grep '"version"'
  "version": "1.4.0"

package-lock.jsonは以下のような構造になる。

f:id:numb_86:20200524165905p:plain

loader-utils@2.0.0loader-utils@1.4.0の位置が先程とは逆になっているのが分かる。

このように、最終的なpackage.jsonの内容が全く一緒だとしても、そこに至るまでにどのような作業をしたかによって、package-lock.jsonの内容は変わり得るのである。

Yarn の場合

Yarn の場合も、基本的には同じ挙動になる。ただし、パッケージをインストールする順序に依存せず、常に同じ構造になる。
先程のbabel-loaderstyle-loaderのケースでは、どちらを先にインストールしても、node_modulesの直下にloader-utils@1.4.0がインストールされ、node_modules/style-loader/node_modulesloader-utils@2.0.0がインストールされる。

f:id:numb_86:20200524170052p:plain

そのため、style-loaderを先にインストールした場合、まずnode_modules直下にloader-utils@2.0.0がインストールされる。
その後、babel-loaderをインストールすると、node_modules/loader-utilsは一旦削除され、そこにloader-utils@1.4.0がインストールされる。そして、node_modules/style-loader/node_modulesに、loader-utils@2.0.0をインストールするのである。

yarn.lockの構造もpackage-lock.jsonとは異なり、入れ子にはならず、全てが並列的に記述される。

f:id:numb_86:20200524170515p:plain

npm update

パッケージをインストールしたりアンイストールしたりすることでpackage-lock.jsonは変化していくが、updateコマンドを実行することでも変化する。

まず、新規のプロジェクトにreact@16.0.0をインストールする。

$ npm i react@16.0.0

react@16.0.0の依存パッケージのひとつにfbjsがあるが、このパッケージには多くの依存パッケージがあり、それらも当然インストールされてpackage-lock.jsonに記録される。

次に、以下のコマンドを実行する。

$ npm update react

こうすると、16.xの記事執筆時点での最新バージョンであるreact@16.13.1にアップデートされる。
そしてそれだけでなく、依存パッケージの整理も行ってくれる。
このバージョンではfbjsへの依存はないため、fbjs自身やその依存パッケージはnode_modulesから削除され、package-lock.jsonにもそれが反映される。

npm ci

パッケージを指定せずにinstallコマンドを実行すると、package.jsonpackage-lock.jsonの内容に基づいてパッケージをnode_modulesにインストールする。
同様のコマンドとしてciがあり、これもnode_modulesにパッケージをインストールしてくれる。
だが両者にはいくつかの違いがある。

まず、ciinstallと違い、既存のnode_modulesを一度削除してからインストールを行う。

確認のため、まず以下のコマンドを実行する。

$ npm i redux-thunk && touch node_modules/redux-thunk/foo

こうすると、node_modules/redux-thunk/fooというファイルが作られる。

$ ls node_modules/redux-thunk/
LICENSE.md  README.md   dist        es      foo     index.d.ts  lib     package.json    src

この状態で$ npm iを実行しても、このファイルは残っている。
次に、$ npm ciを実行してからnode_modules/redux-thunkを確認する。
そうするとfooが消えているのが分かる。

$ ls node_modules/redux-thunk/
LICENSE.md  README.md   dist        es      index.d.ts  lib     package.json    src

そしてciは、package-lock.jsonがないとエラーになり、あったとしてもpackage.jsonの内容と矛盾があればやはりエラーになる。
この特徴によって、予期せぬ形でパッケージのインストールやpackage-lock.jsonの更新を防ぐことができる。

冒頭の例で示したように、package-lock.jsonが存在しなくても$ npm iによるインストールは行われてしまうし、そうするとインストールされるパッケージのバージョンは固定されない。

また、package.jsonpackage-lock.jsonの内容に矛盾があっても、$ npm iによるインストールはそのまま行われ、package.jsonの内容に基づいてpackage-lock.jsonが上書きされてしまう。 例えば、package.jsonpackage-lock.jsonが以下の内容だったとする。

  "dependencies": {
    "redux-thunk": "^1.0.0"
  }
  "dependencies": {
    "redux-thunk": {
      "version": "2.3.0",
      "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz",
      "integrity": "sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw=="
    }
  }

この状態で$ npm iすると、package.jsonの内容に基づき1.xの最新バージョンがインストールされ、package-lock.jsonは以下のように上書きされてしまう。

  "dependencies": {
    "redux-thunk": {
      "version": "1.0.3",
      "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-1.0.3.tgz",
      "integrity": "sha1-d4qgCZ7qBZUDGrazkWX2Zw2NJr0="
    }
  }

だがciの場合、package.jsonpackage-lock.jsonに矛盾があればエラーが発生し、インストールは中止される。
先程の状況で$ npm ciを実行すると、以下のエラーが出る。

npm ERR! Invalid: lock file's redux-thunk@2.3.0 does not satisfy redux-thunk@^1.0.0

このエラーを解消するには、package.jsonpackage-lock.jsonの内容を同期させる必要がある。

この仕組みによって、ciによるインストールは必ずpackage-lock.jsonの内容に基づいて行われることになり、ciでインストールする限り確実に同じ環境が構築されることを保証できる。また、予期せぬ形でpackage-lock.jsonが上書きされてしまうことも防げる。

yarn --frozen-lockfile

Yarn には npm ciと同じように動作するコマンドはない。

ただし、$ yarn(もしくは$ yarn install)を実行する際に--frozen-lockfileオプションをつけると、yarn.lockの更新が必要になった場合、つまりpackage.jsonyarn.lockの間に矛盾があった場合に、エラーを出すようになる。

しかし、npm ciと違い、yarn.lockがなくてもエラーにならない。この場合、package.jsonに基づいてインストールが行われ、yarn.lockは生成されない。
あくまでも「yarn.lockを生成せず、既存のyarn.lockに対して更新が発生した場合はエラーを出す」というオプションである。

参考資料