Pinoco0.4はクロージャですごくなる

Pinoco0.4.0をリリースしました。
Downloads · tanakahisateru/pinoco · GitHub

変更点はこちらで簡単に。
Changelog · tanakahisateru/pinoco Wiki · GitHub

重要な点は、データベースをサポートしたところ。もちろん単にPDO用のライブラリあります、ってだけではなく、Pinocoのコレクションでうまく動くようにあれこれしています。その基礎になるのが、Pinoco_Varsに追加されたregisterAsLazyメソッド。簡単にいうと、これで設定したプロパティは遅延評価で値を決めるというもの。実際にアクセスされるまで値の決定を遅らせます。いちど決まれば再決定のためにコストのかかる処理をしません。

また、Xdebugがない環境でも、通常のPHPエラーにスタックトレースが付いたり、捕捉しそこねた例外がすごく見にくくなるのを改善していたりしています。デザイナーさんの作業中にエラーが出たとき、普通のPHPのエラーはすごく読みづらくて、意味のあるメッセージ部分を判読してもらえなかったけど、そのあたりがだいぶマシになると思います。

じっさいにコードで。まずはエラーと例外

こういうのが

<?php
$a = 100 / 0;
?>

こうなります。

問題を起こした箇所そのものじゃなくて、その箇所がコールされた文脈が見えて欲しかった。

こういうのは

<?php
throw new Exception("my custom exception");
?>

こうなります。

エラーメッセージがちゃんと読めますね。もうスタックトレースがぐちゃぐちゃに詰まって表示されるなんてこともありません。

つぎ、クロージャ。ここ本題。

HTMLページは、こんなふうに

<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Pinoco0.4はクロージャですごくなる</title>
<style>
table { border-collapse:collapse; }
th,td { border: 1px solid gray; padding: 0 2em; }
</style>
</head>
<body>
    <h1>Pinoco0.4はクロージャですごくなる</h1>
    <table>
        <tr>
            <th>ID</th>
            <th>VALUE</th>
            <th>CHILDLEN</th>
        </tr>
        <tr tal:repeat="foo this/all_foo">
            <td tal:content="foo/formatted_id">1</td>
            <td tal:content="foo/value">aaa</td>
            <td>
                <ul>
                    <li tal:repeat="bar foo/children"
                        tal:content="bar/value">a1</li>
                </ul>
            </td>
        </tr>
    </table>
</body>
</html>

_enter.phpあたりにこんなコードが書いてあるとしましょう。
(sqliteのメモリDBで動作テストしようとしているのでフィクスチャがちょっと冗長です。すみません。)

<?php
// データベース接続 db
// PDOWrapper は実際にクエリを発行するまで接続しない
$this->db = Pinoco::newObj('Pinoco/PDOWrapper.php/Pinoco_PDOWrapper', 'sqlite::memory:');
$this->db->setAfterConnection(function($db) {
    $db->exec("create table foo (
        id integer primary key autoincrement,
        value varchar
    )");
    $db->exec("create table bar (
        id integer primary key autoincrement,
        foo_id integer references foo(id),
        value varchar
    )");
    $ps_foo = $db->prepare("insert into foo (value) values(?);");
    $ps_bar = $db->prepare("insert into bar (foo_id, value) values(?, ?);");
    
    $ps_foo->exec('aaa');
    $id = $db->lastInsertId();
    $ps_bar->exec($id, 'a1');
    $ps_bar->exec($id, 'a2');
    $ps_bar->exec($id, 'a3');
    
    $ps_foo->exec('bbb');
    $id = $db->lastInsertId();
    $ps_bar->exec($id, 'b1');
    $ps_bar->exec($id, 'b2');
    
    $ps_foo->exec('ccc');
    $id = $db->lastInsertId();
    $ps_bar->exec($id, 'c1');
});

// コンテキスト変数 all_foo の遅延評価
$this->registerAsLazy('all_foo', function($owner)
{
    $db = $owner->db; // このとき初めてdbが初期化される

    $all_foo = $db->query("select * from foo")->fetchAll();
    // ここで $all_foo の型はPinocoVarsのPinocoList

    $bar_query = $db->prepare("select * from bar where foo_id=?");
    foreach($all_foo as $foo) {
        // IDの書式化文字列を動的に
        $foo->registerAsDynamic('formatted_id', function($owner) {
            //echo '☆1☆';
            return sprintf("%08d", $owner->id);
        });
        // 子要素の遅延評価
        $foo->registerAsLazy('children', function($owner) use($bar_query) {
            $bars = $bar_query->query($owner->id)->fetchAll();
            foreach($bars as $bar) {
                $bar->parent = $owner;
            }
            //echo '☆2☆';
            return $bars;
        });
    }
    //echo '☆0☆';
    return $all_foo;
});

// この時点でまだ何も実行されていない
?>

もうコメントから意図読めますよね。HTMLのほうは普通のTALテンプレート、フックスクリプトがすごいことになっています。メソッドがチェイン($statement->query(...)->fetchAll();って書いてある)したり、実際の接続時にユーザ関数がコールバック(setAfterConnection)されたり、というライブラリの便利さについてはコードを見ればもうOKですね。

ポイントはここです。

$this->registerAsLazy('all_foo', function($owner) { ... });

$thisのall_fooプロパティは遅延評価される値として登録されています。もしテンプレート側でall_fooを使わなければ、データの実体はリクエストが終わるまでいちども実体化されません。そして、このクロージャの中でのみ、$owner->db (ownerは保持しているオブジェクト、つまりPinocoのインスタンス) が使われています。all_fooが使われなければ、データベースにも接続されません。

もっとすごいのいきますよ。

SQLスキーマを見ると、fooはbarと1対多関係(いわゆるhasManyとbelongsToな関係)にあります。こういう関係モデルが大きかったとき、すべてオブジェクトとして構築していると、もしビューで使わなかったときすごく無駄が大きいですよね。大掛かりなORMは要らない、けど、せめて関係モデルは遅延で取ってきたい。そのニーズにこそ応えられるのが遅延評価プロパティです。

    $bar_query = $db->prepare("select * from bar where foo_id=?");
    foreach(...) {
        // 子要素の遅延評価
        $foo->registerAsLazy('children', function($owner) use($bar_query) { ... });
    }

個々のfooに遅延評価プロパティ、childrenを作っています。このとき、プリペアドステートメントを何度も作るのはばかばかしいので、クロージャに束縛させてしまいましょう。あ、そうそう、childrenの個々は、parentとして$ownerを参照するようにもしてます。

最新のPHPTALで動かしたら、「☆」マークのコメントアウト解除で、ほんとにテンプレート評価中にクロージャの中身が呼ばれていることがわかると思います。基本、フック→ページだけど、ページがクロージャを呼び出す。

と、こんな調子で、どこまでも遅延で掘り下げられるデータ構造を設計できます。

あと、地味ですが、

<td tal:content="php:sprintf('%08d',  foo.id);">00000000</td>

みたいなややこしいTALES書かなくても、

<td tal:content="foo/formatted_id">00000000</td>

にできるのは、registerAsDynamicでidからformatted_idを動的に導出しているためで、これがけっこうTALページの可読性に効いてきます。

ここまで書いてあるにもかかわらず、もしビューでall_fooを参照しなかった、ほんとに何もしません。なので「_enter.phpに書いておく」でOKなわけです。

※ほんとはクロージャの実体も、いったん変数に代入して再利用するのがいいのですが、コードのインパクトのためにこう書いてきます。

ここで最重要ポイント、クラスを定義しなくてもこんなダイナミックな操作ができるなんて、新しいPDOWrapperは便利だなぁ、...じゃないですよ。遅延評価フィールドの機能は、Pinoco_Varsという汎用オブジェクトに実装されています。つまり、データソースを、PDOがサポートするRDBMSじゃないもの、NoSQL系にしたとしても、同じ方法論で開発できます。

Pinocoは0.4で大きく化けました。たしかにまだPHP5.1はサポートしていて、この記事のクロージャに代わって、従来のコーラブルなもの(関数名やオブジェクトとメソッド名のペアなど)を入れれば、同じように動きます。でも、こうなるともう、記述性のためにPHP5.3を使いたくてたまらなくなります。これまでjQueryなどを使っていたフロント側のコーダーなんかだと、OOPの大きなフレームワークをいったん飛ばして、PHP5.3クロージャの世界へ、というのが、実はすんなり入れる道なのかもしれませんね。