前提
この記事はlaravel5.0で動作確認をしています
また, 文中で「オブジェクト」という単語と「インスタンス」という単語が入り混じって出てきますが
これらについては特に厳密なルールに基づいて使い分けているわけではないので,
違和感がありましたらすみません.
laravelのDI
最近のフレームワークのご多分に漏れず, laravelにもDI(DependencyInjection)の機能が搭載されています.
このことは問題ないというかとてもありがたいのですが, ちょっと期待していたものとは異なる挙動をされて
(そしてしばらくそのことに気がついてなくて)ハマってしまった点があったので, 自分へのメモがてら書いておきます.
DIの挙動を確認する
オブジェクトをシングルトンとしてコンテナに登録する
自前で作成したサービスクラスも, サービス・プロバイダのregisterメソッド内でコンテナに登録することで,
他のクラスでもコンテナ経由で利用することができます.
また, その際にコンテナのsingletonメソッドを利用することで, シングルトンオブジェクトとして登録することもできる.
例えば, 以下の様なクラスを作成し
<?php namespace App\Services;
/**
* DIの挙動を確認するためのクラス
*/
class Hoge {
private $createTime;
public function __construct()
{
$this->createTime = microtime();
}
public function getCreateTime()
{
return $this->createTime;
}
}
アプリケーションプロバイダで, このクラスのオブジェクトをコンテナに登録すると
public function register()
{
$this->app->singleton('hoge', function()
{
return new \App\Services\Hoge();
});
}
このHogeクラスのオブジェクトをコンテナから取得する場合, 常に同一のオブジェクトを取得することができます.
<?php namespace App\Test;
use App;
use TestCase;
class BasicTest extends TestCase {
public function testDI_Singleton指定でコンテナに登録されたオブジェクトを取得する()
{
$one = App::make('hoge');
$other = App::make('hoge');
$this->assertSame($one, $other, 'コンテナからキー名前指定で取得したオブジェクト同士の比較'); #このテストは通る
}
}
## コンストラクタインジェクションで依存関係を暗黙的に解決する
コントローラやミドルウェアのような, フレームワークで自動的に生成されるオブジェクトのコンストラクタに
クラスを指定しつつ引数と追加すると, 生成時にフレームワークの方で自動的に適切なオブジェクトを渡してくれます.
つまり, 以下の様なコントローラをつくると, HTTPのリクエストを受けて, このコントローラのindexメソッドが
呼び出される時点で, すでに$requestと$hogeが利用可能な形でセットされているのです.
<?php namespace App\Http\Controllers;
use App\Services\Hoge;
use Illuminate\Http\Request;
/**
* constructor injectionの確認用コントローラ
*
*/
class HogeController extends Controller {
private $request;
private $hoge;
public function __construct(Request $request, Hoge $hoge)
{
$this->request = $request;
$this->hoge = $hoge;
}
public function index()
{
....
}
public function getHoge()
{
return $this->hoge;
}
}
で、このインジェクションされたサービスはシングルトンなんだよね?
確認のために, 以下の様なテストを書いてみると...
public function testDI_Singleton指定でコンテナに登録されたオブジェクトを取得する2()
{
$one = App::make('hoge');
$controller = App::make('App\Http\Controllers\HogeController');
$another = $controller->getHoge();
$this->assertSame($one, $another, 'コンストラクタに暗黙的にDIされたオブジェクトとの比較'); # テストは失敗する
}
(´・ω・`)
なぜこんな現象が起きたのか?
先に結論から書きますと, laravelのDIコンテナにsingletonメソッドを使ってオブジェクトを登録することは,
そのキーを使ってコンテナからオブジェクトを取得する際に, 常に同一のオブジェクトが得られることを保証するだけであって
対象オブジェクトが, アプリケーション全体でのクラスの唯一のインスタンスである(Singletonパターン)ことを意味しないのです.
ですので, 同じクラスのインスタンスであっても別のキー値を使ってコンテナに登録してしまえば、たとえsingletonメソッドを使っていたとしても
一つのコンテナに同じクラスの複数インスタンスを登録することはできてしまうわけです.
では, 上述したようなコンストラクタの引数にオブジェクトをインジェクションするときには, どんなキー値を使っているのかというと,
どうもクラス名そのもの(この例では/App/Services/Hogeという文字列)をキー値として使っているようです.
こんなキーでコンテナに何らかのオブジェクトを登録した覚えはないけど, どこで登録しているんだろう?と少しソースを追ってみたところ, コンテナのmakeメソッドでは, 渡されたキー値に対応するオブジェクト(というかオブジェクトを返す関数オブジェクト)を探し, 見つからなかったらキー値そのものをクラス名だと仮定して,
RefractionClassを作成し, ナンヤカンヤした結果依存関係を解決したオブジェクトが無事生まれてくるという動きになっているようです.
(私もソースを完全には理解できなかったので, より詳しい情報は, ソースを追ってください)
つまり, 単純に何らかのサービスクラスのインスタンスをコンストラクタで渡して欲しいだけならば, 別にサービス・プロバイダで
コンテナへの登録など書いてやる必要すらないのです.
(ただし, そのサービスクラスがコンストラクタで何らかの特別な引数を必要としているとなると話は別ですが)
どうすれば良いのか
上記の通り, コンテナは依存関係の解決のためにクラス名をキー値として, コンテナ内のオブジェクトを探します.
なので, シングルトンとして登録したい場合は最初からクラス名をキー値としてコンテナに登録しておけば
どのオブジェクトに対しても同一のインスタンスが渡されるようになります.
public function register()
{
$this->app->singleton('hoge', function()
{
return new \App\Services\Hoge();
});
// クラス名をキーにサービスを登録
$this->app->singleton('\App\Services\Hoge', function()
{
return new \App\Services\Hoge();
});
}
public function testDI_Singleton指定でコンテナに登録されたオブジェクトを取得する2()
{
$one = App::make('Atls\Services\Hoge');
$controller = App::make('Atls\Http\Controllers\HogeController');
$another = $controller->getHoge();
$this->assertSame($one, $another, 'コンストラクタに暗黙的にDIされたオブジェクトとの比較'); # テストは通る
}
オブジェクトを登録する際に, そのクラス名をキー値とする, というのはそのままでは芸の無い話なのですが,
この方法を応用して, クラス名ではなくインタフェイス名でサービスをコンテナに登録しておき
コントローラのインスタンス引数もインタフェイスを指定することで, 実行環境などに合わせてオブジェクトを切り替えることや, あるいは古いクラス名で新しいオブジェクトを登録することで, 呼び出し側を書き換えることなくコードの挙動を
変えることなどができると考えられます.
public function register()
{
// インタフェイス名をキーにサービスを登録
$this->app->singleton('\App\Services\IHoge', function()
{
// 実際のクラス名は環境変数から得る
return new env('SERVICE_HOGE_CLASS')();
});
// 古いクラス名をキーに新しいクラスのオブジェクトをを登録
$this->app->singleton('\App\Services\TheOldOne', function()
{
// このクラスはTheOldOneを継承している必要はある
return new TheGreatestOne();
});
}
どうすればいいのか その2 ファサードを使う
以下のようなファサードを作り
<?php namespace App\Facades;
use Illuminate\Support\Facades\Facade;
/**
* @see \App\Services\Hoge
*/
class Hoge extends Facade {
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor()
{
return 'hoge';
}
}
このサービスを利用する場合は, コンストラクタによる依存関係の解決などしないで
全部このファサード経由で処理を呼び出すようにすれば, 常にコンテナにhoge
という名前で
登録したオブジェクトが使われるようになります.
こちらの機能も便利ですし, テスト時はMockに差し替える機能も用意されているので
この方法も良いのではないでしょうか?
(個人的には, 依存関係はインスタンス変数で明示的に表現しておきたいところですが)
まとめ
- laravelのDIコンテナのsingletonメソッドは, 厳密にはSingletonパターンとはいえないかもよ
- コンストラクタインジェクションの際は, クラス名をキーにオブジェクトの検索/生成を行おうとするよ
- アプリケーション全体を通して, 常の同じオブジェクトにアクセスしたならば, Facedeを使ってサービスを提供するのも手だよ
参考
Container: https://github.com/laravel/framework/blob/5.0/src/Illuminate/Container/Container.php#L640
RefrectionClass : http://php.net/manual/ja/class.reflectionclass.php