はじめに

前回の記事では、Symfony 3.2で継承関係のあるエンティティをDoctrineのSingle Table Inheritanceを使って実装し、テスト時にYAMLのフィクスチャとしてエンティティのテストデータを用意するサンプルを紹介しました。

前回の記事の時点でのエンティティクラスは、下図のようになっています。

前回時点のエンティティクラス図

今回は、配送時期種別を種別(即納 Instant と見積 Estimation)それぞれ別のクラスにします。アプリケーションコード側ではこれらのクラスのインスタンスで扱い、DBにはこれまで通り文字列で保存を行います。この変換にDoctrineのEntityListenerを使います。クラスは以下のように変わります。

今回の実装後のエンティティクラス図

実装を行ったバージョンのソースコードは、以下から参照してください。

利用バージョン

記事執筆時点では、以下のバージョンになっています。

  • Symfony 3.2.0
  • Doctrine ORM 2.5.5

Embeddableではダメなのか?

DoctrineにはValueObjectを実現するためのEmbeddableの機能があり、前回のサンプル実装でも ItemPrice にこの機能を利用していました。商品の価格を Item エンティティから切り出して、価格の値を中心に、それに強く関係のあるメソッドを1箇所にまとめるには、Embeddableを利用したValueObjectが上手く当てはまります。

今回実装しようとしている例では、配送時期種別の値(instantestimation)は単なるラベルであり、この値自身が重要な役割を持っているわけではありません。より重きがあるのは、種別によって異なる計算ロジックの方です。このような種別のバリエーションを実装する場合は、クラス図に示したように1つのインターフェイスを共有する複数のクラスによって表現することがあります(※1)。

今回示した例のように種別が2つくらいだと、ItemPrice と同じように1つのValueObjectクラスにしておいて、その中で分岐させる方が労力が少なそうに思えるかもしれません。しかし、種別の数が多い場合は手に負えないコードになってしまいますね。また、そのような煩雑さと別としても、種別を個々にクラスにしておくことで、今回取り扱っている配送時期というポイントとは異なる振舞のインターフェイスをミックスするといった表現への道も開けます(※2)。

Docrineではインターフェイスを介した複数のクラスをEmbeddableとして扱うことができません。幸いDoctrineにはEntityListenerがあり、これを使うことで、今回のような高度なニーズにも対応できます。

(※1) PHPには表現方法の選択肢が多くないためこうなりますが、別の言語では事情は異なります。

(※2) 書籍『ジェネレーティブプログラミング』ではジェネレーティブドメインモデルとして、ベースとなるフィーチャの組み合わせを定義してドメインモデルを生成するアプローチが紹介されています。

実装例

EntityListenerを使うと決まれば、実装はさほど難しくはありません。方針としては次のようになります。

  • DBには(前回の記事での実装と同様に)文字列で配送時期種別を保存する
  • プログラムコード内でエンティティを取り出したら、DeliveryTimeTypeInterfaceを実装したクラスのインスタンスに自動的に変換されているようにする

Item エンティティのEntityListenerから呼ばれて変換を行う ItemDeliveryTimeTypeTransformer を最初に実装しておきます。このクラス内で種別ごとのValueObjectのインスタンスを作ってしまっている点はやや手抜きになっています。

<?php
namespace AppBundle\Transformer;

use AppBundle\DeliveryTime\DeliveryTimeTypeInterface;
use AppBundle\DeliveryTime\Type\Estimation;
use AppBundle\DeliveryTime\Type\Instant;
use AppBundle\DeliveryTime\Type\Invalid;

class ItemDeliveryTimeTypeTransformer
{
    /**
     * @var array
     */
    private $map;

    public function __construct()
    {
        $this->map = [
            'instant' =>    new Instant(),
            'estimation' => new Estimation(),
        ];
    }

    /**
     * @param DeliveryTimeTypeInterface $deliveryTimeType
     * @return string
     */
    public function toDB(DeliveryTimeTypeInterface $deliveryTimeType)
    {
        return $deliveryTimeType->getType();
    }

    /**
     * @param string $type
     * @return DeliveryTimeTypeInterface
     */
    public function toModel($type)
    {
        if (!array_key_exists($type, $this->map)) {
            return new Invalid();
        }

        return $this->map[$type];
    }
}

変換処理では、正常な種別とそうではない場合(Invalid)とを区別して扱っています。正常ではない場合にすぐさま例外をスローする方針もありえます。しかし、この変換処理をSymfonyのFormなどと連携させる場合には、変換処理として一旦正しく完了した後に中身を判定してエラーにするという流れが必要になります。ここで安易に例外をスローすると、フレームワークの処理と馴染まなくなってしまいます。

このTransformerを利用する ItemListener は次のように、ほぼ定型のコードのみとなります。

<?php
namespace AppBundle\Entity\Listener;

use AppBundle\Transformer\ItemDeliveryTimeTypeTransformer;
use Doctrine\Common\Persistence\Event\LifecycleEventArgs;
use Doctrine\ORM\Mapping as ORM;
use AppBundle\Entity\Item;

class ItemListener
{
    /**
     * @var ItemDeliveryTimeTypeTransformer
     */
    private $transformer;

    public function __construct(ItemDeliveryTimeTypeTransformer $transformer)
    {
        $this->transformer = $transformer;
    }

    /**
     * @ORM\PostLoad()
     * @param Item $item
     * @param LifecycleEventArgs $event
     */
    public function postLoad(Item $item, LifecycleEventArgs $event)
    {
        $deliveryTimeType = $this->transformer->toModel($item->getDeliveryTimeType());
        $item->setDeliveryTimeType($deliveryTimeType);
    }

    /**
     * @ORM\PrePersist()
     * @param Item $item
     * @param LifecycleEventArgs $event
     */
    public function prePersistHandler(Item $item, LifecycleEventArgs $event)
    {
        $deliveryTimeType = $this->transformer->toDB($item->getDeliveryTimeType());
        $item->setDeliveryTimeType($deliveryTimeType);
    }

    /**
     * @ORM\PreUpdate()
     * @param Item $item
     * @param LifecycleEventArgs $event
     */
    public function preUpdateHandler(Item $item, LifecycleEventArgs $event)
    {
        $deliveryTimeType = $this->transformer->toDB($item->getDeliveryTimeType());
        $item->setDeliveryTimeType($deliveryTimeType);
    }
}

これらをサービスとして定義して、利用できるようにします(バンドル配下でYAMLのサービス定義を読み込むように、お決まりのエクステンションクラスなども追加します)。

services:
  app.entity.item_listener:
    class: AppBundle\Entity\Listener\ItemListener
    arguments:
      - '@app.transformer.item_delivery_time_type_transfomer'
    tags:
      - { name: doctrine.orm.entity_listener }

  app.transformer.item_delivery_time_type_transfomer:
    class: AppBundle\Transformer\ItemDeliveryTimeTypeTransformer

最後に Item エンティティクラスで ItemListener を有効にします。

 /**
  * Item
  *
  * @ORM\Table(name="item")
  * @ORM\Entity(repositoryClass="AppBundle\Repository\ItemRepository")
  * @ORM\DiscriminatorColumn(name="item_type", type="string")
  * @ORM\DiscriminatorMap({
  *   "item_abstract": "AppBundle\Entity\Item",
  *   "normal":        "AppBundle\Entity\Item\NormalItem",
  *   "custom_order":  "AppBundle\Entity\Item\CustomOrderItem",
  * })
  * @ORM\InheritanceType(value="SINGLE_TABLE")
+ * @ORM\EntityListeners({"AppBundle\Entity\Listener\ItemListener"})
  */
 abstract class Item
 {

あとは、DeliveryTimeクラス群を用意したら実装は完了です。

テストの修正

テストの側では、まずYAMLフィクスチャを修正する必要があります。配送時期種別のValueObjectをそれぞれ (local) で定義し、Itemのデータ定義ではそれらを参照するように書き換えます。


+AppBundle\DeliveryTime\Type\Estimation(local):
+  estimation1: ~
+AppBundle\DeliveryTime\Type\Instant(local):
+  instant1: ~

 AppBundle\Entity\Item\NormalItem:
   normal_item1:
     name: 商品1
     price: "@price1"
-    deliveryTimeType: instant
+    deliveryTimeType: "@instant1"
   normal_item2:
     name: 商品2
     price: "@price2"
-    deliveryTimeType: instant
+    deliveryTimeType: "@instant1"
   normal_item3:
     name: 商品3
     price: "@price3"
-    deliveryTimeType: estimation
+    deliveryTimeType: "@estimation1"

 AppBundle\Entity\Item\CustomOrderItem:
   custom_order_item1:
     name: カスタム商品1
     price: "@price4"
-    deliveryTimeType: estimation
+    deliveryTimeType: "@estimation1"
   custom_order_item2:
     name: カスタム商品2
     price: "@price5"
-    deliveryTimeType: estimation
+    deliveryTimeType: "@estimation1"

テストコードには、配送時期種別のクラスを表示するコードのみ追加しておきましょう。 (なお、フィクスチャを読み込んだエンティティマネージャを一旦強制的にクリアしているのは、エンティティ読み込み時の変換処理を明示的に動作させるためです)

<?php
namespace AppBundle\Functional\Entity;

use Doctrine\ORM\EntityManagerInterface;
use Liip\FunctionalTestBundle\Test\WebTestCase;
use AppBundle\Entity\Item;

class ItemTest extends WebTestCase
{
    public function test()
    {
        $this->loadFixtureFiles([
            __DIR__.'/../../Resources/fixtures/items.yml',
        ]);

        /** @var EntityManagerInterface $em */
        $em = static::getContainer()->get('doctrine')->getManager();
        $em->clear();

        $items = $em->getRepository(Item::class)->findAll();
        foreach ($items as $item) {
            echo get_class($item) . PHP_EOL;
            echo get_class($item->getDeliveryTimeType()) . PHP_EOL;
            echo sprintf('%s %s %s',
                    $item->getName(),
                    $item->getPrice(),
                    $item->getDeliveryTimeType()) . PHP_EOL;
        }
    }
}

プロジェクト全体のテストを実行すると、以下のようにテキストが出力されます。 テストコードでは、YAMLフィクスチャでDBに投入したデータを読み込んだものを表示しているだけですが、この段階で配送時期種別が文字列データではなく、各配送時期種別クラスのインタスンスになっていることが分かります。

$ vendor/bin/phpunit
PHPUnit 4.8.29 by Sebastian Bergmann and contributors.

.AppBundle\Entity\Item\CustomOrderItem
AppBundle\DeliveryTime\Type\Estimation
カスタム商品1 400 estimation
AppBundle\Entity\Item\CustomOrderItem
AppBundle\DeliveryTime\Type\Estimation
カスタム商品2 500 estimation
AppBundle\Entity\Item\NormalItem
AppBundle\DeliveryTime\Type\Instant
商品1 100 instant
AppBundle\Entity\Item\NormalItem
AppBundle\DeliveryTime\Type\Instant
商品2 200 instant
AppBundle\Entity\Item\NormalItem
AppBundle\DeliveryTime\Type\Estimation
商品3 300 estimation


Time: 446 ms, Memory: 12.00MB

おわりに

この記事では、単一のクラスではなく、複数のクラスを使って表現されたValueObject群をEntityListenerを使ってエンティティのフィールドにマッピングする方法を紹介しました。

次回は今回実装したValueObject群を持つエンティティに対して、WebAPIの入出力を行えるようにするためのSymfonyのFormの作り方を紹介します。