🎻

[Symfony] DoctrineのEmbeddableでValueObjectにもスキーマを持たせる

2020/06/02に公開

ValueObjectとは

DDDの文脈におけるValueObject(値オブジェクト)の正しい定義は 『エリック・エヴァンスのドメイン駆動設計』 などをご参照ください🙏

ここでは、

  • 値と言っても「整数」や「文字列」のようなプリミティブな値ではなく、「貨幣」や「住所」のようにスキーマを持っていてプログラム上でオブジェクトとして表現されるような値

というぐらいの意味でValueObjectという言葉を使います🙏

DoctrineでValueObjectを扱う方法

よくない例: シリアライズして1カラムにぶっ込む

例えば以下のような Person エンティティを考えます。 Person$address という Address クラス型のプロパティを持っています。

/**
 * @ORM\Entity(repositoryClass="PersonRepository")
 */
class Person
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $name;
    
    /**
     * @ORM\Column(type="string", length=255)
     */
    private $email;
    
    /**
     * @var Address
     *
     * @ORM\Column(type="object")
     */
    private $address;
    
    // ...
}
class Address
{
    /**
     * @var string
     */
    private $zipCode;
    
    /**
     * @var string
     */
    private $prefecture;
    
    /**
     * @var string
     */
    private $city;
    
    /**
     * @var string
     */
    private $line;
    
    // ...
}

Person クラスの以下の箇所で、 $address プロパティのDBAL Typeとして object を指定しています。

    /**
     * @var Address
     *
     * @ORM\Column(type="object")
     */
    private $address;

これにより、 $address プロパティは

  • DB上ではオブジェクトをシリアライズした文字列として保存され
  • プログラム上ではデシリアライズされて Address クラスのオブジェクトとして利用できる

ようになります。

具体的には、Doctrineが生成する person テーブルのスキーマは以下のようになります。

カラム名
id int
name varchar(255)
email varchar(255)
address longtext

longtext にオブジェクトのシリアライズ結果の文字列をドカッと保存する感じですね。

object の代わりに json を使っても、シリアライズの方式が違うだけで同じような結果が得られます。

よくない点

この方法だと、先に述べたとおりDB上では1カラムに文字列をドカッと保存しているだけなので、 正常でない値が保存され得る という点でとても不安です。

例えばプログラムにミスがあって Address クラスのオブジェクト以外のものを $address に入れてしまっていても、DB的には何のエラーにもならずに保存できてしまいますよね。

「プロパティをprivateにしてsetterの引数型宣言を Address にしておけばいいだけなんだから、ミスしようがないじゃん」

という声が聞こえてきそうですが、その過信はいつか命取りになります笑

やはり 「DBに間違ったものを入れることは物理的にできない」 ようになっているに越したことはないでしょう。

よい例:Embeddableを使ってValueObjectをDBスキーマに展開する

実はDoctrineにはValueObjectを扱うための Embeddable という機能が用意されています。

これを使えば、ValueObjectをDBスキーマに展開することができます。

先ほどの PersonAddress の例を Embedded を使って書き直すと以下のようになります。

/**
 * @ORM\Entity(repositoryClass="PersonRepository")
 */
class Person
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $name;
    
    /**
     * @ORM\Column(type="string", length=255)
     */
    private $email;
    
    /**
     * @var Address
     *
+    * @ORM\Embedded(class="Address")
-    * @ORM\Column(type="object")
     */
    private $address;
    
    // ...
/**
 * @ORM\Embeddable()
 */
class Address
{
    /**
     * @ORM\Column(type="string", length=255)
     */
    private $zipCode;
    
    /**
     * @ORM\Column(type="string", length=255)
     */
    private $prefecture;
    
    /**
     * @ORM\Column(type="string", length=255)
     */
    private $city;
    
    /**
     * @ORM\Column(type="string", length=255)
     */
    private $line;
    
    // ...

こうしておくと、 person テーブルのスキーマは以下のようになります。

カラム名
id int
name varchar(255)
email varchar(255)
address_zip_code varchar(255)
address_prefecture varchar(255)
address_city varchar(255)
address_line varchar(255)

Address クラスの各プロパティがテーブルのカラムとして独立し、 Address クラスの構造が明確にDBスキーマに反映されていますね。

これなら間違った構造で値を保存することが物理的に不可能なので、とても安心感があります👍

ついでにマイグレーションもしやすい

Embeddable を使っておくとマイグレーションもしやすいです。

例えば Address クラスの構造を

- private $line;
+ private $line1;
+ private $line2;

のように変更したくなったとき、シリアライズ文字列を longtext に放り込む方法だと、マイグレーションスクリプトにおいて

  • adderss カラムの中身をPHPで unserialize() して
  • 新しい構造の Address クラスに対応するよう整形して
  • 再び serialize() して保存する

ということを全レコードに対して行う必要があるでしょう。

単純にこのスクリプトを書くことが面倒ですし、スクリプトの実装にミスが入り込む余地が大いにあってこれも怖いです。

Embeddable を使ってスキーマを持たせておけば、このようなケースでも単純に

/**
 * @ORM\Embeddable()
 */
class Address
{
    /**
     * @ORM\Column(type="string", length=255)
     */
    private $zipCode;
    
    /**
     * @ORM\Column(type="string", length=255)
     */
    private $prefecture;
    
    /**
     * @ORM\Column(type="string", length=255)
     */
    private $city;
    
    /**
     * @ORM\Column(type="string", length=255)
     */
-   private $line;
+   private $line1;

+   /**
+    * @ORM\Column(type="string", length=255)
+    */
+   private $line2;

    // ...

とコードを変更して

$ bin/console doctrine:migrations:diff

を実行するだけで適切なマイグレーションスクリプトが自動生成できて楽ですし、マイグレーションの内容もPHPスクリプトを使う必要はなくSQL文の実行だけで済むので、ミスが入り込む余地がなく安心です👍

まとめ

というわけで、

  • Symfony(Doctrine)でValueObjectを扱いたいときは、Embeddable を使ってValueObjectにもスキーマを持たせるとよい

というお話でした。

GitHubで編集を提案

Discussion