Symfony Advent Calendar 2014 11日目の記事です。 前の日は @iteman師匠 でした。

EntityListenerとは?

Doctrine2.4から導入された新機能です。 簡単に言うとDoctrine2のエンティティに対するイベントリスナーを、エンティティのクラス指定で適用できるというものです。

インストール・アップデート

composer.jsonでDoctrineとDoctrineBundleの依存バージョンを

"doctrine/orm" : "~2.4"
"doctrine/doctrine-bundle" : "~1.3"

のように指定してください。

使い方

<?php
namespace Acme\DemoBundle\Entity;

use Doctrine\ORM\Mappings as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="image_file")
 */
class ImageFile
{
    /**
     * @var User
     * @ORM\ManyToOne(targetEntity="\Acme\DemoBundle\Entity\User")
     */
    private $user;

    /**
     * @var string
     * @ORM\Column(type="string", length=255)
     */
    private $path;
}
<?php
namespace Acme\DemoBundle\EntityListener;

use Acme\DemoBundle\Entity\ImageFile;
use Acme\DemoBundle\Service\ImageFilePathFormatter;
use Doctrine\ORM\Event\LifecycleEventArgs;

class ImageFileListener
{
    /**
     * @var ImageFilePathFormatter
     */
    private $pathFormatter;

    /**
     * @param ImageFilePathFormatter $pathFormatter
     */
    public function __construct(ImageFilePathFormatter $pathFormatter)
    {
        $this->pathFormatter = $pathFormatter;
    }

    public function prePersist(ImageFile $imageFile, LifecycleEventArgs $event)
    {
        $imageFile->setPath($this->pathFormatter->format($imageFile->getUser()));
    }
}
# src/Acme/DemoBundle/Resources/config/services.yml
services:
    acme_demo.path_formatter:
        class: Acme\DemoBundle\Service\PathFormatter
        arguments:
          # ...
    acme_demo.imagefile_listener:
        class: Acme\DemoBundle\Entity\Listener\ImageFileListener
        arguments:
            - @acme_demo.path_formatter
        tags:
           - { name: doctrine.orm.entity_listener }

vs LifecycleCallback

EntityListenerと同様に、特定のエンティティクラスのpersistやupdate等をトリガーにして何かの処理を実行する仕組みとしては、LifecycleCallbackがあります。 http://symfony.com/doc/current/book/doctrine.html#lifecycle-callbacks

エンティティクラス自体の持っているデータに対して、ほんの少し変更を加える程度ならLifecycleCallbackでも良いでしょう。が、他のクラスやパラメーターの設定値を利用したいと思ったら、persistやremoveを実行する前に予め必要なものをセットしておかなければなりません。

<?php
$imageFile->setFilePathFormatter($pathFormatter); // 事前準備
$em->persist($imageFile);

折角DB操作をトリガーにしてシームレスに実行してくれる機能なのに、これではありがたさが半減してしまいます。(複数の画面やコマンドから$imageFileの保存処理を行う場合、どこかで事前準備の行を忘れたことでバグになる恐れもありますね。)

vs Doctrine EventListener, Doctrine EventSubscriber

もう一つ、以前から存在していたDoctrineのEventListenerやEventSubscriberも、同様にエンティティの保存時や削除時に追加の処理を実行するための機能です。

EventListenerとEventSubscriberがEntityListenerと大きく違っている点は、どのエンティティクラスに対する処理でもlistenするという点です。 EventListenerと特定のエンティティクラスのみに適用したい場合は、ListenerやSubscriber側で処理開始前にエンティティのクラス名で対象になるクラスかどうかを調べる必要がありました。

<?php
namespace Acme\DemoBundle\EventListener;

use Acme\DemoBundle\Entity\ImageFile;
use Acme\DemoBundle\Service\ImageFilePathFormatter;
use Doctrine\ORM\Event\LifecycleEventArgs;

class ImageFileListener
{
    /**
     * @var ImageFilePathFormatter
     */
    private $pathFormatter;

    /**
     * @param ImageFilePathFormatter $pathFormatter
     */
    public function __construct(ImageFilePathFormatter $pathFormatter)
    {
        $this->pathFormatter = $pathFormatter;
    }

    public function prePersist(LifecycleEventArgs $args)
    {
        $entity = $args->getEntity();

        if ($entity instanceOf ImageFile) {
            $imageFile->setPath($this->pathFormatter->format($entity->getUser()));
        }
    }
}

$entity instanceOf ImageFile を書かなければならない上に、エンティティクラスの数が多いアプリケーションではパフォーマンス面も心配になってきますね。全エンティティクラスに共通の処理を書くには良いのですが、特定のエンティティクラスだけに適用したい処理を書くには向いていないようです。

まとめ

EntityListenerは従来のLifecycleCallbacksに対する不満(他のクラスや設定が使いにくい)と、Doctrine EventListener・EventSubscriberに対する不満(全てのエンティティクラスについてlistenしてしまう)を両方解消した、とても便利な機能です。 特にEntityがfatになって悩んでいる方は、ぜひお試し下さい。

明日は @issei-m さんです。あまり聞いたことがないバンドルについての記事になる模様です。楽しみですね!