k-holyのPHPとか諸々メモ

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

PHPTALテンプレート変数のパス参照でPHPTAL_VariableNotFoundExceptionがスローされる件

PHPTALにおけるテンプレート変数のパス記述に関するメモです。多分他の人にはあまり役に立たないと思います。

PHPTAL_Context::path() におけるパス参照時の PHPTAL_VariableNotFoundException発生条件

ソースはここ PHPTAL/classes/PHPTAL/Context.php

パスは / 区切りで分割されて終端に達するまで繰り返し処理される。

現在の要素がオブジェクトの場合

  1. オブジェクトがクロージャであれば、実行結果を取得して次の要素へ。 (実行結果がクロージャの場合、さらに実行を繰り返す)

  2. オブジェクトに現在の要素名のメソッドが存在し、呼び出し可能な場合は実行結果を取得して次の要素へ。

  3. オブジェクトに現在の要素名のプロパティが存在すれば、値を取得して次の要素へ。

  4. オブジェクトが ArrayAccess のインスタンスで現在の要素名のキーが有効であれば、値を取得して次の要素へ。

  5. オブジェクトが Countable のインスタンスで現在の要素名が length または size であれば要素を count() した結果を取得して次の要素へ。

  6. オブジェクトに __isset() メソッドが存在し、現在の要素名で __isset() が有効であれば、現在の要素名で値を取得して次の要素へ。

  7. オブジェクトに __get() メソッドが存在し、現在の要素名で参照した値がNULLでなければ、その値を取得して次の要素へ。

  8. オブジェクトに __call() メソッドが存在すれば、現在の要素名のメソッドで実行を試み、BadMethodCallException がスローされなければ、実行結果を取得して次の要素へ。

  9. 実行中の path() メソッドの第3引数 $nothrow が TRUE であれば(初期値は FALSE )NULLを返す。

  10. 上記の流れで次の要素に処理が移っていない場合 PHPTAL_Context::patherror() が実行され PHPTAL_VariableNotFoundException がスローされる。

現在の要素が配列の場合

  1. 現在の要素名のキーが存在すれば、値を取得して次の要素へ。

  2. 現在の要素名が length または size であれば要素を count() した結果を取得して次の要素へ。

  3. 実行中の path() メソッドの第3引数 $nothrow が TRUE であれば(初期値は FALSE)NULLを返す。

  4. 上記の流れで次の要素に処理が移っていない場合 PHPTAL_Context::patherror() が実行され PHPTAL_VariableNotFoundException がスローされる。

現在の要素が文字列の場合

  1. 現在の要素名が length または size であれば要素を strlen() した結果を取得して次の要素へ。

  2. 現在の要素名が数値(is_numeric() が真)の場合、現在の要素からその位置の文字を取得して次の要素へ。

これまでの流れで次の要素に処理が移っていない場合

  1. 実行中の path() メソッドの第3引数 $nothrow が TRUE であれば(初期値は FALSE)NULLを返す。

  2. PHPTAL_Context::patherror() が実行され PHPTAL_VariableNotFoundException がスローされる。

パスの要素を全て処理し終わったら、最終的に取得した値を返す。

なお、頻出する path() メソッドの第3引数 $nothrow についてはPHPDOCコメントにこう書かれています。

$nothrow is used by phptal_exists(). Prevents this function from throwing an exception when a part of the path cannot be resolved, null is returned instead.

コンパイル済みテンプレートファイルを検索してみたところ、後述するdefaultキーワードをパスに付与した場合、このフラグにTRUEが指定されるようでした。

isset() と __isset() と offsetExist() の実装内容によって PHPTAL_VariableNotFoundException がスローされるケース

たとえばテンプレート変数のパスをこう指定したとする。

<p tal:content="object/name"></p>
  1. $object->__isset('name') がFALSEを返した

  2. $object->__get('name') がNULLを返した

  3. $object->__call() が実装されていない、または $object->__call('name', array()) を実行した結果 BadMethodCallException がスローされた

これらの条件に該当する場合に PHPTAL_VariableNotFoundException がスローされる。

配列の場合は array_key_exists('name', $array) がTRUEを返していれば、値がNULLでも例外はスローされない。

これに対して、オブジェクトでプロパティ値がNULLの場合に __isset() がFALSEを返すような実装(isset()関数と同じ動作)をしていると、例外がスローされてしまう。

ArrayAccess を実装したオブジェクトで $object->offsetExists('name') がFALSEを返した場合も同様に例外がスローされる。

以前にもこれに似たような問題にはまって、こういう記事を書きました。

事ここに至って痛感したのは、property_exists($object, 'name') && $object->name !== nullと同じ意味で isset($object->name) と書くのをやめろということです。

ArrayAccess::offsetExists()__isset() は、そういう前提で実装しなくてはいけない。

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

↑こう書いてはダメ!!

<?php
/**
 * 良い __isset()
 * @param mixed
 * @return bool
 */
public function __isset($name)
{
    return property_exists($this, $name);
}

↑こう書く。

これがどう影響するかというと、ユニットテストのコードを見ると一目瞭然です。

<?php
public function testIsset()
{
    $test = new EntityTraitTestData(array(
        'string' => 'Foo',
        'null'   => null,
    ));
    $this->assertTrue(isset($test->string));
    $this->assertTrue(isset($test->null)); // !! CAUTION !!
    $this->assertFalse(isset($test->undefined_property));
}

isset($object) はいいとして isset($object->name)isset($array['name']) という書き方は害でしかなくなってしまうわけで、Notice: Undefined index を親の仇のように憎悪して isset() を使いまくってきた自分にとって非常に困難を伴いますが…。

どうしても isset() を使いたい場合は isset($object->name) && $object->name !== null あるいは isset($array['name']) && $array['name'] !== null と書くしかありません。

PHPTALテンプレートで何とか対処する場合

オブジェクトの利用側コードで isset($objact->name) してて、むしろ出力時に対応した方が良さそうな場合の対応策。

defaultキーワード

PHPTALES の default キーワードを付けることで、値がNULLまたは PHPTAL_VariableNotFoundException がスローされた場合の代替値として、要素の中身(この場合は空文字)が出力されます。

<p tal:content="object/name|default"></p>

exists:式

代替値ではなく値の出力をスルーしたい、そしてより丁寧に書くのであれば PHPTALES の exists:式が使えます。

<p><span tal:condition="exists:object/name" tal:replace="object/name"></span></p>

普通にtal:condition

もちろん $object->hasName() みたいなメソッドが実装されているのであれば、こう書けます。

<p><span tal:condition="object/hasName" tal:replace="object/name"></span></p>

これは良くない例ですが、PHPTALの入力フォーム処理には、フォームオブジェクトみたいな物を導入して、入力値、バリデーション結果、エラーメッセージ等を項目単位でまとめて扱えるようにした方がシンプルに書けますね。

テンプレートの書きやすさだけでなく、一つのフォームの編集対象は一つのエンティティに限らないこと、エンティティの属性とフォームで扱う値の型は必ずしも一致しないこともありますし、何かしらレイヤ間のギャップを吸収する仕組みが必要になります。

具体的な実装については、まだ自分でも納得のいく物は書けてませんが…。

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

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だけで良かったかもしれませんが…。