Pieceの中のSymfony #3: Dependency Injectionコンポーネント
Piece FrameworkのプロダクトのひとつStagehand_TestRunnerは、CLIでユニットテストを実行するための継続的テストランナーです。Stagehand_TestRunner v3の実装には多くのSymfonyコンポーネントが使われています。今回はStagehand_TestRunnerを動作させる上で重要な役割を果たしているDependency Injectionコンポーネントについて具体的な使い方を解説します。
Symfony Dependency Injectionコンポーネント
Dependency InjectionコンポーネントはSymfonyにおけるDIコンテナの実装であり、WebアプリケーションフレームワークとしてのSymfonyの基盤となるプロダクトです。サービスの依存性とその注入方法の定義、サービスの作成・取得、サービスへの依存性の注入、といったDIコンテナの基本機能に加えて、タグによる拡張ポイント・拡張の定義、Configコンポーネントとの統合、コンパイラによるカスタマイズ可能な最適化プロセス等の特徴を持ちます。
可変性を持つアーキテクチャの設計では,必ずしも UML のこうした機能を使う必要はないが,何らかの設計上の意図を明確化する表現法を利用して,資産管理を考えることは重要である.
このようにして可変性を分離できれば,可変部分をカプセル化し変更の影響を局所化し,あるいは,実行環境の外に定義して,可変点での選択肢を柔軟に取ることが可能となる.たとえば,オブジェクト指向におけるインターフェイスと実装の分離の原則を使った,ストラテジーパターンによる実装や,DI(Dependency Injection)による実行時の実装部分の選択がこの例である.
— 萩原正義 ユニシス技報 93号 Software Factories,現状と未来 - マイクロソフト社の次世代開発基盤技術(PDF)
DIコンテナを使用するメリットとしては、依存性の反転によるコンポーネントの疎結合化の促進、テスタビリティの向上等がよく知られています。しかし、PHPのような動的な言語では静的な言語と比べてこのようなメリットが少ないため、これまで何度となく不要論が唱えられてきました。
しかし、ソフトウェアを構成するための実装コンポーネントの組み合わせに関する知識(構成の知識)を記述する言語としてDIコンテナを位置付けるとどうでしょうか。可変性を含む構成の知識を資産と捉え、サービスの定義(とその可変部分によって構成されたDSL)によって設計上の意図を明確化し、コンパイル時や実行時に可変部分を選択しながらソフトウェアを構成する、そのためのエンジンとしてDIコンテナを使うのです。
このようにDIコンテナを使うことは、実装にともなうソフトウェアの設計情報の喪失をより少なくする手段として多くの言語で有用ではないかと私は考えています。
Stagehand_TestRunnerではまさにそのような意図をもってDependency Injectionコンポーネントを使用しています。
Dependency Injectionコンポーネントを単体で使用する
では具体的な使い方をみていきます。まずは全体像を知るためにDependency Injectionコンポーネントの構造をみてみましょう。以下の図はDependency Injectionコンポーネントを使うにあたって登場するクラスの関連を表したものです。
赤はDependency Injectionコンポーネントが提供するクラスおよびインターフェイスで、緑はユーザーのDependency Injectionコンポーネント関連クラス、青はユーザーのDIコンテナ管理対象クラスを表しています。最終的にFooサービスに依存するクライアントは、DIコンテナによる依存性の注入によって暗黙的に、あるいはファクトリーを使って明示的にFooサービスを取得することになります。
ContainerBuilderはその名前の通りアプリケーション固有のDIコンテナを組み立てるためのクラスであり、compile()メソッドによって自身が保持するサービス定義とパラメーターをコンパイルします。コンパイルの実態はDIコンテナの最適化です。
PhpDumperクラスはコンパイル済みのContainerBuilderオブジェクトをContainerを継承したクラスのソースコードに変換します。Symfonyアプリケーションでは起動直後に実行されるブートストラップファイルapp/booststrap.php.cacheにこのコンパイルプロセスが組み込まれているため開発者が意識する必要はありませんが、単体で使う場合はコンパイルプロセスを自前で準備する必要があります。Stagehand_TestRunnerにおけるコンパイルプロセスはtestrunner compileコマンドに組み込まれています。このコマンドによって実行されることになるメソッドから順にみていきましょう。
Stagehand\TestRunner\CLI\TestRunnerApplication\Command\CompileCommand:
<?php ... namespace Stagehand\TestRunner\CLI\TestRunnerApplication\Command; ... use Stagehand\TestRunner\DependencyInjection\Compiler; ... class CompileCommand extends Command { ... protected function execute(InputInterface $input, OutputInterface $output) { $compiler = new Compiler(); $compiler->compile(); return 0; } ...
Compilerはコンパイルプロセスを実装したStagehand_TestRunnerのクラスです。Compiler::compile()はコンパイルプロセスそのものを表現するメソッドです。
Stagehand\TestRunner\DependencyInjection\Compiler:
<?php ... namespace Stagehand\TestRunner\DependencyInjection; ... class Compiler { ... public function compile() { // (1) ContainerBuilderオブジェクトの作成 $containerBuilder = new UnfreezableContainerBuilder(); // (2) エクステンションの登録 foreach (ExtensionRepository::findAll() as $extension) { $containerBuilder->registerExtension($extension); } // (3) エクステンションの設定のロード foreach ($containerBuilder->getExtensions() as $extension) { /* @var $extension \Symfony\Component\DependencyInjection\Extension\ExtensionInterface */ $containerBuilder->loadFromExtension($extension->getAlias(), array()); } // (4) 最適化戦略のカスタマイズ $containerBuilder->getCompilerPassConfig()->setOptimizationPasses( array_filter( $containerBuilder->getCompilerPassConfig()->getOptimizationPasses(), function (CompilerPassInterface $compilerPass) { return !($compilerPass instanceof ResolveParameterPlaceHoldersPass); } )); // (5) コンパイルとダンプ $compiler = new \Stagehand\ComponentFactory\Compiler( $containerBuilder, self::COMPILED_CONTAINER_CLASS, self::COMPILED_CONTAINER_NAMESPACE ); file_put_contents( __DIR__ . '/' . self::COMPILED_CONTAINER_CLASS . '.php', $compiler->compile() ); } ...
このプロセスで重要なのは、ContainerBuilderオブジェクトに対する3つの操作、すなわちエクステンションの登録(2)とその設定のロード(3)、コンパイルとダンプ(5)です。
(3)で空の配列を渡していますが、これはStagehand_TestRunnerの設計に起因するのものです。通常は設定ファイル等から読み込んだ生の設定を渡すことになるでしょう。
(2)で登録したエクステンションは(5)でContainerBuilder::compile()メソッドを経由して呼び出されます。
Stagehand\ComponentFactory\Compiler:
<?php ... namespace Stagehand\ComponentFactory; ... class Compiler { ... public function compile($evaluatable = false) { $this->containerBuilder->compile(); $phpDumper = new PhpDumper($this->containerBuilder); $containerClassSource = $phpDumper->dump(array('class' => $this->class)); if (!is_null($this->namespace)) { $containerClassSource = preg_replace( '/^<\?php/', '<?php' . PHP_EOL . 'namespace ' . $this->namespace . ';' . PHP_EOL, $containerClassSource ); } if ($evaluatable) { $containerClassSource = preg_replace('/^<\?php/', '', $containerClassSource); } return $containerClassSource; } ...
Stagehand_TestRunnerのエクステンションは解説に不向きなため、ここでは別のプロダクトPiece_Questetraのエクステンションをみてみましょう。
Piece\Questetra\DependencyInjection\PieceQuestetraExtension:
<?php ... namespace Piece\Questetra\DependencyInjection; ... use Symfony\Component\HttpKernel\DependencyInjection\Extension; ... class PieceQuestetraExtension extends Extension { public function load(array $configs, ContainerBuilder $container) { $config = $this->processConfiguration(new Configuration(), $configs); $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); $loader->load('services.yml'); $container->setParameter('piece_questetra.context_root', $config['context_root']); $container->setParameter('piece_questetra.user_id', $config['authentication']['user_id']); $container->setParameter('piece_questetra.password', $config['authentication']['password']); } ...
エクステンションのload()メソッドではエクステンションのサービス定義をロードし、受け取った設定をサービス定義やパラメーター等に変換します。
ソフトウェアの可変性の観点からみれば、ここは構成の知識の固定部分(サービス定義)と可変部分(設定)がパラメーターを介して結合し、実装コンポーネントの構成が行われる場所と考えることができます。
DIコンテナの最適化戦略はContainerBuilder::getCompilerPassConfig()メソッドで返されるPassConfigオブジェクトで表現されています。戦略の一つ一つはコンパイラパスと呼ばれるCompilerPassInterfaceインターフェイスのインスタンスです。それらはContainerBuilder::compile()メソッドの呼び出しによって実行され、DIコンテナに適用されます。ユーザーは自由にコンパイラパスの追加・削除・変更を行うことができます。
Stagehand_TestRunnerでは独自のContainerBuilderオブジェクトを使い(1)、パラメーターをインライン化するResolveParameterPlaceHoldersPassオブジェクトを最適化パスから削除することで(4)、コマンドラインパラメーターをDIコンテナのパラメーターに変換できる余地を作っています。
コンパイルが終われば、アプリケーション固有のDIコンテナをインスタンス化して使うことができます。Stagehand_TestRunnerの場合、PluginCommand::execute()メソッドでDIコンテナのインスタンス化とtest_runnerサービスの作成を行っています。
Stagehand\TestRunner\CLI\TestRunnerApplication\Command\PluginCommand:
<?php ... namespace Stagehand\TestRunner\CLI\TestRunnerApplication\Command; ... abstract class PluginCommand extends Command { ... protected function execute(InputInterface $input, OutputInterface $output) { if (!class_exists(Compiler::COMPILED_CONTAINER_NAMESPACE . '\\' . Compiler::COMPILED_CONTAINER_CLASS)) { $output->writeln( 'Please run the following command before running the ' . $this->getName() . ' command:' . PHP_EOL . PHP_EOL . ' testrunner compile' ); return 1; } // アプリケーション固有のDIコンテナの作成 $container = $this->createContainer(); ApplicationContext::getInstance()->getComponentFactory()->setContainer($container); ApplicationContext::getInstance()->setPlugin($this->getPlugin()); ApplicationContext::getInstance()->setComponent('input', $input); ApplicationContext::getInstance()->setComponent('output', $output); $transformation = $this->createTransformation($container); $this->transformToConfiguration($input, $output, $transformation); $transformation->transformToContainerParameters(); // test_runnerサービスの作成とrun()メソッドの実行 $this->createTestRunner()->run(); return 0; } ... protected function createContainer() { return new Container(); } ... protected function createTestRunner() { return ApplicationContext::getInstance()->createComponent('test_runner'); } ...
Stagehand_TestRunnerのプロダクションコードでサービス名を埋めこんでいるのはこの一箇所だけです。サービス名をどのようにコードの外に追いやっているのか、興味のある方は以下のクラスやサービス定義ファイルを覗いてみてください。
Stagehand\TestRunner\Core\ApplicationContext
Stagehand\TestRunner\Core\Plugin\PluginAwareFactory
Stagehand\ComponentFactory\ComponentAwareFactory
Stagehand\ComponentFactory\ComponentFactory
src/Stagehand/TestRunner/Resources/config/general.yml
これまでみてきたようにDependency Injectionコンポーネントは単独で使うには少々手間がかかります。また、依存性とその注入方法の定義が一体となっているため、JSR-330相当の機能を持つDIコンテナ(例えばRay.DiやDingが挙げられます。)と比べると関心の分離という点で遅れをとっているのは確かです。
しかし、Dependency Injectionコンポーネントには、Symfonyアプリケーションとの親和性の高さは当然のことながら、タグによる拡張ポイント・拡張の定義、Configコンポーネントとの統合、コンパイラによるカスタマイズ可能な最適化プロセス等、他にはない特徴があります。これらはジェネレーティブプログラミングの世界を標榜する私にとって大きな魅力となっています。
Dependency Injection (current) - Symfony
CLI のための継続的テストランナー v3 - Stagehand_TestRunner - Piece Framework
Software Factories,現状と未来 - マイクロソフト社の次世代開発基盤技術(PDF)