パッケージマネージャがパッケージをインストールする仕組み

この記事は CAMPHOR- Advent Calendar 2017 の20日目の記事です.

Python では pip,Ruby では Bundler,JavaScript (Node.js) では npm と様々なパッケージマネージャが存在します.これらを使うと,パッケージをいい感じにインストールして使えるようにしてくれますが,どのようにしてパッケージがインストールされているのかあまりよく把握していない人もいると思います.また,Ruby では GemfileGemfile.lock, Python では requirements.txt,Node.js では package.jsonpackage-lock.jsonyarn.lock といったファイルがパッケージマネージャによって使われていますが,このようなファイルは何のために存在するのか分からない人もいるかと思います.

この記事では,パッケージマネージャがどのようにパッケージをインストールしているのかを紹介します.まず,パッケージマネージャが存在しない場合を仮定してみて,そこから様々な機能を追加していくことでパッケージマネージャが何をしているかを順に見ていきます.

注意

この記事では主に JavaScript や Python,Ruby といったスクリプト言語におけるパッケージ管理について紹介します.プログラミング言語によって色々差異があるため,この記事の内容がすべてのプログラミング言語に当てはまらないことに注意して下さい.

パッケージとは

パッケージやライブラリ,モジュールといった言葉は基本的には似ているものです.ただし,プログラミング言語毎によって様々な定義がなされているため,一般的な説明は難しいです.ここでは以下のように定義してみます.

様々な他のプログラムから利用できるように,汎用性の高いプログラムをまとめたものをライブラリと言います.例えば Python の requests は HTTP クライアントの機能を提供してくれるライブラリで,Node.js の express は Web アプリケーションを作成するためのライブラリです.

パッケージというのは,このライブラリを再配布しやすい形にまとめたものだと考えられます.一般的にソフトウェア (ライブラリ自体のソースコード) に加えて,メタデータ (パッケージの名前やバージョンの情報),ドキュメントなどがパッケージとしてまとめられています.Ruby では gem,Rust では crate のように,プログラミング言語毎にパッケージを表す独自の名前が付いていることもあります.

もしパッケージマネージャがなかったら

パッケージマネージャの仕組みを分かるために,パッケージマネージャを使わずにパッケージをインストールする方法を考えてみましょう.実は単純なパッケージはファイル操作だけで簡単にインストールすることが出来ます.

インストールされたパッケージがプログラム中から使える仕組みは次のようになっています.多くのプログラミング言語処理系はライブラリをインストールするためのディレクトリを持っており,このディレクトリにライブラリ (ソースコード) のファイルを置いておくと,プログラム中からライブラリを利用できるようになっています.

つまり,パッケージマネージャはパッケージからライブラリのソースコードを取り出し,決められたディレクトリにファイルをコピーすることで,パッケージをインストールしています.(これは最も単純な場合なので,ファイルをコピーする前にコンパイルするような場合もあります.)

実際に Node.js で,パッケージマネージャを使わずにパッケージをインストールする例を紹介します.架空のmypackage というパッケージの hello という関数を呼び出すプログラムを書いてみます.

const mypackage = require("mypackage");
mypackage.hello();
// Hello, world! と表示される

このプログラムを実行すると以下のようになります:

$ mkdir -p node_modules/mypackage
$ echo '
const mypackage = require("mypackage");
mypackage.hello();' > main.js
$ node main.js
module.js:538
    throw err;
    ^

Error: Cannot find module 'mypackage'
    at Function.Module._resolveFilename (module.js:536:15)
    at Function.Module._load (module.js:466:25)
    at Module.require (module.js:579:17)
    at require (internal/module.js:11:18)
    at Object. (/tmp/pack/main.js:2:19)
    at Module._compile (module.js:635:30)
    at Object.Module._extensions..js (module.js:646:10)
    at Module.load (module.js:554:32)
    at tryModuleLoad (module.js:497:12)
    at Function.Module._load (module.js:489:3)

まだパッケージをインストールしていないので,当然エラーになります.それでは実際にパッケージをインストールしてみましょう.Hello world を表示する関数 hello を含む mypackage のソースコードは次のようになります:

module.exports.hello = () => console.log("Hello, world!");

Node.js では,パッケージは node_modules/<package_name> というディレクトリににインストールすることで利用できます.実際にインストールして再度main.js を実行してみましょう.今度は正しくパッケージが読み込まれて,Hello world! が表示されます:

$ mkdir -p node_modules/mypackage
$ echo 'module.exports.hello = () => console.log("Hello, world!");' > node_modules/mypackage/index.js
$ node main.js 
Hello, world!

これで npm や yarn のようなパッケージマネージャを使わずに簡単なパッケージをインストールすることが出来ました!

しかし,このような方法ではいくつかの問題があります.まず,パッケージのフォーマットが定まっていないため,パッケージ毎にどのファイルをインストールすれば良いか調べる必要があります.また,多くのパッケージをインストールする場合,手動で正しい場所にファイルをコピーするのは不可能です.利用するパッケージの数はプロジェクトの規模や言語によっても異なりますが,Node.js ではそれほど大きいプロジェクトでなくても数千個のパッケージを扱うことがあります.これらの問題を解決する方法を次に見ていきます.

パッケージのフォーマット・メタデータ

自動でパッケージをインストールできるようにするには,まずパッケージのフォーマットを決めることが必要です.例えば,パッケージ名やバージョンのようなメタデータを特定の名前のファイルに記述することや,ソースコードをメタデータを決まった形式 (ZIP など) で圧縮するといったことがあります.

メタデータを含むファイルは Python では setup.py,Ruby では <name>.gemspec,Node.js (JavaScript) では package.json といった名前になっています.Node.js でのメタデータの例を以下に挙げます:

{
  "name": "mypackage",
  "version": "1.2.3",
  "description": "My First Package",
  "main": "index.js",
  "author": "Yusuke Miyazaki",
  "license": "MIT"
}

メタデータにはパッケージ名やバージョン,説明,作者,ライセンスなどの情報が含まれているのが分かると思います.

このようなパッケージのフォーマットが決まれば,作成したパッケージをインターネット上に公開しておいて,それをインストールするようなパッケージマネージャを作ることが出来ると思います.使用イメージは以下のような感じです

my_package_manager install https://example.com/packages/mypackage.zip

手動でファイルをコピーしていた例と比べると大きく進歩していますが,いくつか気になる点が残ります:

  • パッケージを指定するために URL 等を書かなければならない
  • パッケージ名が衝突する (被る) 可能性がある
  • パッケージを公開するためのサーバーをそれぞれが用意する必要がある

次にこれらの問題を解決する方法を紹介します.

パッケージインデックス・リポジトリ

パッケージインデックスやパッケージリポジトリは,様々な人がパッケージをアップロードして,他のユーザーがパッケージを利用できるようにするための仕組みです.具体的には Python では PyPI,Ruby では RubyGems,Node.js では npm と呼ばれるものが存在します.これを利用することによって前節で挙げたような問題を概ね解決できます.

例えば,多くのパッケージマネージャではパッケージ名のみを指定してパッケージをインストールしようとすると,デフォルトのパッケージインデックスからパッケージを検索してインストールしてくれます.例えば npm install lodash とすると npmjs.com で公開されている lodash をインストールしてくれます.

ここまで説明した内容でインターネットからパッケージをダウンロードしてインストールする基本的な部分が実現されている方法が分かったと思います.次は少し複雑な依存関係について紹介します.

依存関係の記述と解決

あるパッケージが他のパッケージを内部で利用していることはよくあります.例えば,Ruby on Rails (rails) の gem (パッケージ) は activerecord や actionsupport といった,他の gem を内部で利用しています.このため rails を使うためには,rails が依存している (必要としている) これらのパッケージをインストールする必要があります.

しかし,あるパッケージを使うためにそれが依存しているパッケージを把握して,インストールするのはなかなか大変です.依存しているパッケージが他のパッケージにさらに依存していることもありますし,依存しているパッケージの数がとても多いライブラリも存在します.

このような問題を解決するため,パッケージのメタデータにはそのパッケージを利用するために他のどのようなパッケージが必要なのかという依存関係を記述できるようになっています.また,依存関係を書く際にはパッケージのバージョンを指定できるようになっていることがあります.これによって,依存しているパッケージがアップデートされ API (仕様) が大きく変わり,パッケージが使えなくなってしまうようなことを防ぎやすくなります.

バージョンの指定方法はパッケージマネージャによっていろいろありますが,バージョンを完全に固定する方法や,あるバージョンより新しいもの古いものを指定する方法,セマンティックバージョニングに基づきメジャーバージョンやマイナーバージョンを指定する方法などがあります.例:

{
  "dependencies": {
    "lodash.assign": ">=4.2",
    "lodash.clonedeep": "^4.0",
    "lodash.debounce": "<5.0",
    "lodash.get": "4.4.2",
    "lodash.isequal": "~4.0"
  }
}

このような依存関係のメタデータを利用すると,あるパッケージをインストールするときに必要なすべてのパッケージをリストアップすることができ,またそれぞれのパッケージのどのバージョンを入れれば良いか制約を解いて必要なバージョンのパッケージをインストールすることが出来ます.

依存関係のロック

依存関係の解決は基本的には上の仕組みで動いているのですが,まだ少し問題が残っています.それは,パッケージの依存関係を解決した結果が時間によって変わってしまうということです.

例えば,パッケージBとパッケージCに依存するパッケージAが存在するとします.開発するときにパッケージAをインストールすると,依存関係が解決されてパッケージB v1.0 とパッケージC v2.0 がインストールされました.数日後,本番のサーバーで同じパッケージAをインストールすると,パッケージB がアップデートされており,パッケージB v1.1 とパッケージC v2.0 がインストールされました

このように,依存関係の解決結果がパッケージをインストールした時間によって変わってしまうと,開発時にはうまく動いていたサービスが,本番環境でインストールしたときには違うバージョンのパッケージが入ってしまい,動作がおかしくなるといったことが起こります.

そこで最近のパッケージマネージャには,メタデータに記述する依存関係と別に,依存関係を解決した結果のロックファイルを作るものが増えています.具体的な例としては,Ruby の Bundler では Gemfile.lock,Node.js の npm では package-lock.json,yarn では yarn.lock があります.npm で express をインストールしたときの package.json と package-lock.json (一部省略) の例をそれぞれ紹介します:

{
  "dependencies": {
    "express": "^4.16.2"
  }
}
{
  "requires": true,
  "lockfileVersion": 1,
  "dependencies": {
    "accepts": {
      "version": "1.3.4",
      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.4.tgz",
      "integrity": "sha1-hiRnWMfdbSGmR0/whKR0DsBesh8=",
      "requires": {
        "mime-types": "2.1.17",
        "negotiator": "0.6.1"
      }
    },
    "etag": {
      "version": "1.8.1",
      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
      "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
    },
    "express": {
      "version": "4.16.2",
      "resolved": "https://registry.npmjs.org/express/-/express-4.16.2.tgz",
      "integrity": "sha1-41xt/i1kt9ygpc1PIXgb4ymeB2w=",
      "requires": {
        "accepts": "1.3.4",
        "array-flatten": "1.1.1",
        "body-parser": "1.18.2",
        "content-disposition": "0.5.2",
        "content-type": "1.0.4",
        "cookie": "0.3.1",
        "cookie-signature": "1.0.6",
        "debug": "2.6.9",
        "depd": "1.1.1",
        "encodeurl": "1.0.1",
        "escape-html": "1.0.3",
        "etag": "1.8.1",
        "finalhandler": "1.1.0",
        "fresh": "0.5.2",
        "merge-descriptors": "1.0.1",
        "methods": "1.1.2",
        "on-finished": "2.3.0",
        "parseurl": "1.3.2",
        "path-to-regexp": "0.1.7",
        "proxy-addr": "2.0.2",
        "qs": "6.5.1",
        "range-parser": "1.2.0",
        "safe-buffer": "5.1.1",
        "send": "0.16.1",
        "serve-static": "1.13.1",
        "setprototypeof": "1.1.0",
        "statuses": "1.3.1",
        "type-is": "1.6.15",
        "utils-merge": "1.0.1",
        "vary": "1.1.2"
      }
    }
  }
}

このようにロックファイルを利用することで,様々な環境で同じバージョンのパッケージをインストールすることが可能になっています.このロックファイルはパッケージマネージャが自動的に生成するので,手動でロックファイルを編集してはいけません.また,依存関係を解決した結果が含まれている重要なファイルなので,ロックファイルも Git 等でバージョン管理すべきです.

まとめ

今回の記事ではパッケージマネージャがパッケージをインストールする仕組みについて簡単に紹介しました.紹介したのは以下のような機能です:

  • パッケージのメタデータの依存関係を元に,必要なパッケージとそのバージョンをリストアップする
  • パッケージを (依存するパッケージを含めて) リポジトリからダウンロードする
  • 依存関係の結果をロックファイルに保存する
  • パッケージを適切なディレクトリにインストールする

今回の記事では紹介しきれませんでしたが,実際のパッケージマネージャではもっと多くの機能が実装されています.例えば,パッケージを作成してアップロードする機能や,パッケージをアンインストールする機能などがあります.

CAMPHOR- Advent Calendar 2017 の明日の担当は @ryota-ka です.@ryota-ka が創業メンバーの一人でで自分もエンジニアとしてお手伝いしている株式会社HERPの採用担当者のための SaaS のティザーサイトが昨日公開されました🎉 HERP ではエンジニアやエンジニアインターンを絶賛募集中ですので,興味のある方はぜひ Wantedly をご覧下さい.