[Symfony] DoctrineのEmbeddableでValueObjectにもスキーマを持たせる
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スキーマに展開することができます。
先ほどの Person
と Address
の例を 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にもスキーマを持たせるとよい
というお話でした。
Discussion