パッケージマネージャを自作するときに考えること

最終更新:

記事 言語処理系

プログラミング言語を自前で創っていると,パッケージマネージャが欲しくなってくるものだ.既存パッケージマネージャやそのラッパーによる配布で事足りることも多いが,自前言語の要件とうまく合わなかったりして,真に自分で実装せねばならないこともある.そうした場合,パッケージマネージャをどんな設計にすべきだろうか? 言語固有の都合には触れずになるべく一般に考慮すべき事項を洗い出し,簡単な設計例も提示してみたい.

なお,本稿はパッケージマネージャの設計に焦点を当てたものであり,効率的に依存制約を解消するアルゴリズムなど実装の詳細については解説しない.実際例えばOCamlでは 0install-solver というOPAMの裏でも使われているパッケージを利用すれば制約解消アルゴリズムそのものに踏み込まずとも制約解消処理を実装でき,(それ自体に興味があるときを除けば)必ずしもアルゴリズムを理解する必要はない.

前提: パッケージマネージャは何をするものか

パッケージマネージャはその設計により種々の差異があるが,おおよそどの設計にもみられる最大公約数的な機能を最初に述べておく.パッケージマネージャは,主として以下のようなタスクをこなしてくれる:

  • 依存解決: プロジェクト中のコンフィグファイルと呼ばれる設定ファイルにユーザが記載した「どんなパッケージのどのバージョンを使いたいか」の情報を読み取り,その依存制約をもとに(間接的に依存するものを含めて)必要なパッケージとそのバージョンを算出する.決定できたらロックファイルと呼ばれるファイルにその結果を書き出し,実際に各パッケージの必要なバージョンの実装を取得してマシン中に適切に配置したりもする.依存制約を満たす組み合わせが存在しなかった場合は「この制約を満たすバージョンの組み合わせはありません」という旨を報告し,異常系として何もせず終える.
    • この過程では,各パッケージの各バージョンがどんなパッケージにどんな制約で依存しているかを網羅した情報が必要になる.これはおおまかに言えばパッケージレジストリまたは単にレジストリと呼ばれる機構へとネットワーク経由で問い合わせることによって行なわれる.実装の取得などもレジストリにあるデータをもとに行なう.
  • ビルド: 依存解決が適切に行なわれていることを前提として,配置された依存パッケージの各実装とともに手元のプロジェクトのコンパイルなどを行なう.
    • ビルドに必要な依存パッケージの情報は,ロックファイルを参照することで得られる.

用語の定義

本稿で使用する用語をここで大雑把に定義しておく:

  • パッケージ
    • リリースのための単位.複数のリリースが1つのパッケージという単位に紐づけられる.ほとんどの場合は識別・指定のためにパッケージ名という名前がついている.パッケージ名がどのような名前空間に属するかは設計により異なる.
  • バージョン
    • パッケージの各リリースがもつ,他のリリースと区別してそのリリースを一意に識別するための番号的文字列.1.3.2 など.新旧や後方互換性の有無などを判定するのにも使うことが多い.本稿では簡単のためsemantic versionを指して単にバージョンと呼ぶとする.
  • パッケージの実装
    • パッケージの1つのリリースを構成する,具体的なソースファイルなどの集まりのこと.
  • パッケージレジストリ(または単にレジストリ):
    • ユーザがネットワークを経由するなどして取得できるようにするために,各パッケージのリリース済みの各実装が登録されている機構.レジストリがただ1つしか存在しないという設計になっているものもあれば,複数扱えるものもある.
  • 依存パッケージ
    • 何らかの実装が,そのビルド・動作のために存在を前提するパッケージのこと.依存パッケージおよびそのうちどのバージョンを使いたいかの指定は,後述のコンフィグファイルによって為される.
  • コンフィグファイル
  • ロックファイル
    • パッケージマネージャがコンフィグファイルの記述をもとに依存解決をした後,各パッケージの実装がどのバージョンのものに固定されたかを記録して出力するファイル.Cargoの Cargo.lock,Rebar3の rebar.lock などがこれに該当する.ユーザのソースファイルをビルドする際に参照されるほか,再現性を担保するのにも使われる.人間がこのファイルを直接手でいじることはないが,gitによるバージョン管理には含めるのが適切である.

設計上の主な選択肢

レジストリは1つか複数か

まずは以下の設計上の選択肢がある:

  • (1) レジストリは固定の1つだけ
  • (2) レジストリが複数扱えて,かつ自由に新規のレジストリが立てられる

レジストリを1個にすると,設計・実装の上で考えるべきことはかなり減る.しかし,レジストリ1個では要件の拡大に応じてすぐに限界が見えてくる.例えば,もしインターネット全体に向けては公開しないが特定のグループ内ではパッケージとみなしたいという社内ライブラリのようなものを扱う要件に対応するには,以下のような対処が必要だ:

  • (1’) レジストリは1個だが,ユーザは自由にパッケージを登録でき,しかも特定の権限を持ったユーザにのみ見えるように設定できたりもする

(1’)を採用するデメリットとしては,閲覧権限を制御するのはレジストリ管理者であり,パッケージマネージャの全てのユーザが中央集権的なレジストリ管理者を信頼することが前提になってしまう.実際には管理者が閲覧制限のあるパッケージの実装を秘密裏に覗いているかもしれず,その可能性を排除するにはさらに暗号理論的な仕組みが必要になってくる.或いは,GitHubのプライベートリポジトリへの参照だけをレジストリに登録して実装そのものはネットワーク的に切り離された箇所に置かれるような方式にすれば,管理者に内容が閲覧されないものとして事足りるかもしれない.ただし,その場合でもプライベートリポジトリの存在自体はレジストリ管理者へと伝達してしまうことになる.

一方で(2)の場合,そもそもアクセスできる人の限られた社内ネットワークなどにレジストリを立てることで閲覧権限の管理をネットワークでの対処に帰着でき,比較的簡単である.

どちらを選ぶかは好みだが,長期的に見れば(2)の方が単純で済む公算が大きいのではないかと個人的には思う.

レジストリ中のパッケージの名前空間

ユーザから見える点ではこれが最も重要かもしれない.以下のいずれかの選択がある:

  • (A) 1つのレジストリ中でパッケージ名が単一の名前空間に平坦に並ぶようにする
  • (B) 開発者ごとに名前空間を分け,\(\mathit{developerName}/\mathit{pkgName}\) の形にする

(A)はパッケージを使う側が開発者名を覚えなくてよいので簡便だが,異なる開発者の間で使いたい名前が衝突する可能性がある.(B)は利点と弱点が反転し,(A)と(B)の間でトレードオフがある.とはいえ,(B)の方式でも「実績と信頼のあるパッケージは開発者名を指定しなくてもよい空間にも登録し,典型的には開発者のユーザ名を覚える必要がないようにする」という方法で煩雑さを緩和したりはできる.

もしレジストリが1つしかない(1)の設計を採用するなら,名前空間は(B)にしておくのが無難ではないかと思う.あらゆるパッケージ名が誰でも好きに取得できて全世界に対して同名を使えないように強いられる(A)のような設計は,衝突するおそれが高すぎるように思えて心もとない.

一方で,もしレジストリを複数扱える(2)の設計を採用するなら,(B)ほどの用心深い選択をしなくともよいかもしれない.衝突しえない自分専用の名前空間が欲しいと思ったユーザは自分用のレジストリを立てれば事足りるからだ.

開発者によるパッケージのレジストリへの登録は自由か否か

  • (X) パッケージ名が未使用ならば早い者勝ちで自由にレジストリに登録できる
  • (Y) レジストリにパッケージを登録するにはレジストリ管理者によるレビューの通過が必要

これもいささかトレードオフ気味で,(X)はレビューなどに要する人的リソースを消費せず登録の障壁も低いが悪意のある登録を防ぐ方法がない.(Y)は悪意のある登録を拒絶しやすいが登録の障壁が上がったり人的リソースを消費する.また,レジストリが1つしかない(1)の場合は,(Y)を採用すると中央集権的すぎるという議論を呼びそうな性質も浮上してくる.

一方で,レジストリが複数建てられる(2)の場合は(Y)を選びやすいかもしれない.というのも,レジストリを好きに立てられるので,既存のレジストリが気に入らなければ自分でレジストリを立てて管理者になることで部分的には(X)の状況を実現できるからだ.とはいえ,自分のパッケージを他人に使ってもらいやすくするという観点では完全に(X)と同等の状況が実現できているとはとても言えず,結局信頼を得て既存レジストリに自分のパッケージを登録してもらうに越したことはない.

結局これはユーザのコミュニティをどのような形にしていきたいかという思想・志向と密接に関わっている設計上の選択肢であろうと思う.

まとめ: 望ましい選択肢は?

上記の3軸各2通りで計8通りが考えられるが,そのうちいくつかは既に部分的に述べたように弱点があり,採用を避けた方がよさそうだ:

  • (1)かつ(A): レジストリが1つしかないのにパッケージ名の名前空間が平坦
    • → 名前の “占有力” が高すぎるきらいがある.
  • (A)かつ(X): 名前空間が開発者ごとに分かれていないが登録が自由
    • → 悪意のある登録により名前が取られすぎたりするおそれがある.
    • SNSのユーザ名もこれに該当するものの,大抵は登録段階でメールアドレスや電話番号などと1対1に紐づけるように課されるので,悪意を持って大量に取得されるおそれが比較的低減されている.

また,以下は弱点とは言わないまでも積極的に選ぶ理由がない:

  • (B)かつ(Y): 名前空間が開発者ごとに分かれているがレビューを受ける必要がある
    • → 結局レビューするのであれば,安全のために名前空間を分けている必要性が薄い.

これらを除くと,望ましい設計は以下の3種類しかない:

  • (1)-(B)-(X): レジストリ1つ,開発者ごとに名前空間分離,登録自由
  • (2)-(A)-(Y): レジストリ複数共存可能,名前空間はレジストリ中では平坦,登録はレビュー通過必要
  • (2)-(B)-(X): レジストリ複数共存可能,開発者ごとに名前空間分離,登録自由

さらに,既に述べたように(2)かつ(B)は名前空間の分離に関してやや冗長すぎるきらいもあり,上2つの2択が無難かといったところ.

既存パッケージマネージャの設計を比較してみる

既にあるパッケージマネージャがどのパターンにあてはまるか見てみる:

  • OPAM: (2)-(A)-(Y) の変種
    • 本稿で言うレジストリのことは “repository” と呼んでいる.
    • (2): opam-repository というデフォルトのレジストリがあるが,誰でもレジストリを新設できる.
    • どのレジストリを使うかは,ユーザがレジストリ名を指定して \(\mathit{regName} \mapsto \mathit{regUrl}\) の写像として手元に保持しておく.ここでユーザが指定したレジストリ名 \(\mathit{regName}\) はそのマシン全体で使われるが,単に表示やCLIでの指定をわかりやすくするための識別に使うものであり,提供元レジストリの情報はコンフィグファイルには明記されない
    • (A): ユーザからみて,パッケージ名は提供元レジストリによらず平坦な名前空間に並ぶ.さらに,異なるレジストリが同一パッケージ名のバージョン違いを提供している場合,それらは同一パッケージの異なるバージョンとみなされる.そのため,或る意味では全世界で1つの平坦な名前空間である.
    • (Y): レジストリにパッケージを登録するには,基本的にはレジストリ管理者によってレビューされる必要がある.
  • Cargo: (2)-(A)-(X)
    • (2): crates.io を事実上のデフォルトのレジストリとするが,誰でもレジストリを新設できる.
    • どのレジストリを使うかは,ユーザ側がレジストリ名を指定して \(\mathit{regName} \mapsto \mathit{regUrl}\) の写像としてプロジェクト中の ./.cargo/config.tomlregistries に記載しておく.レジストリ名は Cargo.toml 中で依存関係の指定に使われるなど,ビルド処理に関しても意味がある.
    • (A): crates.io をはじめとする1つのレジストリの内では,パッケージ名はフラットな名前空間をなす.すなわち,\(\mathit{regName} \mapsto (\mathit{pkgName} \mapsto \mathit{pkgMetadata})\) の形で扱われる.同一パッケージ名の2パッケージも提供元レジストリが違えば別パッケージである.
    • (X): crates.io誰でもレビューなどを経ずに簡単にパッケージが登録できる
  • Elm: (1)-(B)-(X)
    • (1): 唯一のレジストリ package.elm-lang.org で全てのパッケージが管理されている.
    • (B): パッケージはGitHubのリポジトリのように \(\mathit{developerName}/\mathit{pkgName}\) という形式の名前をとり,ユーザごとに名前空間が分かれている.標準ライブラリも elm/http などとして扱われる.
    • (X): パッケージの登録にはレビューの通過が必要.

異なるレジストリ起源でもパッケージ名が同じなら同一パッケージとみなすというOPAMの設計はなかなか尖っている.これはfork版を別レジストリを用いて外挿するのには便利だが,レジストリ間でパッケージ名が意図せず衝突してしまうおそれもある.また,OPAMのコンフィグファイルである .opam にはレジストリの情報を記述する必要がなかったり,依存解決後もロックファイルが必ずしも作成されなかったりするので,再現性の担保の点でOPAMにはやや難があると言えるかもしれない.

Cargoは前節で望ましくないものとして退けた(A)かつ(X)の設計になっている.実際に悪意のあるパッケージ名の確保がやや問題視されているらしい?

設計・実装上面倒なところ

レジストリの識別方法

レジストリを複数扱えて自由に立てられるような設計にする場合,レジストリを識別する方法が必要である.ひとまず以下の2種類の方法が考えられそうだ:

  1. レジストリ名とレジストリURIの紐づけを登録制にし,どこかで中央集権で管理する
  2. レジストリURI自体で識別する

しかし,前者は難しい.レジストリが複数扱えるようにする動機としては,社内ライブラリを登録するレジストリなど非公開のものを扱うためという点が大きい.これが要件である限り,レジストリの存在自体も明らかにする必要がないものにすることが望ましい.したがって,基本的にはレジストリを識別する方法は後者のURIを直接識別に使うくらいしかない

しかし,URIにも少し厄介な点がある: 文字列として等しくないものでも同一視する必要があったりするのである.例えば以下の3つはどれも同じGitHubリポジトリを指す:

  • https://github.com/foo-lang/main-registry
  • https://github.com/foo-lang/main-registry.git
  • https://github.com/foo-lang/Main-Registry

これらのURIを同一視せねばならないほか,もし広範なUnicodeコードポイントを含むURIをサポートするならNFCやNFDなどを取って正規化したりする必要もある.こうした同一視の方法を適切に設計・実装せねばならない

ちなみにCargoはどうしているかというと,実装を見るに意外とアドホックである.ドメインが github.com の場合だけ特別に大文字小文字の同一視や .git の有無の同一視をしているらしい:

同一パッケージの異なるバージョンを共存させるか否か

例えば手元のプロジェクトがパッケージ A^1.0.0 とパッケージ B^1.1.0 に直接依存しており,B^1.1.0 が実装によらず必ず A^2.1.0 に依存している場合,Aの 1.x2.x という非互換な2つのバージョンが共存せねばビルドできない.こうした状況を許容してうまく扱うか,それともこうした状況はそもそもビルドしてはいけないものとして拒否するか,という設計上の選択肢がある.これはパッケージマネージャだけでなく言語仕様にも関わる問題である.また,どちらを選ぶかがパッケージマネージャに於ける依存解決の方法にも関わってくる.

多くの場合,共存を許容できるようにする方が設計に巧妙さを要求される.また,同一パッケージの非互換なバージョンの共存が避けられる場合は避けるという最適化を行なうなら,依存解決のアルゴリズムが難しくなったりもしそうだ.

ダウンロードした依存パッケージの実装の配置方法

ダウンロードした依存パッケージの実装は,ビルドで使うのに備えてローカルマシンに配置する必要があるが,配置時にレジストリの識別をどうするかが問題になる(これはエンドユーザから見える設計の問題ではなく内部実装の問題).まず,取得した実装の配置場所としては以下の2つの選択肢がある:

  1. プロジェクトごとに配置
    • ./_build/./target/ といったディレクトリの下に置かれることが多い.
  2. マシン内の共通の場所に配置
    • ~/.foo-lang-manager/ などのディレクトリが使われることが多い.

前者を採用する場合は柔軟である.例えばプロジェクト中のコンフィグファイルには使用するレジストリとその名前が列挙されているとしよう:

registries:
- name: "default"
  uri: "https://github.com/foo-lang/main-registry"
- name: "enterprise"
  uri: "https://your-company.com/foo-lang-registry"

dependencies:
- name: "json"
  requirement: "^1.2.0"
  registry: "default"
- name: "service_http_handler"
  requirement: "^2.1.3"
  registry: "enterprise"

registries で定義されている default とか enterprise といったレジストリ名はこのプロジェクト内でのみ通用する名前である.この名前を利用して,取得した実装は以下のように配置できる:

your-project/
└── _build/
    └── dependencies/
        ├── default/
        │   ├── future.3.0.1/
        │   │   └── …
        │   ├── json.1.2.1/
        │   │   └── …
        │   └── stdlib.2.6.0/
        │       └── …
        └── enterprise/
            └── service_http_handler.2.1.3/
                └── …

というわけで,プロジェクト単独で扱えるならファイル配置はわりと単純である.

しかし,プロジェクトごとに配置する方式はストレージ容量や取得時の通信コストを無駄に取りやすい.例えば,とりわけNode.jsの ./node_modules/ などは容量を取るものとして悪名高い.したがって容量消費や取得処理に関して無駄のない後者のような配置が志向される.

後者の場合は,複数プロジェクトから同一の形式で見えるように配置せねばならない.したがって,プロジェクト固有のレジストリ名が使えず,レジストリを識別する方法はやはりURIのみとなる.そして,このURIの文字列がファイルシステムとはとても相性が悪いので,URIのファイルシステム上へのマッピングは多少工夫を要する.以下の3つの方法がある:

  1. URI中の / などを別の文字に置換してからファイルパスの一部に使う
  2. URIを正規化した上でパスをそのままディレクトリの階層にしてしまう
  3. URIを正規化した上でMD5などのハッシュ値をとる

まず,1も意外に面倒だ.ファイルシステム中で自信をもって使える文字は存外少ないのである.例えばPOSIXのportable file name character setには大小英数と ._- しかない.それゆえ,もともと含まれていた ___ にエスケープしつつ /_s に変換する,といったぎこちない変換をする必要がある.また,より広範なUnicodeコードポイントを含むURIなどもサポートしようとするとさらに面倒になっていく.

2は要するに以下のような具合である:

~/.foo-lang-manager/
└── packages/
    ├── github.com/
    │   └── foo-lang/
    │       └── main-registry/
    │           ├── future.3.0.1/
    │           │   └── …
    │           ├── json.1.2.1/
    │           │   └── …
    │           └── stdlib.2.6.0/
    │               └── …
    └── your-company.com/
        └── foo-registry/
            └── service_http_handler.2.1.3/
                └── …

妙に階層が深くなってしまったりしてちょっとヘンテコだが,例えばmodule-aware modeでないGo言語での $GOPATH 以下のファイル配置は実際にこのようになっている.

一方で,3は階層が深くならない:

~/.foo-lang-manager/
└── packages/
    ├── 4a651c872776cf4c697505a568ac4102/  # "github.com/foo-lang/main-registry" の MD5
    │   ├── future.3.0.1/
    │   │   └── …
    │   ├── json.1.2.1/
    │   │   └── …
    │   └── stdlib.2.6.0/
    │       └── …
    └── 11c9a72f6215c6103daa7c3a79961b0e/  # "your-company.com/foo-registry" の MD5
        └── service_http_handler.2.1.3/
            └── …

Cargoは実際に配置されたファイルを見るにこの3に近い方式をとっているように見える.

ビルド時に処理系が依存パッケージをどう読み込むのか

依存パッケージのソースファイルを配置した後のことも考える必要がある.パッケージマネージャではなく処理系本体が依存パッケージの実装をどう読み込むかが非自明なのだ.要するにパッケージマネージャと処理系とで責務をどう切り分けるかの問題なのだが,主に以下の3つの選択肢がある:

  1. パッケージマネージャと処理系とが同一ソフトウェアであるとし,したがって処理系はそもそも依存パッケージの実装がどのように配置されているかを知っている
  2. ビルドしたい場合,ユーザはパッケージマネージャを叩くことで間接的に処理系を起動するものとする.ビルドを任されたパッケージマネージャは処理系を起動し,依存パッケージの実装がどこにあるかをコマンドオプションなどによって渡す
  3. ロックファイルの形式を処理系側が規定し,パッケージマネージャはそれに合わせて出力する

ElmやGo言語は1の方式を,Rust/Cargoは2の方式を,それぞれ採っているものと思われる.OCamlも2に近い方式だが,これはパッケージマネージャとビルドシステムが分かれておりさらに複雑である.3は原理的にはできるだろうという程度の話で,実例は寡聞にして見たことがない.

なお,1の方式には「パッケージマネージャが処理系と一体化しているため,使っているパッケージに合わせて処理系のバージョンを適切に選んでビルドする機能の実現が難しい」といった弱点がある.

実際に設計例を与えてみる

ここでは以下のパターンを採用してみよう:

  • (2)-(A)-(Y): レジストリ複数共存,名前空間はレジストリ中では平坦,登録はレビュー通過必要
  • 取得した実装はマシン内の共通の場所に配置し,レジストリURIはハッシュ値を取ってパスに使う文字列にする
  • パッケージマネージャと処理系は一体化している
  • 同一パッケージの非互換なバージョンも共存してよい

コンフィグファイル

ユーザがコンフィグファイルに記述すべき内容は,以下の ConfigFile の形式であるとする(記法はいい加減だが伝わることを期待する):

ConfigFile ::= {
  language:   SemverRequirement,  # プロジェクトが期待する言語のバージョン
  name:       PackageName,        # プロジェクトのパッケージ名
  version:    Semver,             # 現在の実装のバージョン
  authors:    List[String],       # 開発者一覧
  registries: List[Registry],     # コンフィグファイル中で参照するレジストリ
  contents:   Contents,           # プロジェクトの内容
}

# バージョン制約
SemverRequirement ::= "^" Semver | "=" Semver | ...

# バージョン
Semver ::= ...

# レジストリ情報
Registry ::= {
  name: RegistryName,
  uri:  Uri,
}

# コンフィグファイル中で使えるレジストリ名.英数アンダースコアからなる
RegistryName ::= [a-z][a-z0-9_]*

# URI
Uri ::= ...

# プロジェクトの内容
Contents ::= {
  type:               "library" | "binary",
  source_directories: List[RelativePath],
  test_directories:   List[RelativePath],
  dependencies:       List[Dependency],
}

# 相対パス
RelativePath ::= ...

# 依存パッケージとそのバージョン制約
Dependency ::= {
  name:        PackageName,        # パッケージ名
  requirement: SemverRequirement,  # 期待するバージョン
  registry:    RegistryName,       # 取得元レジストリ
  used_as:     Identifier,         # プロジェクトのソースコードからどんな名前で参照されるか
}

# パッケージ名.英数アンダースコアからなる
PackageName ::= [a-z][a-z0-9_]*

# 識別子.英数アンダースコアからなる
Identifier ::= [a-z][a-z0-9_]*

どんなJSONやYAMLのフォーマットに上記の設計を落とし込むかは少しバリエーションがあるが,具体例としては以下のような記述になる:

language: "^0.1.0"
name: "your_service"
version: "2.3.0"

authors:
- "Your Name <your-name@your-mail-server.com>"

registries:
- name: "default"
  uri: "https://github.com/foo-lang/main-registry"
- name: "enterprise"
  uri: "https://your-company.com/foo-lang-registry"

contents:
  source_directories: ["./src"]
  test_directories: ["./test"]
  dependencies:
  - name: "json"
    requirement: "^1.2.0"
    registry: "default"
  - name: "service_http_handler"
    requirement: "^2.1.3"
    registry: "enterprise"

レジストリが保持している情報

各レジストリは,以下の RegistryFile にあたる内容を保持している必要がある:

RegistryFile ::= {
  registries: List[Registry],      # 他のレジストリを参照するためのエントリ
  packages:   List[PackageEntry],  # 登録されているパッケージ群
}

# 登録されている1つのパッケージ
PackageEntry ::= {
  name:            PackageName,
  implementations: List[Implementation],  # パッケージの各バージョン
}

# 1つのバージョンに関するデータ
Implementation ::= {
  version:      Semver,
  source:       Source,             # どのようにソースファイルが取得できるか
  language:     SemverRequirement,  # この実装が言語に期待するバージョン
  dependencies: List[Dependency],   # 依存パッケージの指定(基本的にはコンフィグファイルから転記されたもの)
}

Source ::= TarGzipSource | ...

# .tar.gz 形式で固められたソースファイル群
TarGzipSource ::= {
  uri:      Uri,     # リクエストすべきURI
  checksum: String,  # 取得したファイルの内容が期待通りか確認するためのチェックサム
}

パッケージマネージャは,定期的にこのデータをレジストリに問い合わせて取得し,依存解決のために使う.

ロックファイル

前節のコンフィグファイルとそこで使われたレジストリから得た情報をもとにパッケージマネージャが依存解決の結果生成するロックファイルは,以下の LockFile のような形式とする:

LockFile ::= {
  locks:        List[Lock],
  dependencies: List[LockDependency],  # プロジェクトが直接依存する実装の列挙
}

Lock ::= {
  id:           String,                # 参照用に振られた何らかのID
  registry_uri: Uri,
  name:         PackageName,
  version:      Semver,
  dependencies: List[LockDependency],  # 依存する実装の列挙
}

LockDependency ::= {
  id:      String,
  used_as: Identifier,
}

こちらはバージョンが固定された実装間の依存関係を記録しているのが重要である.ビルドの際にはこの依存関係を参照してトポロジカルソートし,どの実装から読み込めばよいかを判定する.また,同一パッケージの複数バージョンの共存も可能になっている.

まとめ

本稿では,パッケージマネージャを制作するにあたって設計上判断が分かれる部分や厄介な問題が起きやすい部分に焦点を当てて考察し,「望ましい選択肢の組み合わせがいくつかある」ということを導出した.また,その選択肢のうちの1つを選んで実際にミニマルなパッケージマネージャのコンフィグファイルやロックファイルの設計を与えてみた.

おそらく読者からすれば実際に自らパッケージマネージャを設計しようと思案を巡らせてみない限り本稿で取り上げた内容が実感を伴って把握できないのではないかとも危惧するが,パッケージマネージャをつくる際に最低限の転ばぬ先の杖として参考としてもらえれば幸いである.