このエントリーをはてなブックマークに追加

はじめに

WebアプリケーションフレームワークとしてのSymfonyには、フォームやORM、バリデーションetc…といった機能があります。今回は少し視点をずらして、これらの機能の中から、アプリケーション内の何かと何かの「あいだにあるもの」に着目してみます。実は、この「あいだにあるもの」たちの機能をうまく使うことで、アプリケーションコードが想定するデータの形を整えることができます。

例えば、YAMLから設定データを読み込んで、PHPの連想配列で扱うプログラムを記述しているとしましょう。設定データを扱うあらゆるメソッドで、その都度キーの存在有無をチェックしているようなコードを想像してみてください。とてもイヤな感じですね。このようなコードがあったら、単純に無駄なコードが多いという表面的な問題だけでなく、正しいデータの有り様が定まっていないという問題が潜んでいる可能性が大いにあります。そしてこの種の潜在的な問題は、ボディーブローのようにバグの温床になっていきます。

この問題を解決する1つの手段として、ある機能への特に入力の前処理段階でデータを正規化(Normalize)するアプローチが一般的です。Symfonyアプリケーションにおいて、機能の一部として正規化の役割を担う次の5つのコンポーネントについて紹介します。

  • Form
  • JMSSerializer
  • Doctrine
  • Config
  • OptionsResolver

全体図としては以下のようになります。

それぞれの使われ方を簡単に説明します。

Form

アプリケーションのEntityに対して、Webフォームとの入出力の間に位置するのがFormです。名前から「Webフォームとして出力するタグを定義する」というイメージを持ってしまいがちです。しかし、それはどちらかというとオマケと捉えておき、Entityの構造とWebフォームとして入出力する情報の構造とのマッピングや変換を記述していると見た方が素直に扱えます。

Formの中で、データの正規化機能として重要なのは、DataTransformerやフォームのイベントです。また、フォームのデータ変換機能をまとめて処理するDataMapperを独立して作ることもできます(※1)。以下は参考となるドキュメントのリンクです。

※1 フォームのDataMapperの機能は別の記事で解説予定です。

なお、次に紹介するJMSSerializerを使ってJSONのリクエスト/レスポンスを扱っている場合でも、受け取ったデータの基本的な正規化をFormで行うようにしておくと役割がスッキリします。

JMSSerializer

アプリケーションのEntityに対して、JSONのリクエスト/レスポンスとの入出力の間に位置するのがJMSSerializerです。Entityクラスのプロパティやメソッドにアノテーションを記述するだけで、JSON上のプロパティ名などにマッピングできます(@SerializedName)。@TypeでJSON向けのデータ型を指定したり、@VirtualPropertyでEntity側には存在しないプロパティをJSON向けに作るといった機能もあります。これらの機能を使って簡易なデータ正規化は可能ですが、Symfonyで普通に行うEntityクラスに記述したバリデーションの実行なども含めて、通常はFormも合わせて使います。

Doctrine

アプリケーションのEntityに対して、RDBとの入出力の間に位置するのがDoctrine ORMです。これは普通ですね。

Symfony/Doctrineでちょっと複雑なValueObjectを扱う例のような変換をDoctrineのレイヤーに仕込むことで、アプリケーションのEntityをより表現豊かにすることなどもできます。

Config

YAMLやXML形式の設定ファイルを読み込んで扱う場合に、SymfonyではバンドルのExtensionという機能のレイヤで処理することになります。読み込んだYAMLやXMLはPHPコード内では連想配列になっており、これを正規化するのがConfigです。

多くの場合、Extensionの段階で何らかのサービス定義に作用させる等を行うため、ここで読み込んで正規化した連想配列をアプリケーションコード側で直接利用することはありません。しかし、YAMLで編集する際の構造と、プログラム内で扱う構造の違いを吸収し、かつ、イレギュラーな設定を弾いたり正規化したりする場所があることは有用です。

Config の利用シーンは次に紹介する OptionsResolver と似ていますが、Config はツリー全体の定義が可能で、変形操作を定義できるなど高機能です。

OptionsResolver

他の機能と少しレイヤが異なりますが、連想配列の正規化というミクロな目的に絞れば、SymfonyのOptionsResolverが有用です。もしも自分のPHPプロジェクトで、コンポーネント間や複数のメソッドに渡って連想配列でデータを受け渡しており、各所に連想配列内のキーのチェックが散らばっているようであれば、どこか1箇所でOptionsResolverを通してデータを正規化する処理を入れるとスッキリします。使い方は簡単で、以下のようにして必ず定義されているキーや、値が必須のキー、キーごとのデフォルト値などを記述しておきます。最後に $options = $resolver->resolve($options); のようにして連想配列データを OptionsResolver に通します。

$resolver = new OptionsResolver();
$resolver->setRequired(array('host', 'username', 'password'));
$resolver->setDefaults(array(
    'host'     => 'smtp.example.org',
    'username' => 'user',
    'password' => 'pa$$word',
    'port'     => 25,
));

$options = $resolver->resolve($options);

resolve() メソッドの実行時点で形式のチェックが行われ、エラーがある場合は例外(UndefinedOptionsException など)がスローされます。

TIPS 階層を持った連想配列に対して構造を定義する場合は、階層ごとに OptionsResolver の定義インスタンスを作り、値を取り出しながら適用していきます。

おわりに

Symfonyのいくつかの機能を、正規化という役割に着目して紹介しました。アプリケーションの各所で受け渡されるデータを、適切な場所で正規化することで、処理するメソッド内では、考慮しなければならない範囲(スコープ)を狭めることができます。同時に、データの有り様を事前に明らかにする活動にも焦点を当てることができます。

まだまだ型の強制力が弱いPHPでは、このような道具が役立つ場面が多くあるかと思います。ご参考になれば幸いです。


このエントリーをはてなブックマークに追加

この記事は Symfony Advent Calendar 2016 15日目の記事です。

はじめに

ステータスを管理する時などに便利なSymfony/Workflowコンポーネントを紹介したいと思います。

インストール

Composerで楽々インストール出来ます。

$ composer require symfony/workflow

今回のゴール

以下のPull Requestのワークフローのステータス管理を実現したいと思います。

pr-workflow

準備

PullRequestクラスの作成

<?php

class PullRequest {

    private $marking = 'ready';

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

ステータス情報を保持するmarkingプロパティだけを持ったシンプルなクラスになります。
markingプロパティは、自分で任意の名前に変更可能です。
ちなみにGetterSetterが無いと怒られます。

Placeの定義

今回のステータス情報に当たるものは、WorkflowコンポーネントではPlaceと表現されています。

<?php

// use Symfony\Component\Workflow\DefinitionBuilder;

$builder = new DefinitionBuilder();
$builder->addPlaces(['ready', 'wip', 'in-review', 'merged']);

DefinitionBuilder::addPlaces()メソッドで、ready, wip, in-review, mergedの4つのステータスを定義していることになります。

Transitionの定義

どのようにステータス(Place)が遷移していくかをTransitionクラスで定義します。

<?php

// use Symfony\Component\Workflow\Transition;

$builder->addTransition(new Transition('start-work', 'ready', 'wip'));
$builder->addTransition(new Transition('request-review', 'wip', 'in-review'));
$builder->addTransition(new Transition('feedback', 'in-review', 'wip'));
$builder->addTransition(new Transition('merge', 'in-review', 'merged'));

今回の場合は、start-workTransitionは、readyからwipPlaceへの遷移を表現していることになります。

Workflow(StateMachine)インスタンスの作成

Workflow(StateMachine)のインスタンスを作成します。
今回はStateMachineを作成しています。

<?php

// use Symfony\Component\Workflow\DefinitionBuilder;
// use Symfony\Component\Workflow\Transition;
// use Symfony\Component\Workflow\StateMachine;

$builder = new DefinitionBuilder();
$builder->addPlaces(['ready', 'wip', 'in-review', 'merged']);

$builder->addTransition(new Transition('start-work', 'ready', 'wip'));
$builder->addTransition(new Transition('request-review', 'wip', 'in-review'));
$builder->addTransition(new Transition('feedback', 'in-review', 'wip'));
$builder->addTransition(new Transition('merge', 'in-review', 'merged'));
$definition = $builder->build();

$workflow = new StateMachine($definition);

WorkflowとStateMachineの違い

デフォルトでセットされるMarkingStoreが異なるだけで他に違いはありません。
特にMarkingStoreを指定しない場合、StateMachineではSingleMarkingStoreがセットされ、WorkflowではMultipleMarkingStoreがセットされます。

ですので、以下のようにコードを変更しても完全に同じ振る舞いをします。

-$workflow = new StateMachine($definition);
+$marking = new SingleStateMarkingStore();
+$workflow = new StateMachine($definition, $marking);

SingleMarkingStoreとMultipleMarkingStoreの違い

SingleMarkingStoreは名前の通りmarkingに値を1つしか保持出来ませんが、MultipleMarkingStoreは同時に複数保持することが可能です。
今回の場合は1つで事足りると思いますので、SingleMarkingStoreをデフォルトで採用しているStateMachineを利用しています。

実行

それでは実行してみます。

<?php

$pr = new PullRequest();

$workflow->can($pr, 'hoge'); // Thow Symfony\Component\Workflow\Exception\LogicException

$workflow->can($pr, 'merge'); // False

$workflow->apply($pr, 'merge'); // Thow Symfony\Component\Workflow\Exception\LogicException

$workflow->can($pr, 'start-work'); // True

$workflow->apply($pr, 'start-work');
$workflow->can($pr, 'request-review'); // True

$workflow->getEnabledTransitions($pr); // ['feedback', 'merge']

定義したルールに添っていないと例外が投げられたり、Falseが返ってきたりして分かりやすいですね。
ちなみにTwig_Extensionも用意されているので、

{% if workflow_can(pr, 'merge') %}
    <a href="...">Merge</a>
{% endif %}

みたいに書くことも出来ます、便利ですね!

おわりに

ステータス管理にSymfony/Workflowコンポーネントを利用すると「どのように遷移すべきか」「どういった遷移を想定しているか」を明示的に定義することが出来ます。 特に外部サービスのAPIなどを利用している場合は、想定外のレスポンスやステータス遷移を引き起こしてしまう可能性もあるかと思いますので、このように宣言的に設定出来るのは良いプラクティスではないでしょうか。
Symfonyで使う場合は、ワークフローの定義をYamlファイルなどの外部ファイルで簡単に管理出来ますので、一度試してみては如何でしょうか。

補足資料

最終的に実行したコード

Pull Reuest Workflow Sample


このエントリーをはてなブックマークに追加

Symfony Advent Calendar 2016 11日目の記事です。 昨日は @kalibora さんの Symfony Console のコマンド名を自動的に Monolog のログに出そう でした。

Symfony\Component\ExpressionLanguageとは

式言語と訳されますが、一言で言うと条件式を評価することができるライブラリです。 Symfony ExpressionLanguage公式ドキュメント

通常、条件式の内容を書く人として想定されているのはプログラマではなくユーザーです。ユーザー(エンドユーザーなり管理者なり)が直接条件式を編集できることでいちいちプログラマの手を煩わせる必要がないという利便性があり、生のPHPコードを入力させてevalするのに比べて、 できることを制限しているため に安全に実行することができます。

例えば下記のようなコードを予め書いておきます(実際は $_POST を直接使ってはいけません :sweat_smile:

<?php
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;

$condition = $_POST['condition'];

$lang = new ExpressionLanguage();
$isSatisfied = $lang->evaluate($condition, ['stock' => $stock]); 

if ($isSatisfied) {
    // 条件が満たされた場合の処理
} else {
    // 条件が満たされなかった場合の処理
}

ユーザーは、$_POST[‘condition’]に

  • stock == 0 を指定すれば、stockが0の時に条件が満たされた場合の処理を実行させることができます
  • stock < 5 を指定すれば、stockが5より小さい時に条件が満たされた場合の処理を実行させることができます

つまり、ユーザーはプログラマを急かせることなく自分の手で、stockの値に対して必要に応じて様々な条件を指定して、後続の処理を制御できるのです。

上の例では変数を使ってみましたが、基本的な四則計算やand/or条件指定、正規表現、オブジェクトのメソッド呼び出し等も実装されています。 詳しい使い方は Expression syntax を参照してください。

ExpressionLanguageでPHP関数を使う

前述のように、ExpressionLanguageでは、できることを制限してあるため、通常のPHP関数を使うことはできません。ユーザーが条件式として unlink('/') なんて入力してきたら大変ですからね :smirk:

初期状態で使えるPHP関数

初期状態で使えるようになっているのは、 constant() 関数だけです。

PHPの関数をユーザー関数として登録する

PHPの関数を使いたい場合は、ユーザー定義関数として登録してから利用します。 式言語の趣旨を考えると、状態を変える関数は使わず、単に値を返す関数だけにしたほうが良いでしょう。

一例として、 ucfirst() 関数を登録してみます。

<?php
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;

$lang = new ExpressionLanguage();

// 登録してない関数を使う
//$lang->evaluate('ucfirst("quartet")'); // Symfony\Component\ExpressionLanguage\SyntaxError がスローされる

// 関数を登録
$lang->register('ucfirst', function($value){
    // コンパイルするときの動作を定義
    return sprintf('ucfirst(%s)', $value);
}, function($arguments, $value){
    // 評価するときの動作を定義
    return ucfirst($value);
});

// 関数を利用できるようになる
$lang->evaluate('ucfirst("quartet")'); // Quartet

// コンパイルするとPHPコードが文字列で書き出される
$lang->compile('ucfirst("quartet")'); // ucfirst("quartet")

ExpressionLanguage::register() の第一引数は、関数名です。 式言語の中で利用するための関数名なので、PHPの関数名をそのまま使う必要はありません。自由に決めることができます。 PHPの省略されすぎてわかりにくい標準関数名をわかりやすく変えて登録するのもOKです。 ただし、後から同名の違う関数を登録すると最後に登録したもので上書きされてしまうので注意してください。

第二引数と第三引数には callable を指定します(クロージャーなど)。

第二引数には、式言語をコンパイルするときの動作を定義します。 上の例では、 ucfirst("quartet") をコンパイルしたときに ucfirst("quartet") が返ってくるクロージャーを書いてあります。

第三引数には、式言語を評価するときの動作を定義します。 上の例では、 ucfirst("quartet") を評価したときに "Quartet" が返ってくるクロージャーを書いておけば良いですね。

まとめ

ExpressionLanguageを使うと、できることをシステムに危険がない範囲に制限した上で、細かい仕様のコントロールをユーザーに任せることも可能になります。 うまく利用して、開発スピードとユーザー満足度をどちらも高められるようにしたいですね。

明日は @eretica さんです。