k-holyのPHPとか諸々メモ

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

Apache2.2.20でBasic認証しまくったら警告を吐いて止まらなくなった話

今時Basic認証かよ、などと思われそうですが、むしろ今時HTTPSでない認証はあり得ないので、明示的なログアウト/再ログインが必要のないケースであればBasic認証で問題ないんじゃないかというわけで。

非ブラウザのHTTPクライアントアプリケーションと連携して、Webサーバ側に用意した静的ファイルをダウンロードさせるという、サーバ側はほぼAPIのみのアプリケーションです。

しかし、Basic認証の仕様でデータベースを利用した認証を実装したところ、Apacheのエラーログに警告が出るわ出るわ…

[Tue Mar 04 08:55:49 2014] [notice] child pid 8141 exit signal Bus error (7)
[Tue Mar 04 08:55:50 2014] [notice] child pid 7778 exit signal Bus error (7)
[Tue Mar 04 08:55:50 2014] [notice] child pid 7861 exit signal Bus error (7)
[Tue Mar 04 08:55:52 2014] [notice] child pid 7794 exit signal Bus error (7)
[Tue Mar 04 08:56:10 2014] [notice] child pid 7981 exit signal Bus error (7)
[Tue Mar 04 08:58:12 2014] [notice] child pid 7986 exit signal Bus error (7)
[Tue Mar 04 08:58:13 2014] [notice] child pid 8172 exit signal Bus error (7)
[Tue Mar 04 08:58:14 2014] [notice] child pid 7884 exit signal Bus error (7)
[Tue Mar 04 08:58:15 2014] [notice] child pid 8158 exit signal Bus error (7)
[Tue Mar 04 08:58:15 2014] [notice] child pid 8602 exit signal Bus error (7)
[Tue Mar 04 08:58:17 2014] [notice] child pid 8176 exit signal Bus error (7)
[Tue Mar 04 08:58:43 2014] [notice] child pid 8219 exit signal Bus error (7)
[Tue Mar 04 08:58:58 2014] [notice] child pid 7857 exit signal Bus error (7)

動作環境は共用のいわゆるマネージドサーバなので、何が起きてるのかこちらで詳細に調査することもできません。

とりあえず、アプリケーション側で負担になりそうな処理を順々に改善(もしくは手抜き)していったところ、どうもBasic認証を高頻度で行っているのが原因だと判断せざるを得ない結果となりました。

やったこと

ファイルの内容自体は秘密性が要求されるものではないため、このような対処で済ませました。

まだ child pid *** exit signal Bus error (7) の警告が無くなったわけではありませんが、結果的にかなり減少しています。

ついでに output_handler=mb_output_handler にされていたので、これも無効化。(http_output=passだったので関係ないかも。でも気持ち悪いし初期設定でこんなの有効にしないで欲しいなぁ…)

上位プランへの移行も薦められたのですが、現状で何とか耐えられそうです。

不確かな情報ですが、Web上に同様の情報が少なかったので公開してみます。間違いだったらどなたか指摘してください。

参考にした記事

ついでにBasic認証部分のコードも適当に抜粋して載せておきます。(カスタマイズ済みSilexの利用コードだけですが…)

※SilexとPimpleのバージョンは1系なので、2系だとまた書き方変わってるかもしれません。

webapp.php

<?php

$app = include __DIR__ . '/../app.php';

use Silex\Application;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

//-----------------------------------------------
// Basic認証 + 401 XMLレスポンス
//-----------------------------------------------
$app->authentication = $app->protect(function(Request $request, Application $app) {

    $username = $request->server->get('PHP_AUTH_USER');
    $password = $request->server->get('PHP_AUTH_PW');

    if (isset($username) && strlen($username) >= 1 && isset($password) && strlen($password) >= 1) {
        // ユーザー名とパスワードをバリデーションして、データベースからユーザー情報を取得する処理
        $app->user = $user;
        return;
    }

    $data = array(
        'results' => array(
            'status' => 401,
        ),
    );

    $headers = array(
        'WWW-Authenticate' => sprintf('Basic realm="Restricted Area@%s"', $request->getHttpHost()),
    );

    $rootElement = new \SimpleXMLElement('<results />');
    $rootElement->addChild('status', $data['results']['status']);

    return $app->xml($rootElement->asXML(), $data['results']['status'], $headers);

});

//-----------------------------------------------
// XMLの値をエンティティに変換
//-----------------------------------------------
$app->escapeXmlContent = $app->protect(function($value) {
    return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
});

//-----------------------------------------------
// XMLレスポンスを返す
//-----------------------------------------------
$app->xml = $app->protect(function($xml, $status, $headers) {
    $headers['Content-Type'] = 'text/xml';
    $headers['X-Content-Type-Options'] = 'nosniff';
    return new Response($xml, $status, $headers);
});

レスポンスのXMLを生成するために SimpleXML を使ってますが、SimpleXMLElement->addChild() はエスケープしてくれないので、こういう関数を定義しといた方がいいです。

あと $app->xml() での X-Content-Type-Options: nosniff と同様に $app->json() でも継承したメソッドで $response->headers->set('X-Content-Type-Options', 'nosniff'); ってやってます。

メソッド上書きじゃなくて After Middleware とか使った方がコードが分散しなくて良いかもしれません。

index.php

<?php
$app = include __DIR__ . DIRECTORY_SEPARATOR . 'webapp.php';

use Silex\Application;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

//-----------------------------------------------
// ユーザー情報 XMLレスポンス
//-----------------------------------------------
$app->get('/user-info.xml',
    function(Application $app, Request $request)
{

    $data = array(
        'results' => array(
            'status' => 200,
        ),
    );

    $headers = array();

    $rootElement = new \SimpleXMLElement('<results />');
    $rootElement->addChild('status', $data['results']['status']);

    if ($app->user instanceof User) {
        $userElement = $rootElement->addChild('user');
        foreach ($app->user as $name => $value) {
            $userElement->addChild($name, $app->escapeXmlContent($value));
        }
    }

    return $app->xml($rootElement->asXML(), $data['results']['status'], $headers);

})
->before($app->authentication)
->bind('user-info');

$app->run();

こっちはBasic認証かますのに Before Middleware を使ってます。

正直なところ、Basic認証を止めてオレオレ認証に切り替えた方が手っ取り早いとは思うのですが、そうなるとクライアントアプリケーションのコードも変更してもらわないといけないので…。

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

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

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

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

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

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

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

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

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

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

Acme\Domain\Data\DataInterface.php

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

namespace Acme\Domain\Data;

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

    /**
     * __isset
     *
     * @param mixed
     * @return bool
     */
    public function __isset($name);

    /**
     * __get
     *
     * @param mixed
     */
    public function __get($name);

    /**
     * __clone for clone
     */
    public function __clone();

    /**
     * __sleep for serialize()
     *
     * @return array
     */
    public function __sleep();

    /**
     * __set_state for var_export()
     *
     * @param array
     * @return object
     */
    public static function __set_state($properties);

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

}

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

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

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

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

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

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

Acme\Domain\Data\DataTrait.php

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

namespace Acme\Domain\Data;

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

    /**
     * __construct()
     *
     * @param array プロパティの配列
     */
    public function __construct(array $properties = [])
    {
        foreach (array_keys(get_object_vars($this)) as $name) {
            $this->{$name} = null;
            if (array_key_exists($name, $properties)) {
                $value = (is_object($properties[$name]))
                    ? clone $properties[$name]
                    : $properties[$name];
                $camelize = $this->camelize($name);
                if (method_exists($this, 'set' . $camelize)) {
                    $this->{'set' . $camelize}($value);
                } else {
                    $this->{$name} = $value;
                }
                unset($properties[$name]);
            }
        }
        if (count($properties) !== 0) {
            throw new \InvalidArgumentException(
                sprintf('Not supported properties [%s]',
                    implode(',', array_keys($properties))
                )
            );
        }
    }

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

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

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

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

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

    /**
     * __sleep for serialize()
     *
     * @return array
     */
    public function __sleep()
    {
        return array_keys(get_object_vars($this));
    }

    /**
     * __set_state for var_export()
     *
     * @param array
     * @return object
     */
    public static function __set_state($properties)
    {
        return new self($properties);
    }

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

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

}

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

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

<?php

public function __construct(array $properties = [])
{
    foreach (array_keys(get_object_vars($this)) as $name) {
        $this->{$name} = null;
        if (array_key_exists($name, $properties)) {
            $value = (is_object($properties[$name]))
                ? clone $properties[$name]
                : $properties[$name];
            $camelize = $this->camelize($name);
            if (method_exists($this, 'set' . $camelize)) {
                $this->{'set' . $camelize}($value);
            } else {
                $this->{$name} = $value;
            }
            unset($properties[$name]);
        }
    }
    if (count($properties) !== 0) {
        throw new \InvalidArgumentException(
            sprintf('Not supported properties [%s]',
                implode(',', array_keys($properties))
            )
        );
    }
}

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

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

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

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

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

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

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

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

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

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

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

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

<?php

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

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

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

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

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

<?php

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

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

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

<?php

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

こちらも同様です。

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

<?php

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

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

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

<?php

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

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

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

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

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

<?php

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

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

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

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

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

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

<?php

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

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

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

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

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

<?php

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

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

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

<?php

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

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

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

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

Userドメインデータ

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

Acme\Domain\Data\User.php

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

namespace Acme\Domain\Data;

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

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

    use DataTrait;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

}

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

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

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

噂のPhalcon FrameworkをWindowsで動かしてみた

"The fastest PHP Framework" を標榜する Phalcon フレームワーク、実はWindows版もあるんです。

Phalcon for Windows を入れる

extension として実装されてるわけですが、Windowsの場合は DLL ファイルを置くだけなのである意味Linux版よりもインストールは楽ちんです。

環境に合うものをダウンロードして展開、php_phalcon.dll を php.ini で設定された extension_dir に配置します。

(C:\phpPHPをインストールした場合、多分 C:\php\ext になるはず)

php.iniへの設定追加も忘れずに。

php.ini

extension=php_phalcon.dll

Phalcon DevTools を入れて phalcon コマンドの準備

Phalcon の流儀に従って一からファイルを作成してもいいんですが…

Phalcon DevTools に付属の phalcon コマンドを使えば、プロジェクトのひな形を作成してくれるので、最初はこれに頼った方がいいかなぁと。

Composer でのインストールにも対応していますが、プロジェクト単位に配置する物ではないので、普通に git clone して、クライアントアプリケーションと考えて適当な場所に入れた方がいいでしょう。

私の場合は C:\Applications\phalcon-devtools に配置しました。

Windows用に用意されている phalcon.bat の中身はこんな感じになってます。

phalcon.bat

@echo off

set PTOOLSPATH=%~dp0\
php %PTOOLSPATH%\phalcon.php %*

ここにパスを通すなり、シンボリックリンクやジャンクションを作成するなりします。

私の場合はシェルにNYAOSを使ってるので _nya 設定ファイルにこんな感じで alias の設定を追加しました。

%HOME%_nya

alias phalcon C:\Applications\phalcon-devtools\phalcon.bat

なお公式ドキュメントでは c:\phalcon-tools に入れて、このパスをPath環境変数に追加することを推奨しています。

phalcon コマンドでプロジェクトの雛形を作成

コマンドラインで phalcon project と入力すると、ヘルプが表示されます。

$ phalcon project

Phalcon DevTools (1.2.4)

Help:
  Creates a project

Usage:
  project [name] [type] [directory] [enable-webtools]

Arguments:
  help  Shows this help text

Example
  phalcon project store simple

Options:
 --name               Name of the new project
 --enable-webtools    Determines if webtools should be enabled [optional]
 --directory=s        Base path on which project will be created [optional]
 --type=s             Type of the application to be generated (micro, simple, modules)
 --template-path      Specify a template path [optional]
 --use-config-ini     Use a ini file as configuration file [optional]
 --trace              Shows the trace of the framework in case of exception. [optional]
 --help               Shows this help

これによると "project [name] [type] [directory] [enable-webtools]" とあります。

type オプションには micro, simple, modules の三種類あって、プロジェクトのスタイルによっていずれかを選択すればいいようです。

スタイルの違いはこちらが参考になります。

「simple」の場合は controllers ディレクトリにコントローラクラスを配置するのに対して、「micro」の場合は app.php にルーティング設定とハンドラを無名関数で定義するスタイルのようです。

ここは、Silex好きな私には馴染み良さそうな感じの micro で。

$ phalcon project test micro

Phalcon DevTools (1.2.4)


  Success: Project 'test' was successfully created.

testディレクトリが作成され、中には .htaccess, app.php, index.html ファイルと、.phalcon, config, modules, public, views ディレクトリが作成されています。

流れを追ってみます。

.htaccess

<IfModule mod_rewrite.c>
    RewriteEngine on
    RewriteRule  ^$ public/    [L]
    RewriteRule  (.*) public/$1 [L]
</IfModule>

リクエストされたパスの前に public/ を付けて…

public/.htaccess

AddDefaultCharset UTF-8

<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteRule ^(.*)$ index.php?_url=/$1 [QSA,L]
</IfModule>

public/ で受けたパスを index.php に _url パラメータで渡して…

public/index.php

<?php

error_reporting(E_ALL);

try {

    /**
    * Read the configuration
    */
    $config = include __DIR__ . '/../config/config.php';

    /**
    * Include Services
    */
    include __DIR__ . '/../config/services.php';

    /**
    * Include Autoloader
    */
    include __DIR__ . '/../config/loader.php';

    /**
    * Starting the application
   */
    $app = new \Phalcon\Mvc\Micro();

    /**
    * Assign service locator to the application
    */
    $app->setDi($di);

    /**
    * Incude Application
    */
    include __DIR__ . '/../app.php';

    /**
    * Handle the request
    */
    $app->handle();

} catch (Phalcon\Exception $e) {
    echo $e->getMessage();
} catch (PDOException $e){
    echo $e->getMessage();
}

設定ファイル(.phpファイル!)を読み込んで、DIコンテナにサービスを登録して、オートローダーを読み込んで、アプリケーションオブジェクト(Phalcon\Mvc\Micro)を生成して実行しているようです。

例外処理とかすっごい雑ですが、あくまで雛形なので、好きに変えちゃっていい…のでしょうか。

ルーティング設定は、途中で読み込まれる app.php で行われています。

app.php

<?php

/**
 * Add your routes here
 */
$app->get('/', function () use ($app) {
    echo $app['view']->getRender(null, 'index');
});

/**
 * Not found handler
 */
$app->notFound(function () use ($app) {
    $app->response->setStatusCode(404, "Not Found")->sendHeaders();
    echo $app['view']->getRender(null, '404');
});

こういう突然変数が現れる書き方も好きじゃないんですが、なんとなく流れは分かりました。

ビルトインWebサーバ用のルーティングスクリプトを作成

せっかくなので、ビルトインWebサーバで実行できるようにします。

プロジェクト内の public ディレクトリ直下にルーティングスクリプトを作成します。

public/.router.php

<?php
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

if (file_exists(realpath(__DIR__ . $path))) {
    return false;
}
$_GET['_url'] = $_SERVER['REQUEST_URI'];
require __DIR__ . DIRECTORY_SEPARATOR . 'index.php';

Windowsなので、ビルトインWebサーバ起動用のショートカットを作成するのが楽だと思います。

リンク先を C:\php\php.exe -S 127.0.0.1:8080 .router.php として、作業フォルダーにドキュメントルートを指定します。

ビルトインWebサーバを起動して、ブラウザで 127.0.0.1:8080 を見てみます。

ヤッター ∩( ・ω・)∩ とりあえず動くところまで確認できました。

GitHubに置いてるライブラリをCoverallsに対応した

自分のライブラリを Travis CI でのユニットテストに対応しましたが、このたびカバレッジレポートを公開できるサービス Coveralls にも対応したメモです。

恥ずかしながらこれまでカバレッジレポートの意義が理解できてなかったんですが、 php-coveralls を使えば簡単に Travis CI から Coveralls にレポートを転送して可視化してくれるようなので、試しにやってみました。

Coveralls への登録

CoverallsへのサインインはGitHubアカウントがあればOK。

入るとGitHubのリポジトリ一覧が表示されるので、Coverallsへのサービスフックを有効にしたいリポジトリのトグルスイッチを「ON」に設定します。

GitHubへのAPIトークンの設定なども、自動でやってくれます。

以下、 Volcanus_Routing の例

PHPUnit設定ファイルにロギングとカバレッジレポートの設定を追加する

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="Volcanus_Routing">
            <directory suffix="Test.php">./Tests/</directory>
        </testsuite>
    </testsuites>
    <filter>
        <whitelist>
            <directory>.</directory>
            <exclude>
                <directory>./Tests</directory>
                <directory>./vendor</directory>
            </exclude>
        </whitelist>
    </filter>
    <logging>
        <log type="coverage-text" target="php://stdout" showUncoveredFiles="true"/>
    </logging>
</phpunit>

上記では <filter> でカレントディレクトリ以下をカバレッジレポートの対象に、 Tests および vendor ディレクトリ以下を除外としています。

また <logging> で phpunitコマンド実行時のレポートを標準出力に出すようにしています。

Coverallsとの連携にはClover形式のXMLを出力する必要があるのですが、ローカルでの実行時には不要だし自動テストも遅くなるので、こちらには記述せず Travis CI で実行するphpunitコマンドに引数で指定しました。

詳しくは PHPUnitマニュアルの 第18章 ログ出力 および 付録C XML 設定ファイル を参照。

Composerでphp-coverallsを追加する

satooshi/php-coveralls を Composer でインストールしますが、このライブラリ(Volcanus_Routing) 自体の動作には必要ないものなので、composer.json には "require-dev" として設定します。

composer.json

{
    "name": "volcanus/routing",
    "homepage": "https://github.com/k-holy/Volcanus_Routing",
    "type": "library",
    "description": "request-URI routing for page-controller scripts.",
    "version": "0.3.3",
    "license": "MIT",
    "authors": [
        {
            "name": "k-holy",
            "email": "k.holy74@gmail.com"
        }
    ],
    "autoload": {
        "psr-0": { "Volcanus\\Routing": "." }
    },
    "target-dir": "Volcanus/Routing",
    "require": {
        "php": ">=5.3.3"
    },
    "require-dev": {
        "satooshi/php-coveralls": "dev-master"
    }
}

php-coveralls が何をやってくれるかというと、作者の方のブログ記事 PHPでもCoverallsできるよ! - satooshi@blog によると

php-coverallsは、これ自体でカバレッジを計測するわけではなくて、計測されたコードカバレッジAPIに渡している。

とのことで、Travis CIでPHPUnitを実行して出力したカバレッジレポートを、APIを利用してCoverallsに登録するための coveralls コマンドをインストールしてくれるようです。

なので流れとしては、Travis CI の実行環境で composer install --dev して php-coveralls をインストール → phpunit コマンドでレポートを出力 → coveralls コマンドで Coveralls に送信 となります。

Travis CI用の設定ファイルにphp-coverallsインストールとcoverallsコマンドを追加する

Travis CI用の設定ファイル .travis.yml に前述の流れを追加します。

.travis.yml

language: php

php:
  - 5.3.3
  - 5.3
  - 5.4
  - 5.5

before_script:
  - composer install --dev --no-interaction --prefer-source

script:
  - mkdir -p build/logs
  - phpunit -c phpunit.xml --coverage-clover build/logs/clover.xml

after_script:
  - php vendor/bin/coveralls -v -c .coveralls.yml

composerコマンドで --dev オプションを付けて php-coveralls をインストールして、ログファイル出力用のディレクトリを作成して、phpunitコマンドで --coverage-clover オプションを付けて Coveralls用のレポートを出力します。

phpunit.xmlリポジトリルートに置いてあるので、phpunitコマンドの -c オプションは必要ないはずですが、自分が忘れないために明示してます。

coveralls コマンドでは -c オプションで php-coveralls の設定ファイルを指定します。

-v オプションについては Wiki の Troubleshooting ・ satooshi/php-coveralls Wiki に「Failed to submit json_file!」とあったので指定してみました。

php-coveralls用の設定ファイルをリポジトリに追加する

.coveralls.yml

src_dir: .
coverage_clover: build/logs/clover.xml
json_path: build/logs/coveralls-upload.json
exclude_no_stmt: true

"src_dir" で計測対象となるソースファイルのルートディレクトリを指定、"coverage_clover" で Clover形式のXMLレポートファイルの場所を指定します。ここは前述の phpunitコマンドの --coverage-clover オプションでの出力先に合わせます。

"json_path" でCoveralls APIで登録するためのJSONファイルの出力先を指定します。内部動作を追ってはいないので分かりませんが、README の通りの内容で問題なく動作しました。

"exclude_no_stmt" は Wiki の Troubleshooting ・ satooshi/php-coveralls Wiki に「Want to ignore interface files from coveralls stats」とあったので指定してみました。

インタフェースやメソッドの実装が無い例外クラスなどを定義することがありますが、それらをレポート結果から除外してくれるオプションのようです。

詳しくは README satooshi/php-coveralls を参照。

Coverallsで結果を確認する

ここまで対象のリポジトリに必要な設定ファイルなどを追加した上で、Coveralls上でも連携が有効になっているか確認します。

REPOS または UPDATES から「ADD REPO」です。

OKならGitHubにpushすると、まずは Travis CI への連携でユニットテストが実行された後、順次 Coveralls にも更新内容が上がってきます。

こんな感じで、UPDATES にはどのリポジトリのどのブランチに誰がpushしたかと、その結果カバレッジがどう変化したかの概要が一覧で表示されてきます。

リポジトリの詳細に入ると、現在のカバレッジとともにビルド履歴が表示されます。

(なぜか TIME 列の値がおかしいですが、どうせコミッタは自分だけなので、とりあえずおいときます…。)

ビルドの詳細に入ると、FILES の欄にファイルの一覧が表示されます。 ALL で全て、CHANGES でこのビルドで変更されたファイルのみ表示されます。

(上記の画像は適切なフィルタ設定や .coveralls.yml で exclude_no_stmt を有効にする前のものなので、例外クラスのファイルが軒並み「0%」で表示されてたり、テスト用の中身の無いPHPファイルが表示されています…)

ちなみにこの一覧、JavaScriptで非同期に取得しているようでページャやソート機能も付いてますが、ブラウザの履歴には反映されないので、ちょっと使いづらいです。(Operaだけでしょうか?)

「MISSED」の多いファイルを開いて、コードの赤い部分に注目すると、こんな感じでテストケースで実行されていない箇所が把握できます。

手の抜きどころがもろバレです。これは恥ずかしい…!

Travis CIなどと同様に、READMEにバッジを表示するためのコードも取得できます。

リポジトリのREADMEにバッジを表示させてから「REFRESH」ボタンを押すと、この部分は表示されなくなります。

Coverallsの効果

Coveralls以前にカバレッジレポート自体これまで気にしてなかったのですが、テストケースがどの程度実際のコードの流れに反映されているかが分かるようになり、テストの書き忘れや自分が書くテストの癖に気付くことができました。

面白く感じたのは、あるクラスのリファクタリングを行ってクラスを分割したものの、新たに追加したクラスのユニットテストは面倒で書かなかったのですが、カバレッジは下がっていなかったことです。

要はリファクタリング前のテストケースの時点で今回別クラスに分離したロジックのテストも済んでいたため、テストケースを追加しなくても影響がなかったわけですが、これは安心感に繋がると思いました。

ただ、数値は高いに越したことはないのですが、100%を達成するためにコードの質を落としてしまうようでは本末顛倒です。

たとえば自分はメソッドの引数で連想配列を使ったよくある $options 形式の引数を扱う場合、無効なキーや想定外の値を検証して例外をスローするコードを仕込むことが多いです。

そういう連想配列の検証は廃止した方が動作は早くなるし、テストケースの網羅率も上げやすくなりますが、自分はよく配列のキーをtypoするので、これをやっておかないと安心して使えません。

しかし、そのテストを書くのは手間が惜しいということで、この場合については数値が下がっても構わないと割り切ることにしました。

ファイル単位でその結果を把握できていれば、時間ができた時に(これがなかなか難しいのですが…)そこを埋めることもできますし、レポートがいつでも確認できる状態にあることは大きな助けになるんじゃないでしょうか。

マイクロフレームワークをつくろう - Pimpleの上に (フォームオブジェクトとドメインデータで投稿フォーム)

Pimpleを拡張して自分好みに使うために作成した小さなアプリケーションクラスを使って、マイクロフレームワークっぽいものを作る試みです。

記事にはしていないものの、コードの方は頻繁に更新しています。

フレームワーク全体に影響する部分としては以下のような変更を行いました。

  • 例外処理用のクラスを Volcanus_Error に統合した
  • テンプレートレンダラクラスの構成を変更し、volcanus-template-renderer として独立させた
  • 設定値へのアクセス用クラスの内容を volcanus-configuration として独立させた
  • データベース処理用クラスを作成し、volcanus-database として独立させた
  • Twitter Bootstrapのバージョンを2系から3に切り替え、外部のCDNおよびFont Awesomeの利用を廃止した

汎用クラス DataObject の実装

ここのところ、マジックメソッド、ArrayAccessインタフェース、Traversableインタフェースを実装した配列風のクラスに関する記事を書きましたが

これらの試行錯誤を経て、汎用クラス DataObject の実装が固まりました。

src/Acme/DataObject.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */

namespace Acme;

/**
 * データオブジェクト
 *
 * @author k.holy74@gmail.com
 */
class DataObject implements \ArrayAccess, \IteratorAggregate
{

    /**
     * @var array 属性値の配列
     */
    protected $attributes;

    /**
     * コンストラクタ
     *
     * @param array 属性の配列
     */
    public function __construct($attributes = array())
    {
        if (!is_array($attributes) && !($attributes instanceof \Traversable)) {
            throw new \InvalidArgumentException(
                sprintf('The attributes is not Array and not Traversable. type:"%s"',
                    (is_object($attributes)) ? get_class($attributes) : gettype($attributes)
                )
            );
        }
        $this->attributes = array();
        foreach ($attributes as $name => $value) {
            $this->attributes[$name] = $value;
        }
    }

    /**
     * ArrayAccess::offsetGet()
     *
     * @param mixed
     * @return mixed
     */
    public function offsetGet($name)
    {
        if (array_key_exists($name, $this->attributes)) {
            return $this->attributes[$name];
        }
        return null;
    }

    /**
     * ArrayAccess::offsetSet()
     *
     * @param mixed
     * @param mixed
     */
    public function offsetSet($name, $value)
    {
        $this->attributes[$name] = $value;
    }

    /**
     * ArrayAccess::offsetExists()
     *
     * @param mixed
     * @return bool
     */
    public function offsetExists($name)
    {
        return array_key_exists($name, $this->attributes);
    }

    /**
     * ArrayAccess::offsetUnset()
     *
     * @param mixed
     */
    public function offsetUnset($name)
    {
        if (array_key_exists($name, $this->attributes)) {
            $this->attributes[$name] = null;
        }
    }

    /**
     * magic getter
     *
     * @param string 属性名
     */
    public function __get($name)
    {
        return $this->offsetGet($name);
    }

    /**
     * magic setter
     *
     * @param string 属性名
     * @param mixed 属性値
     */
    public function __set($name, $value)
    {
        $this->offsetSet($name, $value);
    }

    /**
     * magic isset
     *
     * @param string 属性名
     * @return bool
     */
    public function __isset($name)
    {
        return $this->offsetExists($name);
    }

    /**
     * magic unset
     *
     * @param string 属性名
     */
    public function __unset($name)
    {
        $this->offsetUnset($name);
    }

    /**
     * magic call method
     *
     * @param string
     * @param array
     */
    public function __call($name, $args)
    {
        if (array_key_exists($name, $this->attributes) && $this->attributes[$name] instanceof \Closure) {
            return call_user_func_array($this->attributes[$name], $args);
        }
        throw new \BadMethodCallException(
            sprintf('Undefined Method "%s" called.', $name)
        );
    }

    /**
     * __toString
     */
    public function __toString()
    {
        return var_export($this->toArray(), true);
    }

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

    /**
     * 配列に変換して返します。
     *
     * @return array
     */
    public function toArray()
    {
        $values = array();
        foreach (array_keys($this->attributes) as $name) {
            $values[$name] = $this->offsetGet($name);
        }
        ksort($values);
        return $values;
    }

}

フォームオブジェクトの仮実装

DataObjectの利用例として、今回はPHPTALテンプレートの記述を簡潔化できるよう、フォームオブジェクトの存在を想定し、 その実装の代わりとして、以下のようなオブジェクトを返すメソッドをアプリケーションに定義しました。

フォームオブジェクトといっても項目の値やエラーの状態を管理するだけのもので、HTMLの出力などは行いません。

app/app.php より一部抜粋

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * Web共通初期処理
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
$app = include realpath(__DIR__ . '/../app/app.php');

use Acme\Application;
use Acme\DataObject;

// …中略…

//-----------------------------------------------------------------------------
// フォームを生成する
//-----------------------------------------------------------------------------
$app->createForm = $app->protect(function($attributes) use ($app) {

    $elements = [];

    foreach ($attributes as $id => $value) {
        $element = new DataObject();
        $element->value = $value;
        $element->error = null;
        $element->isError = function() use ($element) {
            return !is_null($element->error);
        };
        $elements[$id] = $element;
    }

    $form = new DataObject($elements);

    $form->hasError = function() use ($form) {
        foreach ($form as $element) {
            if (false === ($element instanceof DataObject)) {
                continue;
            }
            if ($element->isError()) {
                return true;
            }
        }
        return false;
    };

    $form->getErrors = function() use ($form) {
        $errors = [];
        foreach ($form as $element) {
            if (false === ($element instanceof DataObject)) {
                continue;
            }
            if ($element->isError()) {
                $errors[] = $element->error;
            }
        }
        return $errors;
    };

    return $form;
});

このように定義することで、投稿フォームのテンプレートはこんな感じになりました。(Bootstrap2 → Bootstrap3への変更やフォームのサイズ変更も実施済み)

www/comment.html

<!DOCTYPE html>
<html lang="ja">

<head metal:use-macro="__layout.html/head">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css" />
<script src="/js/jquery-1.9.1.min.js"></script>
<script src="/bootstrap/js/bootstrap.min.js"></script>
<title>投稿フォーム</title>
</head>

<body metal:use-macro="__layout.html/body">

<div class="container">

  <header class="header">
    <h1>投稿フォーム@example.com</h1>
  </header>

  <div class="content" metal:fill-slot="content">

    <div class="alert alert-danger" tal:condition="form/hasError">
      <button class="close" data-dismiss="alert">×</button>
      <span class="glyphicon glyphicon-warning-sign"></span><strong>入力値にエラーがあります</strong>
      <ul>
        <li tal:repeat="error form/getErrors" tal:content="error">名前を入力してください。</li>
      </ul>
    </div>

    <form class="form-horizontal" role="form" method="post" tal:attributes="action server/REQUEST_URI">
      <input type="hidden" name="${token/name}" value="${token/value}" tal:condition="exists:token" />
      <fieldset>
        <legend><span class="glyphicon glyphicon -comment"></span>投稿フォーム</legend>
        <div class="form-group" tal:attributes="class php:form.author.isError() ? 'form-group has-error' : 'form-group'">
          <label class="col-md-2 control-label">名前</label>
          <div class="col-md-5">
            <input type="text" name="author" class="form-control" tal:attributes="value form/author/value" />
          </div>
          <div class="col-md-5" tal:condition="form/author/isError">
            <p class="help-block" tal:content="form/author/error">名前を入力してください。</p>
          </div>
        </div>
        <div class="form-group" tal:attributes="class php:form.comment.isError() ? 'form-group has-error' : 'form-group'">
          <label class="col-md-2 control-label">コメント</label>
          <div class="col-md-5">
            <textarea name="comment" rows="5" class="form-control" tal:content="form/comment/value">コメント内容....</textarea>
          </div>
          <div class="col-md-5" tal:condition="form/comment/isError">
            <p class="help-block" tal:content="form/comment/error">コメントを入力してください。</p>
          </div>
        </div>
        <div class="form-group">
          <div class="col-md-offset-2 col-md-5">
            <input type="submit" value="送信" class="btn btn-primary btn-lg" />
          </div>
        </div>
      </fieldset>
    </form>

  </div>

  <footer class="footer">
    <p>Copyright &copy; 2013 k-holy &lt;k.holy74@gmail.com&gt; Code licensed under <a href="http://opensource.org/licenses/MIT">MIT</a></p>
  </footer>

</div>

</body>
</html>

エラーの情報をフォームオブジェクトを通じてアクセスするようになったので、たとえばフォーム上部のエラー表示部分はこう変わりました。

変更前

<div class="alert alert-error" tal:condition="php:count(errors) > 0">
  <button class="close" data-dismiss="alert">×</button>
  <span class="glyphicon glyphicon-warning-sign"></span><strong>入力値にエラーがあります</strong>
  <ul>
    <li tal:repeat="error errors" tal:content="error">名前を入力してください。</li>
  </ul>
</div>

変更後

<div class="alert alert-danger" tal:condition="form/hasError">
  <button class="close" data-dismiss="alert">×</button>
  <span class="glyphicon glyphicon-warning-sign"></span><strong>入力値にエラーがあります</strong>
  <ul>
    <li tal:repeat="error form/getErrors" tal:content="error">名前を入力してください。</li>
  </ul>
</div>

これまでエラー情報を格納していた配列 $errors がなくなった代わりに、フォームオブジェクトのメソッドを呼び出しています。

フォームの項目部分はこのように変わりました。

変更前

<dl class="form-group" tal:attributes="class php:isset(errors['author']) ? 'form-group has-error' : 'form-group'">
  <dt class="col-lg-2 control-label">名前</dt>
  <dd class="col-lg-8">
    <input type="text" name="author" class="form-control" tal:attributes="value form/author" />
  </dd>
</dl>

変更後

<div class="form-group" tal:attributes="class php:form.author.isError() ? 'form-group has-error' : 'form-group'">
  <label class="col-md-2 control-label">名前</label>
  <div class="col-md-5">
    <input type="text" name="author" class="form-control" tal:attributes="value form/author/value" />
  </div>
  <div class="col-md-5" tal:condition="form/author/isError">
    <p class="help-block" tal:content="form/author/error">名前を入力してください。</p>
  </div>
</div>

フォームオブジェクトの導入によって、値の出力部分のパスは一つ階層が深くなりましたが、 php: 式を使うことなく tal:condition 属性による要素の表示条件、 tal:content 属性による要素の内容出力を定義できました。

また、タグをDLからDIVに変えたのと、エラーメッセージの項目別出力を追加しています。

DataTrait

データベースへのコメント保存機能を実装するにあたり、ドメインデータとデータアクセス層の分離を目標としました。

データベース取得した目的のデータをドメインデータに反映させる処理は必要になりますが、ドメインデータ側では属性値を受け取るのみで、間の処理には一切関与しないようにします。

ドメインデータの基底クラス的な扱いの DataTrait はこんな実装にしました。

src/Acme/Domain/Data/DataTrait.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */

namespace Acme\Domain\Data;

/**
 * ドメインデータTrait
 *
 * @author k.holy74@gmail.com
 */
trait DataTrait
{

    /**
     * 属性値を初期化します。
     *
     * @param array 属性値
     * @return self
     */
    public function setAttributes($attributes = array())
    {
        foreach ($attributes as $name => $value) {
            $this->offsetSet($name, $value);
        }
        return $this;
    }

    /**
     * ArrayAccess::offsetGet()
     *
     * @param mixed
     * @return mixed
     */
    public function offsetGet($name)
    {
        if (method_exists($this, 'get_' . $name)) {
            return $this->{'get_' . $name}();
        }
        $camelize = $this->camelize($name);
        if (method_exists($this, 'get' . $camelize)) {
            return $this->{'get' . $camelize}();
        }
        if (array_key_exists($name, $this->attributes)) {
            return $this->attributes[$name];
        }
        return null;
    }

    /**
     * ArrayAccess::offsetSet()
     *
     * @param mixed
     * @param mixed
     */
    public function offsetSet($name, $value)
    {
        if (method_exists($this, 'set_' . $name)) {
            return $this->{'set_' . $name}($value);
        }
        $camelize = $this->camelize($name);
        if (method_exists($this, 'set' . $camelize)) {
            return $this->{'set' . $camelize}($value);
        }
        if (array_key_exists($name, $this->attributes)) {
            $this->attributes[$name] = $value;
        }
    }

    /**
     * ArrayAccess::offsetExists()
     *
     * @param mixed
     * @return bool
     */
    public function offsetExists($name)
    {
        return array_key_exists($name, $this->attributes);
    }

    /**
     * ArrayAccess::offsetUnset()
     *
     * @param mixed
     */
    public function offsetUnset($name)
    {
        if (array_key_exists($name, $this->attributes)) {
            $this->attributes[$name] = null;
        }
    }

    /**
     * magic getter
     *
     * @param string 属性名
     */
    public function __get($name)
    {
        return $this->offsetGet($name);
    }

    /**
     * magic setter
     *
     * @param string 属性名
     * @param mixed 属性値
     */
    public function __set($name, $value)
    {
        $this->offsetSet($name, $value);
    }

    /**
     * magic isset
     *
     * @param string 属性名
     * @return bool
     */
    public function __isset($name)
    {
        return $this->offsetExists($name);
    }

    /**
     * magic unset
     *
     * @param string 属性名
     */
    public function __unset($name)
    {
        $this->offsetUnset($name);
    }

    /**
     * __toString
     */
    public function __toString()
    {
        return var_export($this->toArray(), true);
    }

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

    /**
     * 配列に変換して返します。
     *
     * @return array
     */
    public function toArray()
    {
        $values = array();
        foreach (array_keys($this->attributes) as $name) {
            $values[$name] = $this->offsetGet($name);
        }
        return $values;
    }

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

}

getIterator() と toArray() の実装内容についてはまだ悩んでいるところですが、前者が属性値の ArrayIterator をそのまま返す、後者が全ての属性に対して offsetGet() で取得した値を配列で返すという違いがあります。

camelize() については別クラスに実装すべき内容かもしれませんが、この程度の処理のためだけに依存を作ってしまうのもどうかと思い、Traitに実装しました。(適切な名前空間やクラス名を考えるのも面倒ですし…)

なお PHP 5.3 では Trait が使えないため Abstract クラスとして実装することになりますが、その場合はアクセス修飾子を private ではなく protected に変更する必要があります。(やらかしました)

ドメインデータ

上記 DataTrait を利用して、ArrayAccess および IteratorAggregate を実装したドメインデータクラス、Commentクラスです。

src/Acme/Domain/Data/Comment.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */

namespace Acme\Domain\Data;

use Acme\DateTime;

/**
 * コメント
 *
 * @author k.holy74@gmail.com
 */
class Comment implements \ArrayAccess, \IteratorAggregate
{
    use DataTrait;

    private $datetimeFormat;
    private $timezone;
    private $attributes = [];

    public function __construct($attributes = array(), $options = array())
    {
        $this->initialize($attributes, $options);
    }

    /**
     * プロパティを初期化します。
     *
     * @param array プロパティ
     * @return self
     */
    public function initialize($attributes = array(), $options = array())
    {
        if (!isset($options['timezone'])) {
            throw new \InvalidArgumentException('Required option "timezone" is not appointed.');
        }
        $this->setTimezone($options['timezone']);
        $this->datetimeFormat = isset($options['datetimeFormat']) ? $options['datetimeFormat'] : 'Y-m-d H:i:s';
        $this->attributes = [
            'author'    => null,
            'comment'   => null,
            'posted_at' => null,
        ];
        $this->setAttributes($attributes);
        return $this;
    }

    /**
     * DateTimeZoneオブジェクトをセットします。
     *
     * @param \DateTimeZone タイムゾーン
     */
    public function setTimezone(\DateTimeZone $timezone)
    {
        $this->timezone = $timezone;
    }

    /**
     * setter for posted_at
     *
     * @param mixed
     */
    public function set_posted_at($datetime)
    {
        if (false === ($datetime instanceof DateTime)) {
            $datetime = new DateTime($datetime, $this->datetimeFormat);
        }
        $datetime->setTimezone($this->timezone);
        $this->attributes['posted_at'] = $datetime->getTimestamp(); // 実体はUnixTimestampで保持
    }

    /**
     * getter for posted_at
     *
     * @return \Acme\DateTime
     */
    public function get_posted_at()
    {
        if (isset($this->attributes['posted_at'])) {
            $datetime = new DateTime($this->attributes['posted_at'], $this->datetimeFormat); // UnixTimestampで保持している値をDateTimeクラスで変換して出力
            $datetime->setTimezone($this->timezone);
            return $datetime;
        }
        return null;
    }

}

プロパティ posted_at への値セット時に DataTrait::offsetSet() 経由で呼ばれるのが set_posted_at() メソッドですが、日付文字列とタイムスタンプのどちらも受け付けるようにするために、Acme\DateTime および DateTimezone クラスに依存しています。

結果的に PSR-1 に違反するメソッド名が定義されてますが、これを気にすると実質的にプロパティ名にもアンダースコアを利用できなくなってしまうので、あえて無視しました。

また、「ドメインデータとデータアクセス層を分離」といいつつ、DateTimeの値を内部でint値に変換して保持している理由が、データの保存先が日付型をサポートしていない SQLite だからというのも微妙ですが、こういう使い方も想定できるという例示ということで。(苦しい)

また、このドメインデータを簡単に生成できるよう、アプリケーションオブジェクトに以下を定義しました。

app/app.php より一部抜粋

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * Web共通初期処理
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
$app = include realpath(__DIR__ . '/../app/app.php');

// …中略…

//-----------------------------------------------------------------------------
// ドメインデータファクトリ
//-----------------------------------------------------------------------------
$app->createData = $app->protect(function($name, $attributes = array(), $options = array()) use ($app) {
    $class = '\\Acme\\Domain\\Data\\' . ucfirst($name);
    if (!class_exists($class, true)) {
        throw new \InvalidArgumentException(
            sprintf('The Domain Data "%s" is not found.', $name)
        );
    }
    switch ($name) {
    case 'comment':
        if (!isset($attributes['posted_at'])) {
            $attributes['posted_at'] = $app->clock;
        }
        if (!isset($options['timezone'])) {
            $options['timezone'] = $app->timezone;
        }
        break;
    }
    return new $class($attributes, $options);
});

switch文を使ってるというだけで怒られそうな昨今ですが、自分としては設定ファイルや謎規約よりもコードで示す方が好きなので…。

アプリケーションオブジェクトの利用側コードから、依存オブジェクトの生成を省略できるだけでも充分だと考えました。

データベース抽象化レイヤ

データベース抽象化レイヤとして PDO をベースに作成したのが volcanus-database ですが、 単にPDOをラッピングするのではなく、データベースとの接続とSQLの実行やメタデータを扱うドライバクラス、実行したSQLの結果を保持するイテレータとなるステートメントクラス、 トランザクション制御を行うトランザクションクラスに分けてインタフェースを定義しました。

データベースドライバとトランザクションの生成は、例のごとくアプリケーションオブジェクト経由で行います。

app/app.php より一部抜粋

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * Web共通初期処理
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
$app = include realpath(__DIR__ . '/../app/app.php');

// …中略…

use Volcanus\Database\Driver\Pdo\PdoDriver;
use Volcanus\Database\Driver\Pdo\PdoTransaction;
use Volcanus\Database\MetaDataProcessor\SqliteMetaDataProcessor;

// …中略…

//-----------------------------------------------------------------------------
// PDO
//-----------------------------------------------------------------------------
$app->pdo = $app->share(function(Application $app) {
    try {
        $pdo = new \PDO($app->config->database->dsn);
        $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
    } catch (\PDOException $e) {
        throw new \RuntimeException(
            sprintf('Invalid DSN: "%s"', $app->config->database->dsn)
        );
    }
    return $pdo;
});

//-----------------------------------------------------------------------------
// データベースドライバ
//-----------------------------------------------------------------------------
$app->db = $app->share(function(Application $app) {
    return new PdoDriver($app->pdo, new SqliteMetaDataProcessor());
});

//-----------------------------------------------------------------------------
// データベーストランザクション
//-----------------------------------------------------------------------------
$app->transaction = $app->share(function(Application $app) {
    return new PdoTransaction($app->pdo);
});

メタデータの取得方法についてはDBMS依存となるため、PdoDriver のコンストラクタに MetaDataProcessorInterface を実装したオブジェクトを渡す仕様としています。

今回は SQLite を使いますので、SqliteMetaDataProcessor を生成しています。

なお、複数データベースへの対応については今のところ必要ないため考慮していません。

コメント投稿フォームの実装

これらの実装によって、コメント投稿フォームのページスクリプトはこのようになりました。

www/comment.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * 投稿フォーム
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
$app = include __DIR__ . DIRECTORY_SEPARATOR . 'app.php';

$app->on('GET|POST', function($app, $method) {

    $form = $app->createForm([
        'author'  => $app->findVar('P', 'author'),
        'comment' => $app->findVar('P', 'comment'),
    ]);

    if ($method === 'POST') {

        // CSRFトークンの検証
        if (!$app->csrfVerify('P')) {
            $app->abort(403, 'リクエストは無効です。');
        }

        // 投稿フォーム処理
        if (strlen($form->author->value) === 0) {
            $form->author->error = '名前を入力してください。';
        } elseif (mb_strlen($form->author->value) > 20) {
            $form->author->error = '名前は20文字以内で入力してください。';
        }

        if (strlen($form->comment->value) === 0) {
            $form->comment->error = 'コメントを入力してください。';
        } elseif (mb_strlen($form->comment->value) > 50) {
            $form->comment->error = 'コメントは50文字以内で入力してください。';
        }

        if (!$form->hasError()) {

            $comment = $app->createData('comment', [
                'author'  => $form->author->value,
                'comment' => $form->comment->value,
            ]);

            $statement = $app->db->prepare(<<<'SQL'
INSERT INTO comments (
    author
   ,comment
   ,posted_at
) VALUES (
    :author
   ,:comment
   ,:posted_at
)
SQL
            );

            $app->transaction->begin();

            try {
                $statement->execute($comment);
                $app->transaction->commit();
            } catch (\Exception $e) {
                $app->transaction->rollback();
                throw $e;
            }

            $cols = [];
            foreach ($comment as $name => $value) {
                $cols[] = sprintf('%s = %s', $name, $value);
            }

            $app->flash->addSuccess(sprintf('投稿を受け付けました (%s)', implode(', ', $cols)));
            return $app->redirect('/');
        }

    }

    return $app->render('comment.html', [
        'title'  => '投稿フォーム',
        'form'   => $form,
    ]);
});

$app->run();

こんな単純な内容なら別にSQL直書きでもいいんじゃね、という気がしてきます。

ただ、これが色々な場所からコメントが投稿されたり編集や削除されることになると、別途ビジネスロジックを扱うクラスを設ける必要があると思います。(まあ普通のアプリケーションはそうですよね)

コメント一覧の実装

コメント一覧も兼ねたトップページのスクリプトはこんな感じ。

www/index.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * トップページ
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
$app = include __DIR__ . DIRECTORY_SEPARATOR . 'app.php';

use Volcanus\Database\Statement;

$app->on('GET', function($app) {

    $statement = $app->db->prepare("SELECT author, comment, posted_at FROM comments LIMIT :limit OFFSET :offset");
    $statement->execute(['limit' => 20, 'offset' => 0]);
    $statement->setFetchMode(Statement::FETCH_FUNC, function($author, $comment, $posted_at) use ($app) {
        return $app->createData('comment', [
            'author'    => $author,
            'comment'   => $comment,
            'posted_at' => $posted_at,
        ]);
    });
    return $app->render('index.html', [
        'title'    => 'トップページ',
        'comments' => $statement,
    ]);

});

$app->run();

これを表示するテンプレートはこうなりました。

www/comment.html

<!DOCTYPE html>
<html lang="ja">

<head metal:use-macro="__layout.html/head">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="bootstrap/css/bootstrap.min.css" />
<title>トップページ</title>
</head>

<body metal:use-macro="__layout.html/body">

<div class="container">

  <header class="header">
    <h1>トップページ@example.com</h1>
  </header>

  <div class="content" metal:fill-slot="content">
    <h2>コメント一覧</h2>
    <table class="table table-condensed" tal:condition="exists:comments">
      <thead>
        <tr>
          <th>名前</th>
          <th>コメント</th>
          <th>投稿日</th>
        </tr>
      </thead>
      <tbody>
        <tr tal:repeat="item comments">
          <td tal:content="item/author"></td>
          <td tal:content="item/comment"></td>
          <td tal:content="item/posted_at"></td>
        </tr>
      </tbody>
    </table>
  </div>

  <footer class="footer">
    <p>Copyright &copy; 2013 k-holy &lt;k.holy74@gmail.com&gt; Code licensed under <a href="http://opensource.org/licenses/MIT">MIT</a></p>
  </footer>

</div>

</body>
</html>

この部分でステートメントオブジェクトのイテレータが前述の Comment クラスのインスタンスを返しているわけですが…。

<tr tal:repeat="item comments">
  <td tal:content="item/author"></td>
  <td tal:content="item/comment"></td>
  <td tal:content="item/posted_at"></td>
</tr>

item/posted_at で DataTrait経由で offsetGet() が呼ばれ、Comment::get_posted_at() が呼ばれます。

Comment::get_posted_at() は Acme\DateTime インスタンスを返して、さらに Acme\DateTime::__toString() が呼ばれた結果、 Commentクラスのデフォルトの dateTimeFormat 設定 'Y-m-d H:i:s' で書式化された文字列として出力されます。

要件にマッチした時の PHPTAL の威力がお分かりいただけるでしょうか。

最後にスクリーンショットを。

投稿フォーム 何の変哲もないフォームですが…。

入力値エラー メッセージがタブってますが、サンプルなので…。

投稿完了 こんな感じで、タイムスタンプが日付文字列に変換されます。

ここまで長かったですが、ようやく準備が整ってきた感じです。