無名関数はincludeできる

配列を返すphpファイルをincludeして変数にセットすることで、配列の定義を外部ファイル化できることは結構知られていると思いますが、思いつきで無名関数を返すphpファイルをincludeして同じことをやってみたら、普通に動きました。

<?php
namespace Acme;

use Acme\Util as U;

class Util
{
    public static function H($data, $default = null)
    {
        if (isset($data) && strcmp($data, '') !== 0) {
            return htmlspecialchars($data, ENT_QUOTES, 'UTF-8');
        }
        return $default;
    }
    public static function is_number($data)
    {
        return (ctype_digit($data));
    }

}

// 配列を返すファイルをincludeして変数にセットできる
$items = include __DIR__ . DIRECTORY_SEPARATOR . 'include_function.items.php';

$orders = array(
    null,
    'name:asc',
    'name:desc',
    'score:asc',
    'score:desc',
);

// 無名関数を返すファイルをincludeして変数にセットできる
$sort = include __DIR__ . DIRECTORY_SEPARATOR . 'include_function.sort.php';

?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style type="text/css">
body {font-family:monospace;}
table {border-collapse:collapse;border-spacing:0px;width:20em;margin:10px;}
caption {background-color:#336;color:#fff;border:inset 2px #669;padding:3px;}
th, td {padding:3px;border:solid 1px #999;}
tr.row1 td {background-color:#eee;color:#333;}
tr.row2 td {background-color:#ddd;color:#333;}
td.number {text-align:right;}
</style>
<title>無名関数をincludeしてみるサンプル</title>
</head>
<body>

<?php foreach ($orders as $order) : ?>
<table>
<caption><?=U::H((!isset($order)) ? 'no-order' : sprintf('order by %s', $order))?></caption>
<thead>
<th>name</th>
<th>score</th>
</thead>
<tbody>
<?php $sort($items, $order); ?>
<?php foreach ($items as $i => $item) : ?>
<tr class="<?= ($i % 2 === 0) ? 'row1' : 'row2' ?>">
<td><?=U::H($item['name'])?></td>
<td class="number"><?=U::H(number_format($item['score']))?></td>
</tr>
<?php endforeach ?>
</tbody>
</table>
<?php endforeach ?>

</body>
</html>

呼び出し元スクリプトです。
配列を定義しているファイルと、配列のソートを行う無名関数を定義しているファイルをincludeして、それぞれ動作させています。

<?php
return array(
    array('name' => 'Abe' , 'score' => 800),
    array('name' => 'Oda' , 'score' => 1000),
    array('name' => 'Hosokawa', 'score' => 600),
);

PHP5.4からは Short syntax for arrays が導入されるので、設定ファイル等でこの手法が流行るかもしれないとは思っていましたが…。

<?php
namespace Acme\Func;

use Acme\Util as U;

class AssertCallback
{
    public static function output($file, $line, $code)
    {
        // include元に定義されているクラスはオートロードに関係なく利用できる
        echo U::H(sprintf('Assertion Failed: at %s[%d]', $file, $line));
    }
}

// 配列をソートする無名関数
return function(array &$arr, $order) {
    if (!isset($arr[0]) || !isset($order)) {
        return $arr;
    }
    $key = $order;
    $asc = true;
    if (false !== ($pos = strpos($order, ':'))) {
        $key = substr($order, 0, $pos);
        $asc = (strcmp('asc', substr($order, $pos + 1)) === 0);
    }
    if (!array_key_exists($key, $arr[0])) {
        throw new \RuntimeException('Invalid key is specified.');
    }
    usort($arr, function($row1, $row2) use ($key, $asc) {
        return ($asc)
            ? strnatcasecmp($row1[$key], $row2[$key])
            : strnatcasecmp($row2[$key], $row1[$key]);
    });

    assert_options(ASSERT_ACTIVE, 1);
    assert_options(ASSERT_WARNING, 0);
    assert_options(ASSERT_QUIET_EVAL, 1);
    // 関数のスコープ外に定義されているクラスも利用できる
    assert_options(ASSERT_CALLBACK, array('Acme\Func\AssertCallback', 'output'));
    assert(U::is_number($arr[0]['score']));
    assert(U::is_number($arr[0]['name']));

    return $arr;
};

実は無名関数を外部ファイルに定義することもできたんです。
無駄に読みづらくなるだけかもしれませんが、呼び出し元に定義されているクラスを利用することもできます。

名前空間を使えば、SilexのようなURLに無名関数をマッピングするフレームワークで、無名関数で扱うクラスを同一ファイルに定義、共通処理の実装はtraitで外部に定義するような手法にも使えるかな?
実用性は微妙かもしれませんが、こんなこともできるということで。

動作サンプルはこちらです。
http://k-holy.sakura.ne.jp/example/include_function.php

このエントリをつぶやくこのWebページのtweets このエントリーを含むはてなブックマークはてなブックマーク - 無名関数はincludeできる この記事をクリップ!Livedoorクリップ - 無名関数はincludeできる BuzzurlにブックマークBuzzurlにブックマーク @niftyクリップに追加 newsing it! Bookmark this on Delicious Share on Tumblr
Posted in PHP | Tagged | Leave a comment

callableタイプヒントの追加と配列から文字列への暗黙の変換時の警告 (PHP5.4 Advent Calendar 2011 Day 21)

PHP5.4 Advent Calendar 2011 21日目の記事です。
20日目は Travis CI で PHP 5.4 も CI する, PHPUnit も Behat もやる – Born Too Late (@yuya_takeyamaさん)でした。

つい先日、Windows7でビルトインサーバを動かしてみたばかりのPHP5.4初心者ですが、個人的に嬉しいふたつの変更点について紹介させていただきます。

タイプヒンティングに callable が追加された(Beta1より)

15 Sep 2011, PHP 5.4.0 Beta1
- General improvements:
. Added callable typehint. (Hannes)

callableという擬似型をタイプヒンティングに指定できるようになりました。
従来、is_callable()関数に対してtrueを返すユーザーコールバックは、文字列・配列・無名関数・マジックメソッド__invoke()実装クラスのインスタンスなど様々な値を取り得る(PHPマニュアルの 疑似的な型および変数参照)ため、タイプヒンティングが利用できない問題がありました。
また、PHP5.4以前ではそれぞれの実行方法も異なっていたため、あらゆるcallableの登録や実行を適切に処理するには煩雑な記述が必要でした。
PHP5.4からは、callableタイプヒントの追加とコールバックの配列を無名関数のように呼べるようになった(@rskyさんの記事 PHP 5.4 の配列Tips で解説されています)ことにより、簡潔に書けるようになります。

まずは検証に当たって、エラータイプおよびメッセージの確認のため下記のようなエラーハンドラを設定しました。

<?php
set_error_handler(function($errno, $errstr, $errfile, $errline){
    $titles = array (
        E_ERROR => 'Fatal error',
        E_WARNING => 'Warning',
        E_NOTICE => 'Notice',
        E_STRICT => 'Strict standards',
        E_RECOVERABLE_ERROR => 'Catchable fatal error',
        E_DEPRECATED => 'Depricated',
    );
    echo sprintf('<p>%s[%d]: %s</p>',
        (isset($titles[$errno])) ? $titles[$errno] : 'Unknown error',
        $errno, htmlspecialchars($errstr, ENT_QUOTES, 'UTF-8'));
    return true;
});

※以下、サンプルコードの動作確認はWindows7、PHP5.3環境はXAMPP1.7.7添付の5.3.8、PHP5.4環境は5.4.0RC3ビルトインサーバで行いました。

<?php
namespace Acme;

class U {
    public static function exec($var, callable $callback) {
        return $callback($var);
    }
    public static function foo($var) {
        return sprintf('%s! foo!', $var);
    }
    public function bar($var) {
        return sprintf('%s! bar!', $var);
    }
    public function __invoke($var) {
        return sprintf('%s! invoke!', $var);
    }
}

$text = '12345';

// 関数名の文字列
echo U::exec($text, 'number_format');
// 12,345
// (PHP5.3の場合) Catchable fatal error[4096]: Argument 2 passed to Acme\U::exec() must be an instance of Acme\callable, string given, called in *** on line ** and defined

// スタティックメソッドを指定したcallback配列
echo U::exec($text, array(__NAMESPACE__ . '\U', 'foo'));
// 12345! foo!

// インスタンスメソッドを指定したcallback配列
echo U::exec($text, array(new U(), 'bar'));
// 12345! bar!

// 無名関数(Closure)
echo U::exec($text, function($var) {
    return sprintf('%s! closure!', $var);
});
// 12345! closure!

// __invoke()実装クラスのインスタンス
echo U::exec($text, new U());
// 12345! invoke!

// callableではない(存在しないクラスのスタティックメソッドを指定したcallback配列)
echo U::exec($text, array('U', 'foo'));
// E_RECOVERABLE_ERROR が発生
// Catchable fatal error[4096]: Argument 2 passed to Acme\U::exec() must be callable, array given, called in *** on line *** and defined
// Fatal error: Class 'U' not found in...

PHP5.3ではcallableタイプヒントは実行時にユーザー定義のcallableクラスとして解釈され、全てのケースで「must be an instance of Acme\callable」のE_RECOVERABLE_ERRORが発生しましたが、PHP5.4ではcallableタイプヒントにより、無効なcallback配列が指定された最後のケースのみ「must be callable」のE_RECOVERABLE_ERRORが発生しました。

余談になりますが、サンプルではユーザー定義のエラーハンドラでtrueを返しているため、E_RECOVERABLE_ERROR発生後も処理が継続され、最後に「Class ‘U’ not found」のE_ERRORが発生してデフォルトのエラーハンドラが呼ばれて終了しています。
ユーザー定義のエラーハンドラを使用しない場合は、E_RECOVERABLE_ERROR発生時点で処理が中断されました。

"Closure"クラスについて

callableタイプヒントについては、こちらの PHP: rfc:callable [PHP Wiki] を拙い英語読解力で読んだところ、将来実装が変更されるかもしれない "Closure" クラスをタイプヒントに使えないことへの対策としての意味もあったようです。
ただ、日本語版のドキュメント PHP: Closure – Manual を確認したところ、現在はこのような記述に変わっていました。

無名関数は PHP 5.3 で実装された機能で、この型のオブジェクトを生成します。かつてこれは、内部実装がたまたまそうなっているだけという扱いでした。しかし今では、この事実を前提として考慮してもかまいません。PHP 5.4 以降ではこのクラスにメソッドが用意され、生成した無名関数をさらにコントロールできるようになります。

PHP5.4で bind(), bindTo() が追加されたことでClosureクラスの実装が確定したということでしょうか。
ともあれ、依然としてClosureではないcallableは存在しますので、特にインタフェース定義などでcallableタイプヒントが有用に機能するケースはあると思います。

配列から文字列への暗黙の変換時に NOTICE が発生するようになった(RC1より)

11 Nov 2011, PHP 5.4.0 RC1
- General improvements:
. Changed silent conversion of array to string to produce a notice. (Patrick)

PHP5.4以前では、echo や print の引数とされた場合、結合演算子により文字列と結合された場合、あるいは sprintf(‘%s’, array()) など、配列から文字列への変換が発生した際、何の警告もなく文字列 "Array" が返されていました。
このような配列から文字列への変換が発生した際に、E_NOTICEレベルのエラーを発生するようになったという変更です。
注意深い方にはそれほど影響はないと思いますが、私のような粗忽者には嬉しい改善です。

<?php
$array = array();

echo $array;
// Notice[8]: Array to string conversion
// Array

'text' . $array;
// Notice[8]: Array to string conversion

var_dump((string)$array);
// Notice[8]: Array to string conversion
// string(5) "Array"

var_dump(strlen(sprintf('%s', $array)));
// Notice[8]: Array to string conversion
// int(5)

$array = new \ArrayObject();
var_dump((string)$array);
// Catchable fatal error[4096]: Object of class ArrayObject could not be converted to string
// string(0) ""

PHP5.3では警告なく「Array string(5) "Array" int(5)」と出力されたのに対して、PHP5.4ではいずれも「Array to string conversion」のE_NOTICEが発生しました。
(string)による明示的なキャストでも警告されますので、そういうトリッキーなコードを書いている方はお気をつけください。(あまりないとは思いますが…)

また、最後のケースでは念のためArrayObjectの挙動を確認しましたが、当然ながらArrayObjectは配列ではなく__toString()メソッドも定義されていないためPHP5.3、PHP5.4共に「could not be converted to string」のE_RECOVERABLE_ERRORが発生しました。
(例によってユーザ定義のエラーハンドラで処理が継続され空文字が返されていますが、デフォルトのエラーハンドラでは処理が中断されました)

以上、PHP5.4の情報をリアルタイムでチェックされていた方にとっては何を今更という感じの小ネタですが、少しばかり膨らませて紹介させていただきました。

明日22日目は @do_aki さんです。

このエントリをつぶやくこのWebページのtweets このエントリーを含むはてなブックマークはてなブックマーク - callableタイプヒントの追加と配列から文字列への暗黙の変換時の警告 (PHP5.4 Advent Calendar 2011 Day 21) この記事をクリップ!Livedoorクリップ - callableタイプヒントの追加と配列から文字列への暗黙の変換時の警告 (PHP5.4 Advent Calendar 2011 Day 21) BuzzurlにブックマークBuzzurlにブックマーク @niftyクリップに追加 newsing it! Bookmark this on Delicious Share on Tumblr
Posted in PHP | Tagged , | Leave a comment

Windows7にPHP5.4(RC3)を入れてビルトインサーバでSilexを動かしてみた

新しい物に手を出すのは慎重(というか臆病というか…)な私ですが、先月の第2回 関西PHP勉強会でのtanakahisateruさんのお話(PHP5.4おいしさつまみぐい)、そして今まさに進行中のPHP5.4 Advent Calendar 2011の記事に触発されて、ようやく重い腰を上げることにしました。
プライベート用途では非力なWindows7のノートPCしかないので…PHP For Windows: Binaries and sources QA Releases から PHP 5.4 (5.4.0RC3) VC9 x86 Thread Safe をダウンロード。
http://windows.php.net/downloads/qa/php-5.4.0RC3-Win32-VC9-x86.zip

C:\php5.4を作成しておいてダウンロードしたzipファイルを展開、php.ini-development → php.iniにコピー&リネームして編集します。
Windows版PHPでは、拡張モジュールはextディレクトリ内にこれだけ同梱されています。

php_bz2.dll
php_curl.dll
php_enchant.dll
php_exif.dll
php_fileinfo.dll
php_gd2.dll
php_gettext.dll
php_gmp.dll
php_imap.dll
php_interbase.dll
php_intl.dll
php_ldap.dll
php_mbstring.dll
php_mysql.dll
php_mysqli.dll
php_oci8.dll
php_oci8_11g.dll
php_openssl.dll
php_pdo_firebird.dll
php_pdo_mysql.dll
php_pdo_oci.dll
php_pdo_odbc.dll
php_pdo_pgsql.dll
php_pdo_sqlite.dll
php_pgsql.dll
php_shmop.dll
php_snmp.dll
php_soap.dll
php_sockets.dll
php_sqlite3.dll
php_sybase_ct.dll
php_tidy.dll
php_xmlrpc.dll
php_xsl.dll

とりあえず拡張を有効にするため、extension_dirのコメントを外します。

extension_dir = "ext"

iniファイルに最初からコメントアウトされた状態で記述されているものについては、外部アプリケーションが必要なもの以外は普通に動くのかな?
XAMPPをすでに入れているのでMySQLはXAMPPの物が使えるだろうということで、とりあえず使う可能性が高い以下の拡張をコメント解除して有効にしてみました。

extension=php_curl.dll
extension=php_mbstring.dll
extension=php_mysql.dll
extension=php_mysqli.dll
extension=php_pdo_mysql.dll
extension=php_pdo_sqlite.dll

モジュール別の設定はこれだけ追加、あとはデフォルトのままです。

[Date]
date.timezone = Asia/Tokyo

まずはビルトインサーバを試そうということで、C:\php5.4\php.exe へのショートカットを作成、こんな感じでプロパティ設定しました。
リンク先:C:\php5.4\php.exe -S 127.0.0.1:80
作業フォルダー:C:\Users\k_horii\Dropbox\Documents\Projects\php54\www

いつも最初に設置しているファイル表示スクリプトを上の作業フォルダーにコピーして、ショートカットからビルトインサーバ起動。

ブラウザに http://127.0.0.1/ と入れてアクセスしてみます。

$_SERVER['SERVER_NAME'], $_SERVER['SERVER_ADDR']がないよとの警告が出たので、$_SERVER見てみます。

HTTP_*** はちゃんと入ってる感じなので、リダイレクトする場合は HTTP_HOST 使えということかな?

何かすぐに動くコードは…ということで、Silex+RedBean+PHPTALのサンプル(RedBean FUSEによるモデルクラスとjQueryTreeView)のソースを丸ごとコピーしてみたところ…

特にエラーも警告もなく動きました!


でもツリー表示ができてない。
json返すURLへのリクエストが通ってないのかな?

ブラウザに http://127.0.0.1/trees/1.json って入れたら…

Not Found…あ、Apacheないしmod_rewriteが効いてないからだ。確か代わりのスクリプト書けばいいんだっけ。

built-in-server.php

<?php
// ポート8080の場合、ショートカットのリンク先 C:\php5.4\php.exe -S 127.0.0.1:8080 built-in-server.php
// 作業フォルダにドキュメントルートを入力して起動
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if (strcmp('/', $path) !== 0) {
    if (file_exists(realpath(__DIR__ . $path))) {
        return false;
    }
}
require __DIR__ . DIRECTORY_SEPARATOR . 'index.php';

で、ショートカットのリンク先を変更、このスクリプトを指定します。
C:\php5.4\php.exe -S 127.0.0.1:80 built-in-server.php


動いたよ!
編集画面からのリダイレクトもOKでした。

しかし、.json以外のURL(/trees/ とか /trees/1 とか)が普通に動いてたのはなぜでしょう…?
ファイル名なしのパスは、ディレクトリが見つからなければドキュメントルートのindex.phpに回してくれてるんでしょうか。
/trees/index.php って入れたらNot Foundになったので、どうやらそういう仕様のようです。

マニュアルの記述には「URI リクエストの処理は、PHP を開始した時点の作業ディレクトリから行われます。 -t オプションを使えば、ドキュメントルートを明示的に指定することができます。URI リクエストにファイルが含まれない場合は、指定したディレクトリにある index.php あるいは index.html を返します。」とあって分かりづらいのですが、確認したところ /foo/ あるいは /foo というパスへのリクエストの場合 /foo/index.php があればそれを、なければ /index.php を呼んでくれるようです。
PHP: ビルトインウェブサーバー – Manual

index.phpファイル1個だけでリクエストハンドリングできるSilexなら、拡張子なしのURLに統一しておけば、何も修正せず動作するはずです。
しかも、フレームワーク本体のコードはpharファイル1個だけ。
PHP5.4のビルトインサーバ・SQLite・Silexの組み合わせは、お手軽Webアプリケーションに最適じゃないでしょうか。

Windows版ならzip展開するだけで設置できてビルトインサーバもショートカットから一発起動、日頃肩身の狭い思いをしている(?)WindowsユーザーのPHPerも、気軽にPHP5.4を使ってみましょう。

このエントリをつぶやくこのWebページのtweets このエントリーを含むはてなブックマークはてなブックマーク - Windows7にPHP5.4(RC3)を入れてビルトインサーバでSilexを動かしてみた この記事をクリップ!Livedoorクリップ - Windows7にPHP5.4(RC3)を入れてビルトインサーバでSilexを動かしてみた BuzzurlにブックマークBuzzurlにブックマーク @niftyクリップに追加 newsing it! Bookmark this on Delicious Share on Tumblr
Posted in PHP | Tagged , , , | Leave a comment

エラーハンドラと例外ハンドラによるエラー処理 (PHP Advent Calendar jp 2011 Day 11)

@calpo22さんの記事 PyrusでプロジェクトローカルなPEARライブラリインストール : PHP Advent Calendar jp 2011 Day 10 – くろまほうさいきょうでんせつ に続き、PHP Advent Calendar jp 2011 11日目の記事です。

皆さん、エラー処理ちゃんとやってますか?
今時は、設定を書いておけばフレームワークがよしなにやってくれるよ、という方が多いかもしれません。

PHP4時代を戦い抜いた人は、皆自分なりのエラー処理関数を書いてset_error_handler()とtrigger_error()を駆使した経験があると思います。
私も当時は、サブルーチンを呼ぶたびに戻り値をチェックして return FALSE をリレーしていき、レスポンス出力の段階でFALSEが返されていればエラー画面をinclude、といった処理をしていました。

そんな泥臭い時代を経験した私にとって、PHP5に完全移行して一番嬉しかったのが、set_exception_handler()とExceptionクラスを使うことでエラー処理がとても楽になったことです。
戻り値をチェックしなくて済むようになったことで、メソッドチェーンを安心して使えるようになり、今ではExceptionを使わないコードなど考えられません。

一方で、今もWarningを返す関数があり、またPEARを使う場合はPEAR::isError()など忌まわしき過去の呪縛もあります。
コアの設定ディレクティブとの関係が深くバージョン毎の差異もあり、アプリケーションフレームワークに頼らず確実に実施しようとすると、意外とハマってしまうところもあります。

特に独自性もないネタではありますが、そんな縁の下の力持ちのエラー処理と例外処理についての情報をまとめてみます。

まずは、エラー処理に関係するPHPの設定ディレクティブについて、ざっと紹介します。
説明に(未確認)と書いてあるものについては実際に動作確認したわけではありませんので、興味のある方は自分で確認してください。
なお今回の内容は、Windows版XAMPP1.7.7に同梱のPHP5.3.8で確認しています。

error_reporting (integer) PHP_INI_ALL
多数あるエラータイプ定数のうちどれをエラーとして出力するかを、PHP定数のまたは整数値で記述します。
ユーザースクリプトで設定する場合、error_reporting()関数の利用が推奨されています。
httpd.conf や .htaccess等、PHP定数が使えない場所で設定する場合は整数値で記述する必要がありますが、全てのエラーを意味する特殊な定数 E_ALL の値はPHPのバージョンによって異なるので要注意です。
(将来に渡っても確実に全てのエラーを有効にする方法として 2147483647(32bitの符号付き整数最大値)を設定しましょう、との旨がPHPマニュアルに書いてますが、それはちょっとどうかと思います)

初期値は E_ALL & ~E_NOTICE ですが、少なくとも E_ALL でエラーや警告が発生することなく動作するように書くべきです。
PHP5から追加されたE_STRICTを有効にすれば、推奨されない記述をチェックしてくれます。(5.4からはE_ALLにE_STRICTが含まれるそうです)
PHP5.3から追加された E_DEPRECATED と E_USER_DEPRECATED も、同様に E_ALL に含まれないエラーですが、E_STRICTとの違いはよく分かりません。(何となく前者はPHP5からの非推奨、後者はPHP5.3からの非推奨と捉えてますが…)
PEARライブラリを利用する場合は、おそらくこれらの警告に頻繁に遭遇することになりますので、何らかの対策が必要となります。
また、自分でエラーハンドリングを行う場合、この設定に関わらず必ずエラーハンドラが呼ばれますので、適切に処理する必要があります。

display_errors (string) PHP_INI_ALL
エラーをレスポンス出力するかどうかを指定します。
PHP5.2.4以降、設定値がbooleanからstringに変更され、”stderr”を設定するとエラーの内容を stdout (標準出力) ではなく stderr (標準エラー出力) に送れるそうです。(恥ずかしながら今回初めて知りました)
これを有効にすると、自分でエラーハンドリングを行わない限り、問答無用でレスポンスの先頭にエラーメッセージが出力されます。
自分でエラーハンドリングを行う場合、この値に関係なく自由にエラー出力を制御できますが、エラーハンドラが有効になる以前の致命的なエラーや構文エラーについては、この値に依存することになります。
またユーザースクリプトで設定する場合、そのスクリプト自体が実行不可能なエラーを発生してしまった場合、当然ながら何も出力されません。
私の場合、開発環境ではphp.iniで”1″、本番環境では”0″を設定しますが、アプリケーションの初期化処理の中でもlocalhostかどうかで設定を動的に変更しています。
自分でエラーハンドリングを行う場合、この設定を見て適切に処理する必要があります。
display_startup_errors (boolean) PHP_INI_ALL
(未確認)
PHP起動時のエラーを出力するかどうかを指定します。
開発時はOnにしていますが、これに該当するエラーを見たことはありません。(E_CORE_ERROR や E_CORE_WARNING がこれに当たるんでしょうか?)
log_errors (boolean) PHP_INI_ALL
エラーメッセージをサーバーのエラーログ、または error_logディレクティブに設定したログファイルに記録するかどうかを指定します。
自分でエラーハンドリングを行う場合、この値に関係なく自由にログを記録できますが、エラーハンドラが有効になる以前の致命的なエラーや構文エラーについては、この値に依存することになります。
私の場合、同じサーバに複数のアプリケーションが同居することも多く、Webサーバーのログとアプリケーション固有のログは別のものとして管理した方が良いとの判断から、この値は常にOnにしています。
log_errors_max_len (integer) PHP_INI_ALL
エラー出力の一行の最大長をバイト単位で指定します。名前に反して、ログだけではなく画面表示にも適用されますのでご注意を。
初期値は1024ですが、スタックトレースを出力する場合など、多分そのままでは不足すると思います。私の場合、とりあえず4096に設定してます。
0を設定すると無制限になりますが、ログの肥大化が問題となることもあるでしょうし、どこかしら制限しておくべきと考えます。
ignore_repeated_errors (boolean) PHP_INI_ALL
(未確認)
同じソースファイルからの同じエラーメッセージの繰り返しを無視するかどうかを指定します。
Onにしたことはありませんが、使うことがあるとすると、すでに本番環境で動作している警告満載のひどいコードの管理を引き継いだ時くらいでしょうか。
ignore_repeated_source (boolean) PHP_INI_ALL
(未確認)
同じエラーメッセージの繰り返しを、異なるソースファイルの場合でも無視するかどうかを指定します。
Onにしたことはありませんが、同上、でしょう。
report_memleaks (boolean) PHP_INI_ALL
(未確認)
デバッグビルドされた環境において、error_reporting で E_WARNING を有効にしている場合に、Zend メモリマネージャーが検出したメモリリークの報告を表示するかどうかを指定します。
今回初めて知ったのですが、コンパイル時のconfigureオプションで –enable-debug を指定するとデバッグビルド版になり、拡張モジュールの作成に有用なこの機能が使えるそうです。(私にとっては未知の領域です)
track_errors (boolean) PHP_INI_ALL
有効にした場合、エラーが発生したスコープで直近に発行されたエラーメッセージが変数 $php_errormsg にセットされます。
自分でエラーハンドリングを行う場合、エラーハンドラが FALSE を返した場合にのみこの機能が有効になります。
html_errors (boolean) PHP_INI_ALL
エラーメッセージをHTMLタグで出力するかどうかを指定します。
下のxmlrpc_errorsとの比較のために、出力内容を提示しておきます。

<br />
<b>Warning</b>:  HOGE in <b>C:\Users\k-holy\Documents\demo\error.php</b> on line <b>5</b><br />

自分でエラーハンドリングを行ってメッセージを生成する場合、この設定は反映されません。

xmlrpc_errors (boolean) PHP_INI_SYSTEM PHP4.1.0以降
エラーメッセージをXML(XML-RPC)形式で出力するかどうかを指定します。
有効にすると、html_errorsの値には関係なく、以下のような構造でエラーメッセージが出力されます。
(分かりやすいようにインデント整形していますが、実際には詰めた状態で出力されます)

<?xml version="1.0"?>
<methodResponse>
    <fault>
        <value>
            <struct>
            <member>
                <name>faultCode</name>
                <value><int>0</int></value>
            </member>
            <member>
                <name>faultString</name>
                <value><string>Warning:HOGE in C:\Users\k-holy\Documents\demo\error.php on line 5</string></value>
            </member>
            </struct>
        </value>
    </fault>
</methodResponse>

自分でエラーハンドリングを行ってメッセージを生成する場合、この設定は反映されません。

xmlrpc_error_number (integer) PHP_INI_ALL PHP4.1.0以降
XML-RPCのfaultCode要素の値を指定します。
xmlrpc_errorsが有効な場合、上記XMLの<member><name>faultCode</name><value><int>0</int></value></member>のint要素にこの設定値が入ります。
自分でエラーハンドリングを行ってメッセージを生成する場合は、この設定は反映されません。
docref_root (string) PHP_INI_ALL PHP4.3.0以降
(未確認)
PHPマニュアルのローカルコピーへのURLを指定します。
html_errors が有効な場合に、この項目と docref_ext を適切に設定すると、エラーメッセージからエラーが発生した関数の説明ページにリンクを張ってくれるそうです。
docref_ext (string) PHP_INI_ALL PHP4.3.2以降
(未確認)
マニュアルのローカルコピーの拡張子を.付きで指定します。
html_errors および docref_root との組み合わせで有効になるそうです。
error_prepend_string (string) PHP_INI_ALL
エラーメッセージの前に出力する文字列を指定します。
何に使うのかと思ったら、エラーメッセージ自体を独自のHTMLタグで囲みたい場合に使えるようです。(php.iniの設定サンプルには<font>とか書いてました)
手頃なところでは、<marquee>あたりを指定すると、一服の清涼剤になって良いかもしれません。
自分でエラーハンドリングを行ってメッセージを生成する場合、この設定は反映されません。
error_append_string (string) PHP_INI_ALL
エラーメッセージの後に出力する文字列を指定します。
error_prepend_string とセットで利用する項目として想定されているようです。
自分でエラーハンドリングを行ってメッセージを生成する場合、この設定は反映されません。
error_log (string) PHP_INI_ALL
エラーメッセージを記録するログファイルへのパスを指定します。
“syslog”を指定した場合、エラーはファイルではなくシステムロガーに送られます。(未確認)
自分でエラーハンドリングを行なう場合、この設定に関係なくログ出力を行うことも可能です。
私の場合、初期設定のままWebサーバのエラーログにも記録しつつ、エラーハンドラと例外ハンドラではアプリケーション固有のログディレクトリに年月別ファイルで記録しています。

参考リンク
PHP: エラー処理 – 実行時設定 – Manual

長くなりましたがここからが本題、設定ディレクティブ内で何度も触れました、自分でエラーハンドリングを行う方法をサンプルコードで紹介します。
サンプルの概要は、エラーハンドラと例外ハンドラをそれぞれ無名関数で作成し、フォーム上から動的にエラー出力の設定を変更し、各ユーザーレベルのエラーや例外を発生させることで、その動作を確認するものです。
実際は1つのスクリプトで完結させていますが、「各処理の呼び出し部分とフォーム」「エラーハンドラ」「例外ハンドラ」「エラーログ処理」「エラー画面出力処理」「ユーティリティクラス」と順を追って紹介します。

各処理の呼び出し部分とフォーム

<?php
namespace Holy\Example;

use Holy\Example\Util as U;

$data = array();

// エラーレベル定数(E_USER_***)の配列
$data['error_levels'] = U::buildErrorLevels();

// エラーメッセージ出力の可否、エラー画面の出力レベル、エラーログの出力レベルを設定
$data['display_errors' ] = 0;
$data['rep_level'] = error_reporting();
$data['log_level'] = error_reporting();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (isset($_POST['display_errors']) && $_POST['display_errors']) {
        $data['display_errors'] = 1;
    }
    if (isset($_POST['rep_level_all'])) {
        $data['rep_level'] = E_ALL;
    } elseif (isset($_POST['rep_level_flags']) && is_array($_POST['rep_level_flags'])) {
        $data['rep_level'] = array_sum($_POST['rep_level_flags']);
    } else {
        $data['rep_level'] = 0;
    }
    if (isset($_POST['log_level_all'])) {
        $data['log_level'] = E_ALL;
    } elseif (isset($_POST['log_level_flags']) && is_array($_POST['log_level_flags'])) {
        $data['log_level'] = array_sum($_POST['log_level_flags']);
    } else {
        $data['log_level'] = 0;
    }
}

// エラーログの内容
$data['messages'] = array();

ini_set('display_errors', $data['display_errors']);
error_reporting($data['rep_level']);

// 例外、エラーハンドラを登録
set_exception_handler($exceptionHandler);
set_error_handler($errorHandler);

// スタックトレースのテスト用クラス
class スマイルセラピー
{
public static function 開始($callback)
{
self::10度($callback, 9, 3.14);
}
public static function 10度($callback, $arg1, $arg2)
{
self::20度($callback, 'text', false);
}
public static function 20度($callback, $arg1, $arg2)
{
self::30度($callback, array(1, 'a', 'foo'=>'bar'), new \stdClass());
}
public static function 30度($callback, $arg1, $arg2)
{
$callback();
}
}

if (isset($_POST['raiseException'])) {
スマイルセラピー::開始(function() {
throw new \Exception('Exceptionのテスト');
});
} elseif (isset($_POST['raiseError'])) {
スマイルセラピー::開始(function() {
if (defined($_POST['raiseError']) &&
strncmp('E_USER_', $_POST['raiseError'], 7) === 0
) {
trigger_error(sprintf('%sのテスト', $_POST['raiseError']),
constant($_POST['raiseError']));
}
});
}
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<title>エラーハンドラ・例外ハンドラのテスト - Holy\Example</title>
</head>
<body>
<form method="post">

<p>
<label>エラーメッセージを出力<input type="checkbox" name="display_errors" value="1"
<?=($data['display_errors'])?' checked="checked"':''?> /></label>
</p>

<table>
<?php foreach ($data['error_levels'] as $item) : ?>
<tr>
<td><?=U::H($item['name'])?></td>
<td style="text-align:right;">(<?=U::H($item['value'])?>)</td>
<td>
<label>画面出力<input type="checkbox" name="rep_level_flags[]"
value="<?=U::H($item['value'])?>"
<?=($data['rep_level'] & $item['value'])?' checked="checked"':'' ?> /></label>
</td>
<td>
<label>ログ出力<input type="checkbox" name="log_level_flags[]"
value="<?=U::H($item['value'])?>"
<?=($data['log_level'] & $item['value'])?' checked="checked"':'' ?> /></label>
<input type="submit" name="raiseError" value="<?=U::H($item['name'])?>" />
</td>
</tr>
<?php endforeach ?>
<tr>
<td>E_ALL</td>
<td style="text-align:right;"><?=U::H(E_ALL)?></td>
<td>
<label>画面出力<input type="checkbox" name="rep_level_all" value="1"
<?=($data['rep_level'] === E_ALL)?' checked="checked"':''?> /></label>
</td>
<td><label>ログ出力<input type="checkbox" name="log_level_all" value="1"
<?=($data['log_level'] === E_ALL)?' checked="checked"':''?> /></label>
</td>
</tr>
</table>

<p>
<input type="submit" name="raiseException" value="Exception" />
<input type="submit" value="何もしない" />
</p>

</form>

<hr />

ログ出力内容
<pre>
<?=U::H(implode("\n",$data['messages']))?>
</pre>

</body>
</html>

スタックトレースのコード確認用の「スマイルセラピー」クラスと、_ini_set(), error_reporting(), set_exception_handler(), set_error_handler()の他は全てフォーム処理になります。
フォームについてはコードよりも画面のスクリーンショットを見た方が早いですね。

上部の「エラーメッセージを出力」にチェックで display_errorsを有効にします。
各エラータイプの「画面出力」にチェックを入れるとそのエラーメッセージをエラーハンドラで画面に表示、「ログ出力」にチェックを入れるとエラーログ処理を行い、画面下部の「ログ出力」のところに表示します。(ログファイルへの記録の代わりです)
「E_USER_***」のボタンはエラーを発生、「Exception」のボタンは例外をスローします。
各ハンドラで行うスタックトレースの整形を確認するために、エラー発生および例外スローは直接実行せず、「スマイルセラピー」クラスにコールバックとして渡し、様々な型の引数とともにメソッドを通過させてから実行しています。
なお、フォーム入力値のバリデーションは記事の主旨から外れるので含んでいません。

エラーハンドラ

<?php
// エラーハンドラのサンプル(main.php内)
$errorHandler = function($errno, $errstr, $errfile, $errline)
    use (&$data, $logger, $errorDisplay)
{
    $titles = array (
        E_ERROR => 'Fatal error',
        E_WARNING => 'Warning',
        E_NOTICE => 'Notice',
        E_STRICT => 'Strict standards',
        E_RECOVERABLE_ERROR => 'Catchable fatal error',
        E_DEPRECATED => 'Depricated',
        E_USER_ERROR => 'Fatal error (User)',
        E_USER_WARNING => 'Warning (User)',
        E_USER_NOTICE => 'Notice (User)',
        E_USER_DEPRECATED => 'Depricated (User)',
    );
    $message = sprintf('%s: %s in %s on line %d',
        (isset($titles[$errno])) ? $titles[$errno] : 'Unknown error',
        $errstr,
        $errfile,
        $errline
    );
    $trace = debug_backtrace();
    $stackTrace = U::formatTrace(array_slice($trace, 2, count($trace)));
    if (!empty($stackTrace)) {
        $message .= sprintf("\nStack trace:\n%s", implode("\n", $stackTrace));
    }
    if ($data['log_level'] & $errno) {
        $logger($message);
    }
    if (error_reporting() & $errno) {
        if (ini_get('display_errors')) {
            echo '<pre>' . U::H($message) . '</pre>';
        }
        // 復帰できないレベルのエラーはエラー画面を表示して終了
        if ((E_ERROR | E_USER_ERROR) & $errno) {
            $errorDisplay($data);
        }
    }
    return true;
};

set_error_handler()関数の引数となる無名関数で、各処理間でデータの入出力を行うための配列 $data と、エラーログ処理を行う無名関数 $logger と、エラー画面出力を行う無名関数 $errorDisplay をuseで受け取っています。
エラーハンドラに渡される引数は、1番目がエラータイプ定数値、2番目がエラーメッセージ、3番目がエラーが発生したファイル、4番目がエラーが発生した行番号、5番目は今回扱っていませんが、エラーが発生したスコープでの全ての変数を格納した配列となっています。
処理の内容としては、エラーメッセージを生成して、フォームから設定された値と実際に発生したエラータイプにより必要な場合はログと画面出力を行い、復帰できないエラーの場合はエラー画面を表示して終了します。
エラーメッセージの生成では、通常のエラーではスタックトレースが出力されませんので、debug_backtrace()関数を使ってスタックトレースを取得し、ユーティリティクラス(名前空間のエイリアスを使って「U」としています)により例外メッセージ風に整形しています。
今回はWebからの利用のみの想定で、メッセージをpreタグで出力したりエラー画面を表示したりしてますが、コマンドラインやバッチ処理でも汎用的に使えるようにする場合、環境変数などで判断するか、外部から何らかの設定を受け取って動作を切り替える必要があるでしょう。
サンプルのように戻り値としてtrueを返した場合はここで処理を終了しますが、逆にfalseを返した場合は通常のエラーハンドラに処理を引き継ぎます。

例外ハンドラ

<?php
// 例外ハンドラのサンプル (main.php内)
$exceptionHandler = function($exception) use (&$data, $logger, $errorDisplay) {
    $message = sprintf("Fatal Error: Uncaught exception '%s' with message '%s' in %s:%d\nStack trace:\n%s\n thrown in %s on line %d",
        get_class($exception),
        $exception->getMessage(),
        $exception->getFile(),
        $exception->getLine(),
        implode("\n", U::formatTrace($exception->getTrace())),
        $exception->getFile(),
        $exception->getLine());
    $logger($message);
    if (ini_get('display_errors')) {
        echo '<pre>' . U::H($message) . '</pre>';
    }
    $errorDisplay($data);
};

set_exception_handler()関数の引数となる無名関数で、エラーハンドラと同様、各処理間でデータの入出力を行うための配列 $data と、エラーログ処理を行う無名関数 $logger と、エラー画面出力を行う無名関数 $errorDisplay をuseで受け取っています。
処理の内容もエラーハンドラとほぼ同じですが、例外処理にはエラーレベルの判定などがなく、エラーメッセージの生成はExceptionクラスの各種メソッドが利用できますので、よりシンプルな記述になっています。
スタックトレースはException::getTraceAsString()メソッドにより簡単に文字列で取得できますが、引数の型や配列の内容を出力するために、エラーハンドラと同様にユーティリティクラスにより加工しています。
より手抜きするなら、実はException::__toString()の戻り値がデフォルトの出力とほぼ同じ(先頭の「Fatal Error: Uncaught」と末尾の「thrown in … on line …」だけがない)状態なので、面倒なら単に文字列にキャストするだけ、というのもありだと思います。
※(2011-12-12追記)確認したところ、Exception::getTraceAsString()で返されるスタックトレースは関数の引数の文字列表現が20バイトで切られる上、その処理がマルチバイト文字列に対応していないことが分かりました。Exception::__toString()の場合も同様なので、詳細な情報が欲しい場合は今回のサンプルのように、面倒でもException::getTrace()からの自力整形をおすすめします。

エラーログ処理

<?php
// ログ処理のサンプル(main.php内)
$logFile = realpath(__DIR__ . '/logs') .
    DIRECTORY_SEPARATOR . date('Y-m') . '.log';

$logger = function($message) use (&$data, $logFile) {
    $logMessage = sprintf("[%s] %s\n",
        date('Y-m-d H:i:s'),
        htmlspecialchars_decode(strip_tags($message)));
    $data['messages'][] = $logMessage;
// error_log($logMessage , 3, $logFile); //テストなのでログ取らない
};
view raw logger.php This Gist brought to you by GitHub.

エラーメッセージのファイルへの記録を行う想定の無名関数で、引数で指定された文字列からHTMLタグを除去し、先頭に日付を、末尾に改行を付与してロギングします。
本来はerror_log()関数によりファイルへ追記しますが、今回は単に useで渡された配列にメッセージを追加しています。
error_log()では、第2引数の値によってerror_logディレクティブの設定先、指定したアドレスへのメール、指定したファイル、SAPIのログ出力ハンドラ(PHP5.2.7以降)と、メッセージの送信先を切り替えることができます。
指定したファイルへの保存の場合、ファイルが存在しなければ作成を試みてくれます。

エラー画面出力処理

<?php
// エラー画面出力処理のサンプル(main.php内)
$errorDisplay = function($data) {
    $status = '500 Internal Server Error';
    if (!headers_sent()) {
        header($_SERVER['SERVER_PROTOCOL'] . ' ' . $status);
    }
    $body = <<< HTML
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<title>%s - Holy\Example</title>
</head>
<body>
<h1>%s</h1>
<h2>システムエラーが発生しました。</h2>
<hr />
<h3>ログ出力内容</h3>
<pre>%s</pre>
<p><a href="javascript:history.back();">戻る</a></p>
</body>
</html>
HTML;
    echo sprintf($body,
        U::H($status),
        U::H($status),
        U::H(implode("\n", $data['messages'])));
    restore_exception_handler();
    restore_error_handler();
    exit();
};

エラー画面を出力する無名関数で、HTTPステータスコードを送出して画面を表示するとともに、登録したエラーハンドラおよび例外ハンドラの解放などの終了処理を行います。
エラーログ処理で追加されたメッセージを表示するために、引数にデータ出力用の配列を受けています。
今回は固定のステータスコードとメッセージとしていますが、実際にはアプリケーション内で共通の例外インタフェースなり基底クラスとエラーコードを定義しておき、例外スロー時に適切なコードを設定することで、エラー画面で出力するステータスコードとメッセージを切り替えたりします。
(業務ロジックの実行に必要なパラメータが不足していたり妥当でない場合に400 Bad Request、実行権限がない業務ロジックが呼び出された場合に403 Forbidden、指定されたデータが見つからなかった場合に404 Not Found、等々)
Webサーバ本来のエラー画面との統合も考えると、このような文字列組み立てやテンプレートシステムを使った出力ではなく、単に静的HTMLファイルを切り替えるというのも一つの方法だと思います。

ユーティリティクラス

<?php
namespace Holy\Example;

use Holy\Example\Util as U;

// ユーティリティクラス
class Util {

    public static function H($var, $default = '') {
        if (isset($var) && strlen($var) >= 1) {
            return htmlspecialchars($var, ENT_QUOTES, 'UTF-8');
        }
        return $default;
    }

    /**
* エラーレベル定数(E_USER_***)の配列を生成して返します。
* @return array
*/
    public static function buildErrorLevels() {
        $levels = array_fill_keys(array_filter(array_keys(get_defined_constants()),
            function($name) {
                return (strncmp('E_USER_', $name, 7) === 0);
            }
        ), array());
        array_walk($levels, function(&$item, $key) {
            if (defined($key)) {
                $item['name' ] = $key;
                $item['value'] = constant($key);
            }
        });
        usort($levels, function($item1, $item2) {
            return ($item1['value'] > $item2['value']) ? +1 : -1;
        });
        return $levels;
    }

    /**
* スタックトレースの配列をエラー表示用に整形して返します。
* @param array
* @return array
*/
    public static function formatTrace($trace) {
        $stack = array();
        foreach ($trace as $i => $t) {
            // 引数は型が分かるよう文字列に整形
            $args = '';
            if (isset($t['args']) && !empty($t['args'])) {
                // 配列は一階層目のみ回す
                $args = implode(', ', array_map(function($arg) {
                    if (is_array($arg)) {
                        $vars = array();
                        foreach ($arg as $key => $var) {
                            $vars[] = sprintf('%s=>%s',
                                U::formatVar($key), U::formatVar($var));
                        }
                        return sprintf('Array[%s]', implode(', ', $vars));
                    }
                    return U::formatVar($arg);
                }, $t['args']));
            }
            $stack[] = sprintf('#%d %s(%d): %s%s%s(%s)',
                $i,
                (isset($t['file' ])) ? $t['file' ] : '', // ファイル
                (isset($t['line' ])) ? $t['line' ] : '', // 行番号
                (isset($t['class' ])) ? $t['class' ] : '', // クラス名
                (isset($t['type' ])) ? $t['type' ] : '', // コール方式(->, ::)
                (isset($t['function'])) ? $t['function'] : '', // 関数名、メソッド名
                $args);
        }
        return $stack;
    }

    /**
* 変数の文字列表現を返します。
* @param mixed
* @return string
*/
    public static function formatVar($var) {
        if (is_null($var)) {
            return 'NULL';
        }
        if (is_int($var)) {
            return sprintf('Int(%d)', $var);
        }
        if (is_float($var)) {
            return sprintf('Float(%F)', $var);
        }
        if (is_string($var)) {
            return sprintf('"%s"', $var);
        }
        if (is_bool($var)) {
            return sprintf('Bool(%s)', $var ? 'true' : 'false');
        }
        if (is_array($var)) {
            return 'Array';
        }
        if (is_object($var)) {
            return sprintf('Object(%s)', get_class($var), $var);
        }
        return sprintf('%s', gettype($var));
    }

}
view raw Util.php This Gist brought to you by GitHub.

各処理の記述をシンプルにするための共通関数集的なユーティリティクラスです。
H()メソッドはhtmlspecialchars()の手抜き用。名前空間のエイリアスとの併用で、U::H()で呼び出しています。(サンプルコードということでお許しください)
buildErrorLevels()メソッドではget_defined_constants()関数で取得した定数からarray_filter()関数を使ってE_USER_***定数のみ抜き出し、array_walk()関数やusort()関数によりフォーム出力用のデータとして加工しています。
formatTrace()メソッドとformatVar()メソッドは、いずれもスタックトレースの整形用です。特に、配列の場合は第1階層のみキーと値の両方の型が分かるようにしているところが便利で自分でも気に入ってます。

動作サンプルはこちらです。
http://k-holy.sakura.ne.jp/php-advent-2011/error_handler.php

参考リンク
PHP: エラー処理 – Manual

ざっと流しましたが、特にエラーハンドラについては色々と制約があったりするので、PHPマニュアルの記述を参考に補足します。

ユーザー定義のエラーハンドラで扱えるエラーと扱えないエラーについて。
以下のエラータイプは、ユーザ定義のエラーハンドラでは扱えません。
E_ERROR
E_PARSE
E_CORE_ERROR
E_CORE_WARNING
E_COMPILE_ERROR
E_COMPILE_WARNING
および set_error_handler() がコールされたファイルで発生した大半の E_STRICT (PHP5以降)

逆に、ユーザー定義のエラーハンドラで処理できるエラーは以下の通りです。
E_WARNING
E_NOTICE
E_USER_ERROR
E_USER_WARNING
E_USER_NOTICE
E_STRICT (PHP5以降)
E_RECOVERABLE_ERROR (PHP5.2以降)
E_DEPRECATED (PHP5.3以降)
E_USER_DEPRECATED (PHP5.3以降)

ユーザー定義のエラーハンドラで扱えない E_ERROR でも、シャットダウン関数で処理できることがあります。
シャットダウン関数はスクリプト処理が完了したとき、あるいは exit() がコールされたときに実行するコールバックで、register_shutdown_function()関数を使って登録します。
このコールバックの中でerror_get_last()関数(PHP5.2以降)を使うとE_ERROR(Fatal error)の情報が取得できるので、そこからエラーハンドラと同様のエラーメッセージ整形やロギング、メール送信などを行うことが可能です。
この方法は、発生したエラーや例外のメッセージをハンドラ内で逐次出力せず、妥当なHTMLドキュメントとしてデバッグ用エラー画面などで表示したい場合にも使えそうです。

通常のエラーハンドラや例外ハンドラとは違った処理手順となるため今回のコードでは取り上げませんでしたが、シャットダウン関数を使ったエラー処理については、以下の記事が参考になります。
PHP の「エラー処理ハンドラ」「シャットダウンハンドラ」「例外処理ハンドラ」の挙動 – Web/DB プログラミング徹底解説
PHPのset_erorr_handlerとregister_shutdown_functionとob関数について ( エラーを整形出力したい ) ::ハブろぐ

またこちらの記事では、設定ディレクティブの auto_prepend_file にシャットダウン関数を仕込んでFatal Errorをメールで通知するアイデアが紹介されています。
fatal errorでアラートメール | GANCHIKU.com

PHP4時代に書かれたレガシー以前の警告満載アプリケーションを引き継いでしまった場合など、まずはauto_prepend_fileでエラーハンドラを組み込んでログを取ってみたり、意外とこの手の知識が役立つことがありますので、未経験の方もぜひ試してみてください。

余談ですが、例外ハンドラ内で例外をスローしてみたところ、捕捉されず Fatal error: Uncaught exception のエラーが発生しました。
例外クラスの定義とただひとつの例外ハンドラで構築されたアプリケーションというネタを思いついて、実際コードも書き始めていたのですが、残念ながら不発に終わってしまいました…。

明日12日目は @kashioka さんです。

追記:GistのURL提示するのを忘れてました。
エラーハンドラ・例外ハンドラのサンプル — Gist
とりあえずコピペして動かしてみようという方はこちらで→https://raw.github.com/gist/1450371/475b95e5eed254f7cb71c7e7e6ac50e1702756e8/main.php
名前空間を使ってますが、外部ライブラリは一切使っていないのでPHP5.3ならどこでも動くはずです。

このエントリをつぶやくこのWebページのtweets このエントリーを含むはてなブックマークはてなブックマーク - エラーハンドラと例外ハンドラによるエラー処理 (PHP Advent Calendar jp 2011 Day 11) この記事をクリップ!Livedoorクリップ - エラーハンドラと例外ハンドラによるエラー処理 (PHP Advent Calendar jp 2011 Day 11) BuzzurlにブックマークBuzzurlにブックマーク @niftyクリップに追加 newsing it! Bookmark this on Delicious Share on Tumblr
Posted in PHP | Tagged | Leave a comment

日出没時刻をGoogle Geocoding API使って住所から取得

PHPにはやたら用途の狭い関数や素人には使い道が分からない関数が色々ありますが、今回は日出時刻を取得するdate_sunrise()と日没時刻を取得するdate_sunset()という関数を使ってみました。どんな環境でも安心の標準関数です。

ネタ元はこちらです。
ををPHPよ、お前は何でそんなにダメなのか – がるの健忘録

ソース

<?php
namespace Holy\Example;

const MAP_API_URL = 'http://maps.google.com/maps/api/geocode/json?sensor=false&region=jp';

use Holy\Example\Util as U;

class Util {

    public static function H($data, $default=null)
    {
        $var = $default;
        if (isset($data) && strcmp($data, '') != 0) {
            $var = htmlspecialchars($data, \ENT_QUOTES, 'UTF-8');
        }
        return $var;
    }

    public static function make_time($yy, $mm, $dd, $hh=0, $mi=0, $ss=0)
    {
        if (checkdate($mm, $dd, $yy)) {
            $time = mktime($hh, $mi, $ss, $mm, $dd, $yy);
            if (is_int($time) && $time > 0) {
                return $time;
            }
        }
        throw new \RuntimeException('invalid date');
    }

    public static function build_options($start, $end, $step, $format, $selected=null)
    {
        return implode("\n", array_map(function($value) use ($format, $selected) {
            return sprintf('<option value="%s"%s>%s</option>',
                sprintf($format, $value),
                ((int)$selected === (int)$value) ? 'selected="selected"' : '',
                $value);
        }, range($start, $end, $step)));
    }

}

$data = array();

$CURRENT_TIME = time();

$data['year'] = (isset($_GET['y']) && strlen($_GET['y']) >= 1)
    ? $_GET['y'] : date('Y', $CURRENT_TIME);

$data['month'] = (isset($_GET['m']) && strlen($_GET['m']) >= 1)
    ? $_GET['m'] : date('m', $CURRENT_TIME);

$data['day'] = (isset($_GET['d']) && strlen($_GET['d']) >= 1)
    ? $_GET['d'] : date('d', $CURRENT_TIME);

$data['latitude'] = (isset($_GET['lat']) && strlen($_GET['lat']) >= 1)
    ? $_GET['lat'] : ini_get('date.default_latitude');

$data['longitude'] = (isset($_GET['lng']) && strlen($_GET['lng']) >= 1)
    ? $_GET['lng'] : ini_get('date.default_longitude');

$data['address'] = (isset($_GET['addr']) && strlen($_GET['addr']) >= 1)
    ? $_GET['addr'] : null;

if (isset($_GET['addr_search']) && isset($data['address'])) {
    $response = json_decode(file_get_contents(sprintf('%s&address=%s', MAP_API_URL,
        rawurlencode($data['address']))));
    if (isset($response->status) && $response->status === 'OK') {
        if (isset($response->results[0]->geometry->location->lat)) {
            $data['latitude'] = $response->results[0]->geometry->location->lat;
        }
        if (isset($response->results[0]->geometry->location->lng)) {
            $data['longitude'] = $response->results[0]->geometry->location->lng;
        }
    }
} elseif (isset($_GET['latlng_search']) && isset($data['latitude']) && isset($data['longitude'])) {
    $response = json_decode(file_get_contents(sprintf('%s&latlng=%s,%s', MAP_API_URL,
        rawurlencode($data['latitude']), rawurlencode($data['longitude']))));
    if (isset($response->status) && $response->status === 'OK') {
        if (isset($response->results[0]->formatted_address)) {
            $data['address'] = $response->results[0]->formatted_address;
        }
    }
}

$target_time = U::make_time((int)$data['year'], (int)$data['month'], (int)$data['day']);

$data['sunrize'] = date_sunrise($target_time, SUNFUNCS_RET_STRING,
    $data['latitude'], $data['longitude']);

$data['sunset'] = date_sunset($target_time, SUNFUNCS_RET_STRING,
    $data['latitude'], $data['longitude']);

?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<script type="text/javascript" src="http://maps.google.com/maps/api/js?sensor=false"></script>
<script type="text/javascript">
var create_map = function(){
var latlng = new google.maps.LatLng(
document.getElementById('latitude').value,
document.getElementById('longitude').value);
var map = new google.maps.Map(document.getElementById('map_canvas'), {
zoom: 10,
center: latlng,
mapTypeId: google.maps.MapTypeId.ROADMAP
});
};
</script>
<title>日出没時刻</title>
</head>
<body onload="create_map();">

<h1>日出没時刻</h1>

<form method="get" action="<?=U::H($_SERVER['SCRIPT_NAME'])?>">
<input type="hidden" id="latitude" value="<?=U::H($data['latitude'])?>" />
<input type="hidden" id="longitude" value="<?=U::H($data['longitude'])?>" />

<p>
<select name="y">
<?=U::build_options(
    intval(date('Y', $CURRENT_TIME) - 5),
    intval(date('Y', $CURRENT_TIME) + 5), 1, '%d', $data['year'])?>
</select>年
<select name="m">
<?=U::build_options(1, 12, 1, '%02d', $data['month'])?>
</select>月
<select name="d">
<?=U::build_options(1, 31, 1, '%02d', $data['day'])?>
</select>日
</p>

<p>緯度<input type="text" name="lat" value="<?=U::H($data['latitude'])?>" /></p>
<p>経度<input type="text" name="lng" value="<?=U::H($data['longitude'])?>"/></p>
<p><input type="submit" name="latlng_search" value="緯度経度から調べる" /></p>

<p>住所<input type="text" name="addr" value="<?=U::H($data['address'])?>" /></p>
<p><input type="submit" name="addr_search" value="住所から調べる" /></p>

<p>日出<?=U::H($data['sunrize'])?></p>
<p>日没<?=U::H($data['sunset'])?></p>

</form>

<div id="map_canvas" style="width:200px; height:200px;"></div>

</body>
</html>

動作サンプル
緯度と経度と言っても覚えていないので、Google Geocoding APIを使って住所から緯度経度を検索してから、日出没時刻を表示してみました。
久しぶりにGoogleMaps APIを使ったんですが、今はAPI Keyなしでも利用できるんですね。
なんか小さい地図を出してるのは、Geocoding APIのみ単体で使うとNGと書かれていたためで、特に意味はありません。
一応、緯度経度に合わせて中心点を設定していますが、地図から緯度経度取得、といったことは今回の趣旨から離れるのでやってません。
エラー処理も入れてません。

このネタでアプリケーションっぽいものを作るなら、GPS付携帯電話やスマートフォン向けにしたり、カレンダー表示付けたり、Geocoding APIの結果キャッシュ機能なんかも必要でしょうか。

ちなみに、php.iniのdate.default_latitudeとdate.default_longitudeのデフォルト値はエルサレムの緯度経度でした。PAAMAYIM_NEKUDOTAYIM!!

今マニュアル見てて、date_sun_info()という似たような関数も見つけてしまいました。(PHP5.1.2以降)
日出没時刻の他に「薄明かり (twilight) の開始/終了時刻」なども合わせて取得できるようですが…。

Wikipediaによれば、常用薄明、航海薄明、天文薄明の三段階あるらしいです。
http://ja.wikipedia.org/wiki/薄明

このエントリをつぶやくこのWebページのtweets このエントリーを含むはてなブックマークはてなブックマーク - 日出没時刻をGoogle Geocoding API使って住所から取得 この記事をクリップ!Livedoorクリップ - 日出没時刻をGoogle Geocoding API使って住所から取得 BuzzurlにブックマークBuzzurlにブックマーク @niftyクリップに追加 newsing it! Bookmark this on Delicious Share on Tumblr
Posted in PHP | Tagged | Leave a comment

Silex+RedBean+PHPTALのサンプル(RedBean FUSEによるモデルクラスとjQueryTreeView)

Silex + RedBean + PHPTALによる系図管理アプリケーションの続きです。
前回は系図の構造と関連付けるための人物マスタの管理機能だけを紹介しましたが、今回は系図の構造を管理するテーブルを作成し、テストデータで系図をツリー表示するところまで紹介します。

系図のモデルはツリー構造になりますが、今回はミック先生の「リレーショナル・データベースの世界」の記事 SQLで木と階層構造のデータを扱う(2)―― 経路列挙モデル を参考に、SQLでの検索がしやすい「経路列挙モデル(Path Emuneration Model)」で実装してみました。

人物情報を扱うための、profilesテーブルです。

CREATE TABLE profiles
(
    id INTEGER NOT NULL PRIMARY KEY,
    name VARCHAR(20) NOT NULL,
    notes TEXT
);

系図の構造を管理するための、tree_nodesテーブルです。

CREATE TABLE tree_nodes
(
    id INTEGER NOT NULL PRIMARY KEY,
    profile_id INTEGER NOT NULL,
    path VARCHAR(255) NOT NULL,
    order_no REAL NOT NULL,
    FOREIGN KEY(profile_id) REFERENCES profiles(id)
);
CREATE INDEX tree_nodes_idx_path ON tree_nodes (path);


このモデルの決め手となるパス(path)については、今回はノードIDの「/」区切りとしています。
order_noは兄弟の並び順を設定するための項目です。

経路列挙モデルの特徴については、以下のテストデータを見てもらった方が早いと思います。

INSERT INTO profiles (id, name) VALUES ( 1, '三好長之');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES ( 1, 1, '/1/' , 1);

INSERT INTO profiles (id, name) VALUES ( 2, '三好之長');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES ( 2, 2, '/1/2/', 1);

INSERT INTO profiles (id, name) VALUES ( 3, '三好勝時');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES ( 3, 3, '/1/3/', 2);

INSERT INTO profiles (id, name) VALUES ( 4, '三好一秀');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES ( 4, 4, '/1/4/', 3);

INSERT INTO profiles (id, name) VALUES ( 5, '三好勝宗');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES ( 5, 5, '/1/5/', 4);

INSERT INTO profiles (id, name) VALUES ( 6, '三好長秀');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES ( 6, 6, '/1/2/6/', 1);

INSERT INTO profiles (id, name) VALUES ( 7, '三好頼澄');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES ( 7, 7, '/1/2/7/', 2);

INSERT INTO profiles (id, name) VALUES ( 8, '芥川長光');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES ( 8, 8, '/1/2/8/', 3);

INSERT INTO profiles (id, name) VALUES ( 9, '三好長則');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES ( 9, 9, '/1/2/9/', 4);

INSERT INTO profiles (id, name) VALUES (10, '三好新五郎');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES (10,10, '/1/3/10/', 1);

INSERT INTO profiles (id, name) VALUES (11, '三好勝長');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES (11,11, '/1/3/11/', 2);

INSERT INTO profiles (id, name) VALUES (12, '三好政長');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES (12,12, '/1/3/12/', 3);

INSERT INTO profiles (id, name) VALUES (13, '三好元長');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES (13,13, '/1/2/6/13/', 1);

INSERT INTO profiles (id, name) VALUES (14, '三好康長');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES (14,14, '/1/2/6/14/', 2);

INSERT INTO profiles (id, name) VALUES (15, '三好政成');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES (15,15, '/1/2/7/15/', 1);

INSERT INTO profiles (id, name) VALUES (16, '三好政康');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES (16,16, '/1/2/7/16/', 2);

INSERT INTO profiles (id, name) VALUES (17, '芥川孫十郎');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES (17,17, '/1/2/8/17/', 1);

INSERT INTO profiles (id, name) VALUES (18, '三好長逸');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES (18,18, '/1/2/9/18/', 1);

INSERT INTO profiles (id, name) VALUES (19, '三好政勝');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES (19,19, '/1/3/12/19/', 1);

INSERT INTO profiles (id, name) VALUES (20, '三好長慶');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES (20,20, '/1/2/6/13/20/', 1);

INSERT INTO profiles (id, name) VALUES (21, '三好義賢');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES (21,21, '/1/2/6/13/21/', 2);

INSERT INTO profiles (id, name) VALUES (22, '安宅冬康');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES (22,22, '/1/2/6/13/22/', 3);

INSERT INTO profiles (id, name) VALUES (23, '十河一存');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES (23,23, '/1/2/6/13/23/', 4);

INSERT INTO profiles (id, name) VALUES (24, '三好可政');
INSERT INTO tree_nodes (id, profile_id, path, order_no) VALUES (24,24, '/1/3/12/19/24/', 1);


子は必ず親のパスを自身のパスに含み、パスの区切り文字は先頭と末尾にも含むのがポイントです。
※データの内容については、今谷明 氏の「戦国 三好一族」記載の系図を使わせていただきました。

今回からは対象のテーブルが増えましたので、この機会にモデルクラスを作成し、フィールドの定義やバリデーション処理、自動生成に向かないSQLの実行をモデルクラスに定義しました。
公式ドキュメントで「FUSE」と呼ばれている機能で、RedBean_SimpleModel を継承したモデルクラスを作成し、定められた名前のメソッドを定義してR::store(), R::load(), R::trash(), R::dispense()の各メソッドをフックしたり、RedBean_OODBBeanの __call()マジックメソッド経由でモデルクラスのメソッドを利用できます。
RedBeanPHP | Models And Fuse
※マジックメソッド経由で呼び出されるだけなので、モデルクラス側でメソッドチェーンが使えないことと、RedBean_OODBBean::__call()の現在の仕様では未定義のメソッドが呼ばれた際にしれっとNULLが返されることに要注意です。

モデルクラスは、標準では「Model_テーブル名」(頭は大文字)という名前のクラスを定義しますが、この方式では名前空間を利用している場合に問題となります。
標準とは異なった命名規約のモデルクラスを利用する場合、RedBean_IModelFormatterを実装したクラスのインスタンスを RedBean_ModelHelper::setModelFormatter() でセットすることで対応できます。
ModelFormatter – RedBean wiki

こういう処理はSilex的にはServiceProviderの引数にコールバックで指定したくなりますが、ServiceProviderに内部クラスを定義することはできないし、かといってクラスの外に定義しようにもRedBean_IModelFormatterをimplementsしないといけないのに外側ではRedBeanのパスが分からないしで、どうにもスマートに解決する方法が思いつかなかったので、ServiceProviderへの適用は諦めて、普通にModelFormatterクラスを定義してインスタンス生成しました。

ModelFormatterは汎用的な内容になるので、名前空間はこのアプリケーションの「Keizu」ではなく「Holy\RedBean」としました。

<?php
/**
* PHP versions 5
*
* @copyright 2011 k-holy <k.holy74@gmail.com>
* @author k.holy74@gmail.com
* @license http://www.opensource.org/licenses/mit-license.php The MIT License (MIT)
*/
namespace Holy\RedBean;

/**
* Holy\RedBean\ModelFormatter
*
* @author k.holy74@gmail.com
*/
class ModelFormatter implements \RedBean_IModelFormatter
{
    /** @var string 名前空間 **/
    protected $namespace;

    /** @var string テーブル名に付与する接頭辞 **/
    protected $prefix;

    /** @var string テーブル名に付与する接尾辞 **/
    protected $suffix;

    public function __construct($namespace = null, $prefix = null, $suffix = null)
    {
        if (isset($namespace)) {
            $this->namespace = $namespace;
        }
        if (isset($prefix)) {
            $this->prefix = $prefix;
        }
        if (isset($suffix)) {
            $this->suffix = $suffix;;
        }
    }

    /**
* テーブル名をモデルクラス名に変換して返します。
* @param string テーブル名
* @return string モデルクラス名
*/
    public function formatModel($model)
    {
        $model = self::camelize($model);
        if (isset($this->namespace)) {
            $model = $this->namespace . '\\'. $model;
        }
        if (isset($this->prefix)) {
            $model = $this->prefix . $model;
        }
        if (isset($this->suffix)) {
            $model .= $this->suffix;
        }
        return $model;
    }

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

}


内容はご覧の通りで、アンダースコア区切りのテーブル名をキャメルケースに変換し、指定された名前空間や接頭辞、接尾辞を付与したものをクラス名として返します。
こうすることで、RedBean_OODBBean::__call() → RedBean_ModelHelper::getModelName() → RedBean_IModelFormatter::formatModel() と呼ばれてきます。

モデルの基底クラスです。

<?php
/**
* PHP versions 5
*
* @copyright 2011 k-holy <k.holy74@gmail.com>
* @author k.holy74@gmail.com
* @license http://www.opensource.org/licenses/mit-license.php The MIT License (MIT)
*/
namespace Keizu;

abstract class AbstractModel extends \RedBean_SimpleModel
{
    protected $db;
    protected $table;
    protected $fields = array();

    public function setDb(\RedBean_FacadeHelper $db)
    {
        $this->db = $db;
    }

    public function getFieldNames()
    {
        return array_keys($this->fields);
    }

    public function update()
    {
        $this->validate(__FUNCTION__);
    }

    public function delete()
    {
        $this->validate(__FUNCTION__);
    }

    abstract function validate($method);

}


公式ドキュメントのサンプル RedBeanPHP | Custom Getters ではモデル独自のSQL実行にはファサードクラスが使われていますが、今回はRedBean_FacadeHelperを使っているので、setDb()でセットできるようにしました。
保存前のバリデーション処理ということで、R::store()前に呼び出される update()を定義し、その中でモデルクラスのvalidate()メソッドを呼ぶようにしています。

モデルクラスのバリデーション処理で不正な入力値の検知時にスローする例外クラス、InputValidationExceptionです。

<?php
/**
* PHP versions 5
*
* @copyright 2011 k-holy <k.holy74@gmail.com>
* @author k.holy74@gmail.com
* @license http://www.opensource.org/licenses/mit-license.php The MIT License (MIT)
*/
namespace Keizu;

/**
* Keizu\InputValidationException
*
* @author k.holy74@gmail.com
*/
class InputValidationException extends \Exception
{

    public function __construct($errors)
    {
        parent::__construct(json_encode($errors));
    }

    public function getErrors()
    {
        return json_decode($this->getMessage());
    }
}


公式ドキュメントではExceptionのメッセージにユーザ向けのエラーメッセージをそのままセットしていましたが、それだと最初の項目だけしか通知できないので、この例外クラスに各項目のエラーをJSON形式で例外メッセージに詰めておき、cath時にgetErrors()メソッドでデコードして返すというけったいな方法を取りました。

人物情報を扱うための、Profilesモデルです。

<?php
/**
* PHP versions 5
*
* @copyright 2011 k-holy <k.holy74@gmail.com>
* @author k.holy74@gmail.com
* @license http://www.opensource.org/licenses/mit-license.php The MIT License (MIT)
*/
namespace Keizu;

use Keizu\AbstractModel;
use Keizu\InputValidationException;

/**
* 人物情報モデル
*/
class ProfilesModel extends AbstractModel
{

    protected $table = 'profiles';
    protected $fields = array(
        'name' => '名前',
        'notes' => '備考',
    );

    public function validate($method)
    {
        $errors = array();
        foreach ($this->fields as $field => $label) {
            switch ($field) {
            case 'name':
                if (!isset($this->{$field}) || 0 === strlen($this->{$field})) {
                    $errors[$field] = sprintf('%sを入力してください', $label);
                } elseif (20 < mb_strlen($this->{$field}, 'UTF-8')) {
                    $errors[$field] = sprintf('%sを20文字以内で入力してください', $label);
                }
                break;
            }
        }
        if (!empty($errors)) {
            throw new InputValidationException($errors);
        }
        return true;
    }

}


前回のサンプルでグローバルに定義していた項目名とバリデーション処理を、ほぼそのまま取り込む形になりました。
バリデーションエラーメッセージの配列を返していたところは、前述の通りInputValidationExceptionをスローするよう変更しています。
リクエストハンドラ側でこれをcatchして、エラー内容をテンプレート変数にセットします。

系図の構造を扱うための、TreeNodesモデルです。

<?php
/**
* PHP versions 5
*
* @copyright 2011 k-holy <k.holy74@gmail.com>
* @author k.holy74@gmail.com
* @license http://www.opensource.org/licenses/mit-license.php The MIT License (MIT)
*/
namespace Keizu;

use Keizu\AbstractModel;
use Keizu\InputValidationException;

/**
* 系図構造モデル
*/
class TreeNodesModel extends AbstractModel
{

    protected $table = 'tree_nodes';
    protected $fields = array(
        'profile_id' => '人物ID',
        'path' => 'パス',
        'order_no' => '並び順',
    );

    public function validate($method)
    {
        if (!isset($this->db)) {
            throw new \RuntimeException('RedBean_FacadeHelper is not set.');
        }
        $errors = array();
        foreach ($this->fields as $field => $label) {
            switch ($field) {
            case 'profile_id':
                if (!isset($this->{$field})) {
                    $errors[$field] = sprintf('%sを入力してください', $label);
                } elseif (!ctype_digit($this->{$field})) {
                    $errors[$field] = sprintf('%sを数値で入力してください', $label);
                } else {
                    $profiles = $this->db->find('profiles', ' id = :id',
                        array('id' => sprintf('%d', $this->{$field})));
                    if (empty($profiles)) {
                        $errors[$field] = sprintf('無効な%sが指定されています', $label);
                    }
                }
                break;
            case 'path':
                if (isset($this->{$field}) && 0 < strlen($this->{$field}) &&
                    !preg_match('~\A/[0-9/]+/\z~', $this->{$field}))
                {
                    $errors[$field] = sprintf('無効な%sが指定されています', $label);
                }
                break;
            case 'order_no':
                if (!isset($this->{$field})) {
                    $errors[$field] = sprintf('%sを入力してください', $label);
                } elseif (!ctype_digit($this->{$field})) {
                    $errors[$field] = sprintf('%sを数値で入力してください', $label);
                }
                break;
            }
        }
        if (!empty($errors)) {
            throw new InputValidationException($errors);
        }
        return true;
    }

    public function getTree()
    {
        if (!isset($this->db)) {
            throw new \RuntimeException('RedBean_FacadeHelper is not set.');
        }
        // ツリー情報を取得
        $query = <<< SQL
SELECT
    PT.id
, PP.name
, PPT.id AS parent_id
, COUNT(CT.id) AS child_count
FROM
    tree_nodes PT
LEFT OUTER JOIN
    profiles PP ON PT.profile_id = PP.id
LEFT OUTER JOIN
    tree_nodes CT ON PT.path =
        (SELECT MAX(path) FROM tree_nodes WHERE CT.path LIKE path || '_%')
LEFT OUTER JOIN
    tree_nodes PPT ON PPT.path =
        (SELECT MAX(path) FROM tree_nodes WHERE PT.path LIKE path || '_%')
GROUP BY PT.id, PP.name, PPT.id
ORDER BY PT.path
;
SQL;
        $id = (!empty($this->id)) ? $this->id : null;
        // jQuery plugin:Treeviewの仕様に合わせてツリーを構成
        // http://bassistance.de/jquery-plugins/jquery-plugin-treeview/
        $tree = call_user_func(function($items) use ($id) {
            $tree = array();
            $node = array();
            foreach ($items as $item){
                if (isset($node[$item['id']]['children'])) {
                    $item['children'] = $node[$item['id']]['children'];
                }
                $node[$item['id']] = array(
                    'id' => $item['id'],
                    'text' => ((int)$item['id'] === (int)$id)
                        ? sprintf('<span class="selected">%d:%s</span>(%d)',
                            $item['id'], $item['name'], $item['child_count'])
                        : sprintf('<a href="/nodes/%d/">%d:%s<a>(%d)',
                            $item['id'], $item['id'], $item['name'], $item['child_count']),
                    'children' => array(),
                    'expanded' => true,
                );
                if (is_null($item['parent_id'])) {
                    $tree[] = &$node[$item['id']];
                } else {
                    $node[$item['parent_id']]['children'][] = &$node[$item['id']];
                }
            }
            return $tree;
        }, $this->db->getAll($query));
        return $tree;
    }

    public function getRoute()
    {
        if (!isset($this->db)) {
            throw new \RuntimeException('RedBean_FacadeHelper is not set.');
        }
        // ルートからの経路を取得
        $query = <<< SQL
SELECT
    CT.*
, CP.name
FROM
    tree_nodes CT
, tree_nodes PT
LEFT OUTER JOIN
    profiles CP ON CT.profile_id = CP.id
WHERE
    CT.path LIKE '/1/%'
AND PT.path LIKE CT.path || '%'
AND PT.id = :id
ORDER BY CT.path
;
SQL;
        return $this->db->getAll($query, array('id' => $this->id));
    }

}


経路列挙モデルを利用した独自メソッドが定義されているため、Profilesと比べてコードが大量になってます。
getTree()…今回の肝となるメソッドで、親IDおよび子ノードの数、関連付けられた人物名を含むノードの情報をSQLで取得し、jQueryのplugin:Treeviewに合わせた構造に変換して返しています。
SQLとプレゼンテーションロジックが同居してしまってますが、ツリー表示専用の処理なので、気にしないことにします。
getRoute()…ルートから指定ノードまでの中間ノードを全て取得して返しています。経路列挙モデルならではの処理と言えるでしょう。サンプルではこれを使ってパンくずリストのようなUIを実装しました。

フロントコントローラとなるスクリプトはこんな感じです。

<?php
/**
* PHP versions 5
*
* @copyright 2011 k-holy <k.holy74@gmail.com>
* @author k.holy74@gmail.com
* @license http://www.opensource.org/licenses/mit-license.php The MIT License (MIT)
*/
namespace Keizu;

require_once realpath(__DIR__ . '/silex.phar');

const INTERNAL_ENCODING = 'UTF-8';
const CSRF_TOKEN_NAME = 'AEg9GE5#4ZyPY$fd';

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

use Holy\Silex\Provider\PhpTalServiceProvider;
use Holy\Silex\Provider\RedBeanServiceProvider;
use Holy\RedBean\ModelFormatter;

use Keizu\InputValidationException;

$app = new Application();
$app['debug'] = true;

$app['autoloader']->registerNamespace('Holy' ,
    realpath(__DIR__ . '/vendor/k-holy/src'));

$app['autoloader']->registerNamespace('Keizu',
    realpath(__DIR__ . '/app/src'));

$app->register(new SessionServiceProvider());
$app->register(new PhpTalServiceProvider(), array(
    'phptal.class_path' => realpath(__DIR__ . '/vendor/phptal'),
    'phptal.options' => array(
        'templateRepository' => realpath(__DIR__ . '/views'),
    ),
));
$app->register(new RedBeanServiceProvider(), array(
    'db.redbean.class_path' => realpath(__DIR__ . '/vendor/redbean'),
    'db.options' => array(
        'dsn' => sprintf('sqlite:%s', realpath(__DIR__ . '/app/data/keizu.sqlite')),
    ),
));
$app['db']->debug(true);

// RedBeanのModelFormatterを設定
\RedBean_ModelHelper::setModelFormatter(new ModelFormatter('Keizu', null, 'Model'));

// Error handler
$app->error(function(\Exception $e, $code) use ($app) {
    $message = 'Internal Server Error';
    if ($e instanceof \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface) {
        switch ($code) {
        case 403:
            $message = 'Forbidden';
            break;
        case 404:
            $message = 'NotFound';
            break;
        case 405:
            $message = 'Method Not Allowed';
            break;
        }
    }
    $app['phptal']->set('message', $message);
    $app['phptal']->set('error', ($app['debug']) ? $e->__toString() : null);
    $app['phptal']->set('title', 'エラー');
    return new Response($app['phptal']->setTemplate('error.html')->execute(),
        (isset($code)) ? $code : 500);
});

// Before filter
$app->before(function(Request $request) use($app) {
    $app['phptal']->set('REQUEST_URI', $request->getRequestUri());
    if ($app['request']->query->has('back')) {
        $app['phptal']->set('BACK_URI', $app['request']->query->get('back'));
    }
    if ($app['session']->hasFlash('message')) {
        $app['phptal']->set('message', $app['session']->getFlash('message'));
        $app['session']->removeFlash('message');
    }
});

//----------------------------------------------------------------------------
// トップページ
//----------------------------------------------------------------------------
$app->get('/', function() use ($app) {
    $app['phptal']->set('title', 'トップページ');
    return $app['phptal']->setTemplate('index.html')->execute();
});

//----------------------------------------------------------------------------
// 人物一覧
//----------------------------------------------------------------------------
$app->get('/profiles/', function() use ($app) {
    $app['phptal']->set('items', $app['db']->find('profiles', ' 1 ORDER BY id'));
    $app['phptal']->set('title', '人物一覧');
    return $app['phptal']->setTemplate('profiles/index.html')->execute();
});

//----------------------------------------------------------------------------
// 人物登録
//----------------------------------------------------------------------------
$app->match('/profiles/register', function() use ($app) {
    $bean = $app['db']->dispense('profiles');
    switch($app['request']->getMethod()) {
    case 'POST':
        $bean->import($app['request']->request->all(), $bean->getFieldNames());
        if ($app['request']->get(CSRF_TOKEN_NAME) === $app['session']->getId()) {
            try {
                $app['db']->store($bean);
            } catch (InputValidationException $ex) {
                $app['phptal']->set('errors', $ex->getErrors());
                break;
            }
            $app['session']->setFlash('message', '人物情報を登録しました。');
            return $app->redirect('/profiles/', 302);
        }
        break;
    }
    $app['phptal']->set('token', array(
        'name' => CSRF_TOKEN_NAME,
        'value' => $app['session']->getId(),
    ));
    $app['phptal']->set('data' , $bean);
    $app['phptal']->set('title', '人物登録');
    return $app['phptal']->setTemplate('profiles/edit.html')->execute();
});

//----------------------------------------------------------------------------
// 人物編集
//----------------------------------------------------------------------------
$app->match('/profiles/{id}/modify', function($id) use ($app) {
    $bean = $app['db']->load('profiles', $id);
    switch($app['request']->getMethod()) {
    case 'POST':
        $bean->import($app['request']->request->all(), $bean->getFieldNames());
        if ($app['request']->get(CSRF_TOKEN_NAME) === $app['session']->getId()) {
            try {
                $app['db']->store($bean);
            } catch (InputValidationException $ex) {
                $app['phptal']->set('errors', $ex->getErrors());
                break;
            }
            $app['session']->setFlash('message', '人物情報を更新しました。');
            return $app->redirect('/profiles/', 302);
        }
        break;
    }
    $app['phptal']->set('token', array(
        'name' => CSRF_TOKEN_NAME,
        'value' => $app['session']->getId(),
    ));
    $app['phptal']->set('data' , $bean);
    $app['phptal']->set('title', '人物編集');
    return $app['phptal']->setTemplate('profiles/edit.html')->execute();
})->assert('id', '\d+');

//----------------------------------------------------------------------------
// 人物削除
//----------------------------------------------------------------------------
$app->match('/profiles/{id}/delete', function($id) use ($app) {
    $bean = $app['db']->load('profiles', $id);
    switch($app['request']->getMethod()) {
    case 'POST':
        if ($app['request']->get(CSRF_TOKEN_NAME) === $app['session']->getId()) {
            try {
                $app['db']->trash($bean);
            } catch (InputValidationException $ex) {
                $app['phptal']->set('errors', $ex->getErrors());
                break;
            }
            $app['session']->setFlash('message', '人物情報を削除しました。');
            return $app->redirect('/profiles/', 302);
        }
        break;
    }
    $app['phptal']->set('token', array(
        'name' => CSRF_TOKEN_NAME,
        'value' => $app['session']->getId(),
    ));
    $app['phptal']->set('data' , $bean);
    $app['phptal']->set('title', '人物削除');
    return $app['phptal']->setTemplate('profiles/delete.html')->execute();
})->assert('id', '\d+');

//----------------------------------------------------------------------------
// ルートノードに転送
//----------------------------------------------------------------------------
$app->get('/nodes/', function() use ($app) {
    return $app->redirect('/nodes/1/', 301);
});

//----------------------------------------------------------------------------
// 人物詳細+ツリー表示
//----------------------------------------------------------------------------
$app->get('/nodes/{id}/', function($id) use ($app) {
    $node = $app['db']->load('tree_nodes', $id);
    $profile = $app['db']->load('profiles', $node->profile_id);
    $node->setDb($app['db']);
    $app['phptal']->set('profile', $profile);
    $app['phptal']->set('node' , $node);
    $app['phptal']->set('route' , $node->getRoute());
    $app['phptal']->set('title' , 'ノード詳細');
    return $app['phptal']->setTemplate('nodes/detail.html')->execute();
})->assert('id', '\d+');

//----------------------------------------------------------------------------
// ツリーをJSONで返す
//----------------------------------------------------------------------------
$app->get('/tree.{id}.json', function($id) use ($app) {
    $app['db']->debug(false);
    $node = $app['db']->load('tree_nodes', $id);
    $node->setDb($app['db']);
    return new Response(json_encode($node->getTree()), 200,
        array('Content-Type' => 'application/json'));
})->assert('id', '\d+');

$app->run();

view raw index.php This Gist brought to you by GitHub.

前回のまちがい探しみたいな状態ですが、違いは大体以下の通りです。(名前空間のuse文などは省略します)
まず、\RedBean_ModelHelper::setModelFormatter(new ModelFormatter(‘Keizu’, null, ‘Model’)); でRedBeanのModelFormatterを設定しています。
次に、全画面共通で遷移元画面へ戻れるよう、beforeフィルタでテンプレート変数REQUEST_URIおよびBACK_URIの定義を追加しています。
各リクエストハンドラでは、人物登録、人物編集、人物削除のバリデーション処理およびエラーメッセージ取得の記述を、InputValidationException をキャッチして getErrors() するよう変更しています。

また今回、以下のリクエストハンドラを3つ追加しています。

ルートノードに転送…今回のテストデータではルートノードは /1/ に固定なので、ノードIDが指定されなかった場合にルートノードの詳細画面へ転送しています

ノード・人物詳細+系図ツリー表示…今回の主要画面で、指定されたノードの情報および関連付けられた人物の情報を取得、指定されたノードへのルートノードからの中間ノードを取得しています。
(前者については外部結合クエリで取得すべきものですが、RedBeanのリレーション機能がよく分からなかったので断念…)

ツリーをJSONで返す…Ajaxリクエストを処理するハンドラです。処理内容はほとんどTreeNodesモデルクラスに記述しています。
JSONを返すハンドラのため、文字列ではなくResponseオブジェクト(Symfony\Component\HttpFoundation\Response)を返し、コンストラクタの第3引数でContent-Typeとしてapplication/jsonを指定しています。
また、グローバルで有効にしているRedBeanのデバッグ機能(実行クエリが全てHTMLで出力される)をこのハンドラ内のみ無効にしています。

レイアウトテンプレートです。

<!DOCTYPE html>
<html>

<head metal:define-macro="head">
<meta charset="UTF-8" />
<link rel="stylesheet" media="screen" href="/css/base.css" />
<script src="/js/jquery-1.6.2.min.js"></script>
<tal:block metal:define-slot="head-extra"></tal:block>
<title tal:content="title">Title</title>
</head>

<body metal:define-macro="body">

  <div id="header">
    <h1>Silex + RedBeanPHP + PHPTAL CRUD Examples</h1>
  </div>

  <div id="container">

    <div id="menu" metal:define-slot="menu">
      <ul>
        <li><a href="/profiles/">人物一覧</a></li>
        <li><a href="/profiles/register">人物登録</a></li>
        <li><a href="/nodes/1/">ツリー表示</a></li>
      </ul>
    </div>

    <h2 tal:content="title">画面名</h2>

    <p class="message" tal:condition="exists:message" tal:content="message">Flashメッセージ</p>

    <div id="main" metal:define-slot="main">
      メイン
    </div>

    <p tal:condition="exists:BACK_URI"><a href="${BACK_URI}">戻る</a></p>

  </div>

  <div id="footer">
    Copyright &copy; k-holy &lt;k.holy74@gmail.com&gt;
  </div>

</body>
</html>

view raw __layout.html This Gist brought to you by GitHub.

これもまちがい探し状態ですが、ナビゲーションメニューのツリー表示画面へのリンク、戻り先URLが指定された場合のリンク表示を追加しています。
前回のサンプルではテンプレートに直接戻り先のパスを記述していましたが、今回はクエリパラメータに”back”を指定することでフロントコントローラのbeforeフィルタでテンプレート変数”BACK_URI”が定義されるので、それをリンクしています。

人物詳細+系図ツリー表示画面のテンプレートです。

<!DOCTYPE html>
<html>

<head metal:use-macro="__layout.html/head">
<meta charset="UTF-8" />
<tal:block metal:fill-slot="head-extra">
<link rel="stylesheet" media="screen" href="/js/jquery-treeview/jquery.treeview.css" />
<link rel="stylesheet" media="screen" href="/css/tree.css" />
<script src="/js/jquery-ui-1.8.16.custom.min.js"></script>
<script src="/js/jquery-treeview/jquery.treeview.js"></script>
<script src="/js/jquery-treeview/jquery.treeview.edit.js" ></script>
<script src="/js/jquery-treeview/jquery.treeview.async.js"></script>
</tal:block>
<title>ツリー表示</title>
</head>

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

  <div id="container">

    <div id="menu" metal:fill-slot="menu">
      <input type="hidden" id="node_id" tal:attributes="value node/id" />
      <script type="text/javascript">
      $(document).ready(function() {
        $('#tree').treeview({
          url: '/tree.' + $('#node_id').val() + '.json',
          unique: true
        });
      });
      </script>
      <ul id="tree">
      </ul>
    </div>

    <div id="main" metal:fill-slot="main">

      <ul class="errors" tal:condition="exists:errors">
        <li tal:repeat="error errors" tal:content="error">エラーメッセージ</li>
      </ul>

      <p><a href="/">TOP</a></p>

      <ul class="route">
        <li tal:repeat="item route">
          <a href="/nodes/${item/id}/">${item/name}</a>
        </li>
      </ul>

      <table>
        <tr>
          <th>ID</th>
          <td tal:content="profile/id">1</td>
        </tr>
        <tr>
          <th>名前</th>
          <td>
            <span tal:content="profile/name">三好長之</span>
            <span class="action">
            [<a href="/profiles/${profile/id}/modify?back=${REQUEST_URI}">編集</a>]
            [<a href="/profiles/${profile/id}/delete?back=${REQUEST_URI}">削除</a>]
            </span>
          </td>
        </tr>
        <tr>
          <th>備考</th>
          <td tal:content="profile/notes">備考</td>
        </tr>
      </table>

    </div>

  </div>

</body>
</html>


系図のツリー表示用に、metal:fill-slotを使ってCSSファイルやjQuery関連ファイルを読み込んでいます。
人物の詳細情報も表示しているので、人物編集・人物削除画面へのリンクも追加しています。
前述のフロントコントローラのbeforeフィルタに仕込んだ戻りURL取得用の処理に合わせて、クエリパラメータの”back”にREQUEST_URIを渡しています。

おまけとして、ツリー表示画面のCSSも紹介しておきます。

@charset "UTF-8";

ul.route > li { display:inline-block; list-style-type:none; margin: 0px 3px; padding: 0; }
ul.route > li:before { content:"≫"; }
ul.route > li:first-child:before { content:""; }
ul#tree span.selected { font-weight:bold; }
ul#tree { padding:5px; margin:0px 5px; float:left; }

view raw tree.css This Gist brought to you by GitHub.

LI要素のインライン表示と、contentプロパティを利用してセパレータを表示しています。
jQueryTreeViewのクライアント側の使い方は、見ての通りデフォルトのままだと非常に簡単です。
JSON取得用のURLを指定するのですが、エスケープが面倒なので <input type="hidden" id="node_id" tal:attributes="value node/id" /> としてスクリプトから取得しています。

こんな感じで、テストデータをツリー表示できました。


ちなみに、最新版のOperaとFirefoxでしか確認してません(`・ω・´)

今回紹介したのはツリー表示まででしたが、ノードの編集機能も必要ですし、まだまだ続く予定です。

このエントリをつぶやくこのWebページのtweets このエントリーを含むはてなブックマークはてなブックマーク - Silex+RedBean+PHPTALのサンプル(RedBean FUSEによるモデルクラスとjQueryTreeView) この記事をクリップ!Livedoorクリップ - Silex+RedBean+PHPTALのサンプル(RedBean FUSEによるモデルクラスとjQueryTreeView) BuzzurlにブックマークBuzzurlにブックマーク @niftyクリップに追加 newsing it! Bookmark this on Delicious Share on Tumblr
Posted in PHP | Tagged , , , | Leave a comment

Silex+RedBean+PHPTALのサンプル(PHPMatsuri2011ハッカソン成果)

さる10/15,16両日、PHPMatsuri2011に参加してきました。
PHPMatsuri自体の感想は、「わったい菜のトマトムースうまかった」「SilexはSymfony2コンポーネントを使ったフレームワークのサンプル実装だった!」「Lithiumのユニットテスト統合っぷりがすげえ」「っていうか今時のフレームワークすげえ」「忍者LT大会わろた」「Epic sax guy 10 hours」「Epic sax guy 10 hours」といったところです。
2日目の午後からは意識朦朧で半分寝てましたので、正直発表内容はあまり覚えていません…ごめんなさい。なんかコタツの入切スイッチの話が妙に盛り上がってたのが印象に残ってます…。

ハッカソンではSilexを使った系図管理アプリケーションの開発に取り組んだのですが、その場ではLTできるレベルまで仕上げられませんでした。
そもそもなぜ系図かというと、私は趣味でいわゆる戦国武将のことを調べたりしているのですが、個々の人物の事績などはWebでたくさん紹介されているものの、構造化された系図データが見当たらないということで、自分で管理できればと考えた次第です。
開発途上ではありますが、あまり日が経つとPHPMatsuri関係ねぇってなってしまうので、まずは系図の構造と関連付ける人物の管理機能を通じて、SilexとRedBean、PHPTALを使ったCRUDのサンプルコードを紹介します。

RedBeanPHPはMySQL, PostgreSQL, SQLiteに対応した軽量ORMツールで、日本語の情報は今のところ皆無なんですが、軽量フレームワークのSilexに合うんじゃないかという漠然とした予感に従って使ってみました。
(@brtriverさん、@tanakahisateruさんのツイートで知りました。お二方に感謝します。)

RedBeanのクラス群は”rb.php”という1ファイルで構成され、「R」という何とも男らしい名前のファサードクラスのスタティックメソッドで各種機能を提供するという、シンプルなインターフェースが特徴です。
(GitHub上のソースコードではPEAR命名規約の1クラス1ファイルで構成されていて、R→RedBean_Facadeになってますので、ソースを読む場合はそちらをおすすめします。)

README.markdownに書かれたQuick Exampleはこの通り。

$book = R::dispense(“book”);
$book->author = “Santa Claus”;
$book->title = “Secrets of Christmas”;
$id = R::store( $book );

$bean = R::dispense(テーブル名) で指定したテーブルのbeanオブジェクトを取得し、フィールドに値を設定してR::store($bean)で保存、戻り値として保存したIDが返されます。
“A Bean is a simple object that acts as a data container.”と説明されているbeanオブジェクト、実体は RedBean_OODBBean というIteratorAggregate, ArrayAccessを実装したクラスで、フィールドへのアクセスは__get(),__set()によって提供されています。

ORMというからには当然リレーションの機能も備えており、設定ファイルを使わずにファサードクラスのAssociation APIで定義したり、RedBean_OODBBeanの__set(),__get()で動的に扱うことができるようです。
※今回の記事ではリレーション機能は使っていないのですが、詳しく知りたい場合は「Connecting Beans A Guide to Implementing Basic Data Relationships in RedBeanPHP」というPDFが公式サイトのComplete Guideから入手できます。

今回はファサードクラスを直接利用せず、以下のようなServiceProviderを作成しました。

<?php
/**
* PHP versions 5
*
* @copyright 2011 k-holy <k.holy74@gmail.com>
* @author k.holy74@gmail.com
* @license http://www.opensource.org/licenses/mit-license.php The MIT License (MIT)
*/
namespace Holy\Silex\Provider;

use Silex\Application;
use Silex\ServiceProviderInterface;

/**
* Holy\Silex\Provider\RedBeanServiceProvider
*
* @author k.holy74@gmail.com
*/
class RedBeanServiceProvider implements ServiceProviderInterface
{
    public function register(Application $app)
    {
        $app['db'] = $app->share(function() use ($app) {
            if (isset($app['db.redbean.class_path'])) {
                $redbean_path = $app['db.redbean.class_path'] . DIRECTORY_SEPARATOR . 'rb.php';
                if ('\\' === DIRECTORY_SEPARATOR) {
                    $redbean_path = str_replace('\\', '/', $redbean_path);
                }
                include_once $redbean_path;
            }
            $default_options = array(
                'dsn' => null,
                'username' => null,
                'password' => null,
                'frozen' => false,
            );
            $databases = \R::setupMultiple(array('default' => (isset($app['db.options']))
                ? array_replace($default_options, $app['db.options']) : array()));
            return $databases['default'];
        });
    }
}


$app['db']に RedBean_FacadeHelper がセットされて、このクラスが__call()によってファサードクラスのスタティックメソッドを代行してくれます。
まずはこれをリクエストハンドラで直接使ってCRUDを実装しました。

HTML出力は使い慣れていないTwigを避けて、事前に作成していたPhpTalServiceProviderを使いました。

<?php
/**
* PHP versions 5
*
* @copyright 2011 k-holy <k.holy74@gmail.com>
* @author k.holy74@gmail.com
* @license http://www.opensource.org/licenses/mit-license.php The MIT License (MIT)
*/
namespace Holy\Silex\Provider;

use Silex\Application;
use Silex\ServiceProviderInterface;

class PhpTalServiceProvider implements ServiceProviderInterface
{
    public function register(Application $app)
    {
        $app['phptal'] = $app->share(function() use ($app) {
            $phptal = new \PHPTAL();
            $app['phptal.options'] = array_replace(
                array(
                    'outputMode' => null,
                    'encoding' => null,
                    'templateRepository' => null,
                    'phpCodeDestination' => null,
                    'phpCodeExtension' => null,
                    'cacheLifetime' => null,
                ),
                (isset($app['phptal.options'])) ? $app['phptal.options'] : array()
            );
            foreach ($app['phptal.options'] as $name => $value) {
                $method = 'set' . ucfirst($name);
                if (!method_exists($phptal, $method)) {
                    throw new \RuntimeException(
                        sprintf('The accessor method to "%s" is not defined.', $name));
                }
                if (isset($value)) {
                    switch ($name) {
                    case 'templateRepository':
                        if ('\\' === DIRECTORY_SEPARATOR) {
                                $value = (is_array($value))
                                    ? array_map(function($var) {
                                return str_replace('\\', '/', $var);
                            }, $value) : str_replace('\\', '/', $value);
                        }
                        break;
                    default:
                        break;
                    }
                    $phptal->{$method}($value);
                }
            }
            return $phptal;
        });
        if (isset($app['phptal.class_path'])) {
            $app['autoloader']->registerPrefix('PHPTAL', $app['phptal.class_path']);
        }
    }
}

データベースはSQLite3で、CRUDの操作対象となる人物情報を扱うprofilesテーブルはこんな感じです。

CREATE TABLE profiles
(
    id INTEGER NOT NULL PRIMARY KEY,
    name VARCHAR(20) NOT NULL,
    notes TEXT
);


将来的には生没年や氏族の情報なども追加したいんですが、あまり深く考えると関連するテーブルが増えてしまうので、まずは名前と内容のみというシンプルな形にしました。

フロントコントローラとなるスクリプトはこんな感じです。

<?php
/**
* PHP versions 5
*
* @copyright 2011 k-holy <k.holy74@gmail.com>
* @author k.holy74@gmail.com
* @license http://www.opensource.org/licenses/mit-license.php The MIT License (MIT)
*/
namespace Keizu;

require_once realpath(__DIR__ . '/silex.phar');

const INTERNAL_ENCODING = 'UTF-8';
const CSRF_TOKEN_NAME = 'AEg9GE5#4ZyPY$fd';

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

use Holy\Silex\Provider\PhpTalServiceProvider;
use Holy\Silex\Provider\RedBeanServiceProvider;

$app = new Application();
$app['debug'] = true;

$app['autoloader']->registerNamespace('Holy' , realpath(__DIR__ . '/vendor/k-holy/src'));

$app->register(new SessionServiceProvider());
$app->register(new PhpTalServiceProvider(), array(
    'phptal.class_path' => realpath(__DIR__ . '/vendor/phptal'),
    'phptal.options' => array(
        'templateRepository' => realpath(__DIR__ . '/views'),
    ),
));
$app->register(new RedBeanServiceProvider(), array(
    'db.redbean.class_path' => realpath(__DIR__ . '/vendor/redbean'),
    'db.options' => array(
        'dsn' => sprintf('sqlite:%s', realpath(__DIR__ . '/app/data/keizu.sqlite')),
    ),
));
$app['db']->debug(true);

// Error handler
$app->error(function(\Exception $e, $code) use ($app) {
    $message = 'Internal Server Error';
    if ($e instanceof \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface) {
        switch ($code) {
        case 403:
            $message = 'Forbidden';
            break;
        case 404:
            $message = 'NotFound';
            break;
        case 405:
            $message = 'Method Not Allowed';
            break;
        }
    }
    $app['phptal']->set('message', $message);
    $app['phptal']->set('error', ($app['debug']) ? $e->__toString() : null);
    $app['phptal']->set('title', 'エラー');
    return new Response($app['phptal']->setTemplate('error.html')->execute(),
        (isset($code)) ? $code : 500);
});

// Before filter
$app->before(function(Request $request) use($app) {
    if ($app['session']->hasFlash('message')) {
        $app['phptal']->set('message', $app['session']->getFlash('message'));
        $app['session']->removeFlash('message');
    }
});

//----------------------------------------------------------------------------
// 人物一覧
//----------------------------------------------------------------------------
$app->get('/profiles/', function() use ($app) {
    $app['phptal']->set('items', $app['db']->find('profiles', ' 1 ORDER BY id'));
    $app['phptal']->set('title', '人物一覧');
    return $app['phptal']->setTemplate('profiles/index.html')->execute();
});


//----------------------------------------------------------------------------
// フィールド、バリデータ定義
//----------------------------------------------------------------------------
$fields = array(
    'name' => '名前',
    'notes' => '備考',
);
$validator = function($bean) use ($fields) {
    $errors = array();
    foreach ($fields as $field => $label) {
        switch ($field) {
        case 'name':
            if (!isset($bean->{$field}) || 0 === strlen($bean->{$field})) {
                $errors[$field] = sprintf('%sを入力してください', $label);
            } elseif (20 < mb_strlen($bean->{$field}, INTERNAL_ENCODING)) {
                $errors[$field] = sprintf('%sを20文字以内で入力してください', $label);
            }
            break;
        }
    }
    return (empty($errors)) ? true : $errors;
};


//----------------------------------------------------------------------------
// 人物登録
//----------------------------------------------------------------------------
$app->match('/profiles/register', function() use ($app, $fields, $validator) {
    $bean = $app['db']->dispense('profiles');
    switch($app['request']->getMethod()) {
    case 'POST':
        $bean->import($app['request']->request->all(), array_keys($fields));
        if ($app['request']->get(CSRF_TOKEN_NAME) === $app['session']->getId()) {
            if (true === ($errors = $validator($bean))) {
                $app['db']->store($bean);
                $app['session']->setFlash('message', '人物情報を登録しました。');
                return $app->redirect('/profiles/', 302);
            }
        }
        $app['phptal']->set('errors', $errors);
        break;
    }
    $app['phptal']->set('token', array(
        'name' => CSRF_TOKEN_NAME,
        'value' => $app['session']->getId(),
    ));
    $app['phptal']->set('data' , $bean);
    $app['phptal']->set('title', '人物登録');
    return $app['phptal']->setTemplate('profiles/edit.html')->execute();
});

//----------------------------------------------------------------------------
// 人物編集
//----------------------------------------------------------------------------
$app->match('/profiles/{id}/modify', function($id) use ($app, $fields, $validator) {
    $bean = $app['db']->load('profiles', $id);
    switch($app['request']->getMethod()) {
    case 'POST':
        $bean->import($app['request']->request->all(), array_keys($fields));
        if ($app['request']->get(CSRF_TOKEN_NAME) === $app['session']->getId()) {
            if (true === ($errors = $validator($bean))) {
                $app['db']->store($bean);
                $app['session']->setFlash('message', '人物情報を更新しました。');
                return $app->redirect('/profiles/', 302);
            }
        }
        $app['phptal']->set('errors', $errors);
        break;
    }
    $app['phptal']->set('token', array(
        'name' => CSRF_TOKEN_NAME,
        'value' => $app['session']->getId(),
    ));
    $app['phptal']->set('data' , $bean);
    $app['phptal']->set('title', '人物編集');
    return $app['phptal']->setTemplate('profiles/edit.html')->execute();
})->assert('id', '\d+');

//----------------------------------------------------------------------------
// 人物削除
//----------------------------------------------------------------------------
$app->match('/profiles/{id}/delete', function($id) use ($app) {
    $bean = $app['db']->load('profiles', $id);
    switch($app['request']->getMethod()) {
    case 'POST':
        if ($app['request']->get(CSRF_TOKEN_NAME) === $app['session']->getId()) {
            $app['db']->trash($bean);
            $app['session']->setFlash('message', '人物情報を削除しました。');
            return $app->redirect('/profiles/', 302);
        }
        break;
    }
    $app['phptal']->set('token', array(
        'name' => CSRF_TOKEN_NAME,
        'value' => $app['session']->getId(),
    ));
    $app['phptal']->set('data' , $bean);
    $app['phptal']->set('title', '人物削除');
    return $app['phptal']->setTemplate('profiles/delete.html')->execute();
})->assert('id', '\d+');


$app->run();

view raw index.php This Gist brought to you by GitHub.

画面は人物の一覧、登録、編集、削除で確認画面はなく、操作完了時に一覧へリダイレクトします。
最初は1テーブルのみを対象としたCRUDということで、フィールドとバリデーション処理をグローバルに定義してリクエストハンドラにuseで渡すという強引なコードになってます。(後でリファクタリングします)
また、SessionServiceProviderを利用して、CSRF対策のセッションIDチェックと、一覧ページへのリダイレクト時にメッセージを表示するためセッションFlashを使っています。
(Silexをご存知の方は知っていると思いますが、$app['session']の実体は、Symfony\Component\HttpFoundation\Sessionクラスです)

Silex的にはリクエストハンドラはget,post,put,deleteで使い分けられるようになっていますが、全部matchで扱って$app['request']->getMethod()で判定しています。
POSTメソッド時のRedBean_OODBBean::import()がミソで、これは第1引数にユーザーリクエストの配列を、第2引数にそこから取得するフィールドのキーを配列で指定することで、RedBean_OODBBeanのプロパティを設定してくれます。

ここからは、PHPTALのテンプレートファイルです。
metal:define-macro, metal:define-slot, metal:fill-slot を使って、各テンプレートをValidなXMLに保ちつつ、レイアウト機能を実現しています。
(その辺の解説はPinocoのチュートリアル TutorialJa03Templating – pinoco – web site environment using PHP and TAL – Google Project Hosting が分かりやすいです)

レイアウト

<!DOCTYPE html>
<html>

<head metal:define-macro="head">
<meta charset="UTF-8" />
<link rel="stylesheet" media="screen" href="/css/base.css" />
<script src="/js/jquery-1.6.2.min.js"></script>
<tal:block metal:define-slot="head-extra"></tal:block>
<title tal:content="title">Title</title>
</head>

<body metal:define-macro="body">

  <div id="header">
    <h1>Silex + RedBeanPHP + PHPTAL CRUD Examples</h1>
  </div>

  <div id="container">

    <div id="menu" metal:define-slot="menu">
      <ul>
        <li><a href="/profiles/">人物一覧</a></li>
        <li><a href="/profiles/register">人物登録</a></li>
      </ul>
    </div>

    <h2 tal:content="title">画面名</h2>

    <p class="message" tal:condition="exists:message" tal:content="message">Flashメッセージ</p>

    <div id="main" metal:define-slot="main">
      メイン
    </div>

  </div>

  <div id="footer">
    Copyright &copy; k-holy &lt;k.holy74@gmail.com&gt;
  </div>

</body>
</html>

view raw __layout.html This Gist brought to you by GitHub.

共通部分のレイアウトとなるテンプレートで、ヘッダおよびフッタ、共通のナビゲーション、画面タイトル、Flashメッセージなどを含みます。
jQueryとか読み込んでますが、後のための布石です。今回は特に意味ありません。

一覧画面

<!DOCTYPE html>
<html>

<head metal:use-macro="__layout.html/head">
<meta charset="UTF-8" />
<tal:block metal:fill-slot="head-extra">
<link rel="stylesheet" media="screen" href="/css/list.css" />
</tal:block>
<title>一覧</title>
</head>

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

  <div id="container">

    <div id="main" metal:fill-slot="main">
      <table class="list">
      <thead>
        <tr>
          <th>ID</th>
          <th>名前</th>
          <th>備考</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        <tr tal:repeat="item items" tal:attributes="class php: repeat.item.even ? 'row1' : 'row2'">
          <td class="number" tal:content="item/id">ID</td>
          <td tal:content="item/name">k-holy</td>
          <td tal:content="item/notes">眠い眠い眠い</td>
          <td class="action">
            <a href="/profiles/${item/id}/modify">編集</a>
            <a href="/profiles/${item/id}/delete">削除</a>
          </td>
        </tr>
      </tbody>
      </table>
    </div>

  </div>

</body>
</html>

登録画面、編集画面

<!DOCTYPE html>
<html>

<head metal:use-macro="__layout.html/head">
<meta charset="UTF-8" />
<title>人物編集</title>
</head>

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

  <div id="container">

    <div id="main" metal:fill-slot="main">

      <ul class="errors" tal:condition="exists:errors">
        <li tal:repeat="error errors" tal:content="error">エラーメッセージ</li>
      </ul>

      <form method="post">
      <input type="hidden" tal:attributes="name token/name;value token/value" />

      <table>
        <tr>
          <th>ID</th>
          <td tal:content="data/id">1</td>
        </tr>
        <tr>
          <th>名前</th>
          <td><input type="text" name="name" tal:attributes="value data/name" /></td>
        </tr>
        <tr>
          <th>備考</th>
          <td><textarea name="notes" tal:content="data/notes"></textarea></td>
        </tr>
      </table>

      <p>
      <input type="submit" name="store" value="保存" />
      </p>

      </form>

      <p><a href="/profiles/">戻る</a></p>

    </div>

  </div>

</body>
</html>

削除画面

<!DOCTYPE html>
<html>

<head metal:use-macro="__layout.html/head">
<meta charset="UTF-8" />
<title>人物削除</title>
</head>

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

  <div id="container">

    <div id="main" metal:fill-slot="main">

      <ul class="errors" tal:condition="exists:errors">
        <li tal:repeat="error errors" tal:content="error">エラーメッセージ</li>
      </ul>

      <form method="post">
      <input type="hidden" tal:attributes="name token/name;value token/value" />

      <table>
        <tr>
          <th>ID</th>
          <td>${data/id}</td>
        </tr>
        <tr>
          <th>名前</th>
          <td>${data/name}</td>
        </tr>
        <tr>
          <th>備考</th>
          <td>${data/notes}</td>
        </tr>
      </table>

      <p>
      <input type="submit" name="delete" value="削除" />
      </p>

      </form>

      <p><a href="/profiles/">戻る</a></p>

    </div>

  </div>

</body>
</html>

登録と編集は同じテンプレートを使うという手抜き作戦を採用しました。
登録時、ID欄に0とか表示されますが、自分用アプリなのでどうでもいいです。
中身は特に分かりにくい箇所はないと思いますが、一点、CSRF対策のセッションIDチェック用に <input type="hidden" tal:attributes="name token/name;value token/value" /> を入れてます。
PHPTALでは配列もオブジェクトも同じ記述でアクセスできるので楽です。

Silex的に定石の書き方など調べず勢いで書いたので、おかしいところがあればご指摘いただけると嬉しいです。

また後日、今回のサンプルのリファクタリングを含めて続きを書く予定です。

このエントリをつぶやくこのWebページのtweets このエントリーを含むはてなブックマークはてなブックマーク - Silex+RedBean+PHPTALのサンプル(PHPMatsuri2011ハッカソン成果) この記事をクリップ!Livedoorクリップ - Silex+RedBean+PHPTALのサンプル(PHPMatsuri2011ハッカソン成果) BuzzurlにブックマークBuzzurlにブックマーク @niftyクリップに追加 newsing it! Bookmark this on Delicious Share on Tumblr
Posted in PHP | Tagged , , , | Leave a comment

Silexを触ってみた感想

PHPMatsuri2011への参加を申し込んだので、
せっかくだからハッカソンでは何かフレームワークを使って作ってみようと、
今さらながら、Symfony2のコンポーネントを利用して作成されたフレームワーク Silex を触ってみました。

Silexを選んだのは、フレームワークが標準で提供している機能が最低限の内容に抑えられていて、とりあえず動くものを書くに当たって覚えなければいけないルールが少なそうだという、後ろ向きな理由だったのですが、実際にその通りで、思いのほか肌に合う感じです。

ユーザが直接操作するクラスの核となるのがSilex\Applicationですが、それ自体が、ArrayAccessを継承して作られた Pimple という超軽量DIコンテナを継承しています。
加えて、このクラスがSymfonyのコンポーネント群(HttpKernel, HttpFoundation, EventDispatcher, Routing)によるリクエストハンドリング機構への橋渡しをすることで、一つのスクリプト内にリクエストメソッド+URLと対になるクロージャを書いていくというスタイルを実現しています。
※このスタイル、RubyのSinatoraというフレームワークがオリジナルだとか。

アプリケーションの書き方は、このページを読めばほぼ分かります。
http://silex.sensiolabs.org/doc/usage.html

Silexが提供している機能は、このページを読めばほぼ分かります。
http://silex.sensiolabs.org/doc/services.html

自分は元々、こんな記事を投稿してたくらいで、MVCフレームワークのフロントコントローラにはあまり良い印象がなかったんですが、それもある論理ページの処理を追うのにいちいち複数の箇所を(フレームワークのルールに従って)追わないといけないのが面倒という理由なので、Silexのようなスタイルであれば歓迎です。
特にIDEを使わずエディタだけで開発している(自分のような)人には、アプリケーションのコードをストレスなく書ける/読めるフレームワークだと感じました。

多分、Silex\Applicationが提供している機能(DI、クラスローダ、例外処理、リクエストハンドリング)が、自分が考えるフレームワークの守備範囲にぴったり合っていたんでしょう。

また、フレームワークの制約が少ないため、外部ライブラリを利用しやすい点も好みです。たとえばテンプレートエンジンなど、複数のリクエストハンドラで共通して利用するクラスについては、 Provider としてSilex\Applicationに組み込むことでDI恩恵を受けられきますが、それが不要なら手っ取り早くuseで直接ハンドラに渡すこともできます。
クラスの読み込み方法も、組込みのクラスローダ(Symfony2でも利用されているPSR-0準拠の Symfony\Component\ClassLoader\UniversalClassLoader)を使う、自分で用意したクラスローダを使う、あるいは直接requireするも自由です。
※他のフレームワークでもその程度の自由はあるのかもしれませんが、制約の多さからくる不安(こういうやり方はこのフレームワーク的にOKなの?)が全く感じられないのがいいんです。

Silexなら、使い慣れたライブラリを無理に捨てる必要もないし、自作フレームワーク派の方にも使いやすいんじゃないでしょうか。

ちょっと試しに SmartyServiceProvider を書いてみましたので、載せておきます。
(どういうことができるかの実験用コードなので、エラーハンドラとかだいぶテキトーです…)

<?php
/**
* PHP versions 5
*
* @copyright 2011 k-holy <k.holy74@gmail.com>
* @author k.holy74@gmail.com
* @license http://www.opensource.org/licenses/mit-license.php The MIT License (MIT)
*/
namespace Holy\Silex\Provider;

use Silex\Application;
use Silex\ServiceProviderInterface;

/**
* Holy\Silex\Provider\SmartyServiceProvider
*
* @author k.holy74@gmail.com
*/
class SmartyServiceProvider implements ServiceProviderInterface
{
    public function register(Application $app)
    {
        $app['smarty'] = $app->share(function() use ($app) {
            if (isset($app['smarty.class_path'])) {
                $smarty_path = $app['smarty.class_path'] . DIRECTORY_SEPARATOR . 'Smarty.class.php';
                if ('\\' === DIRECTORY_SEPARATOR) {
                    $smarty_path = str_replace('\\', '/', $smarty_path);
                }
                include_once $smarty_path;
            }
            $app['smarty.options'] = array_replace(
                array(
                    'template_dir' => null,
                    'config_dir' => null,
                    'plugins_dir' => null,
                    'compile_dir' => null,
                    'cache_dir' => null,
                    'caching' => !$app['debug'],
                    'left_delimiter' => null,
                    'right_delimiter' => null,
                    'force_compile' => $app['debug'],
                    'use_sub_dirs' => null,
                    'default_modifiers' => null,
                ),
                isset($app['smarty.options']) ? $app['smarty.options'] : array()
            );
            $smarty = new \Smarty();
            foreach ($app['smarty.options'] as $name => $value) {
                if (!property_exists($smarty, $name)) {
                    throw new \RuntimeException(
                        sprintf('The field "%s" is not defined.', $name));
                }
                if (isset($value)) {
                    $smarty->{$name} = $value;
                }
            }
            return $smarty;
        });
    }
}


<?php
/**
* PHP versions 5
*
* @copyright 2011 k-holy <k.holy74@gmail.com>
* @author k.holy74@gmail.com
* @license http://www.opensource.org/licenses/mit-license.php The MIT License (MIT)
*/
require_once realpath(__DIR__ . '/silex.phar');

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

use Holy\Silex\Provider\SmartyServiceProvider;

$app = new Application();
$app['debug'] = true;

$app['autoloader']->registerNamespace('Holy', realpath(__DIR__ . '/../vendor'));

$app->register(new SmartyServiceProvider(), array(
    'smarty.class_path' => realpath(__DIR__ . '/../vendor/smarty/libs'),
    'smarty.options' => array(
        'template_dir' => realpath(__DIR__ . '/../templates'),
        'compile_dir' => realpath(__DIR__ . '/../templates_c'),
        'caching' => false,
        'left_delimiter' => '<{',
        'right_delimiter' => '}>',
        'force_compile' => true,
        'use_sub_dirs' => true,
        'default_modifiers' => array('escape:"htmlall"'),
    ),
));

// before filter
$app->before(function(Request $request) use($app){
    $app['smarty']->assign('HTTP_HOST' , $request->getHttpHost());
    $app['smarty']->assign('REQUEST_URI', $request->getRequestUri());
    $app['smarty']->assign('request',
        (0 === strcmp('GET', $request->getMethod()))
            ? $request->query->all() : $request->get->all());
});

// error handler
$app->error(function(\Exception $e, $code) use ($app) {
    $message = 'Internal Server Error';
    if ($e instanceof \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface) {
        switch ($code) {
        case 403:
            $message = 'Forbidden';
            break;
        case 404:
            $message = 'NotFound';
            break;
        case 405:
            $message = 'Method Not Allowed';
            break;
        }
    }
    $app['smarty']->assign('message', $message);
    $app['smarty']->assign('error', ($app['debug']) ? $e->__toString() : null);
    return new Response($app['smarty']->display('error.html'), (isset($code)) ? $code : 500);
});

$app->get('/', function () use ($app) {
    $app['smarty']->assign('title', 'Silex + Smarty');
    $app['smarty']->assign('source', highlight_file(__FILE__, true));
    return $app['smarty']->display('index.html');
});

$app->run();
view raw index.php This Gist brought to you by GitHub.

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title><{$title}>@<{$HTTP_HOST}></title>
</head>
<body>

<h1><{$title}>@<{$HTTP_HOST}></h1>

<h2>source</h2>
<{$source nofilter}>

</body>
</html>

view raw index.html This Gist brought to you by GitHub.

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Error!!</title>
</head>
<body>

<{if isset($message)}>
<p><{$message}></p>
<{/if}>

<{if isset($error)}>
<pre><{$error}></pre>
<{/if}>

@<{$HTTP_HOST}>
</body>
</html>

view raw error.html This Gist brought to you by GitHub.

このエントリをつぶやくこのWebページのtweets このエントリーを含むはてなブックマークはてなブックマーク - Silexを触ってみた感想 この記事をクリップ!Livedoorクリップ - Silexを触ってみた感想 BuzzurlにブックマークBuzzurlにブックマーク @niftyクリップに追加 newsing it! Bookmark this on Delicious Share on Tumblr
Posted in PHP | Tagged , | Leave a comment

Stagehand_TestRunner+PHPUnitで”headers already sent”エラーの件

Stagehand_TestRunner+PHPUnitで”headers already sent”エラーの件

セッション変数を扱うクラスのテストケースで “headers already sent by (output started at …PHPUnit/Util/Printer.php:173″ のエラーが出てずっと困ってましたが、とりあえず解消する方法が一つ分かったのでメモを兼ねて報告します。

shimookaさんの2年前の記事から、PHPUnit_TextUI_TestRunner で PHPUnit_TextUI_ResultPrinter を生成しているところの第1引数を NULL→STDERR とすれば動くとあったので、最初にこれを試してみたのですがうまくいかず…。
“PHPUnit headers already sent”で検索してみると、同じ問題が多くのPHP製ライブラリで起きてるようで、フォーラム等のログに同じ質問が散見されます。
ざっと反応を見たところ、PHPUnit的にはテストランナーの起動オプションで “–stderr” を指定せよ、ということのようですが、Stagehand_TestRunnerではこのオプションを指定する方法が分からず、PHPUnitのXML設定ファイルで指定できないかと試してもみたのですが、残念ながら効いてくれませんでした。

var_dump()で探っていくと、PHPUnit_TextUI_TestRunner が結果の出力に利用しているクラスが PHPUnit_TextUI_ResultPrinter ではなく Stagehand_TestRunner_Runner_PHPUnitRunner_Printer_ResultPrinter だったので、Stagehand_TestRunner_Runner_PHPUnitRunner::run() で Stagehand_TestRunner_Runner_PHPUnitRunner_Printer_ResultPrinter を生成しているところでコンストラクタの第1引数を同様に NULL→STDERR とすれば、エラーは解消されました。
実はheavenshellさんが2年前に通った道で、同じことが記事にも書いてあったんですが、なぜか見落としてしまってました…。

>原因は PHPUnit と同じで、Stagehand/TestRunner/Runner/PHPUnit.php で null を STDERR にすると解消された。
http://d.hatena.ne.jp/heavenshell/20090818

以下、追っかけたコードの内容です。

shimookaさんの記事でも指摘されているところですが、エラーの原因となっている箇所は PHPUnit_TextUI_TestRunner::doRun()にあります。

if ($this->printer === NULL) {
    if (isset($arguments['printer']) &&
        $arguments['printer'] instanceof PHPUnit_Util_Printer) {
        $this->printer = $arguments['printer'];
    } else {
        $this->printer = new PHPUnit_TextUI_ResultPrinter(
          NULL,
          $arguments['verbose'],
          $arguments['colors'],
          $arguments['debug']
        );
    }
}

ここで既に Stagehand_TestRunner の方で生成された Stagehand_TestRunner_Runner_PHPUnitRunner_Printer_ResultPrinter がメソッドの引数で渡されてきているんですね。
コードを追ったところ、Stagehand_TestRunner_Runner_PHPUnitRunner::run()で生成され実行されているStagehand_TestRunner_Runner_PHPUnitRunner_TestRunnerがPHPUnit_TextUI_TestRunnerを継承しており、間接的にPHPUnit_TextUI_TestRunner::doRun()が呼ばれています。
そして、PHPUnit_TextUI_TestRunner::doRun()の第2引数の”printer”キーで Stagehand_TestRunner_Runner_PHPUnitRunner_Printer_ResultPrinter のインスタンスが渡されています。

ちなみに、”–stderr”起動オプションを見てPHPUnitが何をやっているかは、PHPUnit_TextUI_Command::handleArguments()にあります。

case '--stderr': {
    $this->arguments['printer'] = new PHPUnit_TextUI_ResultPrinter(
      'php://stderr',
      isset($this->arguments['verbose']) ? $this->arguments['verbose'] : FALSE
    );
}

また、XML設定ファイルで有効なオプションについては、PHPUnit_Util_Configuration::getPHPUnitConfiguration()にあります。

$result = array();
$root   = $this->document->documentElement;
 
if ($root->hasAttribute('colors')) {
    $result['colors'] = $this->getBoolean(
      (string)$root->getAttribute('colors'), FALSE
    );
}

こんな感じで指定可能な属性値が記述してあり、stderr起動オプションに当たるものは見当たりませんでした。
(前述の通り、”stderr”起動オプションはPHPUnit_TextUI_TestRunner::doRun()に渡される時点で”printer”オプションとしてインスタンスが渡される形になっているので、無理だとは思いましたが…)

また、Stagehand_TestRunnerで指定可能なオプション設定については、Stagehand_TestRunner_TestRunnerCLIController::configureByOption()にあります。

case 'R':
    $this->config->recursivelyScans = true;
    return true;
…中略…
case 'p':
    $this->config->preloadFile = $value;
    return true;
case 'a':
    $this->config->enablesAutotest = true;
    return true;
…中略…
case '--phpunit-config':
    $this->config->phpunitConfigFile = $value;
    return true;

いつもお世話になっている “R” “p” “a” といったオプションに加えて、”–phpunit-config” が記述されています。
ここでStagehand_TestRunnerに設定されたphpunitConfigFileの値(XML設定ファイルのパス)が、PHPUnit_TextUI_TestRunner::doRun()の第2引数の”configuration”キーで渡される形になっています。

ここまで追跡して、やっぱりheavenshellさんと同じ結論に至ってしまうのでした。

>これって Stagehand_Testrunner の不具合か?って言われると凄く微妙な気がする。
>でもまぁ一応報告しとくかと思ったけど、どこに報告すりゃええのかよく分からんかった。
http://d.hatena.ne.jp/heavenshell/20090818

で、その後の記事

Stagehand_Testrunner で Session エラー が出た(解決済み)
http://d.hatena.ne.jp/heavenshell/20090919/1253364295

ZendFrameworkをお使いのheavenshellさんは Zend_Session::$_unitTestEnabled = true を指定すれば解決という結果でしたが、私の場合は自作フレームワークなので…要するに自分の設計が悪いってことですね…。

ちなみにZendFramework2では、セッションクラスの設計が一新されていて、setcookie()やsession_start()といった関数の実行をZend\Session\SessionManagerに局所化することで、テスタビリティを確保しているようです。
また、Symfony2は Symfony\Component\HttpFoundation あたりのコードをざっと見たところ、更に環境依存の排除を進めた設計のようで、Symfonyが支持されるのはこういう徹底した設計方針に因る部分も大きいのかなと思いました。

自分の場合、要件的には単に$_SESSIONの操作をアプリケーション単位でラップするのと、session関係のphp.iniディレクティブとアプリケーションの設定を統合したいだけなんですが、1クラスだけの構成だと実効的なユニットテストは書けない気がします。
(そもそもcookieと併用しないと成り立たないセッション機構なので、Selenium等でのブラウザテストのみでよしとする選択もありとは思いますが)

“TDDは設計技法である”という識者の言葉を痛感する一件でした。

このエントリをつぶやくこのWebページのtweets このエントリーを含むはてなブックマークはてなブックマーク - Stagehand_TestRunner+PHPUnitで&#8221;headers already sent&#8221;エラーの件 この記事をクリップ!Livedoorクリップ - Stagehand_TestRunner+PHPUnitで&#8221;headers already sent&#8221;エラーの件 BuzzurlにブックマークBuzzurlにブックマーク @niftyクリップに追加 newsing it! Bookmark this on Delicious Share on Tumblr
Posted in PHP | Tagged , | Leave a comment

第1回関西PHP初心者勉強会LT「5分で分かる名前空間とオートロード」

8/27に行われた、第1回関西PHP初心者勉強会 (http://atnd.org/events/18761) に参加してきました。

今回は初心者対象とはいえテーマが「できる人の開発環境構築術」なので、会社でもプライベートでも、ぼっち開発道を邁進している自分からすると、他のPHPerがどんな環境で開発しているのかを聞くだけでも参考になると思って参加しました。
ただ正直なところ、今回はLTへの初挑戦が最優先事項だったので、テーマは何でも良かったんです。

2001年の末に全くの素人からこの業界に入り、2004年の7月に「PHP関西セミナー」に参加して以来、それなりの頻度(半年に1度くらい?)でオープンソース系のセミナーや勉強会に顔を出してきました。
で、交流が広がったかというと全然そんなことはなくて、すごい人の話を聞いて賢くなった気がして帰ってくるだけなら、そんな人達に少しでも近付けるよう読書なりコード書くなりした方がいいんじゃないのとか、色んな思いが燻ってました。

そこで、とにかくLTでもやれば自分の中で何かが目覚める(?)かもと、甘い期待でやることにしたわけです。
まあ、結果は惨敗だったんですが…。

そんなLTで使ったスライドはこちらです。

実際には上の内容から、
P.14「+αのオートローダ仕様」
P.15「stream_resolve_include_pah」
P.21「オートロードスタック」
を抜いていたのですが、それでも全く時間が足りず、P.13で終了でした。
(ちなみに最も心残りだったのは、P.20のUnkomanExceptionの話ができなかったことです)

話を聞くよりコードを目で読む方が速いだろうと考えて、ほぼコードだけのページを淡々と進めることで何とか5分に収める計算でしたが、ちょっと無理がありました。
(タイトル詐欺でごめんなさい)

元より、全ての参加者が話を聞いたその場で理解できる内容にするつもりはなくて、名前空間という機能を知らなかった人には概要だけでも、知っていても使ったことがない人には最低限の記述方法を、慣れた人にはオートロードの動きや細かい検証結果など、少しだけでも意義のある情報が出せればという思惑だったんですが、何も伝わらずに終わってしまった感じです。
(ただでさえ話下手なのに、大勢の人前で話した経験も皆無だし、振り返れば無謀としか言い様がなかったわけですが…)

他の参加者の皆さんの貴重な5分間を無駄に費やしてしまう結果になって、申し訳ありませんでした。
あんなgdgdでも前に出るヤツがいるんだってことで、他の方が気軽にLTしてくださるようになったら、それはそれで幸いですけど。

このエントリをつぶやくこのWebページのtweets このエントリーを含むはてなブックマークはてなブックマーク - 第1回関西PHP初心者勉強会LT「5分で分かる名前空間とオートロード」 この記事をクリップ!Livedoorクリップ - 第1回関西PHP初心者勉強会LT「5分で分かる名前空間とオートロード」 BuzzurlにブックマークBuzzurlにブックマーク @niftyクリップに追加 newsing it! Bookmark this on Delicious Share on Tumblr
Posted in PHP | Leave a comment