k-holyのPHPとか諸々メモ

Webで働くk-holyがPHP(スクリプト言語)とか諸々のことをメモしていきます。ソースコードはだいたいWindowsで動かしてます。

Traitで不変オブジェクトにしてキャッシュ可能なクラスを作る

Qiitaに投稿した PHP Advent Calendar 2013 20日目の記事 PDOでオブジェクトをフェッチ&JSONとCSVファイル出力 の補足…というか、むしろ当初はこちらが本題の予定だったものです。

名著と評判の『Effective Java 第2版』を何年か前に読んで、不変オブジェクトという概念を知ったものの、当時は「ふーん、でもPHPには関係なさそう…」という感想で終わりました。

しかし、メインに使うテンプレートエンジンを Smarty3 から PHPTAL に変えるとともに、これまで全てただの連想配列で済ませていた、レイヤ間を横断するデータをオブジェクトに変えてからは、PHPでも不変オブジェクトを積極的に使った方が良いと感じるようになりました。

特にビビッときたのは増田亨さんの記事 いまさら聞けない「オブジェクト指向設計の3つのコツ」~オブジェクト指向設計問題解説 #objectoriented - CodeIQ Blog に書かれている「必要なデータはすべてオブジェクトに持たせておく」ための「完全コンストラクタ」という考え方です。

これまでは、クラスのプロパティを増やすことに抵抗があって、オブジェクトにはなるべく状態を持たせずデータは常にメソッドの引数に渡す方がいいと考えていたんですが、そもそもクラスが扱いづらくなる原因はレイヤ分割のレベルで設計が留まっていて、それ以上の抽象化を放棄していたからだと気付かされました。

(何を今更というレベルの話ですが、ユニットテストを書き、DIも意識しつつインタフェースを使うようになって、ようやく実感できました…)

そんな経緯でまずはここからと、PHP5.4から導入されたTraitを使って不変オブジェクトの実装を模索して、考えたことのメモです。

ドメインデータのインタフェースを定義する

データベースのレコード単位で一意に識別されるデータとして扱うことを想定したクラスとして、ドメインデータのインタフェースを定義します。

Acme\Domain\Data\DataInterface.php

<?php
/**
 * ドメインデータ
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */

namespace Acme\Domain\Data;

/**
 * DataInterface
 *
 * @author k.holy74@gmail.com
 */
interface DataInterface extends \IteratorAggregate
{

    /**
     * __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);

    /**
     * IteratorAggregate::getIterator()
     *
     * @return \ArrayIterator
     */
    public function getIterator();

}

データベース設計における「ドメイン」と同様に、データ型のレベルまで設計したオブジェクトが「バリューオブジェクト」という理解で良いのでしょうか?

そのレベルにまでなると IteratorAggregate::getIterator() などは不要ということになりそうですが、まずはこれを基本として実装してみました。

ドメインデータのTraitを実装する

データベースのレコード単位を扱うので、カラム即ちプロパティとします。

ここは PDOStatement からのオブジェクト生成や、将来的にはテーブルのメタデータを元にしたバリデーションを可能にすることも想定しています。

ただ、このクラス自体はあくまでデータなので、データベースに依存する状態やそれを利用するメソッドなどは持たせません。

Acme\Domain\Data\DataTrait.php

<?php
/**
 * ドメインデータ
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */

namespace Acme\Domain\Data;

/**
 * DataTrait
 *
 * @author k.holy74@gmail.com
 */
trait DataTrait
{

    /**
     * __construct()
     *
     * @param array プロパティの配列
     */
    public function __construct(array $properties = [])
    {
        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];
                $camelize = $this->camelize($name);
                if (method_exists($this, 'set' . $camelize)) {
                    $this->{'set' . $camelize}($value);
                } else {
                    $this->{$name} = $value;
                }
                unset($properties[$name]);
            }
        }
        if (count($properties) !== 0) {
            throw new \InvalidArgumentException(
                sprintf('Not supported properties [%s]',
                    implode(',', array_keys($properties))
                )
            );
        }
    }

    /**
     * __isset
     *
     * @param mixed
     * @return bool
     */
    public function __isset($name)
    {
        return (property_exists($this, $name) && $this->{$name} !== null);
    }

    /**
     * __get
     *
     * @param mixed
     * @throws \InvalidArgumentException
     */
    public function __get($name)
    {
        $camelize = $this->camelize($name);
        if (method_exists($this, 'get' . $camelize)) {
            return $this->{'get' . $camelize}();
        }
        if (!property_exists($this, $name)) {
            throw new \InvalidArgumentException(
                sprintf('The property "%s" does not exists.', $name)
            );
        }
        return $this->{$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)
        );
    }

    /**
     * __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);
    }

    /**
     * IteratorAggregate::getIterator()
     *
     * @return \ArrayIterator
     */
    public function getIterator()
    {
        return new \ArrayIterator(get_object_vars($this));
    }

    /**
     * @param string  $string
     * @return string
     */
    private function camelize($string)
    {
        return str_replace(' ', '', ucwords(str_replace('_', ' ', $string)));
    }

}

メソッドを抜粋して見ていきます。

Acme\Domain\Data\DataTrait::__construct()

<?php

public function __construct(array $properties = [])
{
    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];
            $camelize = $this->camelize($name);
            if (method_exists($this, 'set' . $camelize)) {
                $this->{'set' . $camelize}($value);
            } else {
                $this->{$name} = $value;
            }
            unset($properties[$name]);
        }
    }
    if (count($properties) !== 0) {
        throw new \InvalidArgumentException(
            sprintf('Not supported properties [%s]',
                implode(',', array_keys($properties))
            )
        );
    }
}

コンストラクタではオブジェクトが必要とする全てのプロパティ値を連想配列で受け取る「完全コンストラクタ」を目指しました。

ただし値として受け取るオブジェクトまでが不変かどうかは分からないので、オブジェクトはクローンしてセットする「防御的コピー」を行ってます。

※『Effective Java』ではサブクラス化可能な型のパラメータを防御的コピーする際に clone メソッドを使うことを禁じてますが、そこはPHPということで、多少緩めでも少ない手数で実装したいなと…。

camelize('property_name') で setPropertyName() メソッドがあればそれを呼ぶ仕様としているのは、ここは当初 PDOStatement のフェッチモード PDO::FETCH_CLASS + PDO::FETCH_PROPS_LATE の利用を想定していた名残で、実際のデータベース上ではアンダースコア区切りのカラム名となるため、やむを得ずこのような処理としました。

セッターメソッドは、値の変換や型の検証が必要な場合に限り、特別に定義するという想定です。

引数の連想配列を unset() したり count() しているのは、連想配列に無効なキーが含まれている場合に例外をスローするためです。

無効なキーを無視して有効なキーのみセットする緩めの仕様とすることも検討しましたが、やはりtypoが怖いので…。

Acme\Domain\Data\DataTrait::__isset()

<?php
public function __isset($name)
{
    return (property_exists($this, $name) && $this->{$name} !== null);
}

以前の記事 issetとemptyとoffsetExist で触れた ArrayAccess::offsetExists() と同様の isset() 問題がここで再来するわけですが、ここは自分の使い勝手を優先してこのような実装としました。

つまり $object->property = NULL として isset($object->undefinedProperty) はもちろん isset($object->property) も FALSE を返します。

Acme\Domain\Data\DataTrait::__get()

<?php

public function __get($name)
{
    $camelize = $this->camelize($name);
    if (method_exists($this, 'get' . $camelize)) {
        return $this->{'get' . $camelize}();
    }
    if (!property_exists($this, $name)) {
        throw new \InvalidArgumentException(
            sprintf('The property "%s" does not exists.', $name)
        );
    }
    return $this->{$name};
}

こちらもコンストラクタにおけるセッターメソッドと同様 camelize('property_name') の結果 getPropertyName() メソッドがあれば呼ばれます。

値を変換して取得したい場合、特別に定義するという想定です。

存在しないプロパティへのアクセスに対して例外をスローしているのは、やっぱりtypoが怖いからです…。

Acme\Domain\Data\DataTrait::__set()

<?php

final public function __set($name, $value)
{
    throw new \LogicException(
        sprintf('The property "%s" could not set.', $name)
    );
}

本来メソッドを定義する必要はないのですが、マジックメソッド好きとしてはむしろ使えないことを明示したいなぁと…。

Acme\Domain\Data\DataTrait::__unset()

<?php

final public function __unset($name)
{
    throw new \LogicException(
        sprintf('The property "%s" could not unset.', $name)
    );
}

こちらも同様です。

Acme\Domain\Data\DataTrait::__clone()

<?php

public function __clone()
{
    foreach (get_object_vars($this) as $name => $value) {
        if (is_object($value)) {
            $this->{$name} = clone $value;
        }
    }
}

clone() メソッドは扱いが難しいですが、結局コンストラクタにおける防御的コピーと同じようにしました。

Acme\Domain\Data\DataTrait::__sleep()

<?php

public function __sleep()
{
    return array_keys(get_object_vars($this));
}

__sleep() マジックメソッドは serialize() 関数に対して呼ばれるもので、変換対象とするプロパティの配列を返すよう実装します。

シリアライズ可能なオブジェクトとするには、このメソッドを実装するか Serializable インタフェースを実装して serialize(), unserialize() を定義する必要があります。

シンプルなルールにすることでこんなに実装が楽に…。

Acme\Domain\Data\DataTrait::__set_state()

<?php

public static function __set_state($properties)
{
    return new self($properties);
}

__set_state() マジックメソッドは var_export() 関数に対して呼ばれるもので、エクスポートされた結果からこのメソッドがコールされるというものです。

これも、完全コンストラクタのお陰でまあ、こんなに簡単な実装に…素晴らしいと思いませんか。

var_export() 関数について、PHPマニュアルにはこのように書かれています。

渡された変数に関する構造化された情報を返します。この関数は var_dump() に似ていますが、 返される表現が有効な PHP コードであるところが異なります。

文章だとちょっと分かりづらいのですが、 __set_state() が実装されたオブジェクトに対する var_export() 関数の実行結果をPHPUnitで検証すると、こういうことです。

<?php

public function testVarExport()
{
    $test = new TestData([
        'string'     => 'Foo',
        'null'       => null,
        'boolean'    => true,
        'datetime'   => new \DateTime(),
        'dateFormat' => \DateTime::RFC3339,
    ]);
    eval('$exported = ' . var_export($test, true) . ';');
    $this->assertEquals($test, $exported);
    $this->assertNotSame($test, $exported);
    $this->assertEquals($test->datetime, $exported->datetime);
    $this->assertNotSame($test->datetime, $exported->datetime);
}

serialize() も同様ですが、これを実装することで値を復元可能、つまりキャッシュ可能なオブジェクトになるわけです。

不変オブジェクトってWebアプリケーションにとって、とても重要な概念なんじゃないか…ここまで実装してみて、改めてそう思い至りました。

ちなみに var_export() は Doctrine Cache のキャッシュプロバイダの一つ PhpFileCache でも使われています。

Acme\Domain\Data\DataTrait::getIterator()

<?php

public function getIterator()
{
    return new \ArrayIterator(get_object_vars($this));
}

IteratorAggregate::getIterator() の実装メソッドです。これは説明不要ですね。

Acme\Domain\Data\DataTrait::camelize()

<?php

    private function camelize($string)
    {
        return str_replace(' ', '', ucwords(str_replace('_', ' ', $string)));
    }

例の PDOStatement のフェッチモード PDO::FETCH_CLASS + PDO::FETCH_PROPS_LATE の利用を想定していた名残のメソッドです。

世間のORMなどでは当然、このような処理はデータベースからの値を受け渡す別のクラスが担当しているものと想像しますが、クラス設計とかめんどくさい(特に名前考えるのが)ので、つい手を抜いてしまいます。

今回のアドベントカレンダーの記事のために作って、結局 PDOStatement の紹介をメインにしてしまったためにこんな状態で放置されますが、最終的にはPDOを更に抽象化したライブラリ(現在開発中)でやることになると思います。

Userドメインデータ

ここからは PDOでオブジェクトをフェッチ&JSONとCSVファイル出力 で扱った \Acme\Domain\Data\User クラスの実装です。

Acme\Domain\Data\User.php

<?php
/**
 * ドメインデータ
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */

namespace Acme\Domain\Data;

use Acme\Domain\Data\DataInterface;
use Acme\Domain\Data\DataTrait;

/**
 * User
 *
 * @author k.holy74@gmail.com
 */
class User implements DataInterface, \JsonSerializable
{

    use DataTrait;

    /**
     * @var int
     */
    private $userId;

    /**
     * @var string
     */
    private $userName;

    /**
     * @var \DateTimeImmutable
     */
    private $birthday;

    /**
     * @var \DateTimeImmutable
     */
    private $createdAt;

    /**
     * @var \DateTimeImmutable 現在日時
     */
    private $now;

    /**
     * @var string 日付の出力用書式
     */
    private $dateFormat;

    /**
     * @var string 日時の出力用書式
     */
    private $dateTimeFormat;

    /**
     * birthdayの値をセットします。
     *
     * @param \DateTimeImmutable
     */
    private function setBirthday(\DateTimeImmutable $birthday)
    {
        $this->birthday = $birthday;
    }

    /**
     * createdAtの値をセットします。
     *
     * @param \DateTimeImmutable
     */
    private function setCreatedAt(\DateTimeImmutable $createdAt)
    {
        $this->createdAt = $createdAt;
    }

    /**
     * 現在日時をセットします。
     *
     * @param \DateTimeImmutable
     */
    private function setNow(\DateTimeImmutable $now)
    {
        $this->now = $now;
    }

    /**
     * 日付の出力用書式をセットします。
     *
     * @param string
     */
    private function setDateFormat($dateFormat)
    {
        $this->dateFormat = $dateFormat ?: 'Y-m-d';
    }

    /**
     * 日時の出力用書式をセットします。
     *
     * @param string
     */
    private function setDateTimeFormat($dateTimeFormat)
    {
        $this->dateTimeFormat = $dateTimeFormat ?: 'Y-m-d H:i:s';
    }

    /**
     * birthdayの値に出力用のTimezoneをセットして返します。
     *
     * @return \DateTimeImmutable
     */
    public function getBirthday()
    {
        return (isset($this->birthday) && isset($this->now))
            ? $this->birthday->setTimezone($this->now->getTimezone())
            : $this->birthday;
    }

    /**
     * birthdayの値を出力用の書式で文字列に変換して返します。
     *
     * @return string
     */
    public function getBirthdayAsString()
    {
        $birthday = $this->getBirthday();
        if (isset($birthday)) {
            return $birthday->format($this->dateFormat);
        }
        return null;
    }

    /**
     * 年齢を返します。
     *
     * @return int
     */
    public function getAge()
    {
        $birthday = $this->getBirthday();
        if (isset($birthday)) {
            return (int)(((int)$this->now->format('Ymd') - (int)$birthday->format('Ymd')) / 10000);
        }
        return null;
    }

    /**
     * createdAtの値に出力用のTimezoneをセットして返します。
     *
     * @return \DateTimeImmutable
     */
    public function getCreatedAt()
    {
        return (isset($this->createdAt) && isset($this->now))
            ? $this->createdAt->setTimezone($this->now->getTimezone())
            : $this->createdAt;
    }

    /**
     * createdAtの値を出力用の書式で文字列に変換して返します。
     *
     * @return string
     */
    public function getCreatedAtAsString()
    {
        $createdAt = $this->getCreatedAt();
        if (isset($createdAt)) {
            return $createdAt->format($this->dateTimeFormat);
        }
        return null;
    }

    /**
     * JsonSerializable::jsonSerialize
     *
     * @return \stdClass for json_encode()
     */
    public function jsonSerialize()
    {
        $object = new \stdClass;
        $object->userId = $this->userId;
        $object->userName = $this->userName;
        $object->birthday = $this->getBirthdayAsString();
        $object->createdAt = $this->getCreatedAtAsString();
        $object->age = $this->getAge();
        return $object;
    }

}

正直なところ、日付を文字列で返すためのメソッドをいちいち定義するのは筋が悪いと思いますが…これも、サンプルのためにあえて単純な解決を選択した結果です。

むしろ実務では PHP 5.5 以上の環境はほぼ期待できないので、おそらく DateTime のコンポジションとして不変オブジェクト版の、__toString() を実装した日付クラスを作って対応することになると思います。

DateTimeInterface が導入されたのも PHP 5.5 なので、何とももどかしいですが…。