k-holyのPHPとか諸々メモ

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

マイクロフレームワークをつくろう - Pimpleの上に(例外処理とエラー画面・Twitter Bootstrap導入編)

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

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

Step 8 例外クラスおよびエラー画面を作成 + Twitter Bootstrap導入

RESTインタフェース編で $app->run()メソッドを追加した際に触れた、アプリケーションの例外クラスと開発用のエラー画面を作成します。

エラー・例外処理とロギングの機能はアプリケーションフレームワークには必ず備わっていますし、実際に開発する上でも重要なものなので、アプリケーション開発に入る前に実装しておくべきですね。

ただ、あまり大掛かりなものを作るのも面倒なので、今回はこんな感じで考えました。

  • HTTP例外クラスを作成し、アプリケーション利用側スクリプトでHTTPエラーを返したい場合は、アプリケーションオブジェクトの関数を呼ぶことでこれをスローする
  • カスタムエラーハンドラにより、アプリケーション内で発生した捕捉可能なエラーは全てErrorExceptionに変換してスローする
  • アプリケーション内で発生した例外はエラーログに記録するとともに、HTTP例外クラスの場合はそのステータスコードを、その他の例外はステータス500とともにエラー画面を返す
  • エラー画面ではデバッグ設定が有効な場合のみ、例外メッセージおよびスタックトレースを表示する

Noticeすら例外スローする厳しい仕様ですが、たまにはいいでしょう。

まずはHTTP例外クラス。

src/Acme/HttpException.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\Exception;

/**
 * HTTP例外クラス
 *
 * @author k.holy74@gmail.com
 */
class HttpException extends \RuntimeException
{

    /**
     * @var array HTTPステータスコード + メッセージ定義
     * @see http://www.iana.org/assignments/http-status-codes/http-status-codes.xml
     */
    private static $statuses = array(
        // Informational 1xx
        100 => 'Continue',
        101 => 'Switching Protocols',
        102 => 'Processing',
        // Successful 2xx
        200 => 'OK',
        201 => 'Created',
        202 => 'Accepted',
        203 => 'Non-Authoritative Information',
        204 => 'No Content',
        205 => 'Reset Content',
        206 => 'Partial Content',
        207 => 'Multi-Status',
        208 => 'Already Reported',
        226 => 'IM Used',
        // Redirection 3xx
        300 => 'Multiple Choices',
        301 => 'Moved Permanently',
        302 => 'Found',
        303 => 'See Other',
        304 => 'Not Modified',
        305 => 'Use Proxy',
        306 => '(Unused)',
        307 => 'Temporary Redirect',
        // Client Error 4xx
        400 => 'Bad Request',
        401 => 'Unauthorized',
        402 => 'Payment Required',
        403 => 'Forbidden',
        404 => 'Not Found',
        405 => 'Method Not Allowed',
        406 => 'Not Acceptable',
        407 => 'Proxy Authentication Required',
        408 => 'Request Timeout',
        409 => 'Conflict',
        410 => 'Gone',
        411 => 'Length Required',
        412 => 'Precondition Failed',
        413 => 'Request Entity Too Large',
        414 => 'Request-URI Too Long',
        415 => 'Unsupported Media Type',
        416 => 'Requested Range Not Satisfiable',
        417 => 'Expectation Failed',
        422 => 'Unprocessable Entity',
        423 => 'Locked',
        424 => 'Failed Dependency',
        426 => 'Upgrade Required',
        428 => 'Precondition Required',
        429 => 'Too Many Requests',
        431 => 'Request Header Fields Too Large',
        // Server Error 5xx
        500 => 'Internal Server Error',
        501 => 'Not Implemented',
        502 => 'Bad Gateway',
        503 => 'Service Unavailable',
        504 => 'Gateway Timeout',
        505 => 'HTTP Version Not Supported',
        506 => 'Variant Also Negotiates (Experimental)',
        507 => 'Insufficient Storage',
        508 => 'Loop Detected',
        510 => 'Not Extended',
        511 => 'Network Authentication Required',
    );

    /**
     * @var array HTTPヘッダの配列
     */
    private $headers;

    /**
     * @var string HTTPステータスメッセージ
     */
    private $statusMessage;

    /**
     * コンストラクタ
     *
     * @param int HTTPステータスコード
     * @param array HTTPヘッダの配列
     * @param string スローする例外メッセージ
     * @param object Exception 以前に使われた例外。例外の連結に使用します。
     */
    public function __construct($code = null, $headers = null, $message = null, $previous = null)
    {
        if (!array_key_exists($code, self::$statuses)) {
            throw new \InvalidArgumentException(
                sprintf('The HTTP status code "%s" is not implemented.', $code)
            );
        }
        $code = $code ?: 500;
        $this->headers = $headers ?: array();
        $this->statusMessage = $this->buildStatusMessage($code);
        parent::__construct($message ?: $this->statusMessage, $code, $previous);
    }

    /**
     * この例外のHTTPヘッダを返します。
     *
     * @return array HTTPヘッダの配列
     */
    public function getHeaders()
    {
        return $this->headers;
    }

    /**
     * この例外のHTTPステータスコードに応じたメッセージを返します。
     *
     * @return string メッセージ
     */
    public function getStatusMessage()
    {
        return $this->statusMessage;
    }

    private function buildStatusMessage($code)
    {
        return sprintf('%d %s', $code, self::$statuses[$code]);
    }

}

HTTPステータスコードと"404 Not Found" 等のメッセージ定義のため行数が増えてますが、中身は大したことはありません。

メッセージ生成に Symfony HttpFoundation Responseクラスを使えると良かったのですが、無理そうだったので例外クラスに実装してしまいました。

次に、デバッグ設定等のアプリケーションの設定値をどう管理するかですが。

当初は Pimple::share() で配列を返してたんですが、そうするとプロパティアクセスで設定値を変更しようとした際に「Indirect modification of overloaded property Acme\Application::$config has no effect」の警告が発生してしまいます。

マジックメソッドでプロパティアクセスした配列は参照を返さないので書き換えられないんですね…。

アプリケーションオブジェクトがそういう実装になってる限り、これを回避するには設定オブジェクトを導入するしかないと思いますが、そのプロパティに配列がセットされたら同じことなので、オブジェクトを入れ子にする必要があります。

「この設定値何が入ってたっけ?」って var_dump() なんかしたら、どうしてこうなった!みたいなえらいことになるわけですが、とりあえず作りました、設定クラスを。

src/Acme/Configuration.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 Configuration implements \ArrayAccess, \IteratorAggregate
{

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

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

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

    /**
     * ArrayAccess::offsetGet()
     *
     * @param mixed
     * @return mixed
     */
    public function offsetGet($offset)
    {
        if (!array_key_exists($offset, $this->attributes)) {
            throw new \InvalidArgumentException(
                sprintf('The attribute "%s" does not exists.', $offset));
        }
        return (is_callable($this->attributes[$offset]))
            ? $this->attributes[$offset]($this)
            : $this->attributes[$offset];
    }

    /**
     * ArrayAccess::offsetSet()
     *
     * @param mixed
     * @param mixed
     */
    public function offsetSet($offset, $value)
    {
        if (!array_key_exists($offset, $this->attributes)) {
            throw new \InvalidArgumentException(
                sprintf('The attribute "%s" does not exists.', $offset));
        }
        $this->attributes[$offset] = $value;
    }

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

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

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

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

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

    /**
     * 属性値を配列から再帰的にセットします。
     * 要素が配列またはTraversable実装オブジェクトの場合、
     * ラッピングすることで配列アクセスとプロパティアクセスを提供します。
     *
     * @param array 属性の配列
     * @return array
     */
    private function import($attributes)
    {
        foreach ($attributes as $name => $value) {
            $attributes[$name] = (is_array($value) || $value instanceof \Traversable)
                ? new static($value)
                : $value
            ;
        }
        return $attributes;
    }

}

設定値として受け入れるキーはオブジェクト生成時に確定しますが、値の書き換えは後から配列アクセスでもプロパティアクセスでも可能という仕様です。

(たかが設定値の管理に大げさな感じもしますが、私の拙いPHP力だと他に方法が思いつきませんでした…)

callableな設定値は自身を引数にした実行結果を返したり、callableな設定値がメソッドアクセスされた場合、その実行結果を返します。

(Silexの各種ServiceProviderで設定値を遅延評価してオブジェクトを生成しているのをヒントに実装しました)

アプリケーションオブジェクトの生成・設定コードでは、上記HTTP例外クラスと、設定クラスを利用するための関数を新たに登録します。

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\Configuration;
use Acme\Renderer\PhpTalRenderer;
use Acme\Exception\HttpException;

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

$app = new Application();

// アプリケーション設定オブジェクトを生成
$app->config = $app->share(function(Application $app) {
    $config = new Configuration(array(
        'debug'      => true,
        'app_root'   => __DIR__,
        'web_root'   => realpath(__DIR__ . '/../www'),
        'log_dir'    => __DIR__ . DIRECTORY_SEPARATOR . 'log',
        'log_file'   => date('Y-m') . '.log',
        'error_log'  => null,
        'error_view' => 'error.html',
    ));
    $config['error_log'] = function($config) {
        return $config['log_dir'] . DIRECTORY_SEPARATOR . $config['log_file'];
    };
    return $config;
});

// レンダラオブジェクトを生成、グローバルなテンプレート変数をセット
$app->renderer = $app->share(function(Application $app) {
    $renderer = new PhpTalRenderer(array(
        'outputMode'         => \PHPTAL::XHTML,
        'encoding'           => 'UTF-8',
        'templateRepository' => $app->config->web_root,
        '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);
});

// HTTPエラーを返す
$app->abort = $app->protect(function($statusCode = 500, $message = null, $headers = array()) use ($app) {
    throw new HttpException($statusCode, $headers, $message);
});

// リダイレクトレスポンスを生成
$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) {
    error_reporting(E_ALL);
    set_error_handler(function($errno, $errstr, $errfile, $errline) {
        throw new \ErrorException($errstr, $errno, 0, $errfile, $errline);
    });
    try {
        $method = $app->request->getMethod();
        $handlerName = 'on' . ucfirst(strtolower($method));
        if (!$app->offsetExists($handlerName)) {
            throw new HttpException(405);
        }
        $response = $app->{$handlerName}($app, $method);
    } catch (\Exception $e) {
        error_log(sprintf("[%s] %s\n", date('Y-m-d H:i:s'), (string)$e), 3, $app->config->error_log);
        $statusCode = 500;
        $statusMessage = null;
        $message = null;
        $headers = array();
        if ($e instanceof HttpException) {
            $statusCode = $e->getCode();
            $statusMessage = $e->getStatusMessage();
            $message = $e->getMessage();
            $headers = $e->getHeaders();
        }
        $response = $app->render($app->config->error_view,
            array(
                'title' => 'エラーが発生しました',
                'statusMessage' => $statusMessage,
                'message' => $message,
                'exception' => $e,
                'exception_class' => get_class($e),
            ),
            $statusCode,
            $headers
        );
    }
    $response->send();
});

return $app;

アプリケーション設定値はデバッグ設定の他に現時点で使いそうなものをいくつか登録しました。

エラーログの場所を動的に切り替えるために、早速callableな設定値を使っています。

$app->renderer で静的に設定していたPHPTALのtemplateRepository設定値は、アプリケーション設定値のweb_rootに変更しました。

$app->abort() はアプリケーションオブジェクトの利用側スクリプトからHTTPエラーを返すための関数です。命名はSilexに習いました。

最も変化しているのが $app->run() 内の実装です。

エラー処理は前述の方針に従い、set_error_handler()関数で捕捉可能な全てのPHPエラーがErrorExceptionに変換されます。

また、ErrorExceptionや $app->abort() でスローされたHTTP例外も含め、全ての例外が設定値 error_log に定義したファイルにログを記録した後、エラー画面のレスポンスとしてクライアントに返されます。

エラー画面の生成は error_view 設定値と $app->render() 関数の実装に従い、PHPTALのテンプレートが利用されます。

フレームワークのデフォルトのエラー画面出力にテンプレートエンジンを利用するのは好ましくないかもしれませんが…)

エラー画面のテンプレート変数に例外オブジェクトを直接セットしたりと結構強引なことをやってます。

ページ側のスクリプトはこうなりました。

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'),
        'enable_debug' => $app->findVar('P', 'enable_debug'),
        'move_log_dir' => $app->findVar('P', 'move_log_dir'),
    );

    // 設定を動的に切り替える
    $app->config->debug = (isset($form['enable_debug']));

    if (isset($form['move_log_dir'])) {
        $app->config->log_dir = $app->config->web_root;
    }

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

        if (!is_null($app->findVar('P', 'trigger-notice'))) {
            trigger_error('[E_USER_NOTICE]PHPエラーのテストです', E_USER_NOTICE);
        }

        if (!is_null($app->findVar('P', 'trigger-warning'))) {
            trigger_error('[E_USER_WARNING]PHPエラーのテストです', E_USER_WARNING);
        }

        if (!is_null($app->findVar('P', 'trigger-error'))) {
            trigger_error('[E_USER_ERROR]PHPエラーのテストです', E_USER_ERROR);
        }

        if (!is_null($app->findVar('P', 'throw-http-exception-400'))) {
            $app->abort(400, 'HttpException[400]のテストです');
        }

        if (!is_null($app->findVar('P', 'throw-http-exception-403'))) {
            $app->abort(403,'HttpException[403]のテストです');
        }

        if (!is_null($app->findVar('P', 'throw-http-exception-404'))) {
            $app->abort(404, 'HttpException[404]のテストです');
        }

        if (!is_null($app->findVar('P', 'throw-http-exception-405'))) {
            $app->abort(405, 'HttpException[405]のテストです');
        }

        if (!is_null($app->findVar('P', 'throw-runtime-exception'))) {
            throw new RuntimeException('RuntimeExceptionのテストです');
        }

        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(
        'title'  => '投稿フォーム',
        'form'   => $form,
        'errors' => $errors,
    ));
});

$app->run();

フォームからデバッグ設定やログディレクトリを動的に切り替えたり、PHPエラーやHTTPエラーを返すためのテスト用の項目が増えています。

今回から投稿フォーム(何も処理してませんが…)の他にエラー画面のテンプレートも加わったので、レイアウト用のテンプレートを別に用意しました。

PHPTALでは metal:define-macro や metal:define-slot といった属性を使って、Twitter Bootstrapの関連ファイル読み込みや各ページ共通のヘッダ、ナビゲーション、フッタ、デバッグ情報といった共通部分を、ValidなXML形式を保ちつつテンプレートに定義できます。

www/layout.html

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

<head metal:define-macro="head">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="//ajax.googleapis.com/ajax/libs/jquery/1/jquery.min.js"></script>
<script src="//netdna.bootstrapcdn.com/twitter-bootstrap/latest/js/bootstrap.min.js"></script>
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/twitter-bootstrap/latest/css/bootstrap-combined.no-icons.min.css" />
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/font-awesome/latest/css/font-awesome.min.css" />
<title tal:content="title|default">ページタイトル</title>
</head>

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

<div class="navbar">
  <div class="navbar-inner">
    <div class="container">
      <a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">Menu<span class="caret"></span></a>
      <div class="nav-collapse collapse">
        <ul class="nav pull-left">
          <li><a class="brand" href="/">俺のフレームワーク</a></li>
        </ul>
      </div>
      <div class="nav-collapse collapse">
        <ul class="nav pull-left">
          <li><a href="/"><span class="icon-envelope"></span>投稿フォーム</a></li>
          <li><a href="/index.html"><span class="icon-envelope"></span>投稿フォーム(テンプレート)</a></li>
          <li><a href="/error.html"><span class="icon-warning-sign"></span>エラーページ(テンプレート)</a></li>
          <li><a href="/layout.html"><span class="icon-file"></span>レイアウト(テンプレート)</a></li>
        </ul>
      </div>
    </div>
  </div>
</div>

<div class="container-fluid">

  <header class="header">
    <h1><span tal:replace="title|default">ページタイトル</span>@<span tal:replace="server/HTTP_HOST">example.com</span></h1>
  </header>

  <div class="content" metal:define-slot="content">
    ページコンテンツ
  </div>

  <div class="row-fluid debug" tal:condition="app/config/debug">
    <table class="table table-striped table-condensed">
      <caption><h3>$_SERVER環境変数</h3></caption>
      <tbody>
        <tr tal:repeat="var server">
          <th tal:content="repeat/var/key">環境変数名</th>
          <td tal:content="var">環境変数値</td>
        </tr>
      </tbody>
    </table>
  </div>

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

</div>

</body>
</html>

レイアウトテンプレートでmetal:define-macro属性を定義している要素が、各ページのテンプレートからmetal:use-macro属性を定義する要素と差し替えられます。

また、上記マクロが定義されている要素内でmetal:define-slot属性を定義している要素は、同様に各ページのテンプレートでマクロを利用している要素内でmetal:fill-slot属性を定義した要素と差し替えられます。

(文章にすると分かりづらいですね…)

Bootstrap, Font-Awesome, jQuery関連ファイルの読み込みは、一長一短あるとは思いますが、今回初めてCDNを利用してみました。

投稿フォームのテンプレートです。

www/index.html

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

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

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

<div class="container-fluid">

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

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

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

    <form class="form-horizontal" method="post" tal:attributes="action server/REQUEST_URI">
      <fieldset>
        <legend><span class="icon-envelope"></span>投稿フォーム</legend>
        <dl class="control-group" tal:attributes="class php:isset(errors['name']) ? 'control-group error' : 'control-group'">
          <dt class="control-label">名前</dt>
          <dd class="controls">
            <input type="text" name="name" class="input-xlarge" tal:attributes="value form/name" />
          </dd>
        </dl>
        <dl class="control-group" tal:attributes="class php:isset(errors['comment']) ? 'control-group error' : 'control-group'">
          <dt class="control-label">コメント</dt>
          <dd class="controls">
            <textarea name="comment" rows="5" class="input-xlarge" tal:content="form/comment">コメント内容....</textarea>
          </dd>
        </dl>
        <div class="form-actions">
          <input type="submit" value="送信" class="btn btn-primary btn-large" />
        </div>
      </fieldset>

      <fieldset>
        <legend><span class="icon-wrench"></span>設定値/エラーのテスト</legend>
        <dl class="control-group">
          <dt class="control-label">デバッグ設定</dt>
          <dd class="controls">
            <label class="checkbox">
              <input type="checkbox" name="enable_debug" value="1" tal:attributes="checked exists:form/enable_debug" />
              有効にする
            </label>
          </dd>
        </dl>
        <dl class="control-group">
          <dt class="control-label">ログディレクトリ設定</dt>
          <dd class="controls">
            <label class="checkbox">
              <input type="checkbox" name="move_log_dir" value="1" tal:attributes="checked exists:form/move_log_dir" />
              ドキュメントルートに変更する
            </label>
          </dd>
        </dl>
        <div class="form-actions">
          <input type="submit" value="PHPエラー(Notice)" class="btn btn-primary btn-danger" name="trigger-notice" />
          <input type="submit" value="PHPエラー(Warning)" class="btn btn-primary btn-danger" name="trigger-warning" />
          <input type="submit" value="PHPエラー(Error)" class="btn btn-primary btn-danger" name="trigger-error" />
          <input type="submit" value="HTTP例外(400)" class="btn btn-primary btn-danger" name="throw-http-exception-400" />
          <input type="submit" value="HTTP例外(403)" class="btn btn-primary btn-danger" name="throw-http-exception-403" />
          <input type="submit" value="HTTP例外(404)" class="btn btn-primary btn-danger" name="throw-http-exception-404" />
          <input type="submit" value="HTTP例外(405)" class="btn btn-primary btn-danger" name="throw-http-exception-405" />
          <input type="submit" value="RuntimeException" class="btn btn-primary btn-danger" name="throw-runtime-exception" />
        </div>
      </fieldset>

    </form>

  </div>

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

</div>

</body>
</html>

metal:use-macro属性でマクロが定義されているレイアウトテンプレートとマクロの名前を指定しています。

BootstrapとFont-Awesomeはテンプレートの見た目を確認するために必要なので、結局こちらにも重複して定義しているのですが、ローカルのものを読み込んでいます。

チェックボックスなどPOST送信後にフォームの属性を動的に変更する部分が、PHPTALとBootstrapの流儀に従うと結構面倒でした。

(control-groupのところとか、明らかに冗長ですね…)

余談になりますが、PHPTALはテンプレート自体ValidなXMLにはなるものの、テンプレートから利用する値を全てプロパティや配列アクセスによる参照を提供していない限り、結局テンプレート内にロジックが混入してしまいます。

特に tal:attributes や tal:condition といった属性でPHPコードを多用せざるを得ないようなら、デザイナーとの並行作業が発生するなど特別な理由がない限り、PHPTALの採用は見送った方が良いと感じています。

こういう問題を何とかするにはテンプレートエンジンだけではどうしようもなくて、いわゆるツーステップビューのような仕組みを導入するしかないでしょうか。

(既存フレームワークの多くがオブジェクトフォームを用意してバリデーションと統合しているのは、その辺りも理由としてあるかもしれません)

エラー画面のテンプレートです。

www/error.html

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

<head metal:use-macro="layout.html/head">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="bootstrap/css/bootstrap-responsive.min.css" />
<link rel="stylesheet" href="font-awesome/css/font-awesome.min.css" />
<title>エラー画面</title>
</head>

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

<div class="container-fluid">

  <header class="header">
    <h1>エラー画面@example.com</h1>
  </header>

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

    <div class="hero-unit">
      <h2 tal:content="statusMessage|default">500 Internal Server Error</h2>
      <h3><span class="icon-warning-sign"></span><span tal:replace="message|default">エラーが発生しました。</span></h3>
      <div class="row-fluid debug" tal:condition="app/config/debug">
        <h2><span tal:replace="exception_class">RuntimeException</span> [<span tal:replace="exception/getCode">0</span>]</h2>
        <p>
          &quot;<strong tal:content="exception/getMessage">例外メッセージ...</strong>&quot;
          in <strong tal:content="exception/getFile">例外発生ファイル...</strong>
          on line <strong tal:content="exception/getLine">例外発生行番号...</strong>
        </p>
        <pre tal:content="exception/getTraceAsString">スタックトレース...</pre>
      </div>
    </div>

  </div>

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

</div>

</body>
</html>

$app->run() でテンプレート変数定義を手抜きした影響が tal:content="exception/getTraceAsString" といった記述に現れています。

Twigの場合は一工夫加えられていて exception->getTraceAsString() だと {{exception.traceAsString}} で参照できますが、PHPTALだとこうなるわけです。

もっともSmartyだと {$exception->getTraceAsString()} とそのまんまPHPコードになるので、それに比べるとまあ…という感じですね。

長くなりましたが、今回はここまで。次はそろそろセッション変数でしょうか。