Symfony Workflow Componentと状態

  • 9
    いいね
  • 0
    コメント

Symfony Advent Calendar 2016 第18日目の記事です。 http://qiita.com/advent-calendar/2016/symfony

Symfony Workflow Component

先日、Symfony3.2がリリースされたときに新たにWorkflow Componentが追加されました。
Workflow Componentはその名のとおりワークフローを管理するためのツールです。
ワークフローは製造業においてリソース(人、機械など)を効率的に活用するために生まれた考えのようですが、ここではオブジェクトの状態と処理の流れを管理するものと考えると良いと思います。

サンプル

今回は一般的なショッピングカートのワークフローを実装してみます。

cart_flow.png

商品がないと当然注文が成り立たないので、確認中へ遷移する前に商品が1個以上あるかを確認する必要があります。今回はこれをガードを利用して実装します。

サンプルはこちらからダウンロードできます。

このサンプルではWorkflow Componentの説明に主眼をおいているため、バリデーションや例外の処理は行っておりません。

動かすとこのような感じです。

enjoy_shopping.png

confirming.png

completed.png

ワークフローの定義

ワークフローはYAMLやDefinitionBuilderクラスで定義することができます。
今回はYAMLで定義します。

app/config/workflow.yml
framework:
    workflows:
        cart:
            marking_store:
                type: single_state
            supports:
                - AppBundle\Entity\Cart
            places:
                - shopping    # ショッピング中
                - confirming  # 確認中
                - completed   # 完了
            transitions:
                confirm:  # 確認
                    from: shopping
                    to: confirming
                back:     # 戻る
                    from: confirming
                    to: shopping
                complete: # 完了
                    from: confirming
                    to: completed

workflow.ymlをconfig.ymlにインポートすることでコンテナを経由して利用できるようになります。

app/config/config.yml
imports:
    ...
    - { resource: workflow.yml }

...

管理する対象となるクラス

ワークフローには管理の対象となるオブジェクトが必要です。今回はCartクラスを実装します。
Cartクラスは状態(marking)と注文する商品(products)を持つ単純なクラスです。

src/AppBundle/Entity/Cart.php
<?php

namespace AppBundle\Entity;

class Cart
{
    private $marking;

    private $products;

    public function __construct()
    {
        $this->marking = 'shopping';
        $this->products = [];
    }

    public function getMarking()
    {
        return $this->marking;
    }

    public function setMarking($marking)
    {
        $this->marking = $marking;
        return $this;
    }

    public function addProduct($product)
    {
        $this->products[] = $product;
    }

    public function removeProduct($index)
    {
        unset($this->products[$index]);
        $this->products = array_values($this->products);
    }

    public function getProducts()
    {
        return $this->products;
    }
}

コントローラーとテンプレート

コントローラーでは各ページを表示するアクションメソッド、商品の購入・削除を行うアクションメソッドを定義しています。
ここまではごく一般的なWebアプリケーションと変わりません。

今回はCartオブジェクトの状態を管理するために、必要なタイミングでワークフローオブジェクトを取得して状態を遷移しています。
例えば、確認ページを表示するconfirmAction()メソッドは以下のようになります。

src/AppBundle/Controller/CartController.php
...
public function confirmAction(Request $request)
{
    $cart = $request->getSession()->get(self::SESSION_KEY);

    $this->get('workflow.cart')->apply($cart, 'confirm');
    ...

Symfonyの構成の一部としてワークフローを定義した場合、ワークフローオブジェクトはコンテナから取得できます。
Cartオブジェクトの状態を[ショッピング中]から[確認中]にするために[確認]遷移を指定して状態を遷移しています。
このとき、Cartオブジェクトの状態が[完了]などの場合、定義されていない動きであるため、LogicExceptionが発生します。

この動きは確認ページでページをリフレッシュすることで確認できます。
これは同じ状態への遷移をワークフローで定義していないためです。

Workflow ComponentはTwigのエクステンションを用意しているためテンプレートでも使用できます。
以下は注文ページのテンプレート(enjoy_shopping.html.twig)の購入ボタンを表示するところです。
workflow_canはオブジェクトが指定された遷移を行えるかを検証します。この場合では、Cartオブジェクトが[確認]遷移できるかを検証しています。

app/views/cart/enjoy_shopping.html.twig
...
{% if workflow_can(cart, 'confirm') %}
  <a href="{{ path('confirm') }}"><button type="button">購入</button></a>
{% endif %}
...

workflow_can()はガードの条件を満たしているかも検証してくれます。

ガード

今回、商品が1個以上注文されていない場合は注文できないようにします。
これを実装するためにガードの機能を使用します。Workflow ComponentのガードはEventDispatcherを使用します。

app/config/services.yml
...
services:
    workflow.cart.guard:
        class: AppBundle\Workflow\CartGuard
        tags:
            - { name: kernel.event_subscriber }
...
src/AppBundle/Workflow/CartGuard.php
<?php

namespace AppBundle\Workflow;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Workflow\Event\GuardEvent;

class CartGuard implements EventSubscriberInterface
{
    public function guard(GuardEvent $event)
    {
        /* @var \AppBundle\Entity\Cart $cart */
        $cart = $event->getSubject();

        if (count($cart->getProducts()) == 0) {
            $event->setBlocked(true);
        }
    }

    public static function getSubscribedEvents()
    {
        return ['workflow.cart.guard.confirm' => 'guard'];
    }
}

getSubscribedEvents()で受け取るイベントを定義しています。
今回はcartワークフローのconfirm遷移を行うときにguard()メソッドを実行するようにしています。
guard()メソッドではCartオブジェクトの商品数を確認し、0個の場合には遷移をブロックするようにします。

このガードを定義することで、先ほどのテンプレートと合わせて、商品が0個の場合には[購入]ボタンが表示されず、商品が注文されると表示されるようになっています。

困った点:なぜ遷移できないのかがわからない

今回、実装していて感じたのは「なぜ遷移できないのか知りたい」ということです。
例えば、前述の[購入]ボタンを表示するところの判定では、「商品を購入してください。」などのメッセージを表示したいところです。

app/views/cart/enjoy_shopping.html.twig
{% if workflow_can(cart, 'confirm') %}
  <a href="{{ path('confirm') }}"><button type="button">購入</button></a>
{% elseif カートに商品がないから遷移できない %}
  商品を購入してください。
{% elseif 何か他の理由 %}
  XXXXのため購入できません。
{% endif %}

しかし、Workflow Componentではどうして遷移できないのか、あるいはどのガードの影響で遷移できないのかがわからないためこういった処理ができません。

2016-12-18の時点ではこれを行うためには自分でなんらかのフレームワークを実装する必要があります。

条件はどこに?

最初、今回のフローでは「商品は5個以上購入できない」という条件をガードで実装する予定でした。
しかし、「この条件は商品を管理するCartクラスの責務ではないか?」と考え、ガードでは実装しませんでした。(ガードでないなら、今回の主旨から逸れるためCartクラスにも実装しませんでした。)

しかし、そのあとで「キャンペーン期間中など特別な場合の条件であればガードで実装する方が良いのではないか?」という考えも出てきました。

結局、これはどちらが正しいということではなく設計の話だと思います。
「商品は5個以上購入できない」という条件が、システムにおいて不変で、Cartクラスの機能として重要なものであればCartクラスで実装するべきでしょう。
しかし、キャンペーン期間中のみ適用されるようなものであれば、ガードとして実装する方が柔軟に対応できます。

ワークフローを導入するメリット・デメリット

ワークフローを使用しないでこの実装を行う場合、どのような実装になるでしょうか?
Cartオブジェクトの状態を管理するのはCartオブジェクト自身になるでしょう。あるいはCartクラスの実装が複雑にあるようであれば、Stateパターンを導入するかもしれません。
ガードの部分は、CartクラスでisOK()などの注文可能かを判定するメソッドを用意し、コントローラーやテンプレートで制御するというところでしょうか。

ワークフローを導入することでCartクラスは商品の管理に集中でき、フローにおける条件はCartGuardクラスに任せることができます。このように責務の分離を促進することができるのがメリットのひとつです。
また、実行時においてはユーザーの予期しない動きに対してフローを保護することができます。

デメリットはあまりないように思いますが、フローやステート、遷移をどのように定義するのが良いのかは掴むまでには試行錯誤が必要になるかもしれません。

ワークフロー・ガードの定義とIDEのサポート

ご覧いただいたようにガードの定義はワークフローの定義とは別に行います。

しかし、ガードはワークフローの一部ですから、その定義はワークフローで行うのが良いのではないでしょうか?
ガードの実装は別としても、ワークフローとガードが同時に確認・定義できなければ、ワークフローを構成する知識としては抜けが生じてしまいます。

一方でWorkflow Componentにおけるガードはひとつの遷移を超えた範囲をカバーすることができます。すべてのワークフローに適用したり、ひとつのワークフローのすべての遷移に適用したりすることができるようになっています。
認証済みであることが条件となる場合など広い範囲に条件を適用するときには便利ですが、ワークフローとガードの定義をひとつにするという観点ではまとめるのは難しくなります。

この問題を解決するにはIDEのサポートが必要です。Fowler氏が紹介しているProjectionalEditingのような機能をWorkflow Componentのために実装できれば、わかりやすく視認性の良い編集環境を実現できると思います。(ProjectionalEditingに関しては割愛します。)

しかし、IDEを構成するためのプログラミング言語とアプリケーションを構築するためのプログラミング言語が異なるとどうしてもツールのサポートが遅れてしまいます。ツールを必要とする多くの開発者が、そのツールを実装するためのスキルがなく開発されないためです。

最近ではCloud9Eclipse CheのようなWeb IDEがかなり実用段階に入っており、ここに活路があるのではと感じています。詳しくは調べられていませんが、Web(あるいはWebの技術を使った環境)で動作するわけですから比較的馴染みのあるJavaScript/CSS/HTML5といったフロントの技術でプラグインを構築できると思われます。

フロントエンジニアはその裾野も広く、IDEのプラグインを作るよりは敷居も低いため、ツールの開発が進むのではと期待しています:slight_smile:

まとめ:状態を管理する

コンピューターの進化に伴い、複雑さは増し、その管理をどう行うかが課題となってきました。
管理する対象は「状態」です。

例えば、jQueryでちょっと複雑なUIを実装すると、あっという間にUIの状態の管理が困難になります。
そこでReactやAngularJSのようなフロントエンドのフレームワークが生まれ、状態の管理の多くの部分を任せられるようになりました。

オブジェクト指向プログラミングでは作成後に状態を変更できないイミュータブルなオブジェクトがあります。そもそも状態を変更できなければ状態を管理する必要がないというわけです。
もちろん状態を保持するオブジェクトはどこかに必要ですから、すべてをイミュータブルにはできませんが、イミュータブルかミュータブルかを明示的に使い分けることで状態を管理する複雑さを局所化することができます。

インフラの界隈ではイミュータブルインフラストラクチャというサーバーをイミュータブル化する考えもあります。

これらは対象や粒度はさまざまですが、「状態の管理を局所化する」という点で共通しています。
世の中で変わらないものはない以上、システムにおいても変化を拒絶することはできません。そこで変化する部分を局所化し、管理しやすくするという試みがさまざまなレベルで進んでいるように感じています。

Workflow Componentもそういった流れのひとつと言えるでしょう。
オブジェクトの状態を管理するツールの選択肢として考えてみてはいかがでしょうか?:smiley:

参考

ワークフロー - Wikipedia
The Workflow Component
New in Symfony 3.2: Workflow component
Symfony 3.2.0 released
ProjectionalEditing

この投稿は Symfony Advent Calendar 201618日目の記事です。