k-holyのPHPとか諸々メモ

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

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月頃に書いてたものですし)

SparkleShareをWindows7に入れてみたメモ

SparkleShareWindows7に入れて使ってみたメモです。

SparkleShareが何なのかは、ちょっと古いですがこちらの記事が分かりやすかったです。

SparkleShareは、バージョン管理システムGitを利用したリアルタイムファイル共有システムです。 誤解を恐れず書くと オープンソース版オレオレDropboxが作れるツール というとわかりやすいでしょう。

SparkleShareを利用するにはクライアントソフトのSparkleShareのほかに、ファイルを中継して共有するための共有Gitリポジトリが必要になります。 そう書くと、とても面倒なように聞こえますがGitを利用したWebサービスgithubやGitorious、Bitbucketなどに対応しており、アカウントがあればすぐにSparkleShareを利用できます。

また、SparkleShare自体は 特定のディレクトリに置いたファイルを自動的にコミット・プッシュするGitクライアント ですので、Gitに抵抗のある人向けのGitクライアントとしても利用できます。

ちょっと、何か社内でGitを普及促進するのに良さそうじゃないですか?(今更とか言わないで…)というわけで、試してみました。

インストールと初期状態

クライアントのパッケージダウンロードは公式サイト http://sparkleshare.org から sparkleshare-windows-1.4.msi を入れました。

GitHubhbons/SparkleShare にあるものは古いので要注意…。)

インストーラに従ってユーザー名とメールアドレスを入力すると、以下のファイルが生成されます。

%HOMEPATH%\SparkleShare\ユーザー名's link code.txt

中身はOpenSSHの公開鍵なんですが、SSHとか知らなくてもこの「リンクコード」をGitサーバの管理者に送って設置してもらえばいいというわけですね。

実際に利用されると思われる秘密鍵と公開鍵、アプリケーションの設定らしきファイルやログファイルは、以下の場所に生成されていました。

%HOMEPATH%\AppData\Roaming\sparkleshare\2014-06-05_10h08.key
%HOMEPATH%\AppData\Roaming\sparkleshare\2014-06-05_10h08.key.pub
%HOMEPATH%\AppData\Roaming\sparkleshare\config.xml
%HOMEPATH%\AppData\Roaming\sparkleshare\logs\debug_log_2014-06-05.1.txt

SparkleShare.exe を起動するとタスクバーに入ります。

まずは公開鍵をGitサーバ側に登録しておく必要があるので、とりあえずGitHub上にshare-testプロジェクトを作成して、SparkleShare用の公開鍵を追加します。

タスクバーのアイコンをクリック → SparkleShare → Client ID → Copy to Clipboard でインストール時に作成されたOpenSSH形式の公開鍵をクリップボードにコピーします。

GitHubのプロジェクト k-holy/share-test から Settings → Deploy keys → Add deploy key でフォームが開くので、適当な名前付けてペーストして「Add key」。

タスクバーのアイコンをクリック → SparkleShare → Add hosted project... でGitリポジトリを追加します。

うまくいけば「Your shared project is ready!」というメッセージが表示されるので「Show files」をクリックすると、プロジェクトルート %HOMEPATH%\SparkleShare\share-test が開きます。

初期状態ではこんな構成

%HOMEPATH%\SparkleShare\share-test\.git
%HOMEPATH%\SparkleShare\share-test\.sparkleshare
%HOMEPATH%\SparkleShare\share-test\SparkleShare.txt

SparkleShare.txtを覗いてみると…

Congratulations, you've successfully created a SparkleShare repository!

Any files you add or change in this folder will be automatically synced to 
ssh://git@github.com/k-holy/share-test and everyone connected to it.

SparkleShare is an Open Source software program that helps people collaborate and 
share files. If you like what we do, consider buying us a beer: http://www.sparkleshare.org/

Have fun! :)

おっと思いつつGitHubリポジトリを確認してみると、すでに上記のファイルが commit & push されている状態でした。

.sparkleshare には何かのハッシュ値らしきものが書かれていました。

コミットの内容はこんな感じ。

コミットログには、変更のあったファイル名を列挙した内容が自動的にセットされるようです。なるほど。

SparkleShareは自動でリポジトリと同期してくれるGitクライアント

今度はローカルで新規フォルダ test を作成してみます。Gitでは空のフォルダは無視されるはずですが、どうでしょうか…。

作成したフォルダの直下に .empty というファイルが自動的に作成された状態で commit & push されています。なるほど。

(「I'm a folder!」とか別に要らないよぉって感じですが…)

もちろん、これらの変更をクライアント側で見ることもできます。

タスクバーのアイコンをクリック → SparkleShare → Recent changes...

ユーザー名@プロジェクト名 のリンクをクリックすると、プロジェクトルートのフォルダをエクスプローラで開きます。

一覧に表示されているファイル名のリンクをクリックすると、そのファイルが拡張子に関連付けられたアプリケーションで開きます。

なるほど。これは「Dropboxクローン」というよりも、管理対象フォルダ以下の変更を検知して自動でリポジトリと同期してくれるGitクライアントですね。

日本語ファイル名は一応使えるけど一部で化ける?

新規フォルダ てすと を作成して、日本語のファイル名が使えるかどうかも確認します。

なんか微妙に文字化けしてますね…それに、フォルダ命名してる間にコミットされてしまったようです。

盛んに紹介されていた2012年頃の記事には、SparkleShareに同梱されているmsysgitの問題で日本語ファイルが使えないという情報がありますが、2014年6月現在のバージョン SparkleShare 1.4 + Git 1.8.0 では大丈夫でした。

次はファイルの中身も日本語にしてみます。

ファイルを作成…

ファイル名を変更…

UTF-8で中身を記述。diffは文字化けしてません。

中身をShift_JISに変更して保存したところ、diffが文字化け。

クライアント側のRecent Changesはこんな感じ。

ちょっと惜しい…クライアント側にとってコミットログはそれほど重要な情報ではないと思いますが、文字化けしたファイルへのリンクが切れちゃってるのが残念。

なお、複数ユーザーで触った場合のRecent Changesはこんな感じでした。

クライアント間の更新通知は notifications.sparkleshare.org を仲介して行われる

複数クライアント間でリアルタイムに同期を取るには、別のクライアントで更新されたことを通知する必要があると思いますが、クライアント間の更新通知については、公式リポジトリWikiにそれらしい情報がありました。

SparkleShare uses a small script that handles update notifications between clients.

By default it uses the one running on notifications.sparkleshare.org.

The only information sent to this service is a hash of a folder identifier and a hash of the current revision.

The service then tells the other connected clients that are subscribed to a folder that they can pull new changes from wherever your repository is hosted.

This allows SparkleShare clients to sync new changes instantly, instead of polling with potentially long delays (up to 10 minutes).

英語はよく分かりませんが、notifications.sparkleshare.org を介して、フォルダID(設定ファイルの <identifier>544d01eae5adc8ea5f8765f84f3d6ae5fdd9846e</identifier> の値)とリビジョンIDをやり取りすることで、クライアント間の同期を取る仕組みのようです?

ただ、前述のログファイル %HOMEPATH%\AppData\Roaming\sparkleshare\logs\debug_log_2014-06-05.1.txt を見たところ、1分間隔で notifications.sparkleshare.org へ接続を試みているものの、失敗しているようでした。

23:43:43 | Listener | Trying to reconnect to tcp://notifications.sparkleshare.org:443/
23:43:45 | Listener | Disconnected from tcp://notifications.sparkleshare.org:443/: 対象のコンピューターによって拒否されたため、接続できませんでした。 204.62.14.135:443
23:43:45 | share-test | Falling back to regular polling

対象のコンピューターによって拒否されたため、接続できませんでした。 というメッセージは相手から拒否されたように読めますが、これはクライアント側の問題でしょうか。

該当メッセージでGoogle検索してみると、様々なWindowsアプリケーションでTCP通信時にこのエラーが発生することがあるようですが、ちょっと自分ではすぐに解決できなさそうなので保留…。

リポジトリへの反映は即時、リポジトリからの取り込みは定期的に行われる

前述の通り、管理対象フォルダへの変更は即時に検知され、リポジトリに反映されます。

これもログファイルを見ると、何となく動きが分かりました。

23:41:08 | Cmd | share-test | git status --porcelain
23:41:08 | Local | share-test | Activity detected, waiting for it to settle...
23:41:10 | Local | share-test | Activity has settled
23:41:10 | Cmd | share-test | git status --porcelain
23:41:10 | SyncUp | share-test | Initiated
23:41:10 | Cmd | share-test | git add --all
23:41:10 | Cmd | share-test | git status --porcelain
23:41:10 | Cmd | share-test | git config user.name "k-holy"
23:41:10 | Cmd | share-test | git config user.email "k.holy74@gmail.com"

リポジトリへのコミットで使用される user.name と user.email が、前述の設定ファイル %HOMEPATH%\AppData\Roaming\sparkleshare\config.xml の内容になっています。

<?xml version="1.0" encoding="UTF-8"?>
<sparkleshare>
  <user>
    <name>k-holy</name>
    <email>k.holy74@gmail.com</email>
  </user>
  <notifications>False</notifications>
  <folder>
    <name>share-test</name>
    <identifier>544d01eae5adc8ea5f8765f84f3d6ae5fdd9846e</identifier>
    <url>ssh://git@github.com/k-holy/share-test</url>
    <backend>Git</backend>
  </folder>
</sparkleshare>

git config の後 git commit コマンドを発行しており、コミットログの文字化けはこの時点で発生しているようです。

自動生成されるコミットメッセージへのファイル名の受け渡しでマルチバイトが考慮されていないのでしょうけど、どうすればいいのやら…。

23:41:10 | Cmd | share-test | git commit --all --message="+ ‘test/譁ー縺励>繝・く繧ケ繝・繝峨く繝・繝。繝ウ繝・txt’
" --author="k-holy <k.holy74@gmail.com>"
23:41:10 | Cmd | share-test | git push --progress "ssh://git@github.com/k-holy/share-test" master
23:41:13 | Git | share-test | Counting objects: 6, done.
23:41:13 | Git | share-test | Delta compression using up to 4 threads.
23:41:13 | Git | share-test | Total 4 (delta 1), reused 0 (delta 0)
23:41:14 | Git | share-test | To ssh://git@github.com/k-holy/share-test
23:41:14 | Git | share-test |    41b5b0b..da151e2  master -> master
23:41:14 | SyncUp | share-test | Done
23:41:14 | Cmd | share-test | git log --since=1.month --raw --find-renames --date=iso --format=medium --no-color -m --first-parent
23:41:14 | Cmd | share-test | git rev-parse HEAD
23:41:14 | Listener | Can't send message to tcp://notifications.sparkleshare.org:443/. Queuing message

最後に Listener が Can't send message to tcp://notifications.sparkleshare.org:443/. Queuing message と言ってるのは、更新通知を送ろうとして接続に失敗しているようです。

(表には一切何も表示されませんが、裏側では結構エラーが出てたりするようで、この辺は要注意ですね…。)

また、クライアント間の更新通知とは別に、リモートのGitリポジトリへの更新チェックが5分間隔で行われるようです。

23:45:47 | Git | share-test | Checking for remote changes...
23:45:47 | Cmd | share-test | git rev-parse HEAD
23:45:47 | Cmd | share-test | git ls-remote --heads --exit-code "ssh://git@github.com/k-holy/share-test" master
23:45:51 | Git | share-test | No remote changes, local+remote: 9ecb3784403aa484bd08b618dee765927ab04bda

こちらは問題なく完了していますし、そこまでリアルタイム同期にこだわらないのであれば、これでも充分という気がします。

SparkleShareはテキストコンテンツ管理に向いてるかも

似たような趣旨で紹介される ownCloud が完全なWeb + DBアプリケーションであるのに対して、中身は全く異なることが分かりました。

SparkleShareはGitとおんぶだっこ。だがそれがいい

ディレクトリの状態が変更されるたびに自動コミットされてコミットの粒度が最小になってしまうため、通常のソフトウェア開発には不向きでしょう。

いわゆる静的サイトジェネレータのような、リポジトリを一種のデータストアとして扱う仕組みと相性が良いんじゃないでしょうか。

クライアント側のUIにはSSHやGitコマンドはもちろんリビジョンIDすら登場せず、バージョン管理の概念すら知らなくてもファイルの共有と同期、そして履歴管理の恩恵を受けられるというのは、なかなか凄いことだと思います。

応用例として、RedmineやGitoliteを組み合わせて、フラットODFという単一のXMLファイルに文書とメタ情報とBase64エンコードされた画像などを詰め込んだ文書の管理システムを構築したという方も。

クライアント側ではLibreOffice WriterでフラットODFファイルを出力、SparkleShareでリポジトリに反映、Gitoliteを使うことでユーザーの権限制御を実現しつつ、Redmineを管理者によるタスク管理とリポジトリビューアとして利用する仕組みのようです。

Gitに乗っかることで、こういう組み合わせの可能性が広がるのもいいですね。

mysqliのコンストラクタで"No such file or directory"のエラー

mysqli::__construct() で以下のエラーが発生。

mysqli::mysqli(): (HY000/2002): No such file or directory

調べてみると、MySQLのソケットファイル(mysql.sock)が見当たらない場合にこのエラーが発生することがあるようです。

パッケージでインストールした場合はよしなに設定してくれるはずですが、順序によるものか、MySQLの設定とPHPの設定でソケットファイルのパスが異なってしまうケースもあるとのこと。

(そういえば某レンタルサーバの初期設定で同様の問題が起きていました…)

参考

mysqliモジュールの設定をコマンドで確認してみます。

$ php --ri mysqli
mysqli

MysqlI Support => enabled
Client API library version => mysqlnd 5.0.11-dev - 20120503 - $Id: bf9ad53b11c9a57efdb1057292d73b928b8c5c77 $
Active Persistent Links => 0
Inactive Persistent Links => 0
Active Links => 0

Directive => Local Value => Master Value
mysqli.max_links => Unlimited => Unlimited
mysqli.max_persistent => Unlimited => Unlimited
mysqli.allow_persistent => On => On
mysqli.default_host => no value => no value
mysqli.default_user => no value => no value
mysqli.default_pw => no value => no value
mysqli.default_port => 3306 => 3306
mysqli.default_socket => /var/lib/mysql/mysql.sock => /var/lib/mysql/mysql.sock
mysqli.reconnect => Off => Off
mysqli.allow_local_infile => On => On

pdo_mysqlの方も念のため。

$ php --ri pdo_mysql
pdo_mysql

PDO Driver for MySQL => enabled
Client API version => mysqlnd 5.0.11-dev - 20120503 - $Id: bf9ad53b11c9a57efdb1057292d73b928b8c5c77 $

Directive => Local Value => Master Value
pdo_mysql.default_socket => /var/lib/mysql/mysql.sock => /var/lib/mysql/mysql.sock

どちらも /var/lib/mysql/mysql.sock になっています。

findコマンドで探してみても見つからない…それもそのはず、今回はWebサーバとDBサーバを分離していて、PHPが動作しているWebサーバにはMySQLを入れてなかったのです。

(それなのにMySQLソケットファイルを探しに行ってる時点で、おかしいと気付くべきでしたが…)

なお、MySQLソケットファイルの変更は php.ini や ini_set()関数 でこれらの設定値を変更するほか、以下の方法でも可能です。

mysqli::__construct() の第6引数でMySQLソケットファイルのパスを指定する

PHPマニュアルの mysqli::__construct() の通り、コンストラクタの引数はこうなってます。

mysqli::__construct (
    string $host = ini_get("mysqli.default_host"),
    string $username = ini_get("mysqli.default_user"),
    string $passwd = ini_get("mysqli.default_pw"),
    string $dbname = "",
    int $port = ini_get("mysqli.default_port"),
    string $socket = ini_get("mysqli.default_socket")
)

第6引数でソケットファイルへのパスを指定できます。

注意: socket 引数を指定しても、MySQL サーバーへの 接続時の型を明示的に定義することにはなりません。MySQL サーバーへの 接続方法については host 引数で定義されます

この注意書きはちょっと分かりづらいのですが、「接続時の型」とはUNIXドメインソケットで接続するか、それともTCPソケットで接続するか、という意味でしょうか。

MySQLでは "localhost" と "127.0.0.1" が別物というのは有名な話で、"localhost" ではUNIXドメインソケット、"127.0.0.1" ではTCPソケットが利用されるという違いがあり、MySQLのユーザー権限もそれぞれ個別に設定する必要があります。

あとよく引っかかるものとして、MySQLの設定で skip-networking が有効にされている場合、ネットワーク経由の接続を禁止=UNIXドメインソケットしか受け付けなくなるので、host=127.0.0.1 を指定してると接続できない、ということがあります。

しかし、今回の場合はLAN内の別のホストで動いているDBサーバに接続しようとしているはずで、No such file or directory がソケットファイルを探した結果のエラーだとすると、そんなエラーが発生すること自体がおかしいわけで。

あっ、と思ってアプリケーション側の設定ファイルを確認してみると、いつもの癖で接続先ホストを "localhost" って書いてしまってたという単純なオチでした…。

PDO::__construct() の第1引数でMySQLソケットファイルのパスを指定する

もうオチはついたんですが、せっかくなのでPDOの場合も書いておきます。

PHPマニュアルの PDO::__construct の通り、コンストラクタの引数はこうなってます。

public PDO::__construct (
    string $dsn,
    string $username,
    string $password,
    array $driver_options
)

第1引数のDSNに受け付ける内容は使用するPDOドライバによって異なりますが、MYSQLの場合は PDO_MYSQL DSN に書かれており、ソケットを指定する例があります。

unix_socket MySQL の unix ソケットを指定します (host あるいは port と同時に使用することはできません)。

こんな風にDSNでソケットを指定できます。

mysql:unix_socket=/tmp/mysql.sock;dbname=testdb

なお前述した通り、unix_socket=... と指定していなくても、host=localhost と指定している場合、UNIXドメインソケットを使った接続が行われます。

注意: Unix のみ ホスト名を "localhost" にすると、 サーバーへの接続はドメインソケットを使って行われます。 libmysqlclient を使って PDO_MYSQLコンパイルした場合は、 ソケットファイルの場所は libmysqlclient のコンパイル時の場所になります。 mysqlnd を使って PDO_MYSQLコンパイルした場合は、デフォルトのソケットは pdo_mysql.default_socket の設定を使って作られます。

PDO関数だけでなくmysql、mysqli等のエクステンションでは旧来libmysqlclientライブラリを標準で利用していましたが、PHP5.4以降はPHP拡張として実装された mysqlnd (MySQL native driver for PHP) を標準で利用するように変わりましたので、今回の環境でPDO_MYSQLを利用する場合は前述の通り pdo_mysql.default_socket => /var/lib/mysql/mysql.sock を参照することになります。

なお、PHPMySQLを利用する際の様々な問題については、こちらの記事が非常によくまとめられていて、おすすめです。

Composerでプロジェクトグローバルに Stagehand_TestRunner + PHPUnit をインストール(Windows7 + NYAOS編)

今度はローカル開発環境の Windows7 + NYAOS で composer global install してみました。

以下、シェルは NYAOS を使っています。

Composerをインストール

当然すでにcomposerは導入済みなのですが、Linuxの場合と同じ手順を踏んでやってみます。

PHPのインストールディレクトリ C:\php に composer.phar をインストールします。

$ cd C:\php
$ curl -sS https://getcomposer.org/installer | php
#!/usr/bin/env php
All settings correct for using Composer
Downloading...

Composer successfully installed to: C:\php\composer.phar
Use it: php composer.phar

ここからがWindows限定の作業ですが、以下のようなバッチファイルを作成して、composer.phar を代行させます。

中身はStagehand_TestRunnerの testrunner.bat を参考にしました。

(その testrunner.bat では symfony.bat を参考にされてるみたいです)

@echo off

REM This script will do the following:
REM - check for PHP_COMMAND env, if found, use it.
REM   - if not found detect php, if found use it, otherwise err and terminate

IF "%OS%"=="Windows_NT" @SETLOCAL

REM %~dp0 is expanded pathname of the current script under NT
SET SCRIPT_DIR=%~dp0

GOTO INIT

:INIT

IF "%PHP_COMMAND%" == "" GOTO NO_PHPCOMMAND

IF EXIST ".\composer" (
  "%PHP_COMMAND%" ".\composer.phar" %1 %2 %3 %4 %5 %6 %7 %8 %9
) ELSE (
  "%PHP_COMMAND%" "%SCRIPT_DIR%composer.phar" %1 %2 %3 %4 %5 %6 %7 %8 %9
)
GOTO CLEANUP

:NO_PHPCOMMAND
REM ECHO ------------------------------------------------------------------------
REM ECHO WARNING: Set environment var PHP_COMMAND to the location of your php.exe
REM ECHO          executable (e.g. C:\PHP\php.exe).  (assuming php.exe on PATH)
REM ECHO ------------------------------------------------------------------------
SET PHP_COMMAND=php.exe
GOTO INIT

:CLEANUP
IF "%OS%"=="Windows_NT" @ENDLOCAL
REM PAUSE

REM Local Variables:
REM mode: conf
REM coding: iso-8859-1
REM indent-tabs-mode: nil
REM End:

composerコマンド打ってみる。

$ composer
   ______
  / ____/___  ____ ___  ____  ____  ________  _____
 / /   / __ \/ __ `__ \/ __ \/ __ \/ ___/ _ \/ ___/
/ /___/ /_/ / / / / / / /_/ / /_/ (__  )  __/ /
\____/\____/_/ /_/ /_/ .___/\____/____/\___/_/
                    /_/
Composer version 64ac32fca9e64eb38e50abfadc6eb6f2d0470039 2014-05-24 20:57:50

OKです。

ComposerでStagehand_TestRunnerをインストール

再びこちらを参考

$ composer global require piece/stagehand-testrunner:3.6.*
Changed current directory to C:/Users/k_horii/AppData/Roaming/Composer
./composer.json has been updated
Loading composer repositories with package information
Updating dependencies (including require-dev)
  - Installing symfony/yaml (v2.4.5)
    Downloading: 100%

  - Installing symfony/process (v2.4.5)
    Downloading: 100%

  - Installing symfony/finder (v2.4.5)
    Downloading: 100%

  - Installing symfony/dependency-injection (v2.4.5)
    Downloading: 100%

  - Installing symfony/console (v2.4.5)
    Downloading: 100%

  - Installing symfony/filesystem (v2.4.5)
    Downloading: 100%

  - Installing symfony/config (v2.4.5)
    Downloading: 100%

  - Installing symfony/class-loader (v2.4.5)
    Downloading: 100%

  - Installing piece/stagehand-componentfactory (v1.0.1)
    Loading from cache

  - Installing piece/stagehand-alterationmonitor (2.0.0)
    Loading from cache

  - Installing piece/stagehand-testrunner (v3.6.2)
    Downloading: 100%

symfony/dependency-injection suggests installing symfony/proxy-manager-bridge (Generate service proxies to lazy load them)
symfony/console suggests installing symfony/event-dispatcher ()
piece/stagehand-testrunner suggests installing phpunit/phpunit (>=3.6.0)
Writing lock file
Generating autoload files

Windowsの場合、composer global でインストールしたファイルは %APPDATA%\Composer 以下に配置されるようです。

私の環境では具体的には C:\Users\k_horii\AppData\Roaming\Composer になりました。

composerでインストールしたライブラリの実行ファイルは C:\Users\k_horii\AppData\Roaming\Composer\vendor\bin 以下に配置されました。

testrunner コマンドを実行してみると…

$ source ~/_nya
$ %APPDATA%\Composer\vendor\bin\testrunner
Warning: require_once(Stagehand/TestRunner/Core/Bootstrap.php): failed to open stream: No such file or directory in C:\Users\k_horii\AppData\Roaming\Composer\vendor\piece\stagehand-testrunner\bin\testrunner on line 52

案の定、前回と同じ警告で詰まりましたので、同じように -p オプションでComposerのオートロードスクリプトを読ませてみます。

$ %APPDATA%\Composer\vendor\bin\testrunner -p %APPDATA%\Composer\vendor\autoload.php
Stagehand_TestRunner version @package_version@

Copyright (c) 2005-2013 KUBO Atsuhiro and contributors,
All rights reserved.

Usage:
  [options] command [arguments]

Options:
  --help           -h Prints help and exit.
  --version        -V Prints version information and exit.
  --ansi              Enables ANSI output.
  --no-ansi           Disables ANSI output.

Testing Framework Commands:
  cakephp               Runs tests with CakePHP.
  ciunit                Runs tests with CIUnit.
  phpspec               Runs tests with PHPSpec.
  phpunit               Runs tests with PHPUnit.
  simpletest            Runs tests with SimpleTest.
Other Commands:
  compile               Compiles the DIC for the production environment.
  help                  Prints the help for a command.
  list                  Lists commands.
  phpunit:passthrough   Runs the phpunit command via the testrunner command.

無事にコマンドを実行できました。

ComposerでPHPUnit(3.7系)をインストール

Windowsの方でも3.7系を入れることにします。

$ composer global require phpunit/phpunit:3.7.*
Changed current directory to C:/Users/k_horii/AppData/Roaming/Composer
./composer.json has been updated
Loading composer repositories with package information
Updating dependencies (including require-dev)
  - Installing phpunit/php-text-template (1.2.0)
    Loading from cache

  - Installing phpunit/phpunit-mock-objects (1.2.3)
    Loading from cache

  - Installing phpunit/php-timer (1.0.5)
    Loading from cache

  - Installing phpunit/php-token-stream (1.2.2)
    Downloading: 100%

  - Installing phpunit/php-file-iterator (1.3.4)
    Loading from cache

  - Installing phpunit/php-code-coverage (1.2.17)
    Downloading: 100%

  - Installing phpunit/phpunit (3.7.37)
    Downloading: 100%

phpunit/phpunit-mock-objects suggests installing ext-soap (*)
phpunit/php-code-coverage suggests installing ext-xdebug (>=2.0.5)
phpunit/phpunit suggests installing phpunit/php-invoker (~1.1)
Writing lock file
Generating autoload files

3.7.37が入りました。

$ ls -l %APPDATA%\Composer\vendor\bin
-a--rw-      151 May 27 17:05               phpunit
-a--rw-       96 May 27 17:05               phpunit.bat
-a--rw-      156 May 27 16:31 testru~1      testrunner
-a--rwx      101 May 27 16:31 testru~1.bat  testrunner.bat

こんな感じで、testrunner.batに続いてphpunit.batが配置されてます。

プロジェクトルートでテスト実行

プロジェクトルートに移動してtestrunnerコマンドでPHPUnitのテストを実行してみます。

-c オプションでStagehand_TestRunnerの設定ファイルを読み込ませます。

$ %APPDATA%\Composer\vendor\bin\testrunner phpunit -c testrunner.yml
Please run the following command before running the phpunit command:

  testrunner compile

phpunit command の前に testrunner compile しろって言われたので、その通りにします。

$ %APPDATA%\Composer\vendor\bin\testrunner compile -p %APPDATA%\Composer\vendor\autoload.php
$ %APPDATA%\Composer\vendor\bin\testrunner phpunit -p %APPDATA%\Composer\vendor\autoload.php -c testrunner.yml

テストを実行できましたが、例のごとくエスケープシーケンスによる色付けが効かないので、msysGitのcatにテスト結果を渡します。

$ %APPDATA%\Composer\vendor\bin\testrunner phpunit -p %APPDATA%\Composer\vendor\autoload.php -c testrunner.yml | cat

色付けもできました。

こちらではGrowl経由の通知もされています。

NYAOSにコマンドのエイリアスを定義

最後に、NYAOSでは初期設定ファイル _nya にコマンドのエイリアスを定義できますので、以下を追加します。

alias testrunner-phpunit "%APPDATA%\Composer\vendor\bin\testrunner phpunit -p %APPDATA%\Composer\vendor\autoload.php -c testrunner.yml | cat"
$ source ~/_nya
$ testrunner-phpunit

これで、testrunner-phpunitコマンドでテストが可能になりました。

これまで歴史的な経緯により(?) PEARは C:\xampp\php に入れて動かしていたのですが、今ではローカル開発時はPHPは C:\php でビルトインWebサーバーを動かしており、XAMPPの機能は使っていなかったため、この機会にPEARと同時にXAMPP離れも断行しました。

Composerでプロジェクトグローバルに Stagehand_TestRunner + PHPUnit をインストール

久々にお仕事でroot権限のあるLinuxサーバ (CentOS 6.5)を使えることになったのでComposerで Stagehand_TestRunner(3.6.2) + PHPUnit(3.7.37) をインストールしたメモ。

Composerをインストール

作業用ユーザー(application)のホームディレクトリ以下にプロジェクト単位のディレクトリを作成し、その下にソースコードやWeb公開ファイルを配置してます。

つまり /home/application がプロジェクトグローバルのホームという位置付けなので、ここにcomposerコマンドをインストールします。

$ su - application
$ cd ~
$ curl -sS https://getcomposer.org/installer | php
#!/usr/bin/env php
All settings correct for using Composer
Downloading...

Composer successfully installed to: /home/application/composer.phar
Use it: php composer.phar

~/bin が PATH 環境変数に定義されているか確認します。

$ vi ~/.bash_profile
PATH=$PATH:$HOME/bin

export PATH

composer.phar をcomposerコマンドで呼ぶためにリネーム。

$ mkdir ~/bin
$ mv composer.phar ~/bin/composer

composer コマンド打ってみる。

$ composer
   ______
  / ____/___  ____ ___  ____  ____  ________  _____
 / /   / __ \/ __ `__ \/ __ \/ __ \/ ___/ _ \/ ___/
/ /___/ /_/ / / / / / / /_/ / /_/ (__  )  __/ /
\____/\____/_/ /_/ /_/ .___/\____/____/\___/_/
                    /_/
Composer version 0c85ca426d6e8235366340f29ad0e6aab1747b83 2014-05-19 10:17:07

OKです。

ComposerでStagehand_TestRunnerをインストール

Stagehand_TestRunnerもComposerを使ってプロジェクトグローバルにインストールします。

これまではPHPUnitやStagehand_TestRunnerはPEARでインストールしてたんですが、PEAR離れが加速する中、Symfonyコンポーネントを利用されているStagehand_TestRunnerもいずれはそうなるのかなーと…。

(開発中のStagehand_TestRunner v4ではPEARチャネルのサポートは継続されるようです)

composerでPEAR離れするには composer global コマンドを使います。

こちらを参考

$ cd /home/application
$ composer global require piece/stagehand-testrunner:3.6.*
Changed current directory to /home/application/.composer
./composer.json has been updated
Loading composer repositories with package information
Updating dependencies (including require-dev)
  - Installing symfony/yaml (v2.4.4)
    Downloading: 100%

  - Installing symfony/process (v2.4.4)
    Downloading: 100%

  - Installing symfony/finder (v2.4.4)
    Downloading: 100%

  - Installing symfony/dependency-injection (v2.4.4)
    Downloading: 100%

  - Installing symfony/console (v2.4.4)
    Downloading: 100%

  - Installing symfony/filesystem (v2.4.4)
    Downloading: 100%

  - Installing symfony/config (v2.4.4)
    Downloading: 100%

  - Installing symfony/class-loader (v2.4.4)
    Downloading: 100%

  - Installing piece/stagehand-componentfactory (v1.0.1)
    Downloading: 100%

  - Installing piece/stagehand-alterationmonitor (2.0.0)
    Downloading: 100%

  - Installing piece/stagehand-testrunner (v3.6.2)
    Downloading: 100%

symfony/dependency-injection suggests installing symfony/proxy-manager-bridge (Generate service proxies to lazy load them)
symfony/console suggests installing symfony/event-dispatcher ()
piece/stagehand-testrunner suggests installing phpunit/phpunit (>=3.6.0)
Writing lock file
Generating autoload files

色々入りました。

composerでインストールしたライブラリの実行ファイルは ~/.composer/vendor/bin 以下にリンクとして作成されるようなので…。

$ ls -l ~/.composer/vendor/bin
total 0
lrwxrwxrwx 1 application application 44 May 21 13:06 testrunner -> ../piece/stagehand-testrunner/bin/testrunner

ここをPATH環境変数に追加します。

$ vi ~/.bash_profile
PATH=$PATH:$HOME/bin:$HOME/.composer/vendor/bin
$ source ~/.bash_profile

testrunnerコマンドを実行してみると…

Warning: require_once(Stagehand/TestRunner/Core/Bootstrap.php): failed to open stream: No such file or directory in /home/application/.composer/vendor/piece/stagehand-testrunner/bin/testrunner on line 52

このようなエラーが。おそらくPEAR形式の include_path 前提でファイルを読み込もうとしてエラーが発生しているものと思われます。

該当ソースを読んだところ -p FILE または --preload-script=FILE オプションを付けて呼ぶことで、テストランナーの実行前に任意のスクリプトを読み込めるようなので、これでComposerのオートロードスクリプトを読ませてみます。

$ testrunner -p ~/.composer/vendor/autoload.php
Stagehand_TestRunner version @package_version@

Copyright (c) 2005-2013 KUBO Atsuhiro and contributors,
All rights reserved.

Usage:
  [options] command [arguments]

Options:
  --help           -h Prints help and exit.
  --version        -V Prints version information and exit.
  --ansi              Enables ANSI output.
  --no-ansi           Disables ANSI output.

Testing Framework Commands:
  cakephp               Runs tests with CakePHP.
  ciunit                Runs tests with CIUnit.
  phpspec               Runs tests with PHPSpec.
  phpunit               Runs tests with PHPUnit.
  simpletest            Runs tests with SimpleTest.
Other Commands:
  compile               Compiles the DIC for the production environment.
  help                  Prints the help for a command.
  list                  Lists commands.
  phpunit:passthrough   Runs the phpunit command via the testrunner command.

無事にコマンドを実行できました。

ComposerでPHPUnit(3.7系)をインストール

色々と試行錯誤したのですが、最新版の4.1.0だとStagehand_TestRunnerを動かせなかったので、3.7系を入れることにします。

$ composer global require phpunit/phpunit:3.7.*
Changed current directory to /home/application/.composer
./composer.json has been updated
Loading composer repositories with package information
Updating dependencies (including require-dev)
  - Installing phpunit/php-text-template (1.2.0)
    Downloading: 100%

  - Installing phpunit/phpunit-mock-objects (1.2.3)
    Downloading: 100%

  - Installing phpunit/php-timer (1.0.5)
    Downloading: 100%

  - Installing phpunit/php-token-stream (1.2.2)
    Downloading: 100%

  - Installing phpunit/php-file-iterator (1.3.4)
    Downloading: 100%

  - Installing phpunit/php-code-coverage (1.2.17)
    Downloading: 100%

  - Installing phpunit/phpunit (3.7.37)
    Downloading: 100%

phpunit/phpunit-mock-objects suggests installing ext-soap (*)
phpunit/php-code-coverage suggests installing ext-xdebug (>=2.0.5)
phpunit/phpunit suggests installing phpunit/php-invoker (~1.1)
Writing lock file
Generating autoload files

3.7.37が入りました。

$ ls -l ~/.composer/vendor/bin
total 0
lrwxrwxrwx 1 application application 39 May 21 14:02 phpunit -> ../phpunit/phpunit/composer/bin/phpunit
lrwxrwxrwx 1 application application 44 May 21 14:00 testrunner -> ../piece/stagehand-testrunner/bin/testrunner

こんな感じで、testrunnerに続いてphpunitコマンドへのリンクが張られてます。

なお、4.1.0を入れてから 3.7.*で上書きしてしまうと、やはり動かせなかったので要注意。

Warning: require_once(PHP/CodeCoverage/Autoload.php): failed to open stream: No such file or directory in /home/application/.composer/vendor/phpunit/phpunit/PHPUnit/Autoload.php on line 46

PHPUnit側でもPEAR形式でファイルを読み込もうとしていたようですが、依存ライブラリを全削除してから改めて3.7系を入れ直すとエラーは出ませんでした。

上書きダウングレードの際に依存解決ができてなかったのでしょうか?

(というか、依存するライブラリの未来の変更なんて予測できないのだし、過去バージョンに遡って定義をメンテナンスされてるような例は少ないでしょうね…)

プロジェクトルートでテスト実行

プロジェクトルート /home/application/project に移動して testrunner コマンドでPHPUnitのテストを実行してみます。

前述の通り -p オプションでComposerのオートロードスクリプトを読み込ませるとともに、 -c オプションでStagehand_TestRunnerの設定ファイルを読み込ませます。

$ cd /home/application/project/staging
$ testrunner phpunit -p ~/.composer/vendor/autoload.php -c testrunner.yml
Please run the following command before running the phpunit command:

  testrunner compile

phpunit command の前に testrunner compile しろって言われたので、その通りにします。

$ testrunner compile -p ~/.composer/vendor/autoload.php
$ testrunner phpunit -p ~/.composer/vendor/autoload.php -c testrunner.yml
…中略…
OK (345 tests, 904 assertions)
sh: notify-send: command not found

テストを実行できました。

notify-send コマンドがないって言われてますが、開発環境 (Windows) でこの設定を使ってて、切り替えるのも面倒なのでまあそのままで…。

毎回 testrunner phpunit -p ~/.composer/vendor/autoload.php -c testrunner.yml と入力するのはさすがに面倒なので、コマンドのエイリアスを定義しておきます。

$ vi ~/.bash_profile
alias testrunner-phpunit='testrunner phpunit -p ~/.composer/vendor/autoload.php -c testrunner.yml'
$ source ~/.bash_profile

定義したコマンド testrunner-phpunit を実行してみます。

$ testrunner-phpunit
OK (345 tests, 904 assertions)
sh: notify-send: command not found

同じ結果が返されました。

ちなみに testrunner.yml はこんな感じ。

general:
  framework: phpunit
  test_targets:
    recursive: true
    resources:
      - tests
  autotest:
    enabled: false
    watch_dirs:
      - src
      - tests
  notify: true

phpunit:
  config: phpunit.xml

phpunit コマンド単体でもテストは実行できるよう、PHPUnit側で指定可能な項目は phpunit.xml に定義しています。

<?xml version="1.0" encoding="UTF-8"?>
<phpunit
    backupGlobals="false"
    backupStaticAttributes="false"
    bootstrap="tests/bootstrap.php"
    colors="true"
    convertErrorsToExceptions="true"
    convertNoticesToExceptions="true"
    convertWarningsToExceptions="true"
    forceCoversAnnotation="false"
    processIsolation="false"
    stopOnError="false"
    stopOnFailure="false"
    stopOnIncomplete="false"
    stopOnSkipped="false"
    strict="true"
    verbose="true"
>
    <testsuites>
        <testsuite name="Acme Test">
            <directory suffix="Test.php">./tests/Acme/</directory>
        </testsuite>
    </testsuites>
    <filter>
        <whitelist>
            <directory>./src/</directory>
            <exclude>
                <directory>./tests</directory>
                <directory>./vendor</directory>
            </exclude>
        </whitelist>
    </filter>
</phpunit>

phpunit コマンド単体での実行結果

$ phpunit
PHPUnit 3.7.37 by Sebastian Bergmann.

Configuration read from /home/application/project/staging/phpunit.xml

...............................................................  63 / 345 ( 18%)
............................................................... 126 / 345 ( 36%)
............................................................... 189 / 345 ( 54%)
............................................................... 252 / 345 ( 73%)
............................................................... 315 / 345 ( 91%)
..............................

Time: 293 ms, Memory: 9.00Mb

OK (345 tests, 904 assertions)

こんな感じ。準備環境でのテストは最終確認のためなので、PHPUnitだけで良かったかもしれませんが…。