[go: up one dir, main page]
More Web Proxy on the site http://driver.im/ PHP Mentors (Posts tagged practical.symfony)
1.5M ratings
277k ratings

See, that’s what the app is perfect for.

Sounds perfect Wahhhh, I don’t wanna

Practical Symfony #28: Workflowerを使ったビジネスプロセスの管理

この記事はSymfony Advent Calendar 2015 25日目の記事です。前日の記事はqcmatsuokaさんの「SpBowerBundleのキャッシュエラーを解決する | QUARTETCOM TECH BLOG」でした。


WorkflowerBPMN 2.0に準拠するPHP向けのワークフローエンジンであり、2条項BSDライセンスの下でリリースされているオープンソース製品です。Workflowerの主な用途としては、人間を中心としたビジネスプロセスをPHPアプリケーションで管理することが挙げられます。

この記事では、Workflowerを使ったビジネスプロセスの管理をSymfonyアプリケーション上で行うために必要な作業について示します。

PHPMentorsWorkflowerBundleによるSymfonyインテグレーション

PHPMentorsWorkflowerBundleはWorkflowerをSymfonyアプリケーションで使うためのインテグレーションレイヤーで、以下の機能を提供します。

  • ワークフローに対応するDIコンテナサービスの自動生成とphpmentors_workflower.process_awareタグを使ったサービスオブジェクトの自動注入
  • Symfonyセキュリティシステムを使った業務担当者(パーティシパント)の割り当てとアクセス制御
  • Doctrine ORMを使ったエンティティのための透過的なシリアライゼーション・デシリアライゼーション
  • 複数のワークフローコンテキスト(BPMNファイルが保存されたディレクトリ)

WorkflowerおよびPHPMentorsWorkflowerBundleのインストール

最初に、Composerを使ってWorkflowerおよびPHPMentorsWorkflowerBundleをプロジェクトの依存パッケージとしてインストールします。

$ composer require phpmentors/workflower "1.0.*"
$ composer require phpmentors/workflower-bundle "1.0.*"

次に、PHPMentorsWorkflowerBundleを有効にするためにAppkernelを変更します。

...
class AppKernel extends Kernel
{
    public function registerBundles()
    {
        $bundles = array(
            ...
            new PHPMentors\WorkflowerBundle\PHPMentorsWorkflowerBundle(),
        );
        ...

コンフィギュレーション

続いてPHPMentorsWorkflowerBundleのコンフィギュレーションを行います。以下に例を示します。

app/config/config.yml:

phpmentors_workflower:
    serializer_service: phpmentors_workflower.base64_php_workflow_serializer
    workflow_contexts:
        app:
            definition_dir: "%kernel.root_dir%/../src/AppBundle/Resources/config/workflower"
  • serializer_service - ワークフローのインスタンスとなるPHPMentors\Workflower\Workflow\Workflowオブジェクトのシリアライズに使用するDIコンテナサービスのIDを指定します。指定されたサービスにはPHPMentors\Workflower\Persistence\WorkflowSerializerInterfaceの実装が期待されています。デフォルトはphpmentors_workflower.php_workflow_serializerです。また、シリアライズ済みのオブジェクトをBase64エンコード・デコードするphpmentors_workflower.base64_php_workflow_serializerを使うこともできます。
  • workflow_contexts - ワークフローのコンテキストID毎にdefinition_dir(BPMNファイルが保存されたディレクトリ)を指定します。

BPMNを使ったワークフローの設計

BPMN 2.0をサポートするエディターを使ってWorkflowerで動作させるワークフローを定義します。最初は開始イベント、タスク、終了イベントのみで構成されたワークフローを定義し、それでワークフローの開始から終了までの動作が確認できたら、改めてワークフロー全体を設計し、定義するとよいでしょう。このBPMNファイルの名前はワークフローIDworkflow ID)として使われます。例えばLoanRequestProcess.bpmnのような名前(キャメルケースを推奨)で保存します。分岐に使うシーケンスフローの条件式はSymfony ExpressionLanguageコンポーネントの式として評価されます。シーケンスフローの評価順は不定であるため、他の分岐先と整合的な条件式を設定する必要があることに注意してください。条件式の中では、PHPMentors\Workflower\Process\ProcessContextInterface::getProcessData()から取得される連想配列のキーを使うことができます。

以下のスクリーンショットはEclipseで利用可能なBPMNエディターであるBPMN2 Modelerのものです。

Editing the Workflow

Workflowerがサポートするワークフロー要素

WorkflowerはBPMN 2.0のワークフロー要素のうち以下のものをサポートしています。サポート外の要素はWorkflowerで動作させることができませんのでご注意ください。

  • 接続オブジェクト(connecting objects)
    • シーケンスフロー(sequence flows)
  • フローオブジェクト(flow objects)
    • アクティビティ(activities)
      • タスク(tasks)
    • イベント(events)
      • 開始イベント(start events)
      • 終了イベント(end events)
    • ゲートウェイ(gateways)
      • 排他ゲートウェイ(exclusive gateways)

Note これらの要素はBPMNによるワークフロー定義を通してクライアントと共有されるワークフロードメインモデルを構成します。このドメインモデルはドメイン駆動設計(Domain-Driven Design: DDD)が提唱するユビキタス言語Ubiquitous Language)の語彙となるものです。このようなドメインモデルを筆者はユビキタスドメインモデル(Ubiquitous Domain Model)と呼んでいます。ドメイン特化言語(Domain-Specific Language: DSL)はドメインとそのクライアントの間でユビキタスドメインモデルを媒介する役割を担っているといえるでしょう。

ワークフローのインスタンスを表すエンティティの設計

特定のワークフローのインスタンス(Workflowerではプロセスと呼ばれています)を表す永続化対象のエンティティを設計し、アプリケーションに追加します。このエンティティは通常PHPMentors\Workflower\Process\ProcessContextInterfacePHPMentors\Workflower\Persistence\WorkflowSerializableInterfaceを実装します。PHPMentors\Workflower\Process\ProcessContextInterface::getProcessData()から返される連想配列は、シーケンスフローの条件式内で展開されます。

また、アプリケーションにおける必要性(例:データベースへの問い合わせ)に応じてWorkflowオブジェクトのプロパティのスナップショットを保持するプロパティを用意するとよいでしょう。例えば、アプリケーションで特定のアクティビティに留まるプロセスをデータベースから検索する必要がある場合、現在のアクティビティを表す$currentActivityプロパティをエンティティに追加します。以下に例を示します。

...
use PHPMentors\Workflower\Persistence\WorkflowSerializableInterface;
use PHPMentors\Workflower\Process\ProcessContextInterface;
use PHPMentors\Workflower\Workflow\Workflow;
...
class LoanRequestProcess implements ProcessContextInterface, WorkflowSerializableInterface
{
    ...
    /**
     * @var Workflow
     */
    private $workflow;

    /**
     * @var string
     *
     * @Column(type="blob", name="serialized_workflow")
     */
    private $serializedWorkflow;
    ...
    /**
     * {@inheritdoc}
     */
    public function getProcessData()
    {
        return array(
            'foo' => $this->foo,
            'bar' => $this->bar,
            ...
        );
    }

    /**
     * {@inheritdoc}
     */
    public function setWorkflow(Workflow $workflow)
    {
        $this->workflow = $workflow;
    }

    /**
     * {@inheritdoc}
     */
    public function getWorkflow()
    {
        return $this->workflow;
    }

    /**
     * {@inheritdoc}
     */
    public function setSerializedWorkflow($workflow)
    {
        $this->serializedWorkflow = $workflow;
    }

    /**
     * {@inheritdoc}
     */
    public function getSerializedWorkflow()
    {
        if (is_resource($this->serializedWorkflow)) {
            return stream_get_contents($this->serializedWorkflow, -1, 0);
        } else {
            return $this->serializedWorkflow;
        }
    }
    ...

ビジネスプロセスの管理のためのドメインサービスの設計

Workflowerをプロダクションレベルで使うためには、プロセスの開始やワークアイテムの割り当て・開始・完了等を担当するドメインサービスが必要になるでしょう。特定のワークフローのプロセスとドメインサービスを結びつけるためにはPHPMentors\Workflower\Process\ProcessAwareInterfaceを実装し、そのドメインサービスのDIコンテナサービスに対して、phpmentors_workflower.process_awareタグを付与します。以下に例を示します。

...
use PHPMentors\DomainKata\Usecase\CommandUsecaseInterface;
use PHPMentors\Workflower\Process\Process;
use PHPMentors\Workflower\Process\ProcessAwareInterface;
use PHPMentors\Workflower\Process\WorkItemContextInterface;
use PHPMentors\Workflower\Workflow\Activity\ActivityInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
...
class LoanRequestProcessCompletionUsecase implements CommandUsecaseInterface, ProcessAwareInterface
{
    ...
    /**
     * @var Process
     */
    private $process;

    ...
    /**
     * {@inheritdoc}
     */
    public function setProcess(Process $process)
    {
        $this->process = $process;
    }
    ...

    /**
     * {@inheritdoc}
     */
    public function run(EntityInterface $entity)
    {
        assert($entity instanceof WorkItemContextInterface);

        $this->process->completeWorkItem($entity);
        ...

対応するサービス定義は以下のようになります。

...
app.loan_request_process_completion_usecase:
    class: "%app.loan_request_process_completion_usecase.class%"
    tags:
        - { name: phpmentors_workflower.process_aware, workflow: LoanRequestProcess, context: app }
    ...

この例のような「プロセスと操作の組み合わせ」に対応したユースケースクラスの実装は基本的ものといえますが、プロセス操作の共通性と可変性を分析し、可変性を外部に切り出すことができれば単一のクラスに操作をまとめることもできるでしょう。

ビジネスプロセスの管理

最後に、プロセスの開始やワークアイテムの割り当て・開始・完了等を実行するためのクライアント(コントローラー、コマンド、イベントリスナー等)を実装する必要があります。これらのクライアントも可変性を外部に切り出すことができるはずです。

ここまでの作業が終われば、Webインターフェイスやコマンドラインインターフェイス(Command Line Interface: CLI)からビジネスプロセスに対する一連の操作を実行できるようになります。

BPMSによるジェネレーティブプログラミングの実現に向けて

この記事では、Workflowerを使ったビジネスプロセスの管理をSymfonyアプリケーション上で行うために必要な作業について見てきました。WorkflowerおよびPHPMentorsWorkflowerBundleが提供するのはBPMN 2.0のワークフロー要素に対応するWorkflowドメインモデルと基本的なインテグレーションレイヤーに留まるため、実際にアプリケーションに組み込むためにはさらなる作業(とスキル)が要求されるでしょう。それは決して簡単なことではありません。なぜなら、それは対象ドメインに適したBPMS(Business Process Management System)あるいはBPMSフレームワークの設計に他ならないからです。

また、BPMSによるソフトウェア開発はビジネスプロセスドメインにおけるジェネレーティブプログラミング(Generative Programming)の実践といえます。PHPで利用可能なBPMSがほとんど存在しない現在、これに挑戦する者だけがその果実を手にすることができるのです。

参考

workflow bpm generative.programming dsl symfony practical.symfony multiparadigm.design ddd

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つとしても知られています。

論理的にはファクトリは以下のような構造を持ちます。

Factories

クラスまたはメソッドによるファクトリ

クラスまたはメソッドを使ったファクトリはPHP + Symfonyの環境における基本型といえます。その構造は上記の図と同様になります。生成されるオブジェクトのバリアントはファクトリに与える引数の違いから生じます。変数値がランタイム(runtime)にならないと決まらない場合に特に有用ですが、クライアントコードでファクトリクラスを直接使うとクライアントとファクトリが結合する点には注意が必要です。

変数の値は構成の知識ではない

バリアントが少ない場合、異なる変数値の組を持つそれぞれのメソッドを定義すればよいと思うかもしれません。しかし、この場合は構成の知識がそれぞれのメソッドに重複して存在することに留意しなければいけません。例えば、生成対象クラスに可変点が追加された場合を想像してみてください。構成の知識と変数値の組は定義されるタイミングが異なるため、異なる場所に配置される方がより適切であるといえるのです。

DIコンテナを使ったファクトリ

DIコンテナはSymfonyフレームワークを支える基盤です。構成の知識を集約するという観点から見ると、DIコンテナはファクトリの一種といえます。生成されるオブジェクトのバリアントはDIコンテナのサービス定義の違いから生じます。変数値がソースコード記述時(source time)やアプリケーションのデプロイ時(deploy time)に定まるような場合に効果を発揮します。その構造は論理的には先ほどのものと変わりませんが、物理的にはクライアントがファクトリに依存しない点で異なります。

Compile Time Factories

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)が書籍「マルチパラダイムデザイン」で述べたように、解決ドメインの構造が問題ドメインの構造を変化させるのですから。

参考

symfony practical.symfony design pattern generative.programming

Practical Symfony #26: PHPMentorsPageflowerBundleを使ったページフロー定義と対話の管理

Symfony Advent Calendar 2014 (Qiita) 10日目


PHPMentorsPageflowerBundleは筆者が開発したSymfonyアプリケーション向けのページフローエンジンです。特徴としては、以下のものが挙げられます。

  • アノテーションによるページフロー定義
  • 対話の管理
  • アクセス制御されたアクション
  • 対話スコープのプロパティ
  • 対話開始直後に実行されるユーザー定義メソッド
  • 複数のブラウザーウィンドウまたはタブのサポート

PHPMentorsPageflowerBundleを使うと、コントローラーに断片的に埋め込まれたページフローに関するコードを明示的な定義で置き換えることができます。また、対話対話スコープのプロパティの導入によってコントローラーの状態管理コードを大幅を削減することができます。

では、早速コードを見てみましょう。以下はSymfony2ベースのユーザー登録サンプルのコードです。

Example\UserRegistrationBundle\Controller\UserRegistrationController:

<?php
/*
 * Copyright (c) 2012-2014 KUBO Atsuhiro <kubo@iteman.jp>,
 *               2014 YAMANE Nana <shigematsu.nana@gmail.com>,
 * All rights reserved.
 *
 * This file is part of PHPMentors_Training_Example_Symfony.
 *
 * This program and the accompanying materials are made available under
 * the terms of the BSD 2-Clause License which accompanies this
 * distribution, and is available at http://opensource.org/licenses/BSD-2-Clause
 */

namespace Example\UserRegistrationBundle\Controller;

use PHPMentors\DomainKata\Usecase\UsecaseInterface;
use PHPMentors\PageflowerBundle\Annotation\Accept;
use PHPMentors\PageflowerBundle\Annotation\EndPage;
use PHPMentors\PageflowerBundle\Annotation\Init;
use PHPMentors\PageflowerBundle\Annotation\Page;
use PHPMentors\PageflowerBundle\Annotation\Pageflow;
use PHPMentors\PageflowerBundle\Annotation\StartPage;
use PHPMentors\PageflowerBundle\Annotation\Stateful;
use PHPMentors\PageflowerBundle\Annotation\Transition;
use PHPMentors\PageflowerBundle\Controller\ConversationalControllerInterface;
use PHPMentors\PageflowerBundle\Conversation\ConversationContext;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

use Example\UserRegistrationBundle\Entity\User;
use Example\UserRegistrationBundle\Form\Type\UserRegistrationType;

/**
 * @Route("/users/registration", service="example_user_registration.user_registration_controller")
 * @Pageflow({
 *     @StartPage({"input",
 *         @Transition("confirmation"),
 *     }),
 *     @Page({"confirmation",
 *         @Transition("success"),
 *         @Transition("input")
 *     }),
 *     @EndPage("success")
 * })
 */
class UserRegistrationController extends Controller implements ConversationalControllerInterface
{
    const VIEW_INPUT = 'ExampleUserRegistrationBundle:UserRegistration:input.html.twig';
    const VIEW_CONFIRMATION = 'ExampleUserRegistrationBundle:UserRegistration:confirmation.html.twig';
    const VIEW_SUCCESS = 'ExampleUserRegistrationBundle:UserRegistration:success.html.twig';

    /**
     * {@inheritDoc}
     */
    private $conversationContext;

    /**
     * @var User
     *
     * @Stateful
     */
    private $user;

    /**
     * {@inheritDoc}
     */
    public function setConversationContext(ConversationContext $conversationContext)
    {
        $this->conversationContext = $conversationContext;
    }

    /**
     * @Init
     */
    public function initialize()
    {
        $this->user = new User();
    }

    /**
     * @return Response
     *
     * @Route("/")
     * @Method("GET")
     * @Accept("input")
     * @Accept("confirmation")
     */
    public function inputGetAction()
    {
        if ($this->conversationContext->getConversation()->getCurrentPage()->getPageId() == 'confirmation') {
            $this->conversationContext->getConversation()->transition('input');
        }

        $form = $this->createForm(new UserRegistrationType(), $this->user, array('action' => $this->generateUrl('example_userregistration_userregistration_inputpost'), 'method' => 'POST'));

        return $this->render(self::VIEW_INPUT, array(
            'form' => $form->createView(),
        ));
    }

    /**
     * @param  Request  $request
     * @return Response
     *
     * @Route("/")
     * @Method("POST")
     * @Accept("input")
     * @Accept("confirmation")
     */
    public function inputPostAction(Request $request)
    {
        if ($this->conversationContext->getConversation()->getCurrentPage()->getPageId() == 'confirmation') {
            $this->conversationContext->getConversation()->transition('input');
        }

        $form = $this->createForm(new UserRegistrationType(), $this->user, array('action' => $this->generateUrl('example_userregistration_userregistration_inputpost'), 'method' => 'POST'));
        $form->handleRequest($request);

        if ($form->isValid()) {
            $this->conversationContext->getConversation()->transition('confirmation');

            return $this->redirect($this->conversationContext->generateUrl('example_userregistration_userregistration_confirmationget'));
        } else {
            return $this->render(self::VIEW_INPUT, array(
                'form' => $form->createView(),
            ));
        }
    }

    /**
     * @return Response
     *
     * @Route("/confirmation")
     * @Method("GET")
     * @Accept("confirmation")
     */
    public function confirmationGetAction()
    {
        $form = $this->createFormBuilder(null, array('action' => $this->generateUrl('example_userregistration_userregistration_confirmationpost'), 'method' => 'POST'))
            ->add('prev', 'submit', array('label' => '修正する'))
            ->add('next', 'submit', array('label' => '登録する'))
            ->getForm();

        return $this->render(self::VIEW_CONFIRMATION, array(
            'form' => $form->createView(),
            'user' => $this->user,
        ));
    }

    /**
     * @param  Request  $request
     * @return Response
     *
     * @Route("/confirmation")
     * @Method("POST")
     * @Accept("confirmation")
     */
    public function confirmationPostAction(Request $request)
    {
        $form = $this->createFormBuilder(null, array('action' => $this->generateUrl('example_userregistration_userregistration_confirmationpost'), 'method' => 'POST'))
            ->add('prev', 'submit', array('label' => '修正する'))
            ->add('next', 'submit', array('label' => '登録する'))
            ->getForm();
        $form->handleRequest($request);

        if ($form->isValid()) {
            if ($form->get('prev')->isClicked()) {
                return $this->redirect($this->conversationContext->generateUrl('example_userregistration_userregistration_inputget'));
            }

            if ($form->get('next')->isClicked()) {
                $this->createUserRegistrationUsecase()->run($this->user);
                $this->conversationContext->getConversation()->transition('success');

                return $this->render(self::VIEW_SUCCESS);
            }
        }

        $this->conversationContext->getConversation()->transition('input');

        return $this->render(self::VIEW_CONFIRMATION, array(
            'form' => $form->createView(),
        ));
    }

    /**
     * @return UsecaseInterface
     */
    private function createUserRegistrationUsecase()
    {
        return $this->get('example_user_registration.user_registration_usecase');
    }
}

アノテーションによるページフロー定義

...
/**
 * ...
 * @Pageflow({
 *     @StartPage({"input",
 *         @Transition("confirmation"),
 *     }),
 *     @Page({"confirmation",
 *         @Transition("success"),
 *         @Transition("input")
 *     }),
 *     @EndPage("success")
 * })
 */
...

@Pageflowアノテーションによるページフロー定義では、ページとページ間の関係を記述します。ページは1つの@StartPage0以上の@Page1つの@EndPageで構成されます。@StartPage対話が開始された後に遷移するページ(開始ページ)、@EndPageはそこに遷移すると対話が終了するページ(終了ページ)を示します。ページ間の関係は@Transitionによって記述します。

対話の管理

対話はリクエストされたURLがページフローのコントローラーである場合に自動的に開始される、ページフローのインスタンスです。1つのページフローに対して複数の対話を実行することができます。

対話は固有のID(対話ID)によって識別されます。リダイレクトURLの生成等で対話IDが埋め込まれたURLが必要な場合はController::generateUrl()の代わりにConversationContext::generateUrl()を使うことができます。また、フォームではCONVERSATION_IDフィールドが自動的に提供されます。

開発者はコントローラーの中でConversation::transition()を呼び出すことにより、カレントページを変更します。Conversation::transition()によって終了ページに遷移すると、対話は自動的に破棄されます。

アクセス制御されたアクション

...
    /**
     * ...
     * @Accept("confirmation")
     */
    public function confirmationPostAction(Request $request)
    {
    ...

@Acceptアノテーションによって、アクションを実行可能なページをホワイトリスト形式で記述します。カレントページがリストに存在しない場合、HTTPステータスコード403が返されます。

対話スコープのプロパティ

...
    /**
     * @var User
     *
     * @Stateful
     */
    private $user;
    ...

@Statefulアノテーションによって、プロパティを対話に紐付けることができます。プロパティは対話に限定されたセッション変数のように振る舞います。

対話開始直後に実行されるユーザー定義メソッド

...
    /**
     * @Init
     */
    public function initialize()
    {
        $this->user = new User();
    }
    ...

@Initアノテーションが付与されたメソッドは、対話の開始直後に自動的に実行されます。これらのメソッドは主に@Statefulが付与されたプロパティの初期化のために使われます。

複数のブラウザーウィンドウまたはタブのサポート

ブラウザーウィンドウまたはタブでそれぞれ別々の対話を実行することができます。

PHPMentorsPageflowerBundleを使いはじめるには?

PHPMentorsPageflowerBundleを使いはじめるに際には、ページフローの登録を含めた具体的なコード[Controller] ユーザー登録のページフローを実装した。 · 270b1c3 · phpmentors-jp/phpmentors-training-example-symfonyが参考になるでしょう。

参考

symfony practical.symfony dsl pageflow generative.programming

Practical Symfony #25: Routerを拡張してURLのサブディレクトリを横断的に処理する

Symfony Advent Calendar 2014 (Qiita) 4日目

Webサービスで、ユーザーのアカウントごとにサブディレクトリを割り当てたいとします(最近はユーザーアカウントごとにサブドメインを割り当てる方が主流かもしれませんが)。例えば次のような形です。

トップページ

ユーザー hidenorigoto のコンテンツ

ユーザー someone のコンテンツ

このような形をとるシステムを、みなさん一度は実装したことがあるのではないでしょうか。


問題となる箇所

登録ユーザー(hidenorigoto や someone )がこのサービスのトップページへアクセスした時に「自分のプロフィール」というリンクを表示することを考えてみます。リンク先はプロフィール表示用のアクションで、ProfileControllerクラスのindexAction() で実装します。examplebundle_profile_index というルートを割り当てるとすると、ルート定義は次のようになります。

ルート定義の例

examplebundle_profile_index:
    path: /{userdir}/profile
    defaults: { _controller: ExampleBundle:Profile:index }

このように、ユーザーごとのサブディレクトリがパラメーター (userdir) になっています。

Symfonyのルーティング機能はとても便利で、コントローラーからでもテンプレートからでも(その他の任意の場所からでも)router サービスを使えば、透過的にURLを生成できます。ただし、必要なパラメーターは生成する箇所でその都度渡さなくてはなりません。今回の要件のようにほとんどすべてのURL生成にユーザーのサブディレクトリが必要な場合、どうなるでしょうか? あらゆる処理にログインユーザーオブジェクトを渡し、それを参照してサブディレクトリパラメーターを準備しなくてはなりません。次に示すようなコード(PHPコード、Twigテンプレートコード)をいろいろなところに書くことになってしまいそうです。

問題コード:コントローラー

<php
$subdirectory = $loginUser->getSubdirectory();
$url = $router->generate(‘examplebundle_profile_index’, [‘userdir’ => $subdirectory]);

問題コード:テンプレート

{% set subdirectory = loginUser.subdirectory %}
<a href=“{{ path(‘examplebundle_profile_index’, {‘userdir’: subdirectory}) }}”>プロフィール</a>

「リンクのためのURL生成」というのは、ページの処理本体(プロフィールを表示する etc)からすると補助的な処理でしかありません。補助的な処理のために、本来不要であった依存を1つ増やして機能を実現した、ということになります。このような依存関係は排除すべきです。


解決策:Router を拡張する

今回の例では、URLへのサブディレクトリの付加処理がアプリケーションの個々の機能とは直交しており、横断的な関心事であることが分かります。Webアプリケーションの横断的関心事の多くは、すでにフレームワークが担当している領域でもあります。サブディレクトリの付加はURLに関する処理なので、近い役割をすでに担当している Router を拡張するという方法をとってみます。


Router

Router を継承したクラスで generate() メソッドをオーバーライドします。ログインユーザーに応じてサブディレクトリパラメーターを自動的に追加するようにしています。

ExampleBundle/Routing/Router.php

<php
...
class Router extends RouterBase
{
    private $securityContext;

    public function setSecurityContext($securityContext)
    {
        $this->securityContext = $securityContext;
    }

    public function generate($name, $parameters = array(), $referenceType = self::ABSOLUTE_PATH)
    {
        // 除外
        if (preg_match('/^login/i', $name)) {

        // ルート名がexamplebundleから始まるものを対象
        } elseif (preg_match('/^examplebundle/i', $name)) {
            if ($this->securityContext->getToken()) {
                // ユーザーからディレクトリ名を取得
                // (エンティティの内容による)
                $loginUser = $this->securityContext->getToken()->getUser();
                $userDir = $loginUser->getSubdirectory();
            } else {
                $userDir = 'default';
            }

            // サブディレクトリパラメーターをマージ
            $parameters = array_merge($parameters, ['userdir' => $userDir]);
        }

        return parent::generate($name, $parameters, $referenceType);
    }
}

Router の置き換え定義

デフォルトの router サービスを置き換えます。

ExampleBundle/Resources/config/services.yml

router:
    class: ExampleBundle\Routing\Router
    parent: router.default
    calls:
      - [setSecurityContext, ["@security.context"]]

上記の例は router サービスを「後勝ち方式」で完全に置き換えてしまいます。といっても、もともとの router は router.default のエイリアスなので、定義本体は残っています。その router.default を、新しいルーターサービスの parent に指定しています。これで、組み込みの router サービスに変わって、拡張した router サービスが使われるようになります。

サービスの置き換えは上記の他に、router サービスをデコレートする方法などもあります。[*1]


アクセスしたリソースごとにアクセス権を確認する処理は?

リソースごとのアクセス権の処理は、ルーティングの役割とは別の話になります。ルーティングのレイヤーとしては、単にルート定義にしたがって、アクセスするリソース(someoneのプロフィール)が特定される、という基本機能をそのまま使うのもよいでしょう。

私が実務で今回紹介した拡張を利用したプロジェクトでは、サブディレクトリがそれぞれ完全に独立しており、他のユーザーのサブディレクトリのリソースは絶対に閲覧不可という要件だったため、セキュリティレイヤーの認証処理の一部として実装しました。この時は、他のユーザーのサブディレクトリは「存在しないモノ」という位置づけで処理していました。

Symfony Securityコンポーネントで推奨される方法としては、SecurityVoter を定義する方法[*2]があります。コントローラーごとに異なった指定がある場合等は、こちらの方法がよいですね。

また、Symfony勉強会で@brtriverさんが発表されていたルーティングレイヤーでアクセス権の処理を行うようにする方策[*3]もあります。

いずれにしても、ルートに対してのINとOUT(URL生成とURLへのアクセス)は対になっているので、ルート生成側で横断的な機能を作ったのであれば、URLへのアクセス側でも同じように何らかの横断的処理が必要となります。


まとめ

解決したい問題をどこで解決するとよいのか? 問題によって選択肢はいくつかあり得ますが、上手い場所、特にフレームワークやライブラリがすでに「問題」として扱っている(=その関心を対象とするクラスがある)ものに寄せると、それを軸に拡張して解決するといった方法がとれます。


参考

practical.symfony symfony routing

Practical Symfony #24: ダイナミックなコンフィギュレーショングラマー

Symfonyフレームワークの動作を設定するコンフィギュレーションとその背後の仕組みは、Symfonyのアーキテクチャを支える強力な屋台骨となっています。この仕組みの応用例の1つとして、コンフィギュレーションエントリをダイナミックに定義する仕掛けを見てみます。

通常のコンフィギュレーション

通常の静的なコンフィギュレーションは、バンドル内のDependencyInjectionディレクトリ以下にConfigurationクラスを用意し、そこで定義されたツリー構造に従って読み込まれます。コンフィギュレーションクラスの例は設定の仕様とは等を参照してください。この場合、あらかじめ固定のコンフィギュレーショングラマーがあり、それにもとづいてコンフィギュレーションファイルに設定を記述し、そのファイルの設定を読み込んでサービスコンテナが動作します。

configuration1

アプリケーション開発の多くの場面ではこのような静的な定義で十分ですが、コンフィギュレーションで記述したい内容が、必ずしも1つのバンドルの内容だけで決まるとは限りません。他のバンドルと疎結合な状態を保ちつつ、協調して機能するようなコンフィギュレーションはどうやって定義するのでしょうか。

Securityバンドルのエンティティプロバイダの例

他のバンドルと協調動作するコンフィギュレーションの例として、Securityバンドルのエンティティプロバイダの例を紹介します。Symfonyで認証ユーザーのプロバイダーとしてDoctrineのエンティティクラスを使う場合、次のようにsecurity.ymlのprovidersに定義します。

    providers: 
        my_entity_provider:
            entity:
                class:              SecurityBundle:User
                property:           username
                manager_name:       ~

この定義でentityというのがプロバイダの種類を表しており、他にはmemoryがあります。entityの場合はSecurityバンドルの機能とDoctrineの機能が連携して動作しますが、そういったDoctrineを前提とした機能をSecurityバンドルが抱え込んでいるのでしょうか? ちなみに、EntityUserProviderクラスはDoctrineBridgeという連携パッケージにあります。

このダイナミックな定義の流れは、次の図のようになっています。

configuration2

Securityバンドルにおけるエンティティプロバイダのグラマー定義は、Securityエクステンションが保持しているプロバイダーファクトリーによって決まるようになっています。Securityバンドルには、このファクトリーのためのインターフェイスが定義されています。

SecurityBundle / DependencyInjection / Security / UserProvider / UserProviderFactoryInterface.php

interface UserProviderFactoryInterface
{
    public function create(ContainerBuilder $container, $id, $config);

    public function getKey();

    public function addConfiguration(NodeDefinition $builder);
}

エクステンションは、コンフィギュレーションの仕組みが動作する前に読み込まれています。Doctrineバンドルのbuild()メソッドで、Securityエクステンションがロードされている場合は、Securityエクステンションへエンティティプロバイダーファクトリーを登録しています。

DoctrineBundle / DoctrineBundle.php

    public function build(ContainerBuilder $container)
    {
        parent::build($container);

        $container->addCompilerPass(new RegisterEventListenersAndSubscribersPass('doctrine.connections', 'doctrine.dbal.%s_connection.event_manager', 'doctrine'), PassConfig::TYPE_BEFORE_OPTIMIZATION);

        if ($container->hasExtension('security')) {
            $container->getExtension('security')->addUserProviderFactory(new EntityFactory('entity', 'doctrine.orm.security.user.provider'));
        }
        $container->addCompilerPass(new DoctrineValidationPass('orm'));
    }

プロバイダーファクトリーとして登録されるEntityFactoryは、コンフィギュレーションノードを追加するメソッド(addConfiguration())を持っています。

Bridge / Doctrine / DependencyInjection / Security / UserProvider / EntityFactory.php

class EntityFactory implements UserProviderFactoryInterface
{
    ...
    public function addConfiguration(NodeDefinition $node)
    {
        $node
            ->children()
                ->scalarNode('class')->isRequired()->cannotBeEmpty()->end()
                ->scalarNode('property')->defaultNull()->end()
                ->scalarNode('manager_name')->defaultNull()->end()
            ->end()
        ;
    }
}

Securityバンドルのグラマー準備段階で、登録されたプロバイダーファクトリーのノード追加メソッドを呼び出しています($factory->addConfiguration())。

SecurityBundle / DependencyInjection / MainConfiguration.php

        foreach ($this->userProviderFactories as $factory) {
            $name = str_replace('-', '_', $factory->getKey());
            $factoryNode = $providerNodeBuilder->children()->arrayNode($name)->canBeUnset();

            $factory->addConfiguration($factoryNode);
        }

これにより、security.ymlを読み込む段階では、グラマー定義としてentityという項目がすでに有効になっている状態になります。コンフィギュレーションファイルが読み込まれ、エンティティプロバイダーが有効になります($factory->create())。

SecurityBundle / DependencyInjection / SecurityExtension.php

    private function createUserDaoProvider($name, $provider, ContainerBuilder $container, $master = true)
    {
        $name = $this->getUserProviderId(strtolower($name));

        foreach ($this->userProviderFactories as $factory) {
            $key = str_replace('-', '_', $factory->getKey());

            if (!empty($provider[$key])) {
                $factory->create($container, $name, $provider[$key]);

                return $name;
            }
        }

(しかし、ここはサービスコンテナのコンパイル段階であって、有効になるといってもエンティティプロバイダーのサービス定義が追加されるのみです。アプリケーションのランタイムまで、実際のオブジェクトはインスタンス化されません)

Bridge / Doctrine / DependencyInjection / Security / UserProvider / EntityFactory.php

class EntityFactory implements UserProviderFactoryInterface
{
    public function create(ContainerBuilder $container, $id, $config)
    {
        $container
            ->setDefinition($id, new DefinitionDecorator($this->providerId))
            ->addArgument($config['class'])
            ->addArgument($config['property'])
            ->addArgument($config['manager_name'])
        ;
    }

まとめ

サービスコンテナと、コンフィギュレーションとエクステンションの動きが見えてくると、Symfonyがどのように作られ動いているのか、把握しやすくなります。Securityバンドルはこの仕組みを最も高度に利用しているバンドルの1つです。ソースを読んでみるとさまざまな発見があります。

参考

practical.symfony configuration symfony dsl