読者です 読者をやめる 読者になる 読者になる

k-holyのPHPとか諸々メモ

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

マイクロフレームワークをつくろう - Pimpleの上に(ルーティング編)

PHP Symfony PHPTAL CMOFW

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

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

ページスクリプトへのルーティングのためVolcanus_Routingを導入

ここまで単一のページしか扱っていなかったのであえて後回しにしていましたが、そろそろテスト用ページがごちゃごちゃになってきたので、そろそろいわゆる「URLルーティング」に取り組みます。

自分は以前から「実はフロントコントローラってしっくりこないんです!」などと公言してきましたが、RESTスタイルの流行を見て考えを改めるどころか、より強くそう感じるようになりました。

そういうわけで、ここまで書いてきたスクリプトのスタイルそのままでルーティングを行うため、Volcanus_Routing を導入します。

一般的にURLルーティングと呼ばれるものは、パスとクラスの関連を何らかの方法で定義して振り分けるフロントコントローラ式のルーティングですが、Volcanus_Routingでは所定のルールによってパスとドキュメントルート内のスクリプトを関連付けるページコントローラ式のルーティングを行います。

Packagistにも登録していますので、composerでインストールします。

composer.json

{
    "license": "MIT",
    "authors": [
        {
            "name": "k-holy",
            "email": "k.holy74@gmail.com"
        }
    ],
    "config": {
            "vendor-dir": "vendor"
    },
    "autoload": {
        "psr-0": {
            "Acme":"src"
        }
    },
    "require": {
        "php": ">=5.4",
        "pimple/pimple": "1.0.2",
        "symfony/http-foundation": ">=2.3,<2.4-dev",
        "phptal/phptal": "dev-master",
        "volcanus/routing": "dev-master"
    }
}

Volcanus_Routingではルーティング用のゲートウェイスクリプトを用意して、ディレクトリ/ファイルが存在しないURLへのリクエストに対して、ドキュメントルート以下からURLに対応したページスクリプトを探し出し、カレントディレクトリをその設置先に移動してスクリプトを読み込むことで、ルーティングを実現します。

共通初期処理をアプリケーションのコアとWeb側に分離

Volcanus_Routingはアプリケーションから独立して動作することを想定しており、レガシーなスクリプトをいわゆる「Cool URI」に対応するようなケースでは手軽に使えるのですが、ゲートウェイスクリプトとページスクリプトで二度読み込みが行われるため、アプリケーション側と設定や初期処理などをそれぞれで共有したい場合、ちょっと実装に工夫が必要となります。

元よりこの「マイクロフレームワーク」はバッチスクリプト等Web以外で扱うことも想定しているので、この機会にこれまで一緒くただったアプリケーションオブジェクトの共通初期処理を、コア部分とWeb側のみで利用する部分に分割します。

まずはコア部分。

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;

$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',
        'secret_key' => 'CCi:wYD-4:iV:@X%1zun[Y@:',
    ));
    $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,
    ));
    // アプリケーション設定
    $renderer->assign('config', $app->config);
    return $renderer;
});

// ログ
$app->log = $app->protect(function($level, $message) use ($app) {
    error_log(
        sprintf("[%s] %s: %s\n", date('Y-m-d H:i:s'), $level, $message),
        3,
        $app->config->error_log
    );
});

// エラーページを返す
$app->errorView = $app->protect(function(\Exception $exception, $message = null) use ($app) {
    return $app->renderer->fetch($app->config->error_view, array(
        'title'           => 'エラーが発生しました',
        'message'         => $message,
        'exception'       => $exception,
        'exception_class' => get_class($exception),
    ));
});

// アプリケーション初期処理
$app->registerEvent('init');
$app->addHandler('init', function(Application $app) {
    error_reporting(E_ALL);
    set_error_handler(function($errno, $errstr, $errfile, $errline) {
        throw new \ErrorException($errstr, $errno, 0, $errfile, $errline);
    });
    set_exception_handler(function(\Exception $e) use ($app) {
        $app->log('ERROR', (string)$e);
        echo $app->errorView($e, $e->getMessage());
    });
});

return $app;

Symfony HttpFoundationを扱っていた部分をWeb側に移動したため、随分と短くなりました。

エラーやロギング処理はコア側とWeb側の双方で必要となるため、この機会にログへの追記を $app->log($level, $message) で行えるようにしました。

また、エラー画面のレスポンスオブジェクトを返すのではなく、例外オブジェクトを元にエラー情報を生成して文字列で返すための $app->errorView() 関数を追加しています。

PHPTALでエラー画面のテンプレートファイル error.html を利用して、これまで通りHTMLを返すことになりますが、利用側で $app->config->error_view を切り替えて他の形式のテンプレートを利用することが可能です。

(テンプレートディレクトリが固定されていることや、テンプレート変数にタイトルを渡している部分など改善すべきところはありますが、今回はひとまずルーティングに焦点を絞るため後回しにします…)

以前のコードでは $app->run() の最初で行なっていたエラーハンドラの設定を、Applicationクラスのイベントコールバック内での実行に変えているところも大きな違いです。

エラー設定を $app->run() から移動したことに伴い、これまでの set_error_handler() によるPHPエラーのErrorExceptionへの変換に加えて、set_exception_handler() でPHPの例外ハンドラを上書きしています。

こうしておいた上で、アプリケーションオブジェクト利用側から $app->init() を呼ぶと、$app->addHandler('init', function()) で追加しておいたコールバックが順に実行されるので、その後に発生したエラーと例外については、(カスタムエラーハンドラで捕捉できる限りにおいて)この例外ハンドラの実装が利用されることになります。

(初めに Pimple拡張編 で紹介した独自の機能ですが、今回のような差分処理的にコールバックを適用したい場合に使えます。本当は別クラスに実装するべきですが、これも手抜きのためということで…。)

Web側の入口に当たるURLルーティング用のゲートウェイスクリプトは、こうなりました。

www/__gateway.php

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

use Volcanus\Routing\Router;
use Volcanus\Routing\Exception\NotFoundException;
use Volcanus\Routing\Exception\InvalidParameterException;

use Symfony\Component\HttpFoundation\Response;

$router = Router::instance(array(
    'searchExtensions' => 'php,html',
    'overwriteGlobals' => true,
));

$router->importGlobals();

try {
    $router->execute();
} catch (\Exception $exception) {
    $statusCode = 500;
    $message = null;
    if ($exception instanceof NotFoundException) {
        $statusCode = 404;
        $message = 'ページが見つかりません';
    } elseif ($exception instanceof InvalidParameterException) {
        $statusCode = 400;
        $message = 'リクエストが不正です';
    }
    $response = new Response($app->errorView($exception, $message), $statusCode);
    $response->send();
}

$router->execute() では、リクエストされたURLからルーティング対象のスクリプトが検出できなかった場合や、URLに含まれるパラメータが不正な場合に例外がスローされます。

例外をキャッチした場合には $app->errorView() で生成したエラー画面のHTMLを Responseクラスを使って返すようにしています。

この段階ではセッション関連のオブジェクト生成などは行なわれないので、ここで返すエラー画面のテンプレートからそれらを参照しないよう注意する必要があります。

例外処理とエラー画面・Twitter Bootstrap導入編 で「フレームワークのデフォルトのエラー画面出力にテンプレートエンジンを利用するのは好ましくないかも」と書いたのはそういう理由で、どのタイミングでエラーが発生するか事前に把握できないことによります。

(現在の実装内容はテンプレート出力中にエラーが発生した場合のフォローなども含めて、その辺りの処理が不十分なので、改善の必要がありそうです)

Web側の共通初期処理

www/app.php

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

use Acme\Application;
use Acme\DataObject;
use Acme\Exception\HttpException;

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

use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage;
use Symfony\Component\HttpFoundation\Session\Storage\Handler\NativeFileSessionHandler;

// セッションオブジェクトを生成
$app->session = $app->share(function(Application $app) {
    return new Session(
        new NativeSessionStorage(
            array(
                'use_only_cookies' => 1,
                'cookie_httponly'  => 1,
                'entropy_length'   => 2048,
                'entropy_file'     => '/dev/urandom',
                'hash_function'    => 1,
                'hash_bits_per_character' => 5,
            ),
            new NativeFileSessionHandler($app->config->app_root . DIRECTORY_SEPARATOR . 'session')
        )
    );
});

// フラッシュメッセージ
$app->flash = $app->share(function(Application $app) {
    return new DataObject(array(
        'add' => function($name, $message) use ($app) {
            $app->session->getFlashBag()->add($name, $message);
        },
        'has' => function($name) use ($app) {
            return $app->session->getFlashBag()->has($name);
        },
        'get' => function($name) use ($app) {
            return $app->session->getFlashBag()->get($name);
        },

        // Error
        'addError' => function($message) use ($app) {
            $app->flash->add('error', $message);
        },
        'hasError' => function() use ($app) {
            return $app->flash->has('error');
        },
        'getError' => function() use ($app) {
            return $app->flash->get('error');
        },

        // Alert
        'addAlert' => function($message) use ($app) {
            $app->flash->add('alert', $message);
        },
        'hasAlert' => function() use ($app) {
            return $app->flash->has('alert');
        },
        'getAlert' => function() use ($app) {
            return $app->flash->get('alert');
        },

        // Success
        'addSuccess' => function($message) use ($app) {
            $app->flash->add('success', $message);
        },
        'hasSuccess' => function() use ($app) {
            return $app->flash->has('success');
        },
        'getSuccess' => function() use ($app) {
            return $app->flash->get('success');
        },

        // Info
        'addInfo' => function($message) use ($app) {
            $app->flash->add('info', $message);
        },
        'hasInfo' => function() use ($app) {
            return $app->flash->has('info');
        },
        'getInfo' => function() use ($app) {
            return $app->flash->get('info');
        },
    ));
});

// トークンオブジェクトを生成
$app->token = $app->share(function(Application $app) {
    return new DataObject(array(
        'name'  => function() use ($app) {
            return sha1($app->config->secret_key);
        },
        'value' => function() use ($app) {
            return $app->session->getId();
        },
        'validate' => function($value) use ($app) {
            if (is_null($value)) {
                return false;
            }
            return ($value === $app->token->value());
        },
    ));
});

// CSRFトークンの検証
$app->csrfVerify = $app->protect(function($method) use ($app) {
    return $app->token->validate($app->findVar($method, $app->token->name()));
});

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

// リクエストURIを返す
$app->requestUri = $app->protect(function() use ($app) {
    return $app->request->getRequestUri();
});

// レンダラオブジェクトからのテンプレート出力でレスポンスを生成
$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->error = $app->protect(function(\Exception $exception, $statusCode = 500, $message = null, $headers = array()) use ($app) {
    if ($exception instanceof HttpException) {
        $statusCode = $exception->getCode();
        $headers = $exception->getHeaders();
        if (is_null($message)) {
            $message = $exception->getMessage();
        }
    }
    return new Response(
        $app->errorView($exception, $message),
        $statusCode,
        $headers
    );
});

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

// リクエスト変数を取得する
$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->addHandler('init', function(Application $app) {
    // $_SERVER
    $app->renderer->assign('server', $app->request->server->all());
    // セッション開始
    $app->session->start();
    // フラッシュメッセージ
    $app->renderer->assign('flash', $app->flash);
});

// アプリケーション実行
$app->run = $app->protect(function() use ($app) {

    $app->init();

    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) {
        $app->log('ERROR', (string)$e);
        $response = $app->error($e);
    }

    // CSRFトークン自動出力
    ini_set('url_rewriter.tags', 'form=');
    output_add_rewrite_var(
        $app->escape($app->token->name()),
        $app->escape($app->token->value())
    );

    $response->send();
});

return $app;

Symfony HttpFoundationを扱っていたコードが、全てこちらに移動しました。

ページスクリプトの分割により、リクエストURIの取得が随所で必要になってくることから、$app->requestUri() 関数を追加しました。

エラー画面用レスポンスを返す $app->error($exception, $statusCode, $message, $headers) 関数も追加してあります。

処理の流れ自体はほとんど変わっていませんが、アプリケーションオブジェクトのテンプレート変数への直接アサインをやめて、フラッシュメッセージのみのアサインに変更しました。

ビルトインWebサーバ起動スクリプトを設置

ビルトインWebサーバで動作確認を行うために、起動スクリプトを記述します。

www/__builtin_server.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * ビルトインWebサーバ用起動スクリプト
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

if ('/' === $path || !realpath(__DIR__ . $path)) {
    require __DIR__ . DIRECTORY_SEPARATOR . '__gateway.php';
} else {
    return false;
}

Windowsの場合はドキュメントルート直下に起動スクリプトを設置し、ショートカットのプロパティで作業フォルダ欄にドキュメントルートを、リンク先のコマンドで C:\php\php.exe -S 127.0.0.1:8080 __builtion_server.php などと指定すればOKです。

トップページ (/)

テストのためとはいえ index.php ページスクリプトには様々な機能がごっちゃに実装されていたので、ルーティングのテストも兼ねて、機能別に分割します。

index.phpはトップページになるので、とりあえずGETメソッドのみ対応した中身のないページとしておきます。

www/index.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * トップページ
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
$app = include __DIR__ . DIRECTORY_SEPARATOR . 'app.php';

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

    return $app->render('index.html', array(
        'title' => 'トップページ',
    ));

});

$app->run();

処理がないので特に解説するところはないのですが、最初に読み込む app.php はアプリケーションルートに設置したものではなく、ドキュメントルートに設置したものに変更しています。

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>

  <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>

テンプレートファイルも解説不要ですが、レイアウトテンプレートのファイル名を layout.html から __layout.html に変更しました。

ページスクリプトを増やしてもすぐ辿れるように、というただの作業上の理由ですが、経験上こういうのも案外影響が大きかったりします。

投稿フォーム (/comment)

これまでフォームのサンプル実装としていた「投稿フォーム」(何に対して投稿するのか分かりませんが…)は comment.php としました。

www/comment.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * 投稿フォーム
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
$app = include __DIR__ . DIRECTORY_SEPARATOR . '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') {

        // CSRFトークンの検証
        if (!$app->csrfVerify('P')) {
            $app->abort(403, 'リクエストは無効です。');
        }

        // 投稿フォーム処理
        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->flash->addSuccess('投稿を受け付けました');
            return $app->redirect('/');
        }

    }

    return $app->render('comment.html', array(
        'title'  => '投稿フォーム',
        'form'   => $form,
        'errors' => $errors,
    ));
});

$app->run();

トップページと同様 app.php が変わったのと、テンプレートファイル名を変更した他は、不要な機能が削除されただけです。

www/comment.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>
    </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>

フレームワーク機能テスト用フォーム (/test)

エラーハンドリングやCSRFトークンのテスト機能は今後も必要なので、別ページを用意しました。

(なにせユニットテストを書いていない…というか書けない実装なので…)

www/test.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * フレームワーク機能テスト
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
$app = include __DIR__ . DIRECTORY_SEPARATOR . 'app.php';

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

    $errors = array();

    $form = array(
        'enable_debug'      => $app->findVar('P', 'enable_debug'),
        'move_log_dir'      => $app->findVar('P', 'move_log_dir'),
        'change_secret_key' => $app->findVar('P', 'change_secret_key'),
        'validate_token'    => $app->findVar('P', 'validate_token'),
    );

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

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

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

        // 秘密のキーを変更
        if (isset($form['change_secret_key'])) {
            $app->config->secret_key = bin2hex(openssl_random_pseudo_bytes(32));
        }

        // CSRFトークンの検証
        if (isset($form['validate_token']) && !$app->csrfVerify('P')) {
            $app->abort(403, 'リクエストは無効です。');
        }

        // PHPエラーのテスト
        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);
        }

        // HTTP例外のテスト
        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 (!is_null($app->findVar('P', 'flash-error'))) {
            $app->flash->addError('フラッシュメッセージ[Error]のテストです');
            return $app->redirect($app->requestUri());
        }

        if (!is_null($app->findVar('P', 'flash-alert'))) {
            $app->flash->addAlert('フラッシュメッセージ[Alert]のテストです');
            return $app->redirect($app->requestUri());
        }

        if (!is_null($app->findVar('P', 'flash-success'))) {
            $app->flash->addSuccess('フラッシュメッセージ[Success]のテストです');
            return $app->redirect($app->requestUri());
        }

        if (!is_null($app->findVar('P', 'flash-info'))) {
            $app->flash->addInfo('フラッシュメッセージ[Info]のテストです');
            return $app->redirect($app->requestUri());
        }

    }

    return $app->render('test.html', array(
        'title'  => 'フレームワーク機能テスト',
        'form'   => $form,
        'errors' => $errors,
    ));
});

$app->run();

トップページと同様 app.php が変わったのと、テンプレートファイル名を変更した他は、不要な機能が削除されただけです。

www/test.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">

    <form class="form-horizontal" method="post" tal:attributes="action server/REQUEST_URI">
      <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>

      <fieldset>
        <legend><span class="icon-repeat"></span>セッション関連のテスト</legend>
        <dl class="control-group">
          <dt class="control-label">CSRFトークン</dt>
          <dd class="controls">
            <label class="checkbox">
              <input type="checkbox" name="validate_token" value="1" tal:attributes="checked exists:form/validate_token" />
              検証を有効にする
            </label>
            <label class="checkbox">
              <input type="checkbox" name="change_secret_key" value="1" tal:attributes="checked exists:form/change_secret_key" />
              秘密のキーを変更する
            </label>
          </dd>
        </dl>
        <dl class="control-group">
          <dt class="control-label">フラッシュメッセージ</dt>
          <dd class="controls">
          <input type="submit" value="Error" class="btn btn-primary btn-danger" name="flash-error" />
          <input type="submit" value="Alert" class="btn btn-primary btn-warning" name="flash-alert" />
          <input type="submit" value="Success" class="btn btn-primary btn-success" name="flash-success" />
          <input type="submit" value="Info" class="btn btn-primary btn-info" name="flash-info" />
          </dd>
        </dl>
        <div class="form-actions">
          <input type="submit" value="送信" class="btn btn-primary btn-large" />
        </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>

エラー画面

エラー画面もほとんど変わっていませんが、Bootstrapの「ヒーローユニット」内に出力していたデバッグ用のエラー情報を外に出しました。

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><span class="icon-warning-sign"></span><span tal:replace="message|default">エラーが発生しました</span></h2>
    </div>

    <div class="row-fluid debug" tal:condition="config/debug">

      <h3><span tal:replace="exception_class">RuntimeException</span> [<span tal:replace="exception/getCode">0</span>]</h3>
      <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>

      <h3>StackTrace</h3>
      <pre tal:content="exception/getTraceAsString" class="stack-trace">#0 /path/to/file(999): function('argument')</pre>

    </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>

共通レイアウトテンプレート

ページ分割に伴い、意味を成してなかったナビゲーションがようやく役に立ちました。

テンプレートファイルは今後も増加が予想されるので、メインのナビゲーションから外してドロップダウンにしました。

この辺が手軽に実装できるのはBootstrapの最大の利点ですね。

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-home"></span>トップページ</a></li>
          <li><a href="/comment"><span class="icon-comment"></span>投稿フォーム</a></li>
          <li><a href="/test"><span class="icon-wrench"></span>フレームワーク機能テスト</a></li>
        </ul>
      </div>
      <div class="nav-collapse collapse">
        <div class="btn-group pull-right">
          <button class="btn btn-inverse">テンプレート</button>
          <button class="btn btn-inverse dropdown-toggle" data-toggle="dropdown">
            <span class="caret"></span>
          </button>
          <ul class="dropdown-menu">
            <li><a href="/index.html"><span class="icon-home"></span>トップページ</a></li>
            <li><a href="/comment.html"><span class="icon-comment"></span>投稿フォーム</a></li>
            <li><a href="/test.html"><span class="icon-wrench"></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>

<div class="container-fluid">

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

  <tal:block tal:condition="exists:flash">

    <div class="alert alert-error" tal:condition="flash/hasError">
      <button class="close" data-dismiss="alert">×</button>
      <ul>
        <li tal:repeat="message flash/getError" tal:content="message">Error Flashメッセージ</li>
      </ul>
    </div>

    <div class="alert" tal:condition="flash/hasAlert">
      <button class="close" data-dismiss="alert">×</button>
      <ul>
        <li tal:repeat="message flash/getAlert" tal:content="message">Alert Flashメッセージ</li>
      </ul>
    </div>

    <div class="alert alert-success" tal:condition="flash/hasSuccess">
      <button class="close" data-dismiss="alert">×</button>
      <ul>
        <li tal:repeat="message flash/getSuccess" tal:content="message">Success Flashメッセージ</li>
      </ul>
    </div>

    <div class="alert alert-info" tal:condition="flash/hasInfo">
      <button class="close" data-dismiss="alert">×</button>
      <ul>
        <li tal:repeat="message flash/getInfo" tal:content="message">Info Flashメッセージ</li>
      </ul>
    </div>

  </tal:block>

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

  <div class="row-fluid debug" tal:condition="config/debug">
    <table class="table table-striped table-condensed" tal:condition="exists:server">
      <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>

次はいくつか課題の出た例外処理とエラー画面の改善でしょうか。

スタックトレースで長い引数が途中で省略されてしまってデバッグの役に立たない問題も、併せて改善しようと思います。

それにしても、どこまで実装して終わるか決めずに始めてしまったシリーズ記事ですが、基礎的な内容だけでもなかなか終わりが見えませんね…貼り付けるソースもどんどん長く…。

しかし、これまでバラバラに実践してきた様々なものが、Pimpleを通じて繋がる感じはなかなか面白いです。