Symfony2のエラーページをカスタマイズしてみる

Symfony2でエラーページをカスタマイズしたかったのでやり方を調べてみた。

エラーページのカスタマイズ方法 | Symfony2日本語ドキュメント

エラーページをカスタマイズするには下記の2種類の方法があるみたい。

  1. エラーテンプレートのカスタマイズ
  2. 例外処理のカスタマイズ

1の方法は上記のドキュメントにも載っている通り非常に簡単にできるけど自由度が効かないので、今回は2の方法を試してみた。

例外処理をカスタマイズするには、まず初めに例外処理をするクラスをサービス登録します。
この時、kernel.exceptionイベント発生時に動くようにeventにはkernel.exceptionを設定します。

src/Acme/DemoBundle/Resources/config/services.yml

custom.exception.listener:
    class: Acme\DemoBundle\Listener\CustomExceptionListener
    arguments: ['@templating']
    tags:
        - { name: kernel.event_listener, event: kernel.exception, method: onKernelException }

ここまで設定したら次は実際に例外処理を行うクラスを作成します。
自分は管理画面の時だけこの処理を実行したかったので、URLが/adminの時だけ処理されるように設定しています。
また、Securityコンポーネントでユーザーと権限管理を行なっているので、権限が足りなかった際のページも用意しています。

src/Acme/DemoBundle/Listener/CustomExceptionListener.php

<?php
namespace Acme\DemoBundle\Listener;

use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Bundle\TwigBundle\TwigEngine;

class CustomExceptionListener
{
    /**
     * @var TwigEngine
     */
    protected $templating;


    /**
     * __construct
     *
     * @param \Symfony\Bundle\TwigBundle\TwigEngine $templating
     */
    public function __construct(TwigEngine $templating)
    {
        $this->templating = $templating;
    }

    public function onKernelException(GetResponseForExceptionEvent $event)
    {
        static $handling;

        $exception = $event->getException();

        if (true === $handling) {
            return;
        }

        if (method_exists($exception, 'getStatusCode')) {
            $code = $exception->getStatusCode();
        }

        // URLが/adminの時だけ実行
        if (0 === strpos($event->getRequest()->getPathInfo(), '/admin')) {
            $handling = true;

            // Securityコンポーネントで権限が足りないときの処理
            if ($exception instanceof AccessDeniedHttpException) {
                $message = $this->templating->render('AcmeDemoBundle:Exception:access_denied.html.twig');
            }

            // HTTP 404 Status
            if (isset($code) && 404 == $code) {
                $message = $this->templating->render('AcmeDemoBundle:404.html.twig', array());
                $response = new Response($message, $code);
                $event->setResponse($response);
            }

            if (isset($message)) {
                $response = new Response($message, $code);
                $event->setResponse($response);
            }
        }

        $handling = false;
    }
}

ここまで出来たら後はテンプレートを用意してあげれば完了。
もっと色々エラー内容を拾って処理したいって人は元の例外処理クラスを参考にしてみるといいかもしれません。
元々の例外処理をしているクラスは下記のファイルです。

src/Symfony/Component/HttpKernel/EventListener/ExceptionListener.php

Symfony2でform名を指定する方法

Symfony2でEntityに依存しない同一のformを複数発行したくて、form名を動的に変更する方法を調べてみたので忘れないうちのメモっとく。
結果的にはcreateNameBuilderを通してFormBuilderを発行してあげればいけた。

for ($i = 1;$i <= 3;$i++) {
    $builder = $this->get('form.factory')->createNamedBuilder('form', 'sampleForm' . $i);
}

createNamedBuilderの引数はこんな感じ。

    /**
     * Returns a form builder.
     *
     * @param string|FormTypeInterface  $type       The type of the form
     * @param string                    $name       The name of the form
     * @param mixed                     $data       The initial data
     * @param array                     $options    The options
     *
     * @return FormBuilder The form builder
     *
     * @throws FormException if any given option is not applicable to the given type
     */
    public function createNamedBuilder($type, $name, $data = null, array $options = array())
    {
        // ...
    }

Symfony2のDateTypeのバグ対策

※追記 (2012/02/29 20:40)
というかそもそも、下記を実装した後に気づいたんですが、date_patternをオーバーライドするフォームテーマなんて作成しないでも直接オーバーライド出来るんじゃないか?と思って試してみたら出来ました。

特にテンプレートとか作らずに下記のように指定すれば出来ます。

{{ form_widget(form.sample_date, { 'date_pattern': '{{ year }}年{{ month }}月{{ day }}日' }) }}


id:shimookaさんのブログでDateTypeにバグがあるという記事があがっていたので試してみました。

実際にテストコードを書いて確認してみると確かにformatにyyyy年MM月dd日を指定しているのに無視される。

id:shimookaさんの記事には対応策が載っているが、Symfony2のコアコードを変更するのはあまりスマートではないので、フォームテーマを作成して対策してみました。

デフォルトのフォームテーマを確認すると、下記のようになってます。

vendor/symfony/src/Symfony/Bridge/Twig/Resources/views/Form/form_div_layout.html.twig

{% block date_widget %}
{% spaceless %}
    {% if widget == 'single_text' %}
        {{ block('field_widget') }}
    {% else %}
        <div {{ block('widget_container_attributes') }}>
            {{ date_pattern|replace({
                '{{ year }}':  form_widget(form.year),
                '{{ month }}': form_widget(form.month),
                '{{ day }}':   form_widget(form.day),
            })|raw }}
        </div>
    {% endif %}
{% endspaceless %}
{% endblock date_widget %}

date_patternという変数には{{ year }}-{{ month }}-{{ day }}という文字列が入っています。
なので、ここを独自のフォームテーマを作成して、オーバーライドしてあげればSymfony2のコアコードを変えずに実装できます。

まずは下記のようにデフォルトのフォームテーマを元にdate_widgetのみ拡張するフォームテーマを作成します。

src/My/Bundle/DemoBundle/Resources/views/Form/form_layout.html.twig

{% extends "form_div_layout.html.twig" %}

{% block date_widget %}
{% spaceless %}
    {# _date_patternが定義されていれば、date_patternをオーバーライド #}
    {% if _date_pattern is defined %}
        {% set date_pattern = _date_pattern %}
    {% endif %}

    {% if widget == 'single_text' %}
        {{ block('field_widget') }}
    {% else %}
        <div {{ block('widget_container_attributes') }}>
            {{ date_pattern|replace({
                '{{ year }}':  form_widget(form.year),
                '{{ month }}': form_widget(form.month),
                '{{ day }}':   form_widget(form.day),
            })|raw }}
        </div>
    {% endif %}
{% endspaceless %}
{% endblock date_widget %}

_date_patternが定義されていればdate_patternをオーバーライドするという処理をdate_widgetブロックの直下に追加しました。


後は実際のフォームを利用するテンプレートで下記のようにしてフォームを呼び出すと独自の形式でフォームが呼び出せるようになります。

{% form_theme form "MyDemoBundle:Form:form_layout.html.twig" %}

{{ form_widget(form.sample_date, { '_date_pattern': '{{ year }}年{{ month }}月{{ day }}日' }) }}

Symfony2でEntityの更新前に処理を挟んでみる

よくある登録日時や更新日時は毎回書くのは面倒臭いので、自動で入れたかったりしますね。
Symfony2でもやりたかったので調べて見た。

下記の方法を用いれば出来るっぽい。

  • ライフサイクルコールバックを使う
  • イベントリスナーを使う

ライフサイクルコールバックは非常に簡単なので、このへんを参照してください。
で、今回はイベントリスナーを使って実装しました。

別にライフサイクルコールバックでもよかったんですが、全部のEntityに書くのは面倒くさいのと、登録者・更新者情報も入れたくてコンテナを経由してユーザー情報を取ってきたかったのでイベントリスナーで作ったほうがスマートかなと思ってそっちでやりました。

まずはこのへんを読みながらprePersistで登録日時を入れてみた。
すげー簡単に出来た!

でも自分は登録者情報も入れたかったので下記のように変更。

config/services.yml

services:
    # Persist時に呼び出されるイベント
    my.listener.pre.persist:
        class: Csp\Bundle\AdminBundle\Listener\EntityListener
        arguments: ['@service_container']
        tags:
            - { name: doctrine.event_listener, event: prePersist }

    # Update時に呼び出されるイベント
    my.listener.pre.update:
        class: Csp\Bundle\AdminBundle\Listener\EntityListener
        arguments: ['@service_container']
        tags:
            - { name: doctrine.event_listener, event: preUpdate }

ユーザー情報を取りたいので、argumentsでコンテナを渡すように設定。
で、次に肝心のイベントリスナークラスの実装。

<?php
// ...

use Doctrine\ORM\Event\LifecycleEventArgs;
use Symfony\Component\DependencyInjection\ContainerInterface;

class Product
{
    protected $container;

    /**
     * Construct
     *
     * @param \Symfony\Component\DependencyInjection\ContainerInterface $container
     */
    public function __construct(ContainerInterface $container)
    {
        $this->container = $container;
    }

    /**
     * prePersist event
     *
     * @param \Doctrine\ORM\Event\LifecycleEventArgs $args
     */
    public function prePersist(LifecycleEventArgs $args)
    {
        $entity = $args->getEntity();
        $entityManager = $args->getEntityManager();

        // createdAtプロパティがあれば登録日時を自動で入れる
        if (property_exists($entity, 'createdAt')) {
            $entity->setCreatedAt(new \DateTime());
        }

        // createdUserプロパティがあれば登録ユーザーを自動で入れる
        if (property_exists($entity, 'createdUser')) {
            $user = $this->container->get('security.context')->getToken()->getUser();
            $entity->setCreatedUser($user);
        }
    }

    // ...
}

これで登録時には登録日時と登録ユーザー情報がちゃんと入った。
これと同じノリでpreUpdateメソッドを実装して見たんだけど、Update時に呼び出されるけどデータベースの値が書き換えられない。

なんでだーとか悩んでてもしょうがないので、ドキュメントを読んでみる。
英語なんて読めないので、google翻訳先生とコードを見ながらああ、こんな感じにやるのねっていうのを把握。
で、下記のようにしてみました。

<?php
// ...

    public function preUpdate(LifecycleEventArgs $args)
    {
        $user = $this->container->get('security.context')->getToken()->getUser();

        $args->setNewValue('updatedAt', new \DateTime());
        $args->setNewValue('updatedUser', $user);
    }

で、動かしてみると下記のようなエラーが発生。

Field 'updatedAt' is not a valid field of the entity 'My\Bundle\DemoBundle\Entity\Product' in PreInsertUpdateEventArgs.

なんかこのアクションで、変更されたプロパティを更にバリデーションしたり値を変えたりは出来るけど、何も変更がないプロパティはまずそもそもchangedFieldとして扱われてない。
じゃあどうすりゃいいんだーとか言いながら更に調べてみるが分からないので、ちゃんとドキュメントを読みなおしてみる。

Changes to associations of the updated entity are never allowed in this event, since Doctrine cannot guarantee to correctly handle referential integrity at this point of the flush operation. This event has a powerful feature however, it is executed with a PreUpdateEventArgs instance, which contains a reference to the computed change-set of this entity.

http://www.doctrine-project.org/docs/orm/2.1/en/reference/events.html#preupdate

なんか修正したいなら再計算しろとか書かれてる?
で、もっと調べてみてようやく分かったのはUnitOfWorkを使って再計算すれば良いっぽい。
下記が直したコード。

<?php
// ...

    public function preUpdate(LifecycleEventArgs $args)
    {
        $entity = $args->getEntity();
        $em = $args->getEntityManager();

        $recompute = false;

        // updatedAtプロパティがあれば更新日時を自動で入れる
        if (property_exists($entity, 'updatedAt')) {
            $entity->setUpdatedAt(new \DateTime());
            $recompute = true;
        }

        // updatedUserプロパティがあれば更新ユーザーを自動で入れる
        if (property_exists($entity, 'updatedUser')) {
            $user = $this->container->get('security.context')->getToken()->getUser();
            $entity->setUpdatedUser($user);
            $recompute = true;
        }

        if ($recompute) {
            // 再計算
            $meta = $em->getClassMetadata(get_class($entity));
            $em->getUnitOfWork()->recomputeSingleEntityChangeSet($meta, $entity);
        }
    }

ちゃんと動いた!

一応まとめ。

  • prePersistの場合はEntityをそのまま加工すればいい
  • preUpdateの場合、changedFieldにプロパティが含まれない場合、UnitOfWorkを使って再計算する

Doctrine2系の日本語記事が全然ないので結構苦労する…

Symfony2について

Symfony2ってどんなフレームワークなの?って聞かれたので答えてみる。

あ、最初に書いておきますが、これはあくまで自分の主観です。
自分もSymfony2のコアな部分のコードはそんなに読んでいないので、Symfony2を学習するうえで思ったことです。
ここは違うだろう、とかあれば突っ込んでいただけるととても嬉しいです。

Symfony2ってのはSymfony2 Componentsを利用して作られたフルスタックフレームワークです。
ここSymfony2Symfony2 Componentsは別に考えることが重要。
Silexってフレームワークもあるんだけど、これもSymfony2 Componentsが利用されて作られています。

ようは何が言いたいのかっていうと、Symfony2 Componentsってのは物凄く優秀なコンポーネント群で各々のコンポーネント疎結合度が高いので、コンポーネントの再利用がめちゃくちゃしやすいってことです。
Symfony2の開発者であるFabien氏もSymfony2を使って独自のフレームワークの作り方みたいな記事を書いていますしね。
(参照: http://fabien.potencier.org/article/50/create-your-own-framework-on-top-of-the-symfony2-components-part-1


コンポーネントが再利用しやすいと、フレームワークを使うまでもないときでも特定のコンポーネントだけ使うこともできますし、
Fabien氏の記事の内容からもコンポーネントだけ再利用してオレオレフレームワークを作ることだって出来ます。

これだけ聞くとZend Frameworkと似てる気がするけど、実際は似て非なるものだと思う。
確かに設計思想は似ているけど、フルスタックフレームワークとして考えるとSymfony2のほうが遥かに高機能。
個人的にはどちらかというと、Zend Frameworkはフレームワークというよりコンポーネント群な気がする。


OOPな考え方的にオブジェクトの独立性は高いに越したことはないと思っています。
コンポーネント疎結合度の低いフレームワークは色々な縛りが大きくプラグインなどの作成も面倒だったりすることもあります。
そういう意味ではZend Frameworkは良い設計思想だと思います。


ようは何が言いたいかというとみんなSymfony2やればいいよってことです。

Symfony2で複雑なフォームの実装について

Symfony2で少し複雑なフォームを作る際のメモ書き。

例として1つの商品は複数のカテゴリに属する。
要件としては管理画面の商品の設定画面にて、複数のカテゴリを設定することとする。
更に設定したカテゴリにはついでに何かメモ書きを設定出来ることとする。

各Entityは以下のように実装する。

Product.php (商品)

<?php
// ...
class Product
{
    /**
     * @var integer $id
     *
     * @ORM\Column(name="product_id", type="integer", nullable=false)
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    private $id;

    /**
     * @var string $productName
     *
     * @ORM\Column(name="product_name", type="string", length="255", nullable=true)
     */
    private $productName;

    /**
     * @var string $amount
     *
     * @ORM\Column(name="amount", type="integer", nullable=false)
     */
    private $amount;

    /**
     * @var ArrayCollection $productCategories
     *
     * @ORM\OneToMany(targetEntity="ProductCategory", mappedBy="product", cascade={"persist", "remove"})
     */
    private $productCategories;


    public function __construct()
    {
        $this->productCategories = new \Doctrine\Common\Collections\ArrayCollection();
    }

    public function getId()
    {
        return $this->id;
    }

    public function setProductName($productName)
    {
        $this->productName = $productName;
    }

    public function getProductName()
    {
        return $this->productName;
    }

    public function setAmount($amount)
    {
        $this->amount = $amount;
    }

    public function getAmount()
    {
        return $this->amount;
    }

    public function addProductCategory(Category $category)
    {
        $this->productCategories[] = $category;
    }

    public function getProductCategories()
    {
        return $this->productCategories;
    }
}

Category.php (カテゴリ)

<?php
// ...
class Category
{
    /**
     * @var integer $id
     *
     * @ORM\Column(name="category_id", type="integer", nullable=false)
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    private $id;

    /**
     * @var string $categoryName
     *
     * @ORM\Column(name="category_name", type="string", length="100", nullable=true)
     */
    private $categoryName;


    public function __toString()
    {
        return $this->getCategoryName();
    }

    public function getId()
    {
        return $this->id;
    }

    public function setCategoryName($categoryName)
    {
        $this->categoryName = $categoryName;
    }

    public function getCategoryName()
    {
        return $this->categoryName;
    }
}

ProductCategory.php (商品・カテゴリのリレーション)

<?php
// ...
class ProductCategory
{
    /**
     * @var integer $id
     *
     * @ORM\Column(name="product_category_id", type="integer", nullable=false)
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    private $id;

    /**
     * @var Product $product
     *
     * @ORM\OneToOne(targetEntity="Product")
     * @ORM\JoinColumn(name="product_id", referencedColumnName="id")
     */
    private $product;

    /**
     * @var Category $category;
     *
     * @ORM\OneToOne(targetEntity="Category")
     * @ORM\JoinColumn(name="category_id", referencedColumnName="id")
     */
    private $category;

    /**
     * @var string $memo
     *
     * @ORM\Column(name="memo", type="string", nullable=true)
     */
    private $memo;


    public function getId()
    {
        return $this->id;
    }

    public function setProduct(Product $product)
    {
        $this->product = $product;
    }

    public function getProduct()
    {
        return $this->product;
    }

    public function setCategory(Category $category)
    {
        $this->category = $category;
    }

    public function getCategory()
    {
        return $this->category;
    }

    public function setMemo($memo)
    {
        $this->memo = $memo;
    }

    public function getMemo()
    {
        return $this->memo;
    }
}

で、次に商品登録用のフォームを作成する。
下記がその実装。

ProductType.php

<?php
namespace Sample\Bundle\DemoBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;

use Sample\Bundle\DemoBundle\Form\Type\ProductCategoryType;

class ProductType extends AbstractType
{
    public function buildForm(FormBuilder $builder, array $options)
    {
        $builder
            ->add('productName')
            ->add('amount')
            ->add('productCategories', 'collection', array(
                'type'         => new ProductCategoryType(),
                'allow_add'    => true,
                'allow_delete' => true,
                'prototype'    => true,
            ))
        ;
    }

    public function getName()
    {
        return 'product';
    }
}

呼び出しているProductCategoryTypeクラスは下記のとおり。

ProductCategoryType.php

<?php
namespace Sample\Bundle\DemoBundle\Form\Type\Edit;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;

class ProductCategoryType extends AbstractType
{
    public function buildForm(FormBuilder $builder, array $options)
    {
        $builder
            ->add('category', 'entity', array(
                'empty_value' => '選択してください',
                'class'       => 'Sample\\Bundle\\DemoBundle\\Entity\\Category',
            ))
            ->add('memo')
        ;
    }

    public function getDefaultOptions(array $options)
    {
        return array(
            'data_class' => 'Sample\Bundle\DemoBundle\Entity\ProductCategory',
        );
    }

    public function getName()
    {
        return 'productCategory';
    }
}

ここまで実装できたら後は普通にProductTypeを呼び出してフォームを作成するだけ。
Twig内では下記のようにしてProductCategoryのフォームを呼び出すことができる。

{# プロトタイプを取得 #}
{{ form_widget(form.productCategories.get('prototype').getChild('category')) }}
{{ form_widget(form.productCategories.get('prototype').getChild('memo')) }}

{# bindされているものを取り出す #}
{% for productCategory in form.productCategories %}
    {{ form_widget(productCategory.category) }}
    {{ form_widget(productCategory.memo) }}
{% endfor %}

後は自分でJavascriptの実装をするなりして複雑なフォームを実装していけた。