k-holyのPHPとか諸々メモ

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

マイクロフレームワークをつくろう - Pimpleの上に(PHPTAL導入編)

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

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

Step 7 PHPTALを導入

Rendererクラス作成編にて、素のPHPをスクリプトから分離した際にRendererクラスを作成しましたが、この仕様を元にPHPTALに対応してみます。

なんでPHPTALなのかというと、PHPTALなら昔Silex用に書いたサービスプロバイダのコードが流用できるからです。

まずはRendererクラスの名前空間Acme\Renderer に変更して、PHPファイルのテンプレート用に作成した旧Rendererクラスが持つassign(), render(), fetch() の各メソッドをインタフェース化します。

src/Acme/Renderer/RendererInterface.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\Renderer;

/**
 * レンダラインタフェース
 *
 * @author k.holy74@gmail.com
 */
interface RendererInterface
{

    /**
     * 出力データに値を追加します。
     *
     * @param string 名前
     * @param mixed 値
     */
    public function assign($name, $value);

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

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

}

旧RendererクラスはPhpRendererに名前を変更、新たにPhpTalRendererクラスを作成して、どちらもこのRendererInterfaceを実装することとします。

assign()メソッドとrender()メソッドはPhpRendererとPhpTalRendererのどちらも同じ実装で良さそうなので、Traitにしました。

(PHP5.4系を使い出してからは、Abstractクラスは使わずInterface + Traitをよく使ってます。オブジェクト指向的にどうなのか分かりませんが、その方が楽なので…)

src/Acme/Renderer/RendererTrait.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\Renderer;

/**
 * レンダラ用Trait
 *
 * @author k.holy74@gmail.com
 */
trait RendererTrait
{

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

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

}

PhpRendererは名前と配置先を変更してRendererInterfaceを実装し、RendererTraitを利用しただけです。

src/Acme/Renderer/PhpRenderer.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\Renderer;

/**
 * PHPレンダラ
 *
 * @author k.holy74@gmail.com
 */
class PhpRenderer implements RendererInterface
{

    use RendererTrait;

    /**
     * @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 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;
        }
    }

}

PhpTalRendererも同様にRendererInterfaceを実装し、RendererTraitを利用しますが、fetch()メソッドではPHPTALを使ってテンプレート出力します。

src/Acme/Renderer/PhpTalRenderer.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\Renderer;

/**
 * PHPTALレンダラ
 *
 * @author k.holy74@gmail.com
 */
class PhpTalRenderer implements RendererInterface
{

    use RendererTrait;

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

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

    /**
     * @var \PHPTAL
     */
    public $phptal;

    /**
     * @var array PHPTAL用オプション設定
     */
    private static $phptal_options = array(
        'outputMode',
        'encoding',
        'templateRepository',
        'phpCodeDestination',
        'phpCodeExtension',
        'cacheLifetime',
        'forceReparse',
    );

    /**
     * コンストラクタ
     *
     * @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_fill_keys(static::$phptal_options, null);
        if (!empty($configurations)) {
            foreach ($configurations as $name => $value) {
                $this->config($name, $value);
            }
        }
        $this->phptal = new \PHPTAL();
        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 'templateRepository':
                case 'phpCodeDestination':
                    if (!is_string($value) && !is_array($value)) {
                        throw new \InvalidArgumentException(
                            sprintf('The config parameter "%s" only accepts string.', $name));
                    }
                    break;
                case 'encoding':
                case 'phpCodeExtension':
                    if (!is_string($value)) {
                        throw new \InvalidArgumentException(
                            sprintf('The config parameter "%s" only accepts string.', $name));
                    }
                    break;
                case 'outputMode':
                case 'cacheLifetime':
                    if (!is_int($value) && !ctype_digit($value)) {
                        throw new \InvalidArgumentException(
                            sprintf('The config parameter "%s" only accepts bool.', $name));
                    }
                    $value = (int)$value;
                    break;
                case 'forceReparse':
                    if (!is_bool($value) && !is_int($value) && !ctype_digit($value)) {
                        throw new \InvalidArgumentException(
                            sprintf('The config parameter "%s" only accepts bool.', $name));
                    }
                    $value = (bool)$value;
                    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 array テンプレートに展開する変数の配列
     * @return string
     */
    public function fetch($view, array $data)
    {
        foreach ($this->config as $name => $value) {
            if (isset($value) && in_array($name, static::$phptal_options)) {
                $method = 'set' . ucfirst($name);
                if (!method_exists($this->phptal, $method)) {
                    throw new \InvalidArgumentException(
                        sprintf('The accessor method to "%s" is not defined.', $name));
                }
                switch ($name) {
                case 'phpCodeDestination':
                case 'templateRepository':
                    if ('\\' === DIRECTORY_SEPARATOR) {
                        $value = (is_array($value))
                            ? array_map(function($val) {
                                return str_replace('\\', '/', $val);
                            }, $value)
                            : str_replace('\\', '/', $value);
                    }
                    break;
                }
                $this->phptal->{$method}($value);
            }
        }
        if (strpos($view, '/') === 0) {
            $view = substr($view, 1);
        }
        $data = array_merge($this->data, $data);
        foreach ($data as $name => $value) {
            $this->phptal->set($name, $value);
        }
        return $this->phptal->setTemplate($view)->execute();
    }

}

DI的にはクラス内で依存オブジェクトを生成してはいけないと言われてることを思い起こして、少し考え込んでしまいましたが…。

アプリケーションオブジェクト設定コードの $app->render() はSilexにおけるServiceProviderに相当するもので、それが自分には回りくどく感じたから、PHPTALのオブジェクトを楽に生成して共通のインタフェースで利用するためにこのクラスを作ったわけで。

とりあず自分が使いやすければいいかなと…アプリケーションオブジェクトの利用側スクリプトに影響がないようにしておけば、何かまずくなっても後で変えることができるので。

そんな訳で、アプリケーションオブジェクトの生成・設定コードはこうなりました。

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\PhpTalRenderer;

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

$app = new Application();

// レンダラオブジェクトを生成、グローバルなテンプレート変数をセット
$app->renderer = $app->share(function(Application $app) {
    $renderer = new PhpTalRenderer(array(
        'outputMode'         => \PHPTAL::XHTML,
        'encoding'           => 'UTF-8',
        'templateRepository' => realpath(__DIR__ . '/../www'),
        'phpCodeDestination' => sys_get_temp_dir(),
        'forceReparse'       => true,
    ));
    // $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) {
    return new Response($app->renderer->fetch($view, $data), $statusCode, $headers);
});

// リダイレクトレスポンスを生成
$app->redirect = $app->protect(function($url, $statusCode = 303, $headers = array()) use ($app) {
    return new RedirectResponse(
        (false === strpos($url, '://'))
            ? $app->request->getSchemeAndHttpHost() . $url
            : $url,
        $statusCode, $headers
    );
});

// リクエストオブジェクトを生成
$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);
});

// アプリケーションへのリクエストハンドラ登録
$app->on = $app->protect(function($allowableMethod, $function) use ($app) {
    $allowableMethods = explode('|', $allowableMethod);
    $handler = $app->protect(function(Application $app, $method) use ($function) {
        return $function($app, $method);
    });
    if (in_array('GET', $allowableMethods)) {
        $app->onGet = $handler;
    }
    if (in_array('POST', $allowableMethods)) {
        $app->onPost = $handler;
    }
    if (in_array('PUT', $allowableMethods)) {
        $app->onPut = $handler;
    }
    if (in_array('DELETE', $allowableMethods)) {
        $app->onDelete = $handler;
    }
});

// アプリケーション実行
$app->run = $app->protect(function() use ($app) {
    $method = $app->request->getMethod();
    $handlerName = 'on' . ucfirst(strtolower($method));
    if (!$app->offsetExists($handlerName)) {
        $response = new Response('Method Not Allowed', 405);
    } else {
        try {
            $response = $app->{$handlerName}($app, $method);
        } catch (\Exception $e) {
            $response = new Response('Internal Server Error', 500);
        }
    }
    $response->send();
});

return $app;

$app->renderer の中身が少し変わっただけです。

せっかくPHPTALを使っているので、この際テンプレートファイルの設置先をドキュメントルートに設定、テンプレートを手早くブラウザで表示確認できるようにして、テンプレートキャッシュはPHPのテンポラリディレクトリをそのまま使ってもらいます。

利用側スクリプトは変化なし。

www/index.php

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

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

    $errors = array();

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

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

        if (strlen($form['name']) === 0) {
            $errors['name'] = '名前を入力してください。';
        } elseif (mb_strlen($form['name']) > 20) {
            $errors['name'] = '名前は20文字以内で入力してください。';
        }

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

        if (empty($errors)) {
            // $app->addFlashMessage('success', '投稿を受け付けました');
            return $app->redirect('/');
        }

    }

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

$app->run();

テンプレートファイルはPHPTAL用に以下のように書き換えて、ドキュメントルート以下に設置しました。

www/index.html

<html>
<body>
<h1>俺のフレームワーク@<span tal:replace="server/HTTP_HOST">example.com</span></h1>

<form method="post" tal:attributes="action server/REQUEST_URI">

<ul tal:condition="php:count(errors) > 0">
<li tal:repeat="error errors" tal:content="error">名前を入力してください。</li>
</ul>

<dl>
<dt>名前</dt>
<dd>
<input type="text" name="name" class="input-xlarge" value="名前..." tal:attributes="value form/name" />
</dd>
<dt>コメント</dt>
<dd>
<textarea name="comment" tal:content="form/comment">コメント内容...</textarea>
</dd>
</dl>

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

<ul>
<li><a href="/">アプリケーション</a></li>
<li><a href="/index.html">テンプレート</a></li>
</ul>

</body>
</html>

出力結果のHTMLはほとんど変わりませんが、テンプレートファイルを直接表示するためのリンクを追加してます。

分業するわけでもないのにValidにこだわる意味はあるのかって気もしますが、そろそろこのダサダサな外見をBootstrapで飾ろうかと考えてますので、HTMLで確認しながらテンプレートを作れるという多少のメリットはあるかなーと。