k-holyのPHPとか諸々メモ

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

マイクロフレームワークをつくろう - Pimpleの上に(Rendererクラス作成編)

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

コードはWindows版 PHP5.4 ビルトインWebサーバにて動作確認しています。

Step 5 Rendererクラスの作成

前回Symfony HttpFoundationコンポーネントを導入したので、HTTPリクエストとレスポンスを操作する際はこれを使えばいいんですが、まだページスクリプトとHTMLが一緒になってますので、これを分離するためにRendererクラスを作成します。

なぜクラスなのかというと、将来的には外部ライブラリのテンプレートエンジンを利用することが予想されることと、(身も蓋もないですが)以前に書いたコードの中に流用できそうなものがあるからです。

とりあえず最初の実装として、普通にPHPで書かれたHTMLファイルをテンプレート的に扱うためのクラスにします。

src/Acme/Renderer.php

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

namespace Acme;

/**
 * レンダラクラス
 *
 * @author k.holy74@gmail.com
 */
class Renderer
{

    /**
     * @var array 設定値
     */
    private $config;

    /**
     * @var array 出力データ
     */
    private $data;

    /**
     * コンストラクタ
     *
     * @param array | ArrayAccess 設定オプション
     */
    public function __construct($configurations = array())
    {
        $this->initialize($configurations);
    }

    /**
     * オブジェクトを初期化します。
     *
     * @param array | ArrayAccess 設定オプション
     */
    public function initialize($configurations = array())
    {
        $this->data = array();
        $this->config = array(
            'template_dir' => null,
        );
        if (!empty($configurations)) {
            foreach ($configurations as $name => $value) {
                $this->config($name, $value);
            }
        }
        return $this;
    }

    /**
     * 引数1の場合は指定された設定の値を返します。
     * 引数2の場合は指定された設置の値をセットして$thisを返します。
     *
     * @param string 設定名
     * @return mixed 設定値 または $this
     */
    public function config($name)
    {
        switch (func_num_args()) {
        case 1:
            return $this->config[$name];
        case 2:
            $value = func_get_arg(1);
            if (isset($value)) {
                switch ($name) {
                case 'template_dir':
                    if (!is_string($value)) {
                        throw new \InvalidArgumentException(
                            sprintf('The config parameter "%s" only accepts string.', $name));
                    }
                    break;
                default:
                    throw new \InvalidArgumentException(
                        sprintf('The config parameter "%s" is not defined.', $name)
                    );
                }
                $this->config[$name] = $value;
            }
            return $this;
        }
        throw new \InvalidArgumentException('Invalid argument count.');
    }

    /**
     * 出力データに値を追加します。
     *
     * @param string 名前
     * @param mixed 値
     * @return string
     */
    public function assign($name, $value)
    {
        $this->data[$name] = $value;
    }

    /**
     * 指定パスのテンプレートを読み込んで配列をローカルスコープの変数に展開し、結果を出力します。
     *
     * @param string テンプレートファイルのパス
     * @param array テンプレートに展開する変数の配列
     */
    public function render($view, array $data)
    {
        echo $this->fetch($view, $data);
    }

    /**
     * 指定パスのテンプレートを読み込んで配列をローカルスコープの変数に展開します。
     *
     * @param string テンプレートファイルのパス
     * @param array テンプレートに展開する変数の配列
     * @return string
     */
    public function fetch($view, array $data)
    {
        $dir = $this->config('template_dir');
        if (isset($dir)) {
            $dir = rtrim($dir, '/');
        }
        $template = (isset($dir)) ? $dir . DIRECTORY_SEPARATOR . $view : $view;
        if ('\\' === DIRECTORY_SEPARATOR) {
            $template = str_replace('\\', '/', $template);
        }
        if (false !== realpath($template)) {
            ob_start();
            $data = array_merge($this->data, $data);
            extract($data);
            include $template;
            $contents = ob_get_contents();
            ob_end_clean();
            return $contents;
        }
    }

}

クラスの設定項目はテンプレートファイルの配置先を指定する template_dir のみです。

assign(string $name, mixed $value) でテンプレート変数に値をセットして、fetch(string $view, array $data) で指定されたテンプレートファイルに対して配列から変数を展開して返し、render(string $view, array $data)で出力します。

要はextract()してincludeしているだけなんですが、簡易テンプレートとしては充分でしょう。

例のごとく、このクラスをアプリケーションオブジェクトに Pimple::share() で生成させるわけですが、せっかくなのでアプリケーション内でグローバルなテンプレート変数のセットも一緒にやっておきます。

また、Symfony HttpFoundation コンポーネントも導入したことですし、この際アプリケーションオブジェクトにRendererクラスの取得結果を出力するための render()関数を追加します。

その方がなんとなくフレームワークっぽく見えますし。

app/app.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * アプリケーション共通初期処理
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
include_once realpath(__DIR__ . '/../vendor/autoload.php');

use Acme\Application;
use Acme\Renderer;

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

$app = new Application();

// レンダラオブジェクトを生成、グローバルなテンプレート変数をセット
$app->renderer = $app->share(function(Application $app) {
    $renderer = new Renderer(array(
        'template_dir' => __DIR__ . DIRECTORY_SEPARATOR . 'templates',
    ));
    // $app
    $renderer->assign('app', $app);
    // $_SERVER
    $renderer->assign('server', $app->request->server->all());
    return $renderer;
});

// レスポンスオブジェクトでレンダラオブジェクトからテンプレート出力
$app->render = $app->protect(function($view, array $data = array(), $statusCode = 200, $headers = array()) use ($app) {
    $response = new Response($app->renderer->fetch($view, $data), $statusCode, $headers);
    $response->send();
});

// リクエストオブジェクトを生成
$app->request = $app->share(function(Application $app) {
    return Request::createFromGlobals();
});

// リクエスト変数を取得する
$app->findVar = $app->protect(function($key, $name, $default = null) use ($app) {
    $value = null;
    switch ($key) {
    // $_GET
    case 'G':
        $value = $app->request->query->get($name);
        break;
    // $_POST
    case 'P':
        $value = $app->request->request->get($name);
        break;
    // $_COOKIE
    case 'C':
        $value = $app->request->cookies->get($name);
        break;
    // $_SERVER
    case 'S':
        $value = $app->request->server->get($name);
        break;
    }
    if (isset($value)) {
        $value = $app->normalize($value);
    }
    if (!isset($value) ||
        (is_string($value) && strlen($value) === 0) ||
        (is_array($value) && count($value) === 0)
    ) {
        $value = $default;
    }
    return $value;
});

// リクエスト変数の正規化
$app->normalize = $app->protect(function($value) use ($app) {
    $filters = array(
        // HT,LF,CR,SP以外の制御コード(00-08,11,12,14-31,127,128-159)を除去
        // ※参考 http://en.wikipedia.org/wiki/C0_and_C1_control_codes
        function($val) {
            return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|\xC2[\x80-\x9F]/S', '', $val);
        },
        // 改行コードを統一
        function($val) {
            return str_replace("\r", "\n", str_replace("\r\n", "\n", $val));
        },
    );
    foreach ($filters as $filter) {
        $value = $app->map($filter, $value);
    }
    return $value;
});

// HTMLエスケープ
$app->escape = $app->protect(function($value, $default = '') use ($app) {
    return $app->map(function($value) use ($default) {
        $value = (string)$value;
        if (strlen($value) > 0) {
            return htmlspecialchars($value, ENT_QUOTES);
        }
        return $default;
    }, $value);
});

// 全ての要素に再帰処理
$app->map = $app->protect(function($filter, $value) use ($app) {
    if (is_array($value) || $value instanceof \Traversable) {
        $results = array();
        foreach ($value as $val) {
            $results[] = $app->map($filter, $val);
        }
        return $results;
    }
    return $filter($value);
});

return $app;

$app->renderer がstaticに参照されるRendererオブジェクトを返し、$app->render($view, array $data = array(), $statusCode = 200, $headers = array()) でSymfony HttpFoundationのResponseクラスを使ったレスポンス出力を行います。

テンプレートを分離するついでに、グローバルなテンプレート変数 $server も参照しておきます。

app/templates/index.html

<html>
<body>
<h1>俺のフレームワーク@<?=$app->escape($server['HTTP_HOST'])?></h1>

<form method="post" action="<?=$app->escape($server['REQUEST_URI'])?>">

<dl>
<dt>名前</dt>
<dd>
<input type="text" name="name" value="<?=$app->escape($form['name'])?>" />
</dd>
<dt>コメント</dt>
<dd>
<textarea name="comment">
<?=$app->escape($form['comment'])?></textarea>
</dd>
</dl>

<input type="submit" value="送信" />
</form>

<hr />

<dl>
<dt>名前</dt>
<dd><?=$app->escape($form['name'])?></dd>
<dt>コメント</dt>
<dd><pre><?=$app->escape($form['comment'])?></pre></dd>
</dl>

</body>
</html>

HTMLのフリをしつつ中身はPHPファイルという詐欺みたいなテンプレートですが、とりあえずこれで。

利用側のスクリプトは以下のようになりました。

www/index.php

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

$form = array(
    'name'    => $app->findVar('P', 'name'),
    'comment' => $app->findVar('P', 'comment'),
);

$app->render('index.html', array(
    'form' => $form,
));

HTMLを分離し、出力はアプリケーションオブジェクトのrender()関数を利用して間接的に行うよう変更しました。

何となく、Silexでのスクリプトの実装に近づいてきた気がします。

しかし、GETメソッドとPOSTメソッドの振り分けがないあたり、なんだかレガシー感が漂ってますね…。

次はそろそろ、RESTインタフェースを考慮した実装を進めようと思います。