#practical.symfony
Explore tagged Tumblr posts
phpmentors · 13 years ago
Text
Practical Symfony #7: Symfonyで複雑なバリデーションを行う方法
Symfonyにおいてフォームのバリデーションは、通常、フォームにエンティティクラスを関連付け、このエンティティクラスに対するバリデーションとして設定します。バリデーションの設定は、エンティティクラスのフィールド単位で行います。エンティティクラスの各フィールドの定義行のアノテーションとしてバリデーションの設定を記述できます。 ただし実際のプロジェクトでは単一のフィールドのバリデーションだけではなく、複数のフィールドに横断するようなバリデーションルールも必要になるでしょう。こういったバリデーションが必要な場合の方法を紹介します。
True制約を使う
Callback制約を使う
True制約を使う
1つ目は、Symfony組み込みのTrue制約を使う方法です。Symfonyの制約(バリデータ)は、エンティティクラスのフィールドだけでなく、名前がgetやisで始まるメソッドに対しても設定できます。これとTrue制約を組み合わせることで、追加のバリデーションを手軽に作成できます。次のコードは、エンティティクラス内のtelフィールドとtelConfirmフィールドの値が一致するかどうかを検証しています。
use Symfony\Component\Validator\Constraints as Assert; /* snip */ /** * @Assert\True(message="電話番号の入力に誤りがあります。") */ public function isValidTel() { if ($this->getTel() !== $this->getTelConfirm()) { return false; } return true; }
このように、エンティティクラスにisから始まる名前のメソッド(上のコードではisValidTel)を定義し、メソッド��DocBlockに制約として利用するための設定を記述します。True制約の場合は、指定したメソッドの戻り値がtrueの場合は成功、falseの場合はエラーとなります。エラーとなった場合、エラーメッセージとしてTrue制約のmessageオプションに指定した文字列が表示されます。このメソッドはエンティティクラスの通常のメソッドと何も変わりませんので、エンティティクラスのプロパティなどに自由にアクセスできます。
単に「同じ値を2度入力し、同じであることを確認する」という目的のフィールドをフォームで使いたい場合は、repeatedを使うと簡単です。
Callback制約を使う
True制約を使うことで、任意のルールをバリデーションとして使えるようになりましたが、エラーメッセージがフォームの先頭部分に表示されてしまいます。エラーメッセージを特定のフィールドにきちんと表示したい場合は、Callback制約を使います。
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\ExecutionContext; /** * @Assert\Callback(methods={"shouldValidTel"}) */ class Registration { /* snip */ /** * @param ExcecutionContext $context */ public function shouldValidTel(ExecutionContext $context) { if ($this->getTel() !== $this->getTelConfirm()) { $propertyPath = $context->getPropertyPath() . '.telConfirm'; $context->setPropertyPath($propertyPath); $context->addViolation('電話番号の入力に誤りがあります。', array(), null); return; } } }
Callback制約は、エンティティクラスのフィールドやメソッドではなく、クラスそのものに対して設定します。Callback制約のmethodsオプションに、バリデーション実行時に呼び出したいメソッドの名前を指定します。ここで呼び出すメソッドの名前には特に制限はありません。
呼び出されるメソッド側では、ExecutionContextオブジェクトを引数で受け取ります。バリデーションエラーの場合、メソッドの戻り値で何か値を返すのではなく、ExecutionContextオブジェクトへ、対象となるフィールドのパスとエラーメッセージを設定します。
Callback制約で呼び出されるメソッドでは、何もreturnしない点に注意してください。
まとめ
Symfonyのフォームで複数のフィールドに対するバリデーションを記述する方法について、True制約を使う方法とCallback制約を使う方法を紹介しました。
Symfonyのフォームでは、データの保持をエンティティクラスに分離できることが大きな特徴です。これにより、コントローラ等からの疎結合を維持したまま、データのバリデーションなどにドメインロジックを直接的に利用できます。
参考
High Performance PHP Framework for Web Development - Symfony
True | Symfony2日本語ドキュメント
Callback | Symfony2日本語ドキュメント
カスタムバリデーション制約の作成方法 | Symfony2日本語ドキュメント
[Symfony2]バリデーションについて - 覇王色を求めて
20 notes · View notes
phpmentors · 14 years ago
Text
Practical Symfony #6: Symfony2の@apiアノテーションによる後方互換性の維持管理
この記事はSymfony Advent Calendar JP 2011の24日目の記事です。
速いペースでマイナーバージョンアップされるSymfony2
Symfony2は、2011年7月に2.0がリリースされて以降、概ね月に1回のペースでメンテナンスリリースをしています。私自身が開発に携わっている案件でも、何度かこのようなメンテナンスリリースによるSymfony2本体のバージョンアップを行いましたが、直接的な問題はほぼ発生していません。フレームワークの更新というと、マイナーバージョンアップでさえ事前に変更点をしっかり調査し、適用しても問題がないという調査・判断が必要でした。Symfony2でももちろん事前に変更内容を調査することは必要ですが、Symfony2側で後方互換性が維持されるルールが導入されており、上手く機能しているようです。
この「後方互換性を維持するルール」の中心となるのが「@apiアノテーション」です。簡単に説明すると、@apiアノテーションのつけられたメソッドは、Symfony2のマイナーバージョン間において、シグニチャや振る舞いが変更されないことが保証されています。
このルールによって後方互換の維持対象となるものを、Symfonyでは「ステーブルAPI」と呼んでいます。たとえばSymfony公式サイトの「The technological benefits of Symfony in 6 easy lessons」のページには、次のような記述があります。
4. Stable and sustainable
Developed by Sensio Labs, major versions of Symfony are all supported for 3 years by the company. And even for life as far as security-related issues are concerned. For even greater stability, the minor versions of Symfony 2.0’s contract and interface are also guaranteed and compatibility between all minor versions will be ensured on the API defined by the public interfaces.
The technological benefits of Symfony in 6 easy lessons - Symfony
ドキュメントにもステーブルAPIを説明したページがあります。日本Symfonyユーザー会で翻訳したページから引用します。
名前空間とクラス名は変更されません。
メソッド名は変更されません。
メソッドのシグネチャ(引数と戻り値の型)は変更されません。
メソッドの振る舞いの意味は変更されません。
Symfony2 ステーブル API | 日本Symfonyユーザー会
@apiアノテーションの例
実際にSymfony2のコードで@apiアノテーションがつけられている例を見てみましょう。たとえばHttpKernelコンポーネントのKernelInterfaceでは、次のようになっています。
<?php /* * This file is part of the Symfony package. * * (c) Fabien Potencier * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\HttpKernel\Bundle; use Symfony\Component\DependencyInjection\ContainerBuilder; /** * The Kernel is the heart of the Symfony system. * * It manages an environment made of bundles. * * @author Fabien Potencier * * @api */ interface KernelInterface extends HttpKernelInterface, \Serializable { /** * Returns an array of bundles to registers. * * @return array An array of bundle instances. * * @api */ function registerBundles(); // **snip** /** * Gets the log directory. * * @return string The log directory * * @api */ function getLogDir(); }
クラスの先頭と、すべてのメソッドに@apiがつけられています。つまり、名前空間やインターフェイスの名前、KernelInterfaceで公開されるすべてのメソッドの名前とその振る舞いが、マイナーバージョンでは変更されない、ということです。
実際に開発者はどうしているのか
@apiアノテーションをつけたからといって、自動的に後方互換性の維持機能が働くというものではありません。これは単なるコメントですから、人間がそれを見て適切に扱う必要が(現時点では)あります。
GitHubを開発リポジトリしているSymfonyでは、バグフィックスや機能追加のリクエストをPull Requestという形で送信しますが、Pull Requestを送る際に後方互換性が維持されているかどうかを次のようなテンプレートによって明示するルールとなっています。
Bug fix: [yes|no] Feature addition: [yes|no] Backwards compatibility break: [yes|no] Symfony2 tests pass: [yes|no] Fixes the following tickets: [comma separated list of tickets fixed by the PR]
パッチの投稿 | Symfony2日本語ドキュメント
このようなテンプレートにより、Pull Requestの送信者による事前の後方互換性チェックと、レビュー担当によるチェックがもれなく行われやすいようになってきています。
まとめ
Symfony2のコアコードでは@apiアノテーションが導入され、ステーブルAPIというルールが設けられました。これを目印とすることで高い後方互換性を維持したままメンテナンスが行われています。
開発しているアプリケーションのコードから、Symfonyコンポーネントやバンドルの機能を呼び出して利用する場合、ステーブルAPIのみに依存する(@apiアノテーションがつけられているものを使う)ようにしておけば、Symfony本体のバージョンアップに対応しやすくなるでしょう。
参考
High Performance PHP Framework for Web Development - Symfony
The Symfony2 Stable API - Symfony
Symfony2 ステーブル API | Symfony2日本語ドキュメント
20 notes · View notes
phpmentors · 14 years ago
Text
Practical Symfony #5: Symfony2でリダイレクトされるアクションでもきちんとデバッグする設定 : Symfony Advent Calender 2011 JP - 17日目
PHPメンターズの後藤です。この記事はSymfony Advent Calendar JP 2011の17日目の記事です。
Webアプリケーションでは、ユーザーがフォームに入力した情報をPOSTメソッドで受け取ってアクションで処理した後、完了画面などの処理結果を表示する画面のURLへリダイレクトによって遷移させることが常套手段となっています。Symfonyは、WebデバッグツールバーやWebプロファイラを使った簡単なデバッグができるのが特徴ですが、あくまでWebブラウザの画面に描画されるものであるため、リダイレクトされる直前の、実際に処理を行なっている時の状況をWebデバッグツールバーやWebプロファイラで見ることができないように思えます。
リダイレクト・インターセプション
そこでSymfony2では、「リダイレクト・インターセプション」という機能が導入されました。この機能を有効にすると、アクションからリダイレクト指示を��スポンスとして返した場合、ユーザーのWebブラウザへ返す前にSymfonyがそれを捕捉(インターセプト)し、「●●のURLへリダイレクトされます」というような文面の通常のHTMLレスポンスに自動的に書き換えてWebブラウザへ返すようになります。もちろんこの場合には、WebデバッグツールバーとWebプロファイラも見ることができます。
リダイレクト・インターセプションを有効化するには
リダイレクト・インターセプションは、Symfony2 Standard Editionのデフォルトの設定では無効になっていますが、単に設定ファイルで「intercept_redirects」をtrueに書き換えるだけで有効化できます。
app/config/config_dev.yml
# snip web_profiler: toolbar: true intercept_redirects: true # trueに変更 # snip
このように変更し、アクションで次のようにリダイレクトレスポンスを返したとします。
public function helloAction($name) { // 何らかの処理 // : // トップページヘリダイレクト return $this->redirect($this->generateUrl('toppage')); }
すると、直接遷移先のページへリダイレクトするのではなく、次のようなページが表示されるようになります。
Tumblr media
中央に表示されているリンクをクリックすれば、本来の遷移先へ画面が切り替わりますが、その前にプロファイル情報などを確認できます。
まとめ
Symfony2のリダイレクト・インターセプションについて概要と設定方法を解説しました。Symfoy2 Standard Editionでは、デフォルトではリダイレクト・インターセプションが無効になっていますが、開発をスタートする段階で有効化しておくと便利です。
Symfony Advent Calendar JP 2011 18日目となる明日の担当は、OpenPNEやSocietoなどの開発をされている@co3kさんです。
参考
High Performance PHP Framework for Web Development - Symfony
内部構造 | Symfony2日本語ドキュメント
13 notes · View notes
phpmentors · 13 years ago
Text
Practical Symfony #9: Symfonyでerror_reporting設定を変更する
PHPメンターズの後藤です。先日、日本Symfonyユーザー会のメーリングリストに次のような質問があり、回答した内容について記事にしておきます。補足の説明とともに、このような拡張を行えるポイントも紹介します。
Symfony2でのerror_reporting設定について - Google Groups
質問内容は「Symfony2でerror_reportingの設定を変更したいが、そのような設定項目は?」というものです。
Symfonyにはさまざまな設定を行うためのコンフィギュレーションファイルがありますが、これは文字通り「バンドルのコンフィギュレーション」、つまり「バンドルが公開している拡張ポイントに対する設定」を目的としています。ですので、PHPに関するあらゆる設定がSymfonyの設定ファイルから行えるというのではなく、むしろ逆で、Symfonyで扱っているバンドルの関心事以外のことは、コンフィギュレーションファイルでは設定できません。
今回の質問にあるerror_reportingの設定であれば、Symfonyはフレームワークとして使いやすいデフォルト値を内部で設定していますが、そのデフォルト値をコンフィギュレーションファイルから変更するといったことはできません。
Symfonyでは、追加で必要な設定は開発者が自らコードを記述して行います。たとえばapp/AppKernel.phpは開発者の手にあるスクリプトなので、ここに設定用のメソッドやコードを追加することは自由に行えます。適切なタイミングで呼び出されるメソッドをオーバーライドし、希望の処理を追加すればよいでしょう。
今回の問題であれば、error_reportingはKernelのinit()メソッド内で設定されています。この部分を何らかの形で上書きできればよさそうです。
Symfony/Component/HttpKernel/Kernel.php
<php // snip abstract class Kernel implements KernelInterface { // snip public function init() { if ($this->debug) { ini_set('display_errors', 1); error_reporting(-1); DebugUniversalClassLoader::enable(); ErrorHandler::register(); if ('cli' !== php_sapi_name()) { ExceptionHandler::register(); } } else { ini_set('display_errors', 0); } }
開発者が設定を差し込める部分
実際にどういったメソッドが、開発者がオーバーライドしてもよいものなのでしょうか。いくつか紹介します。
web/app.php web/app_dev.php
フロントコントローラスクリプトです。Symfony Standard Editionに付属するコードはフレームワークで処理を実行する最小限のものしか書かれていませんが、ここに独自のコードを追加することはできます。
app/AppKernel.php
アプリケーションカーネルクラスです。初期化が行われるメソッドをオーバーライドして設定を挿し込めばよいでしょう。
コンストラクタ
boot()
boot()メソッドはHttpKernel\KernelInterfaceに定義されており、ステーブルAPIです。
src/Acme/Bundle/DemoBundle/AcmeDemoBundle.php
バンドルクラスです。バンドルはすべて開発者の領域ですから当然バンドルクラスに開発者のコードを入れられます。
コンストラクタ
boot()
boot()メソッドはHttpKernel\Bundle\BundleInterfaceに定義されており、ステーブルAPIです。
Kernelのinit()メソッドはKernelのコンストラクタから呼び出されています。init()メソッド自体をAppKernel側でオーバーライドしてもよさそうですが、init()メソッドはステーブルAPIとして宣言されていません。他のメソッドでも代用可能な場合は、ステーブルAPIとして宣言されているものを使う方が望ましいでしょう。今回はKernelクラスのコンストラクタをAppKernel側でオーバーライドします。
app/AppKernel.php
<php // snip public function __construct($environment, $debug) { parent::__construct($environment, $debug); if ($this->debug) { error_reporting(E_ALL | E_STRICT); } else { error_reporting(E_PARSE | E_COMPILE_ERROR | E_ERROR | E_CORE_ERROR | E_USER_ERROR); } }
ごく稀にフレームワークのアップデート時にAppKernel.phpファイルやフロントコントローラファイルに修正が必要になる場合もありますが、万が一そのような場合はフレームワークのUPGRADEファイルに手順が記載されることになっています。AppKernel.phpやapp.phpは開発者の手にある領域なので、フレームワークのバージョンアップ時の修正などは、開発者の責任ということです。
まとめ
Symfonyでerror_reporting設定を変更する方法について解説しました。フレームワークは万能のツールキットではありません。特にSymfonyは、このような意図を明確に体現しています。フレームワークの責務と開発者自身が制御する領域を明確に認識することで、フレームワークの用意していない拡張が必要な場合の実装の糸口を見つけやすくなるでしょう。
参考
High Performance PHP Framework for Web Development - Symfony
PHPメンターズ -> Symfony2の@apiアノテーションによる後方互換性の維持管理
Symfony2 ステーブル API | Symfony2日本語ドキュメント
10 notes · View notes
phpmentors · 14 years ago
Text
Practical Symfony #4: SymfonyのテストコードをPhakeで書き換えてみる : Symfony Advent Calender 2011 JP - 16日目
PHPメンターズの後藤です。クリスマスまでの24日間をSymfonyの記事で楽しもう!というSymfony Advent Calendar JP 2011の16日目の記事です。前回はSymfony2のテストコードを読むにてSymfonyのHttpKernelコンポーネントにあるKernelクラスのテストコードを紹介し、PHPUnitのモックオブジェクトを使っている例を説明しました。
今回は、前回紹介したテストコードを、PHPのモッキングフレームワークであるPhakeを使って書き換えてみましょう。
mlively/Phake - GitHub
Phake - PHP Mocking Framework
SymfonyのテストでPhakeを使うための準備については、こちらの記事を参照してください。
まず、前回取り上げたテストコードを最初に再掲します。
tests/Symfony/Tests/Component/HttpKernel/KernelTest.php:
class KernelTest extends \PHPUnit_Framework_TestCase { // snip public function testBootInitializesBundlesAndContainer() { $kernel = $this->getMockBuilder('Symfony\Tests\Component\HttpKernel\KernelForTest') ->disableOriginalConstructor() ->setMethods(array('initializeBundles', 'initializeContainer', 'getBundles')) ->getMock(); $kernel->expects($this->once()) ->method('initializeBundles'); $kernel->expects($this->once()) ->method('initializeContainer'); $kernel->expects($this->once()) ->method('getBundles') ->will($this->returnValue(array())); $kernel->boot(); }
このコードにおけるテストの意図を、Phakeを使って順に再現します。まず最初は、テスト対象であるboot()メソッドの呼び出しを記述します。
class KernelTest extends \PHPUnit_Framework_TestCase { // snip public function testBootInitializesBundlesAndContainer() { $kernel->boot(); }
$kernelを用意しなければなりません。このテストでは、同じクラスの別メソッドを呼び出すという振舞をテストしたいため、Kernelの実オブジェクトではなく、純粋なモックオブジェクトでもなく、パーシャルモックオブジェクトを利用します。
※PHPUnitのテストコードで作成しているのも、同じようにパーシャルモックオブジェクトです。
class KernelTest extends \PHPUnit_Framework_TestCase { // snip public function testBootInitializesBundlesAndContainer() { $kernel = \Phake::partialMock('Symfony\Tests\Component\HttpKernel\KernelForTest', 'env', true); $kernel->boot(); }
基本的な準備はこれで整いましたが、これでは何もテストしていません。元のテストコードでテストしていたboot()メソッドの振る舞いである、initializeBundles(), initializeContainer(), getBundles()メソッドが呼び出されていることを検証するテストコードを追加しましょう。このようなメソッド呼び出しの検証には、Phake::verifyを使います。
class KernelTest extends \PHPUnit_Framework_TestCase { // snip public function testBootInitializesBundlesAndContainer() { $kernel = \Phake::partialMock('Symfony\Tests\Component\HttpKernel\KernelForTest', 'env', true); $kernel->boot(); \Phake::verify($kernel)->initializeBundles(); \Phake::verify($kernel)->initializeContainer(); \Phake::verify($kernel)->getBundles(); }
このようにboot()メソッドを実行した後、実際に各メソッドが呼び出されたことを検証(verify)するように記述します(ここではそれぞれ1回ずつ呼び出されたことを検証しています)。
これでよさそうにも見えますが、このテストではinitializeBundles(), initializeContainer(), getBundles()それぞれの実装には触れたくありません。ですので、これらの3つのメソッドをモックで置き換え��す。メソッドをモックで置き換えるには、Phake::whenを使います。
class KernelTest extends \PHPUnit_Framework_TestCase { // snip public function testBootInitializesBundlesAndContainer() { $kernel = \Phake::partialMock('Symfony\Tests\Component\HttpKernel\KernelForTest', 'env', true); \Phake::when($kernel)->initializeBundles()->thenReturn(null); \Phake::when($kernel)->initializeContainer()->thenReturn(null); \Phake::when($kernel)->getBundles()->thenReturn(array()); $kernel->boot(); \Phake::verify($kernel)->initializeBundles(); \Phake::verify($kernel)->initializeContainer(); \Phake::verify($kernel)->getBundles(); }
これで完成です。最初のコードと見比べてみてください。このテストメソッドでテストしている内容と意図を、コードから明確に読み取れるようになっていることが分かります。
まとめ
モックオブジェクトを使うことで、テストしたい対象だけに焦点を絞ることができるようになりますが、Phakeのように、よりしなやかに開発者の意図を記述できるフレームワークを利用することで、読みやすくメンテナンス性の高いテストコードを書くことができます。
このようにしてテストコードに開発者の意図が反映しやすくなることは、ドメインモデルの改善やブレイクスルーにもつながります。
参考
High Performance PHP Framework for Web Development - Symfony
Phake - PHP Mocking Framework
10 notes · View notes
phpmentors · 13 years ago
Text
Practical Symfony #11: SymfonyでDoctrineエンティティクラスの格納ディレクトリを変更する方法
Symfony 2.0ではDoctrine 2とのインテグレーションが標準で(DoctrineBundleおよびDoctrine Bridgeにて)提供されています。これにより、フレームワークからスムーズにORMの機能を利用できるようになっています。
Doctrine 2でORMの機能を利用する場合、オブジェクトとRDBの情報を対応付けるマッピングを何らかの形で指定する必要があります。Doctrine 2では、エンティティクラス内のアノテーション(Docブロックコメント)によるマッピング情報の記述もサポートされています。Symfonyへのインテグレーションの標準設定では、バンドルディレクトリにEntityという名前のディレクトリを作成し、そこにマッピング情報のアノテーションを記述したエンティティクラスを配置しておけば、doctrine:mapping:infoコマンドやdoctrine:schema:updateコマンドの実行時に自動的にマッピング情報が検出され、データベースのスキーマなどを更新できます。
しかし、実際のプロジェクトではさまざまな理由からエンティティクラスの格納ディレクトリを変更する要求がでてきます。この場合どのようにすればよいでしょうか。以下の2つの方法を紹介します。
マッピング情報をYAMLまたはXMLで記述する
エンティティクラスの格納ディレクトリとプレフィックスを設定する
1つめは、マッピング情報をYAMLまたはXMLにて記述する方法です。Symfony/Doctrine 2ではマッピング情報をYAMLやXMLファイルでも記述できます。これらのファイルを使っている場合は、YAML/XMLファイルの場所や名前さえ規約に従っていれば、エンティティクラスの格納ディレクトリは自由に変更できます。
以下は、YAMLファイルによるマッピングの記述例です。先頭が対応するエンティティクラスのFQCNです。エンティティクラスが物理的にどこに配置されているかには依存しません。
# src/Acme/StoreBundle/Resources/config/doctrine/Product.orm.yml Acme\StoreBundle\Entity\Product: type: entity table: product id: id: type: integer generator: { strategy: AUTO } fields: name: type: string length: 100 price: type: decimal scale: 2 description: type: text
2つめは、Symfonyのコンフィギュレーションにてエンティティクラスのディレクトリを設定する方法です。Symfonyのコンフィギュレーションファイルには、Doctrine 2 ORM用の設定項目がありますが、デフォルトではオートマッピング、つまりSymfonyによって決められたディレクトリにエンティティクラスがあるものとして動作します。バンドルの名前空間がAcme\Bundle\TestBundleで、エンティティクラスの格納ディレクトリをDomain/Entityディレクトリとする場合は、次のようにdirとprefixを設定します。
app/config/config.yml
# Doctrine Configuration doctrine: dbal: driver: %database_driver% host: %database_host% port: %database_port% dbname: %database_name% user: %database_user% password: %database_password% charset: UTF8 orm: auto_generate_proxy_classes: %kernel.debug% entity_managers: default: mappings: AcmeTestBundle: type: annotation dir: Domain/Entity prefix: Acme\Bundle\TestBundle\Domain\Entity
参考
http:/symfony.com/
Mapping Configuration - Configuration Reference - Symfony
7 notes · View notes
phpmentors · 13 years ago
Text
Practical Symfony #15: Swift Mailerによる日本語メールの作成
Swift Mailerは優れたメール送信ライブラリであり、Symfony Standard Editionにおける標準のメール送信コンポーネントです。バージョン4.1.8までは文字エンコーディングにISO-2022-JPを使った日本語メールがサポートされていませんでしたが、先日リリースされたバージョン4.2.0でついにサポートされました。(@ganchikuさん、ありがとうございます。)
Swift Mailerの初期設定
Swift Mailerのドキュメントには日本語メールを使うための初期設定が以下のように記述されています。
Using Swift Mailer for Japanese Emails - Documentation – Swift Mailer
require_once '/path/to/swift-mailer/lib/swift_required.php'; Swift::init(function () { Swift_DependencyContainer::getInstance() ->register('mime.qpheaderencoder') ->asAliasOf('mime.base64headerencoder'); Swift_Preferences::getInstance()->setCharset('iso-2022-jp'); }); /* rest of code goes here */
\Swift::init()関数はSwift Mailerのオートローダーが最初に動作する(Swift_で始まるクラスへアクセスする)ときに実行されるコールバック関数を登録するものです。コードの配置先はどこでも構いませんが、Symfonyアプリケーションの場合はアプリケーションのバンドルに配置するのが最も簡単です。
<?php ... require_once '/path/to/swift-mailer/lib/swift_required.php' ... class ...Bundle extends Bundle { ... public function boot() { ... $this->configureSwiftMailer(); ... } ... protected function configureSwiftMailer() { \Swift::init(function () { \Swift_DependencyContainer::getInstance() ->register('mime.qpheaderencoder') ->asAliasOf('mime.base64headerencoder'); \Swift_Preferences::getInstance()->setCharset('iso-2022-jp'); }); } ...
なお、Swift MailerをComposerで管理している場合は、swift_required.phpをインクルードする必要はありません。
日本語メールの作成
文字エンコーディングにISO-2022-JPを使った日本語メールを作成するためにはメッセージ毎に\Swift_Message::setCharset()メソッドを呼び出して文字エンコーディングを設定する必要があります。以下に使用例を示します。
<?php ... class UserTransfer { ... public function sendActivationEmail(User $user) { $sentRecipientCount = $this->mailer->send($this->messageFactory->newInstance() ->setCharset('iso-2022-jp') ->setEncoder(new \Swift_Mime_ContentEncoder_PlainContentEncoder('7bit')) ->setFrom(self::$ACTIVATION_EMAIL_FROM) ->setTo($user->getEmail()) ->setSubject(self::$ACTIVATION_EMAIL_SUBJECT) ->setBody($this->templateLoader->loadTemplate(self::$ACTIVATION_EMAIL_TEMPLATE)->render(array( 'user' => $user, 'activationURI' => self::$ACTIVATION_EMAIL_ACTIVATION_URI . rawurlencode($user->getActivationKey()), ))) ); return $sentRecipientCount == 1; } ...
このとき、件名などのヘッダーの値、ボディの値、いずれの文字エンコーディングもUTF-8にしておきます。
参考
Using Swift Mailer for Japanese Emails - Documentation – Swift Mailer
Pull Request #199: added japanese iso-2022-jp support. by ganchiku · swiftmailer/swiftmailer
6 notes · View notes
phpmentors · 13 years ago
Text
Practical Symfony #12: モックオブジェクトによるページフローのテスト
Symfonyの機能テストでは最初にWebTestCase::createClient()メソッドでClientオブジェクトを作成し、そのオブジェクトを使ってリクエストを行い、その返り値を確認することによってテストを行います。
テストクラス:
<?php ... class ...Test extends WebTestCase { public function test...() { $client = static::createClient(); $client->request('GET', '/path/to/foo'); $this->assertEquals(200, $client->getResponse()->getStatusCode()); $this->assertThat($client->getCrawler()->filter('title')->text(), $this->stringContains('入力ページ')); ...
WebTestCase::createClient()メソッドは内部でDIコンテナを初期化するため、Clientオブジェクト作成後にモックオブジェクトをサービスのインスタンスとして設定することができます。
テストクラス:
<?php ... class ...Test extends WebTestCase { public function test...() { $client = static::createClient(); // モックオブジェクトを構成し、サービスのインスタンスとして設定する。 $entityManager = \Phake::mock('Doctrine\ORM\EntityManager'); \Phake::when($entityManager)->... ... $client->getContainer()->set('doctrine.orm.default_entity_manager', $entityManager); $client->request('GET', '/path/to/foo'); $this->assertEquals(200, $client->getResponse()->getStatusCode()); $this->assertThat($client->getCrawler()->filter('title')->text(), $this->stringContains('入力ページ')); ...
ここまでは何の問題もありません。さて、ここから正常系のページフロー全体のテストへ拡張することを考えます。入力・確認・完了のページフローの場合、2回目のリクエストはフォームの送信になります。
テストクラス:
<?php ... class ...Test extends WebTestCase { public function test...() { ... $client->request('GET', '/path/to/foo'); $this->assertEquals(200, $client->getResponse()->getStatusCode()); $this->assertThat($client->getCrawler()->filter('title')->text(), $this->stringContains('入力ページ')); $form = $client->getCrawler()->selectButton('next')->form(); $form['foo'] = ... $client->submit($form); $this->assertEquals(200, $client->getResponse()->getStatusCode()); $this->assertThat($client->getCrawler()->filter('title')->text(), $this->stringContains('確認ページ')); ...
残念ながらこのテストは失敗します。これはClient::submit()メソッドでDIコンテナを含む実行環境が初期化されることが原因です。WebTestCase::createClient()メソッドとは異なりモックオブジェクトを設定するタイミングもありません。
ではWebTestCase::createClient()メソッドを再度実行することで解決できるかというとそうはいきません。この場合はリクエストの度にセッションが異なるため、フォームのCSRFトークンなどが障害になりページフローのテストは困難になります。ページフローをテストするためにはあくまでも同一のClientオブジェクトを使う必要があります。
この問題を解決する一つの手段はイベントディスパッチャーです。例えばBundle::boot()メソッドで独自のイベントを発生させるようにします。
バンドルクラス:
<?php ... class ...Bundle extends Bundle { public function boot() { ... foreach (BundleEvent::getPostBootListeners() as $postBootListener) { $this->container->get('event_dispatcher')->addListener(BundleEvent::POST_BOOT, $postBootListener); } $this->container->get('event_dispatcher')->dispatch(BundleEvent::POST_BOOT, new BundleEvent($this->container)); } public function shutdown() { foreach (BundleEvent::getPostBootListeners() as $postBootListener) { $this->container->get('event_dispatcher')->removeListener(BundleEvent::POST_BOOT, $postBootListener); } } ...
BundleEventクラスはイベントリスナーの登録とDIコンテナの保持のために用意したクラスです。テストクラスではリクエストの前後でイベントリスナーを登録・削除するようにします。
テストクラス:
<?php ... class ...Test extends WebTestCase { protected function addPostBootListeners(array $postBootListeners) { foreach ($postBootListeners as $postBootListener) { BundleEvent::addPostBootListener($postBootListener); } } protected function removePostBootListeners(array $postBootListeners) { foreach ($postBootListeners as $postBootListener) { BundleEvent::removePostBootListener($postBootListener); } } public function test...() { $postBootListeners = array(...); $this->addPostBootListeners($postBootListeners); $client = static::createClient(); $this->removePostBootListeners($postBootListeners); ... $client->request('GET', '/path/to/foo'); $this->assertEquals(200, $client->getResponse()->getStatusCode()); $this->assertThat($client->getCrawler()->filter('title')->text(), $this->stringContains('入力ページ')); $form = $client->getCrawler()->selectButton('next')->form(); $form['foo'] = ... $postBootListeners = array(...); $this->addPostBootListeners($postBootListeners); $client->submit($form); $this->removePostBootListeners($postBootListeners); $this->assertEquals(200, $client->getResponse()->getStatusCode()); $this->assertThat($client->getCrawler()->filter('title')->text(), $this->stringContains('確認ページ')); ...
これで無事に2回目以降のリクエストでもモックオブジェクトを使えるようになりました。
参考
Testing (2.0) - Symfony
Internals (2.0) - Symfony
テスト | Symfony2日本語ドキュメント
内部構造 | Symfony2日本語ドキュメント
6 notes · View notes
phpmentors · 11 years ago
Text
Practical Symfony #23: ドメインの知識を使ったフォームバリデーション
フォームは、PHPメンターズの設計と実装の型で述べているように、アプリケーションレイヤーにて実装されます。今回はフォームのバリデーションの拡張についてとりあげます。
バリデーションの仕組みの基本
ユーザーが入力した値を受け取り、アプリケーションのフォームでその入力を表すオブジェクト(フォームのデータを格納する入れ物、フォームDTO: Data Transfer Objectと名づけます)が組み立てられます。このフォームDTOの持つデータが妥当かどうかをチェックするのがバリデーションの役割です。
Tumblr media
バリデーションはフォームDTOに対して行われるため、SymfonyではフォームDTOクラスにバリデーションの定義を記述します。
class Author { /** * @Assert\NotBlank() * @Assert\Length(min = "3") */ private $firstName; }
フォームDTOのデータを確認するための様々な制約��Symfonyに用意されています。基本的には、フォームDTOのフィールドについて検証を行うもので、処理はフォームDTO内に閉じている前提になっています。
しかし、フォームを使うアプリケーションでは単純に単一エントリのデータの妥当性検査を行うだけでなく、関連データも含めた整合性検査も行いたい場合があります。簡単な例は「登録するメールアドレスがシステム内でユニークであること」といった条件です。ユーザーが入力したデータだけでなく、すでに登録されているデータを検索してチェックしなくてはなりません。Symfonyにはこの目的に特化したUniqueEntityという制約が用意されていますが、このような処理を一般化して考えると、単なるユニーク制約ではなく、「ドメインのルールに基づくバリデーション」を行いたいということになります。
ドメインのルールはどこに表されている?
ドメイン駆動設計に「仕様(Specification)」というパターンがあります(Symfonyでの実装例)。この名前から想起されるとおり、バリデーションで使いたいようなルールは、ドメインの仕様として表すことができます。先ほど例に挙げたユニークであるかどうかという制約もドメインにおける仕様です。仕様オブジェクトとして独立させる他に、リポジトリのメソッドとして表現してもよいでしょう。
Tumblr media
仕様として表現する場合、これは1つのオブジェクトで、状態を持たないサービスの一種です。この中では自由に他のサービスやリポジトリを組み合わせて使えます。ですから、アプリケーションのフォームにおけるバリデーションに、ドメインレイヤーのサービスを手軽に指定できればよさそうです。
バリデーションにサービスのメソッドを使う
Alert  これ以降の「サービス」は、DDDのサービスのことではなくて、Symfonyのコンテナで扱うサービスを指します。
Symfonyはサービスコンテナをアーキテクチャの基盤に持っており、さまざまな場面でサービスに処理を分散させることができます。ドメインレイヤーのサービスでも、コンテナに登録してあればどこからでも呼び出せます。
Tumblr media
問題は、サービスを使ったバリデーションのための制約が、Symfonyの組み込みでは用意されていないということです。また、制約を記述しているフォームDTOやエンティティは、サービスコンテナの参照を保持しません。どうやってサービスをバリデーション時に呼び出せばよいでしょうか?
筆者の使っているServiceCallbackという制約を紹介します。ServiceCallback制約を使うと、フォームDTOに対してサービスのメソッドをバリデーションに使えます。以下は、サービスコンテナに登録されたdomain.member.allow_upgrade_specというサービスのisSatisfiedByメソッドをバリデーション時に呼び出す記述例です。
/** * @AssertServiceCallback(service="domain.member.allow_upgrade_spec", method="isSatisfiedBy", message="you can't upgrade.") */ class Member {
呼び出すサービスの方はとてもシンプルで、フォームDTOを引数で受け取るisSatisfiedByを定義し、サービスとして登録するのみです。必要であれば他のサービスやリポジトリなどをDIで注入して使うこともできます。検査結果をtrue/falseで返します。
use JMS\DiExtraBundle\Annotation As DI; /** * @DI\Service("domain.member.allow_upgrade_spec") */ class AllowUpgradeSpecification { /** * inject services if you need */ private $memberRepository; public function isSatisfiedBy($member) { // リポジトリなどを使った条件のチェック ... return true; } }
ServiceCallbackバリデーターの実装
カスタムバリデーターの実装には、ServiceCallback制約とServiceCallbackValidatorを作り、バリデーターとして使えるようサービスコンテナに登録しています。
<?php namespace PHPMentors\ValidatorBundle\Validator\Constraints; use Symfony\Component\Validator\Constraint; /** * @Annotation */ class ServiceCallback extends Constraint { public $method; public $service; public $message = 'Service callback returns an error.'; /** * {@inheritdoc} */ public function getTargets() { return self::CLASS_CONSTRAINT; } /** * {@inheritdoc} */ public function validatedBy() { return 'PHPMentorsServiceCallbackValidator'; } }
<?php namespace PHPMentors\ValidatorBundle\Validator\Constraints; use Symfony\Component\DependencyInjection\ContainerAwareInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; use Symfony\Component\Validator\Exception\ConstraintDefinitionException; class ServiceCallbackValidator extends ConstraintValidator implements ContainerAwareInterface { /** * @var ContainerInterface */ protected $container; /** * {@inheritdoc} */ public function validate($object, Constraint $constraint) { if (null === $object) { return; } if (!$this->container->has($constraint->service)) { throw new ConstraintDefinitionException; } $service = $this->container->get($constraint->service); if (!method_exists($service, $constraint->method)) { throw new ConstraintDefinitionException(sprintf('Method "%s" targeted by ServiceCallback constraint does not exist', $constraint->method)); } $result = call_user_func(array($service, $constraint->method), $object); if (false == $result) { $this->context->addViolation($constraint->message); } } /** * {@inheritdoc} */ public function setContainer(ContainerInterface $container = null) { $this->container = $container; } }
parameters: php_mentors_validator.service_callback.class: PHPMentors\ValidatorBundle\Validator\Constraints\ServiceCallbackValidator services: php_mentors_validator.service_callback: class: %php_mentors_validator.service_callback.class% calls: - [ setContainer, ["@service_container"] ] tags: - { name: validator.constraint_validator, alias: PHPMentorsServiceCallbackValidator }
上記コードはSymfony 2.4で動作確認しているものです。2.0向けでは多少書き換えないといけません。動作しているコード例をこちらにあげてあります。
まとめ
ServiceCallback制約を使うことで、バリデーションの記述の自由度が増し、バリデーションのためにフォームDTOやエンティティに余計な知識を埋め込んでしまうことを避けられます。同時に、ドメインの知識がアプリケーションに散らばってしまうことをも防げます。
この制約の実装自体が(厳密なエラー処理等をしていないことを除外しても)とてもシンプルなのは、サービスコンテナやバンドルを土台としているSymfonyのアーキテクチャの恩恵です。しかしその一方で、今回紹介したようにアプリケーションを開発する上でSymfonyに足りない要素があることも事実です。少しだけ手間をかけて仕組みを用意することで、アプリケーションレイヤーとドメインレイヤーの分離を維持することができます。こうしてSymfonyのようなOSSのフレームワークを自分用に育てていけば、頑強な基盤と同時に高い生産性を無理なく両立していけるでしょう。
参考
How to create a Custom Validation Constraint (current) - Symfony
バリデータリファレンス | Symfony2日本語ドキュメント
5 notes · View notes
phpmentors · 13 years ago
Text
Practical Symfony #14: フォームでCollectionTypeを使う 第2回
Symfony2のフォームでCollectionTypeを使う方法について、第1回ではエンティティクラスと対応付けたFormTypeクラスを作る部分を解説しました。作成したFormTypeは、以下のようにAnswerTypeとAnswerDetailTypeで親子関係を持っています。
Tumblr media
今回は、フォームのレンダリングとFormViewについて見てみましょう。
デフォルトのレンダリング
フォームにはデフォルトのレンダリングエンジンがあり、FormTypeで定義したウィジェットを階層構造も含めて手軽にレンダリングすることができます。コントローラでテンプレートをレンダリングする時に'form' => $form->createView()のようにしてFormViewインスタンスをformという変数名で渡している場合、テンプレート側では{{ form_widget(form) }}とするだけで、このフォームにあるウィジェットをすべて表示できます。
<form action="{{ path("phpmentors_sample_formsample_default_inputpost") }}" method="POST"> {{ form_widget(form) }} {{ form_rest(form) }} <input type="submit" name="next" value="確認"> </form>
このコードの場合、以下のように表示されます。
Tumblr media
開発初期段階ではこのような表示でも十分ですね。
フォームの要素を個々に扱う
実務では、フォームの個々のウィジェットのレンダリングを細かく制御したいことがほとんどでしょう。FormViewインスタンスには、FormTypeで構成したウィジェットの階層構造が引き継がれています。
Tumblr media
以下のようにTwigのプロパティアクセス構文、および配列要素アクセス構文を使って個々のフィールドを指定すれば、特定のフィールドのみをレンダリングできます。この時、フィールドのウィジェットのみ、ラベルのみ、エラーのみというようにフィールドの特定のパーツのみをレンダリングすることもできます。
{{ form_widget(form.name) }} {{ form_widget(form.email) }} {{ form_widget(form.answerDetails[0].input) }} {{ form_widget(form.answerDetails[1].input) }} {{ form_widget(form.answerDetails[2].input) }}
form.answerDetailsは配列アクセスできますので、forを使うこともできます。
{% for answerDetail in form.answerDetails %} {{ form_widget(answerDetail.input) }} {% endfor %}
form_widget()でレンダリングすると、フィールドごとにレンダリング済みかどうかの管理が行われます。同じフィールドに対して複数回form_widget()を呼び出しても、2度目以降はレンダリングされません。
テンプレートで以下のように記述すれば、第1回で示��たアンケートフォームの画面が表示されます。
<form action="{{ path("phpmentors_sample_formsample_default_inputpost") }}" method="POST"> <h2>ご連絡先を教えて下さい</h2> <div>・お名前:{{ form_widget(form.name) }}</div> <div>・メールアドレス:{{ form_widget(form.email) }}</div> <h2>住んだことのある都道府県を教えて下さい</h2> {% for answerDetail in form.answerDetails %} <div>・{{ loop.index }}つめ:{{ form_widget(answerDetail.input) }}</div> {% endfor %} <input type="submit" name="next" value="確認"> {{ form_rest(form) }} </form>
まとめ
今回はSymfonyのフォームのうち、レンダリング部分に焦点を当てて解説しました。今回の解説ではほとんど触れていませんが、CollectionTypeを使った場合、FormViewインスタンス内の配列としてCollectionType内の要素へ自然にアクセスできていることが重要です。元となるエンティティクラスのオブジェクトの構成、それに対応するFormTypeの構成、さらにFormTypeから生成されるFormViewの構成。これらの対応関係が自然であるほど扱いやすいといえるでしょう。
参考
Forms (current) - Symfony
How to customize Form Rendering (current) - Symfony
Symfony2 Form Architecture - Web Mozarts
5 notes · View notes
phpmentors · 12 years ago
Text
Practical Symfony #22: 出力バッファはどこで送信されるのか?
PHPの出力バッファリングは、日本において文字エンコーディングの変換を中心に広く使われてきました。文字エンコーディングとしてUTF-8が定着してきた現在、出力バッファリングは出力文字エンコーディングの変換という用途では余り使われなくなってきていると思われますが、古いアプリケーションでは依然として必要とされていることでしょう。
以下のコードは出力バッファリングの簡単な例です。
<?php ob_start(function ($buffer) { return '__START__' . $buffer . '__END__'; }); echo 'foo'; ob_end_flush(); echo 'bar'; // "__START__foo__END__bar" と表示される
出力バッファの内容は、ob_end_flush()関数やob_get_flush()関数の呼び出しによって明示的に出力バッファをフラッシュするか、スクリプトが終了するタイミングで標準出力に送信されます。
Symfonyにおける出力バッファの扱い
SymfonyではSymfony\Component\HttpFoundation\Response::send()メソッドによってHTTPヘッダと内容の送信が行われます。
Symfony\Component\HttpFoundation\Response(v2.3.4):
<?php ... class Response { ... /** * Sends HTTP headers and content. * * @return Response * * @api */ public function send() { $this->sendHeaders(); $this->sendContent(); if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } elseif ('cli' !== PHP_SAPI) { // ob_get_level() never returns 0 on some Windows configurations, so if // the level is the same two times in a row, the loop should be stopped. $previous = null; $obStatus = ob_get_status(1); while (($level = ob_get_level()) > 0 && $level !== $previous) { $previous = $level; if ($obStatus[$level - 1]) { if (version_compare(PHP_VERSION, '5.4', '>=')) { if (isset($obStatus[$level - 1]['flags']) && ($obStatus[$level - 1]['flags'] & PHP_OUTPUT_HANDLER_REMOVABLE)) { ob_end_flush(); } } else { if (isset($obStatus[$level - 1]['del']) && $obStatus[$level - 1]['del']) { ob_end_flush(); } } } } flush(); } return $this; } ...
出力バッファの送信は、HTTPヘッダと内容の送信の後の少々込み入ったコードで実装されています。コードを見ると、出力バッファのうち削除フラグがtrueのものだけが実際に送信されることがわかります。
Symfonyにはob_start()関数相当のAPIはありません。また、ob_end_clean()関数による出力バッファのクリアも行われません。よってSymfonyでは設定ファイル等による実行時設定やユーザーによるob_start()関数の呼び出しを通した出力バッファ���ング設定が、そのまま実行環境で有効となります。
出力バッファリングを使う場合はバージョン2.3.4以降または2.2.6以降を使うこと
Symfony 2.3.3以前および2.2.6以前のバージョンは、PHP 5.4で導入された新しい出力APIをサポートしていないため、前述のコードによる出力バッファのクリアが行われません。出力バッファリングを使う場合は、バージョン2.3.4以降または2.2.6以降を使うようにしましょう。
参考
symfony/symfony
3 notes · View notes
phpmentors · 14 years ago
Text
Practical Symfony #1: Symfony2のテストコードを読む : Symfony Advent Calender 2011 JP - 3日目 -
Symfony Advent Calendar JP 2011の3日目の記事です。
みなさん、Symfony2本体のソースコードやテストコードを読んだことはありますか? Symfony2本体は、コアコンポーネント、コアバンドル等で成り立っており、それぞれ独立したテストコードが付属しています。PHPUnitのモックオブジェクト等を使ったテストがふんだんにあり、レイヤー化されたコンポーネントのテスト方法の参考になります。
今回は、Symfony Componentの中核をなす「HttpKernel」コンポーネントの中の、Kernelクラスのテストを1��見てみましょう。
tests/Symfony/Tests/Component/HttpKernel/KernelTest.php:
<?php // snip namespace Symfony\Tests\Component\HttpKernel; use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\HttpKernel\Bundle\BundleInterface; use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Config\Loader\LoaderInterface; class KernelTest extends \PHPUnit_Framework_TestCase { // snip public function testBootInitializesBundlesAndContainer() { $kernel = $this->getMockBuilder('Symfony\Tests\Component\HttpKernel\KernelForTest') ->disableOriginalConstructor() ->setMethods(array('initializeBundles', 'initializeContainer', 'getBundles')) ->getMock(); $kernel->expects($this->once()) ->method('initializeBundles'); $kernel->expects($this->once()) ->method('initializeContainer'); $kernel->expects($this->once()) ->method('getBundles') ->will($this->returnValue(array())); $kernel->boot(); }
このコードではKernelクラスのboot()メソッドをテストしていますが、その中でもboot()メソッドの基本的な振舞をテストしている部分です。上記コードからは、boot()メソッドの基本的な振舞は次の内容だということになります。
initializeBundles()メソッドを呼び出す
initializeContainer()メソッドを呼び出す
getBundles()メソッドを呼び出す
このような振舞をテストするために、PHPUnitのモックオブジェクトの機能が使われています。 モックオブジェクトを使うことで、期待するメソッドが呼び出されたどうかをテストすることができるようになります。
この例では、テスト対象となるメソッドのクラス(Kernelクラス)自身をモックオブジェクトにしていますが、一般的にモックオブジェクトが必要となるのは、テスト対象のクラス/メソッドで依存している別のクラスがある場合です。それらをモックオブジェクトで置き換え、依存メソッドの呼び出しをエミュレートするとともに、意図したとおりに呼び出されたことも検査できます。
このように、テストにおいて単に対象メソッドの入出力だけを検査するのではなく、モックオブジェクトを使ってメソッドの振舞の部分もテストで表現できるようになるのです。モックオブジェクトを使ったテストコードを読みこなせるようになれば、Symfonyのテストコードをサクサクと理解できるようになり、それぞれのコンポーネントがどういった意図を持って作られているのかといったことについても理解が進むようになります。
参考
High Performance PHP Framework for Web Development - Symfony
PHPUnit 3.6 日本語マニュアル「第10章 テストダブル」
Mockery (PHPのモッキングフレームワーク)
Phake (PHPのモッキングフレームワーク)
3 notes · View notes
phpmentors · 11 years ago
Text
Practical Symfony #26: PHPMentorsPageflowerBundleを使ったページフロー定義と対話の管理
Symfony Advent Calendar 2014 (Qiita) 10日目
前(12月9日)
次(12月11日)
PHPMentorsPageflowerBundleは筆者が開発したSymfonyアプリケーション向けのページフローエンジンです。特徴としては、以下のものが挙げられます。
アノテーションによるページフロー定義
対話の管理
アクセス制御されたアクション
対話スコープのプロパティ
対話開始直後に実行されるユーザー定義メソッド
複数のブラウザーウィンドウまたはタブのサポート
PHPMentorsPageflowerBundleを使うと、コントローラーに断片的に埋め込まれたページフローに関するコードを明示的な定義で置き換えることができます。また、対話と対話スコープのプロパティの導入によってコントローラーの状態管理コードを大幅を削減することができます。
では、早速コードを見てみましょう。以下はSymfony2ベースのユーザー登録サンプルのコードです。
Example\UserRegistrationBundle\Controller\UserRegistrationController:
<?php /* * Copyright (c) 2012-2014 KUBO Atsuhiro <[email protected]>, * 2014 YAMANE Nana <[email protected]>, * 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つの@StartPage、0以上の@Page、1つの@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が参考になるでしょう。
参考
PHPMentorsPageflowerBundle
Symfony2ベースのユーザー登録サンプル
2 notes · View notes
phpmentors · 11 years ago
Text
Practical Symfony #24: ダイナミックなコンフィギュレーショングラマー
Symfonyフレームワークの動作を設定するコンフィギュレーションとその背後の仕組みは、Symfonyのアーキテクチャを支える強力な屋台骨となっています。この仕組み��応用例の1つとして、コンフィギュレーションエントリをダイナミックに定義する仕掛けを見てみます。
通常のコンフィギュレーション
通常の静的なコンフィギュレーションは、バンドル内のDependencyInjectionディレクトリ以下にConfigurationクラスを用意し、そこで定義されたツリー構造に従って読み込まれます。コンフィギュレーションクラスの例は設定の仕様とは等を参照してください。この場合、あらかじめ固定のコンフィギュレーショングラマーがあり、それにもとづいてコンフィギュレーションファイルに設定を記述し、そのファイルの設定を読み込んでサービスコンテナが動作します。
アプリケーション開発の多くの場面ではこのような静的な定義で十分ですが、コンフィギュレーションで記述したい内容が、必ずしも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という連携パッケージにあります。
このダイナミックな定義の流れは、次の図のようになっています。
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つです。ソースを読んでみるとさまざまな発見があります。
参考
SecurityBundle Configuration (“security”) (master) - Symfony
Managing Configuration with Extensions
2 notes · View notes
phpmentors · 12 years ago
Text
Practical Symfony #19: SymfonyのProfilerを特定のアクションで無効にするには
Symfonyには強力なProfilerがあり、開発時にはとても役に立ちますが、Profilerが開発の邪魔をするケースもあります。例えば、Doctrine経由で件数の多いレコードを取得しようとすると、Profiler用のDataCollectorへの記録に大量にメモリを消費し、応答に時間がかかったりPHPのメモリ制限値を超えて実行できなくなったりします。
回避策として、そのような特殊な処理を実行するアクションで、プロファイラを無効にしてしまいます。Profilerはdevの時に組み込まれるサービスの1つとして機能しているので、サービスコンテナから該当するオブジェクトを取得することで、Profilerを操作できます。
<?php // プロファイラを無効にしたい // コントローラのアクションメソッド内 if ($this->has('profiler')) { /* @var $profiler \Symfony\Component\HttpKernel\Profiler\Profiler */ $profiler = $this->get('profiler'); $profiler->disable(); }
関連
カスタムデータコレクタの作成方法 | Symfony2日本語ドキュメント
2 notes · View notes
phpmentors · 13 years ago
Text
Practical Symfony #13: フォームでCollectionTypeを使う 第1回
Symfony2のFormコンポーネントは、symfony 1.x系から大きく進化したものの1つです。Formコンポーネントにはさまざまなウィジェット(Typeと呼ぶ)が組み込まれていますが、その中でも特殊なのがコレクションに対応するCollectionTypeです。要素数が可変の項目を扱う場合に、CollectionTypeを使います。
アンケートシステム
ごく簡単なアンケートシステムを考えてみましょう。フォームの画面は、以下のようになります。
Tumblr media
以下のようなヘッダ/明細構成のデータモデルを使います。
Tumblr media
データモデルに対応させて、AnswerエンティティクラスとAnswerDetailエンティティクラスを用意し、1対多のリレーション(One-to-Many)を設定します。注目するのは、データ構造としてはAnswerに対応するAnswerDetailの数は不定(可変)であるという点です。
親子関係のあるエンティティにはFormTypeでも親子関係を作る
このアンケートシステムのようなデータモデルへデータを投入するフォームを作る場合、親子関係のある1まとまりのエンティティ群を1つのフォームで扱うことになります。symfony 1.xではEmbedForm(埋め込みフォーム)の機能がありましたが、Symfony2のFormコンポーネントではさらに進化し、親子関係をきれいに扱えるようになっています。フォーム内の1つ1つの入力欄、およびAnswerといった1まとまりを表す場合のどちらも「FormType」で表すことができ、Compositeパターンで実装されているため透過的に扱うことができます。このようなFormシステムのアーキテクチャについては別の記事で解説します。
今回はフォームで扱うエンティティクラスがAnswerとAnswerDetailなので、これに対応するフォームの定義としてAnswerTypeクラスとAnswerDetailTypeクラスを作ります。
Form/Type/AnswerType.php
<?php namespace PHPMentors\Sample\FormSampleBundle\Form\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilder; class AnswerType extends AbstractType { public function buildForm(FormBuilder $builder, array $options) { $builder ->add('name') ->add('email') ->add('answerDetails', 'collection', array( 'type' => new AnswerDetailType(), )) ; } public function getName() { return 'answertype'; } }
AnswerTypeでは、通常のフィールドであるnameとemailの定義を行い、その後、可変数の子要素部分をCollectionTypeとして定義しています。CollectionTypeで各コレクション要素にAnswerDetailTypeを使うよう指定しています。
Form/Type/AnswerDetailType.php
<?php namespace PHPMentors\Sample\FormSampleBundle\Form\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilder; class AnswerDetailType extends AbstractType { public static $PREF_CHOICES = array( '' => '(選択してください)', '東京都' => '東京都', '大阪府' => '大阪府', '北海道' => '北海道', '福岡県' => '福岡県', '愛知県' => '愛知県', ); public function buildForm(FormBuilder $builder, array $options) { $builder ->add('input', 'choice', array( 'label' => '', 'choices' => self::$PREF_CHOICES, )) ; } public function getName() { return 'answerdetailtype'; } }
これらのFormTypeの実装ができたら、コントローラから呼び出します。コントローラ側では、最初にAnswerエンティティとAnswerDetailエンティティからなるオブジェクトの構成をAnswerFactoryにて用意しておき、それをcreateForm()メソッドのパラメータとして渡しています。この時点でAnswerエンティティが4つのAnswerDetailエンティティを保持していれば、CollectionTypeによって、AnswerDetailTypeに該当する入力欄がフォームに4つ表示されます。
<?php // *snip* public function inputAction() { // *snip* $answerFactory = new AnswerFactory(); $answer = $answerFactory->create(); $answerForm = $this->createForm(new AnswerType(), $answer); // *snip* }
このフォームのレンダリング方法については、次回で解説します。
参考
フォーム | Symfony2日本語ドキュメント
collection Field Type (current) - Symfony
Composite パターン - Wikipedia
2 notes · View notes