[CakePHP3] PSR-11対応のDIコンテナをCakePHP3で使う
この記事は CakePHP Advent Calendar 2018 - Qiita の15日目の記事です。
こんにちは、ナカエです。 本日の記事はCakePHP3でもDIコンテナによるDIパターンを利用したいと思い、試してみたという内容です。
きっかけ
CakePHP2を利用している国産CMS baserCMSの次期バージョン、baserCMS5のキックオフミーティングが OSC翌日の島根で開催されました。 CakePHP3(ないし4)へのバージョンアップを始め、CakePHPのプラグインとしての開発やREST API化など、様々な方針が決定されました。
私もコアデベロッパーの1人として参加して次期バージョンへの要望と夢を語ってきたわけですが、基礎設計に一つ要望を出しました。
「CakePHPでもDIコンテナを使いたい!」
弊社の普段の開発では主にLaravelを利用しており、DIによりService層を注入することでControllerを薄くすることが多いです。 CakePHPに限った話ではないのですが、基本的にUI層であるControllerはテストしづらくbaserCMSでも苦労しています。 Model(注:ActiveRecordの意味でのModel。Cake3だとEntityクラス)をまたぐロジックの置き場所に困り、Controllerに書かれていることもよくあります。
これらのロジックをServiceクラスに移しテストを容易にしたい、というのがメインのモチベーションです。
下記はDIコンテナ中毒者の様子です。
DIコンテナによる依存の解決に慣れすぎたことにより、プログラマのDIコンテナへの依存性が高まりDIコンテナを導入していない案件なんてやってられるかと叫び出す事案を依存性の注入と呼びます
— n (@n_1215) 2017年3月29日
CakePHPとDI
この要望の実現可能性を確かめるため、CakePHP3と4への動向を少し調べました。 CakePHP4にはDIコンテナが導入されるのではないかと期待していたこともあったのですが、GitHubのIssueや開発関係者のブログを読むにCakePHPがDIコンテナを採用する未来は今の所やって来なさそうです。
CakePHPはControllerやその他のクラスに外部からロジックを注入するためにサービスロケータパターンを利用しています。 CakePHP2の頃はClassRegistryというグローバルなコンテナが使われていましたが、CakePHP3ではこのコンテナを目的ごとに分割し、局所的なサービスコンテナを作る方針に変えたようです。
サービスロケータはアンチパターンとされることも多いですが、フレームワークの開発者にとっては有用なパターンです。 DIコンテナによる依存解決の起点となるオブジェクトはコンテナから直接取り出さざるを得ないからです。 多くのフレームワークでは依存解決の起点はコントローラやコマンドオブジェクトになっていることが多いですね。DIについての戦略がよく練られたフレームワークならアプリケーションオブジェクトを取り出すだけ、ということもあります。 参考: アプリケーションはオブジェクトグラフ
これらを踏まえ、個人的な結論としては、フレームワークのユーザコードレベルで様々な依存を詰め込んだサービスロケータが現れるのはアンチパターンだと考えています。 要するにフレームワークの中にコンテナからオブジェクトを取り出すサービスロケータの部分を隠蔽しさえすれば、利用者はサービスロケータパターンを使う必要がなくDIパターンだけで設計できる、ということです。
CakePHPはこの考えとは思想が異なるようです。ただし前述のように、グローバルなサービスコンテナをやめてローカルのコンテナに分けることで、コンテナへの依存による危険性を減らしています。たとえば、ControllerにはObjectRegistryを継承したComponentRegistryが注入されていて、このオブジェクトからユーザが使いたいComponentだけを取り出すことができます。
DIパターンを使うことを視野に入れなかったことはないはずだと思うのですが、おそらくグローバルなDIコンテナそのものやそれによるユーザの自由度の過剰な増加をあまり好ましく考えていないのではないでしょうか。
はい、CakePHP3の設計から溢れ出る力強い思想を独断と偏見で感じ取ったので、なるべく思想に寄り添えるようにDIコンテナを組み合わせていきたいと思います。
Componentではダメなのか?
CakePHP3のComponentがFWとの結合度の低いクラスであればComponentをService層として使う方向でも良かったのですが、 ControllerやRequest, ResponseなどHTTP層のオブジェクトを内包しているのでフレームワークやUIに依存したロジックを書きやすくなっています。 これはプロジェクトの規約で縛るのもちょっと辛いだろうと判断しました。
PSR-11
前置きが長くなりましたが本題です。PHPのDIコンテナは様々な実装がありますが、PHP-FIGにてオススメのコンテナのインターフェースがPSR-11として採択されています。 あとで利用するDIコンテナのライブラリを切り替えられるように、今回はPSR-11のインターフェースを利用します。
PSR-11は先日PHP-FIGを抜けたSymfonyのリード開発者も一目おく、コンパクトにまとまったインターフェースです。 ポイントは、インターフェースをコンテナからオブジェクトを取り出す側面に絞っている点、そして依存解決が失敗した場合の例外のインターフェースをしっかり定めた点というところでしょうか。
<?php
namespace Psr\Container;
/**
* Describes the interface of a container that exposes methods to read its entries.
*/
interface ContainerInterface
{
public function get($id);
public function has($id);
}
コンテナへの依存関係の登録のインターフェースについて規定がないため、各実装において自由に決めることができます。
今回作ったプラグイン
以上を踏まえて実装するプラグインの満たすべき要件を考えました。
- 好きなPSR-11のコンテナと一緒に使える
- CakePHPのプラグインとしてComposerでインストールできる
- Controllerの依存解決はアクションの実行時に依存を解決するAssisted Injection(Method Injection)を基本とする
- Console Commandの依存解決はConstructor Injectionを基本とする
- @InjectアノテーションによるProperty Injectionが使えるDIコンテナ実装ならそれも使える
- なるべくCakePHPの元のコードからの変更を少なくする
試作品
がこちらになります。CakePHP4を見据えたコードの変更が激しそうだったので、CakePHP3.6以上必須になっています。
インストール
# CakePHP3のプロジェクトを作成
composer create-project --prefer-dist cakephp/app your_app
cd your_app
# プラグインをComposerでインストール
composer require n1215/cake-candle
使い方
1. PSR-11のコンテナを選ぶ
サンプルではPHP-DIを利用します。
composer require php-di/php-di
2. Applicationクラスを変更
コントローラへの対応のため、プロジェクトのApplicationクラスを変更します。 \N1215\CakeCandle\Http\ContainerAwareApplication、1.で選んだコンテナを設定して、N1215\CakeCandle\ContainerBagLocatorに渡します。 例ではオートワイヤリングを有効にしているので省略していますが、DIコンテナへの依存解決方法の登録が必要な場合は別途登録してください。
<?php
namespace App;
// ...
use Cake\Http\BaseApplication;
// ...
use DI\ContainerBuilder;
use N1215\CakeCandle\ContainerBagLocator;
use N1215\CakeCandle\Http\ContainerAwareApplication;
// ...
class Application extends BaseApplication
{
// 1. ContainerAwareApplication を利用.
use ContainerAwareApplication;
// 2. PSR-11のコンテナを好きに作る
private function configureContainer()
{
$builder = new ContainerBuilder();
$builder->useAutowiring(true);
$builder->useAnnotations(false);
return $builder->build();
}
// 3. bootstrap時にContainerBagLocatorを初期化する
public function bootstrap()
{
////// ここから
try {
$container = $this->configureContainer();
ContainerBagLocator::init($container);
} catch (\Exception $e) {
throw new \RuntimeException('Failed to configure the di container.', 0, $e);
}
////// ここまで
// Call parent to load bootstrap from files.
parent::bootstrap();
// ...
}
// ...
}
3. bin/cake.phpを変更
こちらはConsoleコマンドのための対応です。コンソールコマンドのエントリファイルを変更します。 CommandRunnerの引数に、コンテナ対応のCommandFactoryを渡すようにします。
// ...
+ use N1215\CakeCandle\Console\CommandFactory;
// ...
- $runner = new CommandRunner(new Application(dirname(__DIR__) . '/config'), 'cake');
+ $runner = new CommandRunner(new Application(dirname(__DIR__) . '/config'), 'cake', new CommandFactory());
4. コントローラやコマンドに注入したい依存クラスを作成
自由にPOPOのクラスを作ります。
<?php
namespace App;
class GreetingService
{
public function hello(string $name)
{
return "Hello, {$name}";
}
}
5. コントローラへの注入
アプリケーションのコントローラ基底クラスを継承して、\N1215\CakeCandle\Http\AssistedActionトレイトをつかいます。 このトレイトにより、アクション呼び出し時に型宣言した引数をコンテナから補完します。
<?php
namespace App\Controller;
use App\GreetingService;
use N1215\CakeCandle\Http\AssistedAction;
class HelloController extends AppController
{
use AssistedAction;
public function index(string $name, GreetingService $greetingService)
{
$suffix = $this->request->getQuery('suffix', 'san');
$this->response
->getBody()
->write($greetingService->hello($name . ' ' . $suffix));
return $this->response;
}
}
個人的にはコンストラクタ注入が好みなのですが、CakePHP3のコントローラの基底クラスのコンストラクタ引数が多すぎるため断念しました。
6. コマンドへの注入
通常通り\Cake\Console\Commandを継承します。DIコンテナで設定された、もしくは自動解決によるコンストラクタ注入が行われます。
<?php
namespace App\Command;
use App\GreetingService;
use Cake\Console\Arguments;
use Cake\Console\Command;
use Cake\Console\ConsoleIo;
use Cake\Console\ConsoleOptionParser;
class HelloCommand extends Command
{
/**
* @var GreetingService
*/
private $greetingService;
public function __construct(GreetingService $greetingService)
{
parent::__construct();
$this->greetingService = $greetingService;
}
protected function buildOptionParser(ConsoleOptionParser $parser)
{
$parser->addArgument('name', [
'help' => 'name'
]);
return $parser;
}
public function execute(Arguments $args, ConsoleIo $io)
{
$name = $args->getArgument('name');
$io->out($this->greetingService->hello($name));
}
}
コンソールコマンドは最近コードが整理されているせいかかなり綺麗で、望み通りコンストラクタ注入を実現できました。
おまけ
@Injectアノテーションによる注入
DIの依存関係の登録方法についてはこのプラグインで規定しておらず、選択したDIコンテナ実装に依存します。 DIコンテナが対応してさえいれば、Injectアノテーションによる注入も可能です。 以下は引き続きPHP-DIの例です。
Composerでdoctrine/annotationsのパッケージをインストールします。
composer require doctrine/annotations
コンテナのアノテーションを有効にします。
// in Application::configureContainer();
$builder = new ContainerBuilder();
$builder->useAutowiring(true);
$builder->useAnnotations(true);
あとは、@Injectと@paramによる型の宣言を行えば、プロパティに依存が注入されます。
<?php
namespace App\Controller;
use App\GreetingService;
use Cake\Http\Response;
class HelloController extends AppController
{
/**
* @Inject
* @param GreetingService
*/
private $greetingService;
public function index(string $name)
{
$suffix = $this->request->getQuery('suffix', 'san');
$this->response
->getBody()
->write($this->greetingService->hello($name . ' ' . $suffix));
return $this->response;
}
}
コマンドについても同様です。
本プラグイン実装と設計のポイント
CakePHP3にDIコンテナを組み合わせるには、ControllerFactory、CommandFactoryでそれぞれのクラスをインスタンス化する際にDIコンテナを経由させる変更がメインとなります。 Commandはすごく作りが素直だったため、CommandFactoryInterfaceの実装を変更してCommandRunnerに与えるだけで済んでいます。 Controllerの解決過程は互換性の事情もあり少し複雑で、Applicationクラスの一部の処理をトレイトにより変更しました。 今回のようにAssisted Injectionに対応する場合は、Controllerについてはアクションを呼び出す処理の変更も必要です。
PSR-11のコンテナを独自のContainerBagというクラスでラップし、必要なメソッドを呼び出せるようにしています。
まとめ
- CakePHPはDIコンテナ推しではなさそう
- プラグインとしてDIコンテナを使うことは可能
CakePHP3を使う新規案件がないので実戦では試していません。baserCMS5に向けて、実験を進めていきたいと思います。