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の実装をするなりして複雑なフォームを実装していけた。

Symfony2のバリデーションについて

アプリケーションを開発していくうえでカスタムバリデーションを作るまでもないけど、
独自のバリデーションを追加したかったり、動的にバリデーションを行いたいことがよくある。
Symfony2ではどのように行うのかメモっておく。

例えばお問い合わせフォームなどで、利用規約の同意にチェックが入っていないと送信できないようにしたい。
この例の場合、フォームクラスは下記のようになる。

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

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

use Symfony\Component\Form\CallbackValidator;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormError;

class ContactType extends AbstractType
{
    public function buildForm(FormBuilder $builder, array $options)
    {
        $builder
            ->add('name', 'text')
            ->add('email', 'email')
            ->add('body', 'textarea')
            ->add('terms', 'checkbox', array('property_path' => false))
        ;

        $builder->addValidator(new CallbackValidator(function(FormInterface $form) {
            if (!$form['terms']->getData()) {
                $form['terms']->addError(new FormError('利用規約に同意してないと送信できないよ'));
            }
        }));
    }

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

termsのオプションでproperty_pathをfalseにすることによって、Entityクラスに依存しなくなる。
で、addValidatorにCallbackValidator経由でクロージャを登録することによって独自のバリデーションを行えるようになる。

コントローラに書くことも出来るけど、コントローラがあまり複雑になるのはよくないので、フォームクラスで簡潔させたほうがスマートだと思う。


【参考ページ】

  1. http://fivestar.hatenablog.com/entry/2011/12/06/013145
  2. http://www.richsage.co.uk/2011/07/20/adding-non-entity-fields-to-your-symfony2-forms/