Pieceの中のSymfony #4: Configコンポーネント
Symfony Advent Calendar JP 2012 - Day 6
今回はStagehand_TestRunner、Piece_Flow等、Piece Frameworkのいくつかのプロダクトで使われているConfigコンポーネントについて解説します。
Configコンポーネントはソフトウェアの可変部分を表現する言語を定義し、処理するためのフレームワークです。Symfonyフレームワークにおいてはバンドルおよびその背後にあるコンポーネントの可変部分をユーザーが構成するために使われています。
Configコンポーネントが取り扱うのは一般的に設定や構成と呼ばれるものです。
ドメイン固有言語 (DSL) は特定用途向けの言語です。ドメイン固有言語は、システムファミリの具体的なメンバを「発注」するのに使い、ゆえにジェネレーティブプログラミングにおいて重要な役割を果たします。
— ジェネレーティブプログラミング (IT Architects’Archive CLASSIC MODER)
設定はソフトウェアの可変部分(可変点)に対する具体的な値であり、最終的にオブジェクトのような実装コンポーネントの構成に変換されます。別の言い方をすると、設定によって具体的なソフトウェアが構成されます。
設定はドメイン固有の言語の体裁を持っているため、ドメイン特化言語(DSL: Domain Specific Language)であるといえます。
設定言語が存在する以上そのパーサーは必須となりますが、PHPの世界ではこれまでは手書きのもので済ませてきた事例が大部分を占めるのではないでしょうか。手軽に使えるパーサージェネレーターが存在しないことが大きな理由かもしれませんが、それだけでしょうか?
Symfonyフレームワークでは設定の記述にINI、PHP、XML、YAML、その他任意のフォーマットを使うことができます。通常のパーサージェネレーターが単一のテキストフォーマットに対する文法を定義することでパーサーを生成するものだとしたら、フォーマット毎に文法定義が必要となり現実的ではありません。
まず、抽象構文を定義します。これは抽象形の「スキーマ」です。
次に「エディタ」を定義し、抽象形を投影を通じて操作可能にします。
それから「ジェネレータ」を定義します。これは抽象形をどのように実行可能形に変換するかを定義しています。実際には、ジェネレータはDSLのセマンティクスを定義します。
— Martin Fowler's Bliki in Japanese - LanguageWorkbench 新しいDSLの定義
Configコンポーネントでは特定のフォーマットについてではなく、設定の抽象形(抽象構文木)について文法を定義します。これによって単一の文法で複数のフォーマットをサポートすることが可能になります。
DSLの観点から見れば、Configコンポーネントはグラマー言語による設定言語の文法定義を核とするDSL構築フレームワークといえるでしょう。
Configコンポーネントを単体で利用するのはそれほど難しくはありません。まずは設定言語定義と設定の処理の関係を図でみてみましょう。
Configコンポーネントを利用する場合の主な活動はグラマー言語によって設定の抽象構文を定義することです。以下は現在開発中のPiece_Flow v2におけるページフロー定義の文法を記述したものです。
Piece\Flow\PageFlow\Definition17Configuration:
<?php ... namespace Piece\Flow\PageFlow; ... use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; ... class Definition17Configuration implements ConfigurationInterface { public function getConfigTreeBuilder() { $treeBuilder = new TreeBuilder(); $treeBuilder->root('definition17') ->children() ->scalarNode('firstState')->isRequired()->cannotBeEmpty()->end() ->arrayNode('viewState') ->isRequired() ->requiresAtLeastOneElement() ->prototype('array') ->children() ->scalarNode('name')->isRequired()->cannotBeEmpty()->end() ->scalarNode('view')->isRequired()->cannotBeEmpty()->end() ->arrayNode('transition') ->prototype('array') ->children() ->scalarNode('event')->isRequired()->cannotBeEmpty()->end() ->scalarNode('nextState')->isRequired()->cannotBeEmpty()->end() ->arrayNode('action') ->children() ->scalarNode('class')->defaultNull()->cannotBeEmpty()->end() ->scalarNode('method')->isRequired()->cannotBeEmpty()->end() ->end() ->end() ->arrayNode('guard') ->children() ->scalarNode('class')->defaultNull()->cannotBeEmpty()->end() ->scalarNode('method')->isRequired()->cannotBeEmpty()->end() ->end() ->end() ->end() ->end() ->end() ->arrayNode('entry') ->children() ->scalarNode('class')->defaultNull()->cannotBeEmpty()->end() ->scalarNode('method')->isRequired()->cannotBeEmpty()->end() ->end() ->end() ->arrayNode('exit') ->children() ->scalarNode('class')->defaultNull()->cannotBeEmpty()->end() ->scalarNode('method')->isRequired()->cannotBeEmpty()->end() ->end() ->end() ->arrayNode('activity') ->children() ->scalarNode('class')->defaultNull()->cannotBeEmpty()->end() ->scalarNode('method')->isRequired()->cannotBeEmpty()->end() ->end() ->end() ->end() ->end() ->end() ->arrayNode('actionState') ->prototype('array') ->children() ->scalarNode('name')->isRequired()->cannotBeEmpty()->end() ->arrayNode('transition') ->isRequired() ->requiresAtLeastOneElement() ->prototype('array') ->children() ->scalarNode('event')->isRequired()->cannotBeEmpty()->end() ->scalarNode('nextState')->isRequired()->cannotBeEmpty()->end() ->arrayNode('action') ->children() ->scalarNode('class')->defaultNull()->cannotBeEmpty()->end() ->scalarNode('method')->isRequired()->cannotBeEmpty()->end() ->end() ->end() ->arrayNode('guard') ->children() ->scalarNode('class')->defaultNull()->cannotBeEmpty()->end() ->scalarNode('method')->isRequired()->cannotBeEmpty()->end() ->end() ->end() ->end() ->end() ->end() ->arrayNode('entry') ->children() ->scalarNode('class')->defaultNull()->cannotBeEmpty()->end() ->scalarNode('method')->isRequired()->cannotBeEmpty()->end() ->end() ->end() ->arrayNode('exit') ->children() ->scalarNode('class')->defaultNull()->cannotBeEmpty()->end() ->scalarNode('method')->isRequired()->cannotBeEmpty()->end() ->end() ->end() ->arrayNode('activity') ->children() ->scalarNode('class')->defaultNull()->cannotBeEmpty()->end() ->scalarNode('method')->isRequired()->cannotBeEmpty()->end() ->end() ->end() ->end() ->end() ->end() ->arrayNode('lastState') ->addDefaultsIfNotSet() ->children() ->scalarNode('name')->isRequired()->cannotBeEmpty()->end() ->scalarNode('view')->isRequired()->cannotBeEmpty()->end() ->arrayNode('entry') ->children() ->scalarNode('class')->defaultNull()->cannotBeEmpty()->end() ->scalarNode('method')->isRequired()->cannotBeEmpty()->end() ->end() ->end() ->arrayNode('exit') ->children() ->scalarNode('class')->defaultNull()->cannotBeEmpty()->end() ->scalarNode('method')->isRequired()->cannotBeEmpty()->end() ->end() ->end() ->arrayNode('activity') ->children() ->scalarNode('class')->defaultNull()->cannotBeEmpty()->end() ->scalarNode('method')->isRequired()->cannotBeEmpty()->end() ->end() ->end() ->end() ->end() ->arrayNode('initial') ->children() ->scalarNode('class')->defaultNull()->cannotBeEmpty()->end() ->scalarNode('method')->isRequired()->cannotBeEmpty()->end() ->end() ->end() ->arrayNode('final') ->children() ->scalarNode('class')->defaultNull()->cannotBeEmpty()->end() ->scalarNode('method')->isRequired()->cannotBeEmpty()->end() ->end() ->end() ->end() ; return $treeBuilder; } ...
この設定言語に対応する設定例は以下のようになります。
Warning この例にはルートノードdefinition17の記述がありませんが、通常はルートノードを記述する必要があります。
firstState: Input lastState: name: Finish view: Finish activity: method: onFinish viewState: - name: Input view: Input activity: method: onInput transition: - event: next nextState: Validation - name: Confirmation view: Confirmation activity: method: onConfirmation transition: - event: next nextState: Registration - event: prev nextState: Input actionState: - name: Validation activity: method: onValidation transition: - event: invalid nextState: Input - event: valid nextState: Confirmation - name: Registration activity: method: onRegistration transition: - event: done nextState: Finish
DSLはそのソフトウェアのユーザーのための高レベルなAPIであり、ドメインの言語を使ってユーザーの要求に適合した抽象度で書けることが望まれます。
設定の読み込み、結合、バリデーション、デフォルト値入力
設定の読み込みは任意の方法で行います。基本的にはYamlコンポーネント(YAML)やDependency Injectionコンポーネント(INI、PHP、XML、YAML)が提供するものを使うと良いでしょう。前述のConfigurationInterfaceオブジェクトと読み込まれた設定をSymfony\Component\Config\Definition\Processor::processConfiguration()メソッドに渡すことで、複数の設定の結合、バリデーション、デフォルト値入力が行われ、最終的な設定が返されます。
Piece\Flow\PageFlow\PageFlowGenerator:
<?php ... namespace Piece\Flow\PageFlow; ... use Symfony\Component\Config\Definition\Processor; use Symfony\Component\Yaml\Yaml; ... class PageFlowGenerator { ... protected function readDefinition() { $processor = new Processor(); return $processor->processConfiguration( new Definition17Configuration(), array('definition17' => Yaml::parse($this->pageFlowRegistry->getFileName($this->pageFlow->getID()))) ); } ...
最終的な設定は抽象構文木を表現する配列になっているため、簡単に利用することができます。以下のコードでは、前述のreadDefinition()メソッドの呼び出しによって得られた設定にアクセスし、その値を使ってPiece\Flow\PageFlow\PageFlowオブジェクトを構成しています。
Piece\Flow\PageFlow\PageFlowGenerator:
<?php ... namespace Piece\Flow\PageFlow; ... class PageFlowGenerator { ... public function generate() { $definition = $this->readDefinition(); if (State::isProtectedState($definition['firstState'])) { throw new ProtectedStateException("The state [ {$definition['firstState']} ] cannot be used in flow definitions."); } $this->fsmBuilder->setStartState($definition['firstState']); if (!empty($definition['lastState'])) { if (State::isProtectedState($definition['lastState']['name'])) { throw new ProtectedStateException("The state [ {$definition['lastState']['name']} ] cannot be used in flow definitions."); } $this->fsmBuilder->addTransition($definition['lastState']['name'], Event::EVENT_END, State::STATE_FINAL); $this->configureViewState($definition['lastState']); $this->pageFlow->addEndState($definition['lastState']['name']); $this->pageFlow->addView($definition['lastState']['name'], $definition['lastState']['view']); } $this->configureViewStates($definition['viewState']); $this->configureActionStates($definition['actionState']); if (!empty($definition['initial'])) { $this->fsmBuilder->setExitAction(State::STATE_INITIAL, $this->wrapAction($definition['initial'])); } if (!empty($definition['final'])) { $this->fsmBuilder->setEntryAction(State::STATE_FINAL, $this->wrapAction($definition['final'])); } $this->pageFlow->setFSM($this->fsmBuilder->getFSM()); return $this->pageFlow; } ...
ConfigコンポーネントをSymfonyバンドルで利用する
ConfigコンポーネントをSymfonyバンドルで利用するのはとても簡単です。FooBarBundle\DependencyInjection\Configurationクラスで設定言語を定義し、FooBarBundle\DependencyInjection\FooBarExtensionクラスで設定の結合、バリデーション、デフォルト値自動入力を行い、最終的な設定をDIコンテナのパラメーターやサービス定義に変換します。以下のコードはSymfony\Bundle\FrameworkBundle\DependencyInjection\FrameworkExtensionクラスの例です。
Symfony\Bundle\FrameworkBundle\DependencyInjection\FrameworkExtension:
<?php ... namespace Symfony\Bundle\FrameworkBundle\DependencyInjection; ... class FrameworkExtension extends Extension { ... public function load(array $configs, ContainerBuilder $container) { $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); $loader->load('web.xml'); $loader->load('services.xml'); ... $configuration = $this->getConfiguration($configs, $container); $config = $this->processConfiguration($configuration, $configs); $container->setParameter('kernel.secret', $config['secret']); ...
ジェネレーティブプログラミングを標榜する私にとって、ConfigコンポーネントはSymfonyコンポーネントの中でもとりわけ重要な位置付けにあります。今後も様々な場面で積極的に使うことになるでしょう。
Config (current) - Symfony
Martin Fowler's Bliki in Japanese - LanguageWorkbench