みなさんはPHPで書かれたツール類などを、どうやってインストールしているでしょうか。
まあComposerを使うのが基本ではあるのですが、 Phiveみたいなツールもあったり、Composerを使うとしても composer global require
を使うとか、composer require --dev
を使うとか、Composer bin pluginを使うとかの方法がありますよね。
PHPStan開発者のOndřejはこのように言っています。
PHPStanのインストール方法の選択肢については別に記事を書いています。
PHPStanについては上の記事にも書きましたが、composer require --dev phpstan/phpstan
でインストールするだけで外部に依存性を持たない状態でパッケージをインストールできます。
Pharファイルを見る
そもそもPHPStanのComposerパッケージはどのように実現されているのか順に見ていきましょう。
ツールをcomposer require --dev
でインストールする問題
composer require --dev
は開発時にしか必要のない依存パッケージをインストールするためにとても便利なコマンドです。たとえばテスティングフレームワークであるPHPUnitなどはテストに必要なものなので、もちろん本番にデプロイする必要はないわけです。
多くのプロジェクトはツール単体で開発されているわけではなく、ツール自体も他のパッケージに依存しています。PHPUnitは不用意に外部パッケージに依存しないようになっていますが(ほとんどが自作パッケージ)、それでもゼロではありません。そのPHPUnitでさえ、開発者のSebastianはこのように言っています。
たとえばコードフォーマッターのPHP-CS-Fixerは多くのSymfonyコンポーネントに依存しているので、何かの事情があって古いバージョンに依存しているパッケージがインストールできないことがあります。そのようなパッケージがある場合はツールを含めた依存パッケージをまるごと同時に上げる必要が生じるために、依存解決がrequire-dev
だけで完結せず、プロダクションで依存しているパッケージにも波及しがちです。
ツールのための依存がプロダクションと絡みあうことで、最悪の場合には静的解析もユニットテストも全てパスしているが、実はPHP-CS-Fixerがrequireしていたパッケージのクラスや関数を使ってしまっていて、本番環境にデプロイしてみると突然エラーが発生することもありえます。
ところで、PHPStan・PHPUnit・PHP-CS-Fixerのような静的解析・テスティング・フォーマッターなどを含む品質管理に関わる開発支援ソフトウェア類は「QAツール」と総称されます。sasezakiさんの「このPHP QAツールがすごい!2019」などを参照されると良いでしょう。
Pharとは何か
Pharファイルは複数ファイルからなるPHPプロジェクトをまるごと1ファイルに固めたアーカイブファイルです。PHPスクリプトだけでなく任意のファイルを内包できる非常に便利なものです。
PHPで開発しているみなさんも普段から非常にお馴染みであろうComposerもcomposer.phar
というファイルで配布されています。
また、PHPUnitなどもPharファイルとして配布されています。このようなファイルはComposerの依存関係(vendor/
ディレクトリ)をまるごと同梱することで、個々のユーザーがcomposer install
などで依存をかきあつめてくることなく完成品のパッケージとしてダウンロードできるのです。
こんなにも便利なPharアーカイブはPHPの標準機能で作成可能ですが、BOXやPHP Autoload Builder(phpab)などを使うことでより簡単に作成できます。
Pharとして公開されているプロジェクトはPhive(PHAR.IO)というツールで管理できます。
残念ながらPhiveを使おうとしたが使いにくい… という話は以下の記事に書きました。
この記事で取り上げているのは前述のComposer bin pluginと同じ方法を手動でやる方法です。
この方法にもまだ問題があります、PharアーカイブでインストールしたフレームワークからComposerで依存関係をインストールしたプロジェクトのコードを実行した場合は先に読み込まれたクラスが優先されてしまいます。クラス定義が重複しても動作に一貫性があれば問題なく動きますが、インターフェイスが食い違っていたり挙動に違いがあると意図しないエラーが生じます。
このような問題を避けるために、PHP-Scoperというツールを使うことで名前空間の隔離ができます。BoxはPHP-Scoperと統合されてますし、PHP-Scoperで名前空間を隔離してからほかのPhar構築ツールを使うこともできます。
単体Pharファイルの制約
PHPスクリプトのファイル群を単一ファイルで配布でき、ComposerやPHPUnitといったPHPエコシステムになくてはならない重要ツールを担うComposerですが、みなさまは普段どのようにして起動していらっしゃいますでしょうか。
- a:
php composer.phar
のようにファイルとして実行 - b:
composer
コマンドとして実行- b-1: https://getcomposer.org/download/ の説明通りにダウンロードして
/usr/local/bin
などに移動 - b-2: Homebrewやapt-getなどのパッケージマネージャでインストール
- b-3: Dockerfileで
COPY --from=composer /usr/bin/composer /usr/bin/composer
- b-1: https://getcomposer.org/download/ の説明通りにダウンロードして
などなど、いろいろな実行方法と導入方法がありますが、どれも実体は同じcomposer.phar
です。なぜこれらの方法で動くのでしょうか。
composer.phar
をテキストエディタで開いてみると、冒頭部分が以下のような構造になっていることが確認できます。
なんと、Pharファイルは冒頭部分はただのテキストによるPHPスクリプトで1、__HALT_COMPILER()
よりも後の部分にバイナリが含まれています。バイナリといってもシンプルにファイルを連結したものになっています。
重要なのが #!/usr/bin/env php
の部分です。これはshebangと呼ばれるもので、LinuxやmacOSを含むUNIX系のOSではファイルパーミッションで実行権限がついているテキストファイルの先頭に #!
から始まる行があると、OSがここで指定された実行ファイルを使ってプログラムを起動してくれます。通常のシェルであれば単に php
とコマンドとして実行すれば $PATH
環境変数に設定されたディレクトリを探索して実行ファイルを探してくれますが、shebangは #!php
のように書いて勝手に探してくれるようなことはなく、 #!/usr/bin/php
のようにファイルパスで指定してあげる必要があります。しかし実際には環境によってPHPの実行ファイルは /usr/local/bin/php
だったり $HOME/
$HOME/local/bin/php
だったりします。
env
は env FOO=bar php
のように「環境変数を設定しつつコマンドを起動する」コマンドですが、環境変数の設定が0個でも env php
として実行するだけでそのまま$PATH
からコマンドを検索して起動します。そしてこのコマンドの実行ファイルは一般的なUNIX環境では /usr/bin/env
に置いてあります。よって、#!/usr/bin/env php
のように書くだけでユーザー環境の$PATH
に沿ってコマンドが探索されるようになっています(一般的でないUNIX環境を除く2)。
このような仕掛けによってPHPスクリプトやPharファイルをUNIX環境での実行スクリプトとして利用可能にしているのですが、何かおかしくないでしょうか。PHPの文法では<?php ... ?>
の外側にある文字は標準出力されるのが原則のはずです。だからこそ<p><?= h($str) ?></p>
のようなHTMLテンプレートが書けます。
実はPHPはphpコマンドでファイルを直接実行したときだけ例外でshebangの#!
行を読み飛ばすようになっています。そうではない場合、たとえば cat script.php | php
として実行されたときや include 'script.php'
で読み込まれた場合は #!
行がそのまま出力されてしまいます。この制約によって、Pharファイルを単体で配布する場合は実行スクリプト用途とincludeでライブラリとして読み込める用途を両立できません。
そのために、PHPUnitは直接実行用のphpunit-x.y.z.phar
とライブラリとしての読み込み用途のphpunit-library-x.y.z.phar
に分けて配布されています。
「えっ、Phar::loadPhar()
を使えばいいでしょ」という指摘は完全に正しいです。
ただ、Pharファイルをライブラリとして使うと一般的なPHPスクリプトとしては別の取り扱いになってしまいます。
改めてComposerのPharパッケージ
Phiveは単に使いにくいだけでなく、バージョン指定でのインストールはできるものの再帰的な依存管理の仕組みではありません。また、Pharファイルそのものを実行スクリプトにしてしまうとライブラリとしての読み込みができないという問題もあります。
これを解決するのがComposerパッケージでPharファイルを配布するというアイディアです。
現在のPackagistでphpstan/phpstanとして配布されているパッケージのファイル構造を見てみましょう。
vendor/phpstan/phpstan/
├── LICENSE ← ライセンスファイル
├── README.md ← ドキュメント(README)
├── bootstrap.php ← PHPStanをクラスとして使いたいときに読み込むファイル
├── composer.json ← Composer パッケージ設定ファイル
├── conf
│ └── bleedingEdge.neon ← bleedingEdgeを有効化するための設定ファイル
├── phpstan ← PHPStan実行スクリプト
├── phpstan.phar ← PHPStanのクラス一式がパッケージされたアーカイブ
└── phpstan.phar.asc ← PGP署名ファイル
お手元のプロジェクトでPHPStanをインストールしていたら同じものが見えているはずです。
そろそろお気付きでしょうか、この記事で紹介したい「Pharパッケージ」とは、中にPharアーカイブが入っているだけのただの変哲もないComposerパッケージです。
https://github.com/phpstan/phpstanはかつてPHPStanのメインリポジトリでしたが、現在はソースコードの開発はhttps://github.com/phpstan/phpstan-srcに移動し、phpstanはissueの受け付けとPharファイルのホスティングとWebサイト・ドキュメント・ツール類の関連リソースの管理、PHPStan開発としてのPull Requestは基本的にphpstan-src側で受け付けています。
また、bootstrap.php
はこのようなスクリプトになっていて、このスクリプトはComposerで自動的に読み込まれるのでPHPStanが提供するクラスをライブラリとして読み込むことで普通のPHPスクリプトからPharファイル内のクラスを普通に使えるようになっています。
このようにユーザーにPharファイルの存在を一切意識させずに (Phar::loadPhar()
とか知らなくても) composer require
と'vendor/autoload.php'
を読み込ませる(要はComposerユーザーが標準でやってること)だけで機能を提供できることがPharファイル単体ファイルを大きく上回るComposerを介したPharパッケージ配布の大きなメリットです。
Pharパッケージはどのように実現されているのか
基本的にはGitリポジトリとPackagistで公開されるComposerパッケージは一対一で対応していますが、ここで示しているPHPStanのファイル構成にはWebサイト・ドキュメント・ツール類の関連リソースの
が含まれていません。Composerはファイルを配布するときに.gitattributes
を反映してくれるのでライブラリとして不必要なファイルは含まれません。
Pharファイルを管理するリポジトリはソースコードの開発リポジトリからGitHub Actionsなどで自動的にアップデートするか、コマンドで半自動的にアップデートできそうです。phar-io/composer-distributor: Distribute PHAR files via Composerのようなツールもあります。
2023年現在ComposerでPharパッケージ配布されているプロジェクト
Pharパッケージはshimと呼ばれることもあります。この最初にこの形態のパッケージを「shim」と呼んだのは、おそらくPHPStan3ではないかと思います[要出典]。
PHPStanはかつてphpstan/phpstan-shimというパッケージに分離されていましたが、2019年にリリースされたPHPStan 0.12.0以降はphpstan/phpstanがデフォルトでPharパッケージ化され、実装コードがphpstan-srcに移動しました。
Psalmのpsalm/pharは長らくPHP 8.1との互換性の問題があり実用できない状態が続いていたのですが、2022年11月末に修正されたので現在は問題なくなっていると思います。
Deployerのようにdistパッケージが廃止されてPharファイル配布に回帰したプロジェクトもあるようです。ただこれは名前空間/autoloadの問題も提起されており、単に名前空間隔離のノウハウが不足しているだけに見えるので、誰かがコントリビューションすれば再分離して本格的なPharパッケージにできるのではないでしょうか。 (私はDeployerユーザーではないので…)
Pharパッケージの課題とこれから
上記の表にRectorを混ぜていますが、実は厳密にはPhar管理はしておらず、Composerでインストールしたvendor
ディレクトリ以下のPHPファイルをPHP-Scoperで名前空間を隔離してPHPファイルのまま配布リポジトリに自動コミットするという手法をとっています。
具体的に何をやっているのかは開発者のTomas Votruba氏が書いた「PHP 8.1で開発して7.2にダウングレードする方法」や「単一リポジトリでPHP 8.1以上向けと7.2〜8.0向けのバージョンを同時リリースする方法」といった記事も参考になるでしょう。
上記の表に記載していませんが、同作者のsymplify/easy-coding-standardは標準リリース(PHP 8.1以上向け)では単に外部パッケージへの依存を持っていますが、7.2リリースではダウングレードと名前空間隔離を行なうという構成になっています。
そもそもComposerでのパッケージバージョンの衝突の問題はPHPの名前空間は階層化されているものの空間としては実行プロセス全体でひとつのものを共有していてJavaScriptモジュールのように局所的に依存できず、Composerでもパッケージにつきひとつのバージョンにしか依存できないということにあります。
じゃあパッケージごとにばらばらのバージョンに個別に依存できたりするのが良いのかというとそれはそれでめんどくさいというかnode_modules
みたいなネスト地獄でファイル数が肥大化したりnpm v3で依存をできるだけフラット化しようとかいろいろな話が出てくるわけですが、PHP-Scoperのような名前空間隔離は単一名前空間の中でもツールごとのプレフィクスを付加することで擬似的に空間を分けて再現しようという手法なわけですよね。このようなものを推進していくことが良いかは議論があると思うのですが、ツールのような独立性の高いアプリケーションに限って局所的に適用される選択肢があるのは良いことなのかなと思っています。
ComposerでPharパッケージ配布とTomas Votruba氏が実践している書き換えたファイル直コミット(vendor含む)配布のどちらが良いか議論はあると思うのですが、クラスがPharファイル内にあると(現在の一般的な環境では)コードジャンプで飛んでいけないとか静的解析で辿れないとかの問題も無視はできないかなと思っていて、Pharファイル版のPHPUnitが使いにくいと感じる理由もここにあります。かといってPharのためにスタブファイル用意するのかとか考えるとうーん悩ましい。
とはいえ将来的にはPHPUnitのようなテスティングフレームワークもPharパッケージないしライブラリの名前空間隔離して配布してもらえるとやはり嬉しみもあるので、たとえば公開APIのクラスとインターフェイスだけPHPスクリプト直配布してフレームワーク内部機能や名前空間隔離されたライブラリはPhar内に押し込めるみたいなハイブリッド構成も考えられそうです。
あとはDockerとかでホストのディレクトリをマウント(共有)するときにちっちゃいファイルが大量にあるよりもPharファイル一個の中に入れてメモリに載せちゃった方がもしかすると高速なのでは[未検証の臆測]などとも思ったりするので、一概にどちらが良いとも今は判断できなさがあります。
夢は無限大なので、PHPでのQAツール開発文化がもっと便利で使いやすく洗練されていくといいですね。
-
正確にはPharファイルのファイルフォーマットには「Phar形式」「zip形式」「tar形式」の三種があるのですが、この記事で紹介しているPhar形式以外を目にすることはあまりないので、これを前提としています。本文で触れられているshebangはPhar形式でしか利用できないからです ↩
-
たとえばAndroidには
/usr/bin
がありません。AndroidをUNIX環境として意識することはTermuxなどを使っていない限りはないでしょうが。 ↩ -
2017年に公開されたfprochazka/phpstan-compilerと、それをxificurk/phpstan-shimが再配布、それらをOndřej Mirtesが継承してPHPStan公式として発展させていったという流れがあります。 ↩