TL;DR
- 「Composerの利用を前提に」作る場合、CakePHP2アプリケーションでも独自のオートロードから脱却して良いのではないか
- 根幹となるのはComposerのclassmap autoload
- CakePHP2のオートロードを無くすわけではない(できない)ので、共存させつつ恩恵を受ける方法
- コアコード等の読み込みはCakePHPのクラスローダーに依存させる等
狙いと背景
狙い
元々、業務上の問題に対するアプローチを模索して得たアイディアです。
狙うのは「テストの信頼性をあげる」ことにあります。
後述する通り、素のCakePHP2の機構では単体テスト実装上のアンチパターンを踏みやすいというリスクがあります。これを改善したい、というのが狙いです。
より正確に言うなら、「テストの質を高める」というよりは、SUT側の挙動の変更によって「テスト実行時とプロダクト側の実行時の状態を近づける」というアプローチです。端的に言えば「テストとしてCI上で実行した時だけ動くコード」を排除したい、というのが取り組みのコアです。
「プログラマーの責任」を減らして「ミスが減る」という世界 になる訳です。
思考を停止して述べれば、この種の不安は(テストにだけ頼るなら)徹底的経路テストによって成仏させるしか無さそうですが、緩和するだけならSUT側を「easy」に動くようにすることでも可能なのでは・・・という風に整理しております。
背景
CakePHP2は「PHPのオートロード1以降」「Composer時代より前」に開発されたフレームワークである、という認識からスタートしましょう。
そのために、App
クラスを用いた独自の機構を実装しています。
-> App::loadとbootstrap
ざっくりと言えば、この 「App::load
で探索可能なクラス(パス)を登録する」のが App::uses()
で、また App::import()
はuses()に加えて即時的なファイルの読み込みも行う〜というものです。
namespaceの有るや無しやとオートロード
問題となるのは、namespaceの利用を前提とした(PSR-4のような)「読み込み」でない場合、オートロードにより意図せぬ副作用がもたらされる可能性が高くなる という点です。
PHPのオートロードというのは、結局の所「その行を実行した時点で、呼び出されているクラスが存在しなかったら、いい感じにファイルを読み込む」というものになります。掻い摘んで言えば「新しくファイルを include 'hoge';
する」という機能です。
ちょ〜素朴にコードを示せば
if (!lcass_exists('Hoge')) {
include 'Hoge.php';
}
です。
ここまで簡潔化すると「何が起きそうか」が透けて見えてくるのではないでしょうか。
すなわち、「どっかのファイルの実行時に行われたオートロードによって、その後に処理される別のファイル上でもHogeクラスを使える」という状態が生じます。
これは、namespace云々やPSR-0/4とは関係なく起きていることです。
例えばCakePHP2において、「ArticlesController::beforeFilter()
の中で$this->loadModel('Author');
した場合、 /articles/create
ページ(action)において 実行される $this->Article->doSome()
メソッドの中では無条件に Author
モデルの(static)メソッドを呼び出せる」という事になります。
とりわけ「PHPプログラムの実行単位」という意味でいうと、(CI上など)複数の単体テストを連続で行う場合には「全ての副作用を最後まで引き継ぐ」ということになります。
そうしてアンチパターンであるErratic Testを誘発し、Independentであることを侵害されかねません。
では、なぜ「namespaceがしっかりしていると安心感が有る」のでしょうか?
ここではPSR-0か4かの違いは問題になりません。2
肝要なのは、「利用者(プログラマーではなく、実行者=コンピューター)が、呼び出したい対象を 明確に 意識できる」という点だと思います。これは実際、すごくシンプルな話です。
例えばHashクラスを使いたい場合(これはCakePHPを使うとほぼ確実に読み込まれ済みですが)を例に考えてみましょう。
// cake2: namespaceなし
Hash::flatten($ar);
// cake3: namespaceあり
\Cake\Utility\Hash::flatten($ar);
後者の方が 詳細に呼び出される事になります。(ファイルの冒頭で use \Cake\Utility\Hash;
を宣言していても同じ事です。)
また、PSR-4ベースのオートロードであれば、クラスメソッドを呼び出すために\Cake\Utility\Hash::{$method}
と書いた時点で、未読み込みクラスを解決してくれることになります。
このように、「クラスの存在を意識すれど、クラスを読み込めるかどうかを意識しなくていい」というのがPSR-0/4及びそれをベースとしたオートローダーのもたらす開発体験である、という事ができます。
CakePHP2のオートロードであっても「やりたいこと」は同じですが、そのやり方が(開発者にとって)自然で、シームレスになるのです。
なぜCakePHP2のオートロードは「まどろっこしく」なっているのか
では、何故にこのような「真の意味で勝手に読んでくれる」状態が実現できるのか?あるいは出来ないのか?という疑問があります。
PSR-0/4の方は、クラス名(FQCN)から「どのパスにあるのか」を解決することが出来ます。
他方で、「namespaceを利用していない」場合に、クラス名から「どこのパスに有るファイルか」を特定するのは大変です。
例えば Bridge
という名前のクラスがあったら、どうでしょうか?suffixのない単数形の名称なので、Lib
Utility
そして Model
辺りでしょう。このように、どの(サブ)パッケージがあればパスを解決しに行くことが出来ます3。
App::uses('Bridge', 'Model');
$brigde = Bridge::build();
いかがでしょう、ともすればおまじない的で見覚えのあるコードが「理屈が透けて見える」ようになったのではないでしょうか。
根底にある「使いたい対象であるクラス名」という与えられた情報から取得できる情報が少なすぎる・・という問題に対して、このように「人力で」読み込み対象のクラスファイルについてヒントを与えておこう!というのが、CakePHP2形式のアプローチなのだなと思いました。
どうするか
改めて話を整理すると、我々が実現したい世界は「意識することが少なくても、ちゃんと動くコードが書ける」というものです。それに関連して、「テストの時だけどうにかなった」を避けたい・・という気持ちを持っています。
PSR-0/4及びそれをベースとしたオートローダーの活用は、そうした状態への到達を支援できているように思います。それを雑に云うなら、「使おうと思ったクラスが呼び出したとおりに存在する」という点に本質を求めらそうです。
ということで、「我々のCakePHP2」の世界において如何にそれを実現するか?を考えます。
Composerの活用: CakePHP2が標準としていなかった手段が用意されている
CakePHP2は「Composer以前」のフレームワークなので、Comopserに依存しないように作られていました。
今回の話でいうと、ComposerはPHPアプリケーションの開発において2つの「違い」をもたらしている、と言えると思います。
- PSR-0/4を含め、オートロードの手段を提供している
- デプロイ時点で利用される「セットアップ」手順の提供
-
composer install
のことです!言い方難しい・・
-
ここまでに「パスの解決」「クラス名とファイルの紐付け」に関するコストについて、何度か言及してきました。
具体的には「ファイナライズ」のような、アプリケーション実行に際しての事前処理です。これを行う(事を前提とした)のを可能とした点がデカいな〜と感じています。
これによって、やろうと思えば「利用されるクラスを全てオートロード可能にしておく」ための処理、コストが高いと言われた「パスの解決をやっておいちゃう」余地が生まれます。
Composerのautoloadファイル
Composerのオートロードにおける「解決先」の指定(=CakePHP2でいうApp::build()
に当たるような、どの名前(ないしパッケージ)がどこにあるかを登録する処理)はcomposer.jsonにて行われます。
3つの遅延読み込みと1つの即時(事前)読み込みのためのスキーマがあります。
- 遅延読み込み
- PSR-0
- PSR-4
- ClassMap
- 即時読み込み
- Files
よく使うのはPSR-4だと思いますが、今回は「ClassMap」に着目します。
Comopserがautoloadファイルを吐き出す際に、ココに指定されたパス(ディレクトリ、ファイル名)にあるクラスを読み取って[$fqcn => $filePath]
という形式の連想配列 = クラスマップ を作成します。そうしてできあがるのが、 autoload_classmap.php
というファイルです。
内容を見ていきましょう。
公式ドキュメントでは以下のような記述が例示されています。
{
"autoload": {
"classmap": ["src/", "lib/", "Something.php"]
}
}
イメージをつかみやすくするために、次のようなファイルを作ってみました。
$ tree
.
├── Something.php
├── composer.json
├── lib
│ └── Utility.php
└── src
├── Entry.php
└── Model
└── User.php
この状態で composer dumpautoload
を実行し、生成された(classmapの)オートロードファイルは次のようになります。
// vendor/composer/autoload_classmap.php
<?php
// autoload_classmap.php @generated by Composer
$vendorDir = dirname(dirname(__FILE__));
$baseDir = dirname($vendorDir);
return array(
'App\\Entry' => $baseDir . '/src/Entry.php',
'App\\Model\\User' => $baseDir . '/src/Model/User.php',
'NotAnything' => $baseDir . '/Something.php',
'Something' => $baseDir . '/Something.php',
'Utility' => $baseDir . '/lib/Utility.php',
);
このファイルは vendor/autoload.php
を起点として読み込まれるものですが、コレさえあれば App::uses()
のような手間をアプリケーション側に持たせずとも「NotAnythingクラスがどこにあるか分かる」訳です。
// src/Model/User.php
namespace App\Model;
class User
{
}
// Something.php
class NotAnything
{
}
class Something
{
}
このように、PSR-0/1/4の制約に縛られずに、「名前空間とファイルパスがどうなっていようと、同じファイルに複数のクラスが定義されていようと、構わずに FQCNとファイルパスのマップを作成する 」という挙動となります。
autoload_classmapの利用コスト
このように「愚直なクラスマップを作成する」機能にておいては、使えば使うほど容易に「クラスマップが肥大化する」という事態を招きます。
単なる連想配列ですが、今まで使い慣れていないものでもあると「パフォーマンス的にどうなのか?」という疑念も湧きます。
基本的には、(PSR-4ベースのオートロードよりも)低コストにクラスのオートロードを実現させるものだと考えて良いはずです。
実際に、Composerの「最適化オプション」である composer dumpautoload -o
(あるいは comopser install -o
でも良いですが)のアイディアは、「 通常であればautoload_psr4.phpに入るような内容も、autoload_classmap.phpに入れてしまおう 」というものになります。
なので、少なくとも「普段からComopserのパッケージ(PSR-4によるautoload)を使っている」局面においては、「classmapベースのオートロードが遅いか速いか」は気にするようなコストではなさそうに思います。
この話題については、StackOverflow上にいい感じな議論があるので確認してください。
php - Why use a PSR-0 or PSR-4 autoload in composer if classmap is actually faster? - Stack Overflow
また、この中で紹介されているblogも有用です。
Patrick Allaert's Blog about Free Software: Benchmarking Composer autoloading, Symfony and eZ Publish 5
ということで、結論としては「開発・実装における安心感と細やかなパフォーマンスチューニングによって受けられる恩恵」を天秤にかけることになると思います。その上で、「かなり肥大化しなければ目につく問題にはならなそう」であり、「最終的にはベンチとってみるしかないよね」という事も意識しながら、「どこまでやるか」の落とし所を見つけていくと良さそうです。
-
「PSR-0だから」といっても、オートロードでやるべきことは「区切り文字で区切ってパス(ディレクトリ名+ファイル名)を探し出し、includeする」です。例えばPackagistを漁ったらこんな実装が見つかりました。 https://github.com/Respect/Loader/blob/0.2.1/library/Respect/Loader.php ↩
-
「どこのディレクトリを探しに行くか?」という情報は、
App::build()
で予め登録している内容が基になります。また、「全く独特な場所から探しに行き、かつAppクラスのクラスマップに登録する」場合にはApp::improt()
に適切な引数を渡して利用することになります。 ↩