Entity Object + Value Object 実践中
現在、データベースのレコード単位で一意に識別されるデータを不変オブジェクトをベースにしたEntity Objectと、その属性となるValue Objectの設計を実践中ですが、やったこと&やっていることのメモです。
内容的には Traitで不変オブジェクトにしてキャッシュ可能なクラスを作る からの続きになります。
Entity Objectの利点
Entityではなく単なる連想配列として実装していた頃は、たとえば管理画面にログイン中のユーザーが一覧のうち自身の情報のみ編集できるとして、こんな感じで書いてました。(例はSmarty3 + Bootstrap2)
配列の場合
{{foreach $administrators as $administrator}} <tr> <td>{{$administrator.name}}</td> <td>{{$administrator.login_id}}</td> <td>{{$administrator.mailaddress}}</td> <td> {{if $administrator.id == $current.administrator.id}} <a href="/administrators/{{$administrator.id|escape:'url'}}?back={{$smarty.server.REQUEST_URI|escape:'url'}}" class="btn btn-primary"><span class="icon-edit"></span>編集</a> {{else}} <button type="button" class="btn disabled" disabled="disabled"><span class="icon-edit"></span>編集</button> {{/if}} </td> </tr> {{/foreach}}
上記の {{if $administrator.id == $current.administrator.id}}
の判定はこの画面固有の要件ではなく、「管理ユーザーは自身の情報のみ編集できる」という業務要件に基づくものなので、これを Administrator::isEditableBy() メソッドとして実装すると、こんな感じで書けるようになりました。(例はPHPTAL + Bootstrap3)
Entity Objectの場合
<tr tal:repeat="administrator administrators"> <td><span tal:replace="administrator/name">田中一郎</span></td> <td><span tal:replace="administrator/loginId">i_tanaka</span></td> <td><span tal:replace="administrator/mailaddress">tanaka@example.com</span></td> <td> <a tal:condition="php:administrator.isEditableBy(current.administrator)" href="/administrators/${urlencode:administrator/id}?back=${urlencode:server/REQUEST_URI}" class="btn btn-primary"><span class="glyphicon glyphicon-edit"></span> 編集</a> <button tal:condition="not:php:administrator.isEditableBy(current.administrator)" type="button" class="btn disabled"><span class="glyphicon glyphicon-edit"></span> 編集</button> </td> </tr>
もちろん、このような制御は単なるリンクの有効/無効だけではなく、ユースケース別のエントリスクリプトなりコントローラクラスなりで制御を行って適切なステータスコードを返す必要もあります。
そういった場合でも、たとえば管理者に権限プロパティが導入され「Root権限を持つ管理ユーザーは全てのユーザーの情報を編集できる」という仕様が追加された時、既存のコードに与える影響範囲を考えると、Entity Objectにメソッドとして実装する利点がよく分かります。
Value Objectの利点
先ほどの例と同じく単なる連想配列に日時の値が文字列でセットされており、これをテンプレートで出力する場合、こんな感じで書いていました。(例はSmarty3)
文字列の場合
{{foreach $administrators as $administrator}} <td>{{$administrator.updated_at|date_format:$config.dateTimeFormat}}</td> </tr> {{/foreach}}
これを DateTime Value Object(DateTime のラッパークラス)に __toString() メソッドを用意し、設定値 timezone と format に基いて DateTime::format() の結果を返すよう実装することで、こんな感じで書けるようになりました。(例はPHPTAL)
Value Objectの場合
<tr tal:repeat="administrator administrators"> <td><span tal:replace="administrator/createdAt">2014-10-07 00:00:00</span></td> </tr>
出力対象がオブジェクトの場合は toString() を呼んでくれるというPHPTALテンプレートエンジンの機能によって、こんなシンプルに書けるわけです。
もちろん、DateTime Value ObjectはAdministrator Entityのプロパティだけではなく、日時を扱うあらゆる箇所で利用できるものです。
たとえば日時の書式を一括して変更することになった場合、上記Smartyテンプレートの例でも $config.dateTimeFormat
に集約はされていますが、データと書式の依存はテンプレートファイルの記述のみとなるため、データ毎にこの出力書式を変えたい(秒単位は不要、など)となると、なかなか大変な作業になりそうです。
これをEntityのプロパティとして実装しておくことで、Entity毎にデフォルトの設定を持つことも可能になるわけです。
あるいは、現在の日時を元に経過時間を表示するようなケースでも、EntityのプロパティなりValue Objectの設定に現在日時を追加することで、値を出力する側のコードではなく値自体に処理を任せることができます。
(なお、本当はDateTime Value Objectには組み込みインタフェースの DateTimeInterface を実装したかったのですが、PHP 5.5.7 → 5.5.10 に更新したタイミングで Fatal error: DateTimeInterface can't be implemented by user classes in...
というエラーが発生するようになりました…。)
Value Objectの導入によって、Entityクラスの実装もよりシンプルに書けるようになります。
また、Value Objectを充実していった結果、ごった煮状態になっていたいわゆるビューヘルパーの実装が軽減されるという効果もありました。
ソース
ここからソースです。
いずれも、以前の記事 Traitで不変オブジェクトにしてキャッシュ可能なクラスを作る で作成したものがベースになってます。
EntityInterface
Entityクラスのインタフェース実装は、以前作成した DataInterface がベースになってます。
<?php /** * エンティティオブジェクト * * @copyright k-holy <k.holy74@gmail.com> * @license The MIT License (MIT) */ namespace Acme\Domain\Entity; /** * EntityInterface * * @author k.holy74@gmail.com */ interface EntityInterface { /** * このオブジェクトを配列に変換して返します。 * * @return array */ public function toArray(); /** * __isset * * @param mixed * @return bool */ public function __isset($name); /** * __get * * @param mixed */ public function __get($name); /** * __clone for clone */ public function __clone(); /** * __sleep for serialize() * * @return array */ public function __sleep(); /** * __set_state for var_export() * * @param array * @return object */ public static function __set_state($properties); /** * ArrayAccess::offsetExists() * * @param mixed * @return bool */ public function offsetExists($name); /** * ArrayAccess::offsetGet() * * @param mixed * @return mixed * @throws \InvalidArgumentException */ public function offsetGet($name); /** * IteratorAggregate::getIterator() * * @return \ArrayIterator */ public function getIterator(); }
EntityTrait
EntityクラスのTrait実装も同様に、以前作成した DataTrait がベースになってます。
コードはほぼプロパティアクセスに関するもので、プロパティを書き換え禁止にするためにマジックメソッドを使ってます。
initialize() で set**() メソッドがあれば呼んでいますが、setterメソッドをタイプヒンティング指定で定義することで、プロパティの型を保証するためです。
toArray() にはプロパティにEntity Objectを持つ、いわゆる OneToOne のリレーションを配列として返すための処理を入れています。
<?php /** * エンティティオブジェクト * * @copyright k-holy <k.holy74@gmail.com> * @license The MIT License (MIT) */ namespace Acme\Domain\Entity; use Acme\Domain\Entity\EntityInterface; /** * EntityTrait * * @author k.holy74@gmail.com */ trait EntityTrait { /** * __construct() * * @param array プロパティの配列 */ public function __construct(array $properties = array()) { $this->initialize($properties); } /** * データを初期化します。 * * @param array プロパティの配列 */ private function initialize(array $properties = array()) { foreach (array_keys(get_object_vars($this)) as $name) { $this->{$name} = null; if (array_key_exists($name, $properties)) { $value = (is_object($properties[$name])) ? clone $properties[$name] : $properties[$name]; if (method_exists($this, 'set' . $name)) { $this->{'set' . $name}($value); } else { $this->{$name} = $value; } unset($properties[$name]); } } if (count($properties) !== 0) { throw new \InvalidArgumentException( sprintf('Not supported properties [%s]', implode(',', array_keys($properties)) ) ); } return $this; } /** * このオブジェクトを配列に変換して返します。 * * @return array */ public function toArray() { $values = array(); foreach (array_keys(get_object_vars($this)) as $name) { $value = $this->__get($name); $values[$name] = ($value instanceof EntityInterface) ? $value->toArray() : $value; } return $values; } /** * __isset * * @param mixed * @return bool */ public function __isset($name) { return property_exists($this, $name); } /** * __get * * @param mixed * @return mixed * @throws \InvalidArgumentException */ public function __get($name) { if (method_exists($this, 'get' . $name)) { return $this->{'get' . $name}(); } if (!property_exists($this, $name)) { throw new \InvalidArgumentException( sprintf('The property "%s" does not exists.', $name) ); } return $this->{$name}; } /** * __clone for clone */ public function __clone() { foreach (get_object_vars($this) as $name => $value) { if (is_object($value)) { $this->{$name} = clone $value; } } } /** * __sleep for serialize() * * @return array */ public function __sleep() { return array_keys(get_object_vars($this)); } /** * __set_state for var_export() * * @param array * @return object */ public static function __set_state($properties) { return new self($properties); } /** * ArrayAccess::offsetExists() * * @param mixed * @return bool */ public function offsetExists($name) { return $this->__isset($name); } /** * ArrayAccess::offsetGet() * * @param mixed * @return mixed * @throws \InvalidArgumentException */ public function offsetGet($name) { return $this->__get($name); } /** * IteratorAggregate::getIterator() * * @return \ArrayIterator */ public function getIterator() { return new \ArrayIterator(get_object_vars($this)); } /** * __set * * @param mixed * @param mixed * @throws \LogicException */ final public function __set($name, $value) { throw new \LogicException( sprintf('The property "%s" could not set.', $name) ); } /** * __unset * * @param mixed * @throws \LogicException */ final public function __unset($name) { throw new \LogicException( sprintf('The property "%s" could not unset.', $name) ); } /** * ArrayAccess::offsetSet() * * @param mixed * @param mixed * @throws \LogicException */ public function offsetSet($name, $value) { throw new \LogicException( sprintf('The property "%s" could not set.', $name) ); } /** * ArrayAccess::offsetUnset() * * @param mixed * @throws \LogicException */ public function offsetUnset($name) { throw new \LogicException( sprintf('The property "%s" could not unset.', $name) ); } }
他にも、Traitが使えないPHP5.3系の環境のための AbstractEntity クラスも用意してますが、中身はほぼTraitと一緒(一部のprivateメソッド/プロパティがprotectedに変わるだけ)なので省略。
(ホスティングサーバとか難しいのは分かるけど、早く5.5系を選択できるようにして欲しい…。)
ValueInterface
Value Objectのインタフェース実装もDataInterfaceがベースでプロパティアクセスの部分はEntityInterfaceと一緒ですが、toArray() がありません。
また、素の値を返すための getValue()メソッドと、値の文字列表現を返す __toString() を定義しています。
<?php /** * バリューオブジェクト * * @copyright k-holy <k.holy74@gmail.com> * @license The MIT License (MIT) */ namespace Acme\Domain\Value; /** * ValueInterface * * @author k.holy74@gmail.com */ interface ValueInterface { /** * このオブジェクトの素の値を返します。 * * @return mixed */ public function getValue(); /** * __toString * * @return string */ public function __toString(); /** * __isset * * @param mixed * @return bool */ public function __isset($name); /** * __get * * @param mixed */ public function __get($name); /** * __clone for clone */ public function __clone(); /** * __sleep for serialize() * * @return array */ public function __sleep(); /** * __set_state for var_export() * * @param array * @return object */ public static function __set_state($properties); /** * ArrayAccess::offsetExists() * * @param mixed * @return bool */ public function offsetExists($name); /** * ArrayAccess::offsetGet() * * @param mixed * @return mixed * @throws \InvalidArgumentException */ public function offsetGet($name); }
ValueTrait
こちらもEntityTraitと似てますが、コンストラクタの第1引数をこのオブジェクトの値、第2引数をオプション設定としています。
ただし、どちらも同列のプロパティとして実装することで、Entityと同様の書き換え禁止や型の保証を容易にしています。
var_export()関数で呼ばれるマジックメソッド __set_state() はコンストラクタの仕様に合わせて少し分かりづらいことをしてます。
<?php /** * バリューオブジェクト * * @copyright k-holy <k.holy74@gmail.com> * @license The MIT License (MIT) */ namespace Acme\Domain\Value; use Acme\Domain\Value\ValueInterface; /** * ValueTrait * * @author k.holy74@gmail.com */ trait ValueTrait { /** * __construct() * * @param mixed 値 * @param array オプション */ public function __construct($value = null, array $options = array()) { $this->initialize($value, $options); } /** * データを初期化します。 * * @param mixed 値 * @param array オプション */ private function initialize($value = null, array $options = array()) { foreach (array_keys(get_object_vars($this)) as $name) { $this->{$name} = null; if (array_key_exists($name, $options)) { $option = (is_object($options[$name])) ? clone $options[$name] : $options[$name]; if (method_exists($this, 'set' . $name)) { $this->{'set' . $name}($option); } else { $this->{$name} = $option; } unset($options[$name]); } } if (count($options) !== 0) { throw new \InvalidArgumentException( sprintf('Not supported properties [%s]', implode(',', array_keys($options)) ) ); } $this->value = (is_object($value)) ? clone $value : $value; return $this; } /** * __isset * * @param mixed * @return bool */ public function __isset($name) { return property_exists($this, $name); } /** * __get * * @param mixed * @return mixed * @throws \InvalidArgumentException */ public function __get($name) { if (method_exists($this, 'get' . $name)) { return $this->{'get' . $name}(); } if (!property_exists($this, $name)) { throw new \InvalidArgumentException( sprintf('The property "%s" does not exists.', $name) ); } return $this->{$name}; } /** * __clone for clone */ public function __clone() { foreach (get_object_vars($this) as $name => $value) { if (is_object($value)) { $this->{$name} = clone $value; } } } /** * __sleep for serialize() * * @return array */ public function __sleep() { return array_keys(get_object_vars($this)); } /** * __set_state for var_export() * * @param array * @return object */ public static function __set_state($options) { $value = null; if (isset($options['value'])) { $value = $options['value']; unset($options['value']); } return new static($value, $options); } /** * ArrayAccess::offsetExists() * * @param mixed * @return bool */ public function offsetExists($name) { return $this->__isset($name); } /** * ArrayAccess::offsetGet() * * @param mixed * @return mixed * @throws \InvalidArgumentException */ public function offsetGet($name) { return $this->__get($name); } /** * __set * * @param mixed * @param mixed * @throws \LogicException */ final public function __set($name, $value) { throw new \LogicException( sprintf('The property "%s" could not set.', $name) ); } /** * __unset * * @param mixed * @throws \LogicException */ final public function __unset($name) { throw new \LogicException( sprintf('The property "%s" could not unset.', $name) ); } /** * ArrayAccess::offsetSet() * * @param mixed * @param mixed * @throws \LogicException */ public function offsetSet($name, $value) { throw new \LogicException( sprintf('The property "%s" could not set.', $name) ); } /** * ArrayAccess::offsetUnset() * * @param mixed * @throws \LogicException */ public function offsetUnset($name) { throw new \LogicException( sprintf('The property "%s" could not unset.', $name) ); } }
DateTimeバリューオブジェクト
コンストラクタで値として文字列と DateTimeInterface 実装オブジェクト(前述の通りこのインタフェースはユーザー定義クラスに実装できないので、実質的には DateTime または DateTimeImmutable)を受け付けて、文字列の場合は強制的に DateTime に変換しています。
オプション引数にはタイムゾーンおよびデフォルトの出力書式を指定できますが、タイムゾーンの指定がなければ date_default_timezone_get() で強制的にセットしています。
わざわざラッパークラスで DateTimeZone を扱っている理由は、DateTime::__construct() でUnixタイムスタンプを引数に指定した場合、なぜかタイムゾーン指定が無視されて強制的にUTCにされてしまうという変な制限を回避するためです。
メソッドはマジックメソッド toString() と call() の他、DateTime::format() の抽象化メソッドを定義しています。
あと希望としてはぜひ Comparable を実装したいところですが、そこはPHP本体の対応待ちでしょうか…。
<?php /** * バリューオブジェクト * * @copyright k-holy <k.holy74@gmail.com> * @license The MIT License (MIT) */ namespace Acme\Domain\Value; use Acme\Domain\Value\ValueInterface; /** * 日時 * * @author k.holy74@gmail.com */ class DateTime implements ValueInterface, \ArrayAccess { use \Acme\Domain\Value\ValueTrait; /** * @var \DateTime */ private $value; /** * @var \DateTimeZone タイムゾーン */ private $timezone; /** * @var string 出力用書式 */ private $format; /** * __construct() * * @param mixed 値 * @param array オプション */ public function __construct($value = null, array $options = array()) { if ($value === null) { $value = new \DateTime(); } else { if ($value instanceof \DateTimeInterface) { if (!isset($options['timezone'])) { $options['timezone'] = $value->getTimezone(); } } elseif (is_int($value) || ctype_digit($value)) { $value = new \DateTime(sprintf('@%d', $value)); } elseif (is_string($value)) { $value = new \DateTime($value); } if (false === ($value instanceof \DateTimeInterface)) { throw new \InvalidArgumentException( sprintf('Invalid type:%s', (is_object($value)) ? get_class($value) : gettype($value) ) ); } } if (!isset($options['timezone'])) { $options['timezone'] = new \DateTimeZone(date_default_timezone_get()); } else { if (is_string($options['timezone'])) { $options['timezone'] = new \DateTimeZone($options['timezone']); } if (false === ($options['timezone'] instanceof \DateTimeZone)) { throw new \InvalidArgumentException( sprintf('Invalid type:%s', (is_object($options['timezone'])) ? get_class($options['timezone']) : gettype($options['timezone']) ) ); } } if ($value instanceof \DateTimeImmutable) { $value = $value->setTimezone($options['timezone']); } else { $value->setTimezone($options['timezone']); } if (!isset($options['format'])) { $options['format'] = 'Y-m-d H:i:s'; } $this->initialize($value, $options); } /** * このオブジェクトの素の値を返します。 * * @return mixed */ public function getValue() { return $this->value; } /** * 現在の日時をデフォルトの書式文字列で返します。 * * @return string */ public function __toString() { return $this->value->format($this->format); } /** * __call * * @param string * @param array */ public function __call($name, $args) { if (method_exists($this->value, $name)) { return call_user_func_array(array($this->value, $name), $args); } throw new \BadMethodCallException( sprintf('Undefined Method "%s" called.', $name) ); } /** * タイムゾーンをセットします。 * * @param mixed */ private function setTimezone(\DateTimeZone $timezone) { $this->timezone = $timezone; } /** * 日時の出力用書式をセットします。 * * @param string */ private function setFormat($format) { $this->format = $format; } /** * 現在の年を数値で返します。 * * @return int 年 (4桁) */ public function getYear() { return (int)$this->value->format('Y'); } /** * 現在の月を数値で返します。 * * @return int 月 (0-59) */ public function getMonth() { return (int)$this->value->format('m'); } /** * 現在の日を数値で返します。 * * @return int 日 (1-31) */ public function getDay() { return (int)$this->value->format('d'); } /** * 現在の時を数値で返します。 * * @return int 時 (0-23) */ public function getHour() { return (int)$this->value->format('H'); } /** * 現在の分を数値で返します。 * * @return int 分 (0-59) */ public function getMinute() { return (int)$this->value->format('i'); } /** * 現在の秒を数値で返します。 * * @return int 秒 (0-59) */ public function getSecond() { return (int)$this->value->format('s'); } /** * UnixTimeを返します。 * * @return int */ public function getTimestamp() { return (int)$this->value->format('U'); } /** * DateTimeを返します。 * * @return \DateTime */ public function getDatetime() { return $this->value; } /** * 現在の月の日数を数値で返します。 * * @return int 日 (28-31) */ public function getLastday() { return (int)$this->value->format('t'); } }
Value Objectの設計はとっつきやすい
オブジェクト指向の知識に乏しくパターンとかようわからん自分ですが、Value Objectは文字列のままだと不便に感じた値をオブジェクト化して便利メソッドを実装するだけなので、何も難しいことはありません。
Entity Objectが多くの場合、プロジェクト個別の業務要件を含むのに対して、Value Objectはそうではないことが多いので再利用しやすいし、依存関係が複雑にはならないはずなのでテストも書きやすい。
即効性があり、あるべき処理があるべき所に凝集され、ビューヘルパーみたいなユーティリティクラスがペラペラになっていく、テンプレートが書きやすくなるのも気持ちが良いです。
先日は、アクセスログのユーザーエージェントを整形して表示するために woothee/woothee-php のパース結果をプロパティに持つ UserAgent クラスを作成したら、とても楽になりました。
データベース等の外部リソースから取得した文字列をValue Objectに変換するためには、データアクセス層に手を入れる必要がありますが、素のPDOで何とかしようという方には、こちらの記事も参考になるでしょうか。
そもそもValue Objectの導入に至ったのは、この記事でEntity(記事中では\Acme\Domain\Data\Userクラス)にdateFormatとかdateTimeFormatといった設定を持たせており、DateTimeImmutableなbirthdayやcreatedAtといったプロパティの出力がこれに依存していたことへの違和感が発端でした。
ドメイン駆動設計とかまだ全然ピンときてないんですが(訳本も買ってないし…)、Entity Object + Value Object という構成でビジネスロジックがかなり整理できてきた感触はあります。
あと、PHPTALが結構オブジェクト強制ギプス的な感じで役立ってます。tal:condition="php:foo.bar == 'foo'"
みたいな記述をいかに減らしていくかという。
フォーム関連の動的な属性変更とか、まだまだ試行錯誤の最中ですが…。
参考記事
追記
2014年2月から開催されています「ドメイン駆動設計読書会@大阪」のレポート記事にはいつも学ばせていただいていますが、タイムリーなことに、先日行われた第8回ではエンティティと値オブジェクトがテーマだったようです。
なんか自分がこれに便乗して書いたみたいですが、本当に偶然でした。(この記事のドラフトは5月頃に書いてたものですし)