Practical Symfony #27: コンパイルタイムファクトリ(Compile Time Factories)
この記事はSymfony Advent Calendar 2015 8日目の記事です。前日の記事は@__tai2__さんの「DQLのJOIN WITH構文を使えば、無用な関係を定義せずにテーブルの結合ができる」でした。
ファクトリ(Factories)は、オブジェクトの生成(Creation)に関するデザインパターンで、オブジェクトまたはオブジェクトグラフの組み立て方法についての知識(構成の知識)を集約するものです。Eric Evans氏(@ericevans0)の提唱するドメイン駆動設計(Domain-Driven Design: DDD)のビルディングブロックの1つとしても知られています。
論理的にはファクトリは以下のような構造を持ちます。
クラスまたはメソッドによるファクトリ
クラスまたはメソッドを使ったファクトリはPHP + Symfonyの環境における基本型といえます。その構造は上記の図と同様になります。生成されるオブジェクトのバリアントはファクトリに与える引数の違いから生じます。変数値がランタイム(runtime)にならないと決まらない場合に特に有用ですが、クライアントコードでファクトリクラスを直接使うとクライアントとファクトリが結合する点には注意が必要です。
変数の値は構成の知識ではない
バリアントが少ない場合、異なる変数値の組を持つそれぞれのメソッドを定義すればよいと思うかもしれません。しかし、この場合は構成の知識がそれぞれのメソッドに重複して存在することに留意しなければいけません。例えば、生成対象クラスに可変点が追加された場合を想像してみてください。構成の知識と変数値の組は定義されるタイミングが異なるため、異なる場所に配置される方がより適切であるといえるのです。
DIコンテナを使ったファクトリ
DIコンテナはSymfonyフレームワークを支える基盤です。構成の知識を集約するという観点から見ると、DIコンテナはファクトリの一種といえます。生成されるオブジェクトのバリアントはDIコンテナのサービス定義の違いから生じます。変数値がソースコード記述時(source time)やアプリケーションのデプロイ時(deploy time)に定まるような場合に効果を発揮します。その構造は論理的には先ほどのものと変わりませんが、物理的にはクライアントがファクトリに依存しない点で異なります。
DIコンテナを使ったファクトリでは、変数値はSymfonyアプリケーションのキャッシュクリア時に生成されるDIコンテナクラスのメソッド内に埋め込まれます。DIコンテナの生成(コンパイル)が行われるタイミングのことを私たちはコンパイルタイム(compile time)と呼んでいます。
ドメイン特化言語として表現される可変性
Symfonyの設定ファイルapp/config/config.yml等のコードは、問題ドメインにおける可変性の表現です。そこではソフトウェアの内部的な用語ではなく、そのドメインのクライアントとソフトウェアの間で共通に使われる用語(と構造)が使われます。ドメイン特化言語(Domain-Specific Language: DSL)は解決ドメインにおける実装コンポーネントの可変性を別の形で表現したものといえます。
Note このあたりの話は書籍「ジェネレーティブプログラミング」のp.146「5.9.7 コンフィギュレーションDSL」に詳しく書かれていますので興味のある方は是非ご覧ください。
DIコンテナを使ったファクトリでオブジェクトのバリアントを扱う場合、変数値はサービス定義やパラメーターではなくconfig.ymlで記述されるのが一般的です。なぜなら、config.ymlが配置されるパスapp/config/config.ymlに表されているように変数値はドメイン(フレームワーク)ではなくアプリケーションに属するものだからです。
問題:変数値は事前にわからないためファクトリサービスを定義できない
さて、ここで1つ問題があります。変数値は事前にわからないため、ソースコードを書いているタイミングではファクトリサービスを定義できないのです。
解決:エクステンションやコンパイラーパスを使ってサービス定義を生成する
この問題はメタプログラミングを使ってDIコンテナのサービスを定義することで解決できます。Symfonyにはそのようなプログラミングを行う場所として、エクステンション(Symfony\Component\DependencyInjection\Extension\ExtensionInterface)とコンパイラーパス(Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface)があります。例としてPHPMentorsWorkflowerBundleのコードを見てみましょう。
Workflowerでは1つ以上のワークフロー定義ファイル(BPMNファイル)を持つコンテキストを1つ以上定義することができます。それぞれのコンテキストはBPMNファイルを配置するディレクトリを1つ持ちます。PHPMentors\Workflower\Definition\Bpmn2WorkflowRepositoryオブジェクトはコンテキスト毎のBPMNファイルのパスを保持し、findById()によってワークフローID(ファイル名から拡張子を取り除いたもの)に対応するPHPMentors\Workflower\Workflow\Workflowオブジェクトを返します。このPHPMentors\Workflower\Definition\Bpmn2WorkflowRepositoryのサービス定義を生成するコードは以下のようになります。
PHPMentors\WorkflowerBundle\DependencyInjection\PHPMentorsWorkflowerExtension:
<?php ... namespace PHPMentors\WorkflowerBundle\DependencyInjection; ... class PHPMentorsWorkflowerExtension extends Extension { ... /** * @param array $config * @param ContainerBuilder $container */ private function transformConfigToContainer(array $config, ContainerBuilder $container) { ... foreach ($config['workflow_contexts'] as $workflowContextId => $workflowContext) { $workflowContextIdHash = sha1($workflowContextId); $bpmn2WorkflowRepositoryDefinition = new DefinitionDecorator('phpmentors_workflower.bpmn2_workflow_repository'); $bpmn2WorkflowRepositoryServiceId = 'phpmentors_workflower.bpmn2_workflow_repository.'.$workflowContextIdHash; $container->setDefinition($bpmn2WorkflowRepositoryServiceId, $bpmn2WorkflowRepositoryDefinition); $definitionFiles = Finder::create() ->files() ->in($workflowContext['definition_dir']) ->depth('== 0') ->sortByName() ; foreach ($definitionFiles as $definitionFile) { $workflowId = Bpmn2File::getWorkflowId($definitionFile->getFilename()); $bpmn2FileDefinition = new DefinitionDecorator('phpmentors_workflower.bpmn2_file'); $bpmn2FileDefinition->setArguments(array($definitionFile->getPathname())); $bpmn2FileServiceId = 'phpmentors_workflower.bpmn2_file.'.sha1($workflowContextId.$workflowId); $container->setDefinition($bpmn2FileServiceId, $bpmn2FileDefinition); $bpmn2WorkflowRepositoryDefinition->addMethodCall('add', array(new Reference($bpmn2FileServiceId))); $processDefinition = new DefinitionDecorator('phpmentors_workflower.process'); $processDefinition->setArguments(array(pathinfo($definitionFile->getFilename(), PATHINFO_FILENAME), new Reference($bpmn2WorkflowRepositoryServiceId))); $processServiceId = 'phpmentors_workflower.process.'.sha1($workflowContextId.$workflowId); $container->setDefinition($processServiceId, $processDefinition); } } } ...
エクステンションとコンパイラーパスのどちらを使うべきかはケースバイケースですが、やることは変わりません。いずれの場合においても、DIコンテナのコンパイル時にファクトリが動作するように見えることから、私はこのような解決策をコンパイルタイムファクトリ(Compile Time Factories)と名付け、ソフトウェアパターンとして積極的に活用しています。
おわりに
コンパイルタイムファクトリを使うと、バリアントの定義をアプリケーションに委ねつつ、構成の知識をドメイン(フレームワーク)に集約することができます。また、コンパイルタイムファクトリを使うことで、従来必要とされたファクトリクラスの多くは不要になります。それは単なる機構を超えて、関連するドメインモデルにもいくらかの影響を与えることでしょう。James Coplien氏(@jcoplien)が書籍「マルチパラダイムデザイン」で述べたように、解決ドメインの構造が問題ドメインの構造を変化させるのですから。
参考
Factory Pattern | Object Oriented Design
ジェネレーティブプログラミング (IT Architects’Archive CLASSIC MODER)
新装版 マルチパラダイムデザイン














