k-holyのPHPとか諸々メモ

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

PHPUnit 4.1系で \Symfony\Component\HttpFoundation\File\UploadedFile のモックオブジェクトを作成しようとすると "Erroneous data format for unserializing" のエラーが発生した件

PHPUnit 4.1系で \Symfony\Component\HttpFoundation\File\UploadedFile のモックオブジェクトを作成しようとすると "Erroneous data format for unserializing" のエラーが発生します。

PHP 5.6.1 + PHPUnit 4.1.6 で確認しました。

エラーメッセージで検索してみると、phpunit/phpunit-mock-objects にこんなissueが。

whatthejeff さん曰く

Unfortunately, since Symfony\Component\HttpFoundation\File\UploadedFile extends SplFileInfo, you will not be able to instantiate it without calling its constructor in 5.5.16. This was previously possible with a serialization hack, but it has been deemed unsafe by the PHP core team.

(英語分からないので機械翻訳を元にしたフィーリング訳ですが)

Symfony\Component\HttpFoundation\File\UploadedFile は SplFileInfo を継承していますが、組み込みオブジェクトの多くがシリアライズ不可なため、以前は "serialization hack" と呼ばれる手段で回避していたとのこと。

しかし、PHP 5.5.16 から(?)この方法が安全ではないと判断されて禁止され、コンストラクタを呼ばずにインスタンスを生成できなくなったため、エラーが発生するようになったみたいです。

issueのコメントからリンクされていたコミットを見たところ phpunit/phpunit-mock-objects の 2.3で対処されたみたい?

差分を見たところ ReflectionClass::isInternal() での分岐処理が追加されてます。

ユーザークラスの場合は ReflectionClass::newInstanceWithoutConstructor() で生成されて、内部クラスの場合は従来通りのコード、つまり "serialization hack" で生成しているようです。

クラス名を元に空のオブジェクトをシリアライズした文字列を組み立てて unserialize() すると、インスタンスが生成できるというトリックのようですね。

<?php
// We have to use this dirty trick instead of ReflectionClass::newInstanceWithoutConstructor()
// because of https://github.com/sebastianbergmann/phpunit-mock-objects/issues/154
$object = unserialize(
    sprintf('O:%d:"%s":0:{}', strlen($className), $className)
);

コメントを見たところ、そもそもこのトリックを使い出したきっかけもSymfony2のテストで不具合が発生したためのようです。

ともあれ、現在の phpunit/phpunit-mock-objects のバージョンを調べてみると…。

$ composer global show -i phpunit/phpunit-mock-objects
Changed current directory to C:/Users/k_horii/AppData/Roaming/Composer
name     : phpunit/phpunit-mock-objects
descrip. : Mock Object library for PHPUnit
keywords : mock, xunit
versions : * 2.1.5
type     : library
license  : BSD-3-Clause
source   : [git] https://github.com/sebastianbergmann/phpunit-mock-objects.git 7878b9c41edb3afab92b85edf5f0981014a2713a
dist     : [zip] https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/7878b9c41edb3afab92b85edf5f0981014a2713a 7878b9c41edb3afab92b85edf5f0981014a2713a
names    : phpunit/phpunit-mock-objects

(以下略)

2.1.5らしい。こりゃあかんわ。

とりあえずPHPUnitのバージョンを4.1系から4.2系に上げてみます。

$ composer global require "phpunit/phpunit=4.2.*"
$ composer global update
Changed current directory to C:/Users/k_horii/AppData/Roaming/Composer
Loading composer repositories with package information
Updating dependencies (including require-dev)
  - Installing doctrine/instantiator (1.0.4)
    Downloading: 100%

  - Removing phpunit/phpunit-mock-objects (2.1.5)
  - Installing phpunit/phpunit-mock-objects (2.3.0)
    Downloading: 100%

  - Removing phpunit/phpunit (4.1.6)
  - Installing phpunit/phpunit (4.2.6)
    Downloading: 100%

Writing lock file
Generating autoload files

試してみたところ、これで通るようになりました。同じ問題に引っ掛かった方へのヒントになれば幸いです。

SilexでUrlGeneratorを使ったサブディレクトリ運用でサブリクエスト

相変わらずSilexとPimpleを拡張した自作フレームワークを行ったり来たりしています。

SilexアプリケーションでUrlGeneratorを使ったサブディレクトリ運用への対応(たとえば http://example.com/foo/ 以下をアプリケーションのルートとする)で、コントローラから別のコントローラに処理を委譲する、いわゆるサブリクエストをApplicationクラスに実装しようとしてハマってしまいました。

Silexのサブリクエスト機能

公式ドキュメントにはサブリクエストの方法として以下のようなコードが記載されています。

<?php
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpKernelInterface;

$app->get('/', function () use ($app) {
    // redirect to /hello
    $subRequest = Request::create('/hello', 'GET');

    return $app->handle($subRequest, HttpKernelInterface::SUB_REQUEST);
});

しかし、サブディレクトリで運用している場合、上記の実装ではうまく動いてくれません。

リンクのパスをUrlGeneratorで生成した際、サブリクエスト時に生成されるパスが、サブディレクトリ部分を除いたものになってしまうのです。

UrlGenerator (実体は Symfony\Component\Routing\Generator\UrlGenerator) のコードを追ったところ、Symfony\Component\Routing\RequestContext::getBaseUrl() メソッドが呼ばれており、これがURLの生成時にサブディレクトリを付与してくれていることが分かりました。

この RequestContext が上記コードでサブリクエストのために生成している Request::create() を元に生成されるのですが、このサブリクエストで本来のリクエストが持っていた baseUrl が失われているために、このような現象が起きていたわけです。

しかしRequestクラスには setBaseUrl() や setBasePath() といったメソッドは用意されていません。

更にコードを追った結果、Request::getBasePath() や Request::getBaseUrl() が返す値はRequestオブジェクトのserverプロパティの値を元に生成されており、上記のようなコードでサブリクエストを生成した場合、その値が失われてしまうことが原因だと分かりました。

SilexでRequestオブジェクトがどのように生成され利用されるかは、以下の Application::run() および Application::handle() のコードを読むと何となく分かります。

Silex\Application.php より一部抜粋

<?php

/**
 * The Silex framework class.
 *
 * @author Fabien Potencier <fabien@symfony.com>
 */
class Application extends \Pimple implements HttpKernelInterface, TerminableInterface
{

    /**
     * Handles the request and delivers the response.
     *
     * @param Request $request Request to process
     */
    public function run(Request $request = null)
    {
        if (null === $request) {
            $request = Request::createFromGlobals();
        }

        $response = $this->handle($request);
        $response->send();
        $this->terminate($request, $response);
    }

    /**
     * {@inheritdoc}
     *
     * If you call this method directly instead of run(), you must call the
     * terminate() method yourself if you want the finish filters to be run.
     */
    public function handle(Request $request, $type = HttpKernelInterface::MASTER_REQUEST, $catch = true)
    {
        if (!$this->booted) {
            $this->boot();
        }

        $current = HttpKernelInterface::SUB_REQUEST === $type ? $this['request'] : $this['request_error'];

        $this['request'] = $request;

        $this->flush();

        $response = $this['kernel']->handle($request, $type, $catch);

        $this['request'] = $current;

        return $response;
    }

}

通常は run() メソッド内でスーパーグローバル変数を元に生成されたRequestオブジェクトが handle() メソッドでルーティング処理された結果、コントローラ(無名関数)が呼ばれるようですが、前述の通り、コントローラから別途生成したRequestオブジェクトを第1引数に、HttpKernelInterface::SUB_REQUEST を第2引数に指定してhandle() メソッドを呼ぶと、一時的にコンテナ内のRequestオブジェクトが入れ替えられるようになっています。

サブリクエスト機能は、いわば擬似的なリクエストをアプリケーション内部で生成することで、サーバ側でリダイレクトのような処理を実現しているわけで、本来のリクエストが持っていた情報をちゃんと引き継ぐ必要があるということです。

Applicationクラスにサブリクエスト用のforward()メソッドを実装する

今回の問題への対策を含めて、Silexアプリケーション用のApplicationクラスにサブリクエストを実行するためのforward()メソッドを実装してみました。

<?php
/**
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */

namespace Acme;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpKernelInterface;

/**
 * アプリケーションクラス
 *
 * @author k.holy74@gmail.com
 */
class Application extends \Silex\Application
{

    use \Silex\Application\UrlGeneratorTrait;
    use \Silex\Application\TwigTrait;

    /**
     * 指定されたパスへのサブリクエストを生成し、処理結果を返します。
     * パスに {foo} が含まれる場合、パラメータ foo の値に置換されます。
     *
     * @param string リクエスト対象のパス
     * @param array パラメータ
     * @return mixed サブリクエスト結果
     */
    public function forward($path, $parameters = array())
    {
        $path = preg_replace_callback('/{([^}]+)}/', function($matches) use ($parameters) {
            return (isset($parameters[$matches[1]]))
                ? rawurlencode($parameters[$matches[1]])
                : $matches[0];
        }, $path);

        $method = $this['request']->getMethod();
        $parameters = array();
        switch($method) {
        case 'GET':
            $parameters = $this['request']->query->all();
            break;
        case 'POST':
            $parameters = $this['request']->request->all();
            break;
        }

        return $this->handle(
            Request::create(
                $this['request']->getBasePath() . $path,
                $method,
                $parameters,
                $this['request']->cookies->all(),
                $this['request']->files->all(),
                $this['request']->server->all()
            ),
            HttpKernelInterface::SUB_REQUEST
        );
    }

}

/hello/{name} のようなパラメータ付きルーティングに対応するために、パスの置換機能も付けました。

Routingコンポーネントにも同様の機能があるはずですが、探すのが面倒だったのとこれだけの機能のために依存ができるのも嫌なので、自前実装です。

公式のサンプルが前述のようなコードになっていたのも何か理由があると思うのですが、とりあえず元のリクエストから引き継げるものは引き継ぐようにしています。

(まだこの機能の利用経験がなく問題を把握できていないので、理由をご存知の方は指摘してくださると助かります…)

ともあれ、こうすることで利用側のスクリプトはこんな風に書けるようになりました。

<?php
/**
 * デモアプリケーション
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
$app = include __DIR__ . '/../app.php';

use Acme\Application;

use Symfony\Component\HttpFoundation\Request;

// index
$app->get('/', function(Application $app, Request $request) {

    return $app->forward('/home');

});

// ホーム
$app->get('/home', function(Application $app, Request $request) {

    return $app->render('index.html', [
        'title'  => 'ホーム',
    ]);

})->bind('home');

// Hello
$app->get('/hello', function(Application $app, Request $request) {

    return $app->forward('/hello/{name}', ['name' => '名無し']);

})->bind('hello');

// Hello (with name)
$app->get('/hello/{name}', function(Application $app, Request $request, $name) {

    return $app->render('hello.html', [
        'title'  => 'こんにちは',
        'name'   => $name,
    ]);

})->bind('hello-with-name');

$app->run();

そしてテンプレート側、たとえば hello.htmlはこんな感じに。

(TwigでUrlGeneratorも併用している場合)

{% extends 'layout.html' %}

{% block content %}
<div class="content">

<p>こんにちは {{name}} さん</p>

<ul>
  <li><a href="{{app.path('hello-with-name', {name:name})}}">{{name}}さん</a></li>
  <li><a href="{{app.path('hello')}}">こんにちは</a></li>
</ul>

</div>
{% endblock %}

Twigのように、テンプレートから呼ぶ関数やメソッドの引数に配列を渡す書式が定められている場合は良いんですが、そうでない場合は一工夫必要になりますね。

これで、hello-with-nameの方はテンプレート変数 "name" の値がTraitによって実装されたApplicationオブジェクトのpathメソッドに渡されて生成されるパスによって"/hello/{name}"のコントローラが呼ばれ、helloの方は"/hello"コントローラによってパスの変数 name に "名無し" が渡されてサブリクエスト処理されました。

[追記]

公式ドキュメントをよく読んだら、Cookbookに独立したページとして How to make sub-requests - Documentation - Silex がありました。

"Dealing with the request base URL" として項目になってます。(最初にこれを見ていれば…)

In the context of sub-requests this can lead to issues, because if you do not prepend the base path the request could mistake a part of the path you want to match as the base path and cut it off. You can prevent that from happening by always prepending the base path when constructing a request:

<?php
$url = $request->getUriForPath('/');
$subRequest = Request::create($url, 'GET', array(), $request->cookies->all(), array(), $request->server->all());

This is something to be aware of when making sub-requests by hand.

また、そちらのサンプルコードには、セッション変数にも配慮された以下のようなものもありました。

<?php
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpKernelInterface;

$subRequest = Request::create('/', 'GET', array(), $request->cookies->all(), array(), $request->server->all());
if ($request->getSession()) {
    $subRequest->setSession($request->getSession());
}

$response = $app->handle($subRequest, HttpKernelInterface::SUB_REQUEST, false);

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

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を通じて繋がる感じはなかなか面白いです。

マイクロフレームワークをつくろう - Pimpleの上に(Symfony HttpFoundationでフラッシュメッセージとCSRF対策編)

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

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

Symfony HttpFoundation Sessionクラスを導入

昔はユースケース毎に 入力 → 確認(入力内容を表示) → 完了(処理結果を表示) という流れで専用画面を用意することが多かったのですが、最近は確認・完了とも専用画面なし、処理結果は任意の遷移元にリダイレクトして簡単なメッセージのみ表示という実装が増えてます。

ダイレクト先で表示するためのメッセージを定義するためには、いわゆるセッション変数を使うわけですが、すでにSymfony HttpFoundationコンポーネントを導入済みなので、同梱されているSessionクラスを使って実装します。

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 Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage;
use Symfony\Component\HttpFoundation\Session\Storage\Handler\NativeFileSessionHandler;

$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->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->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);
    });

    // セッション開始
    $app->session->start();

    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;

こんな感じで、まずは $app->session でセッションオブジェクトを取得できるようにして、アプリケーションの実行時に自動的にセッションを開始します。

セッション変数はPHPのデフォルト実装と同様にファイルで管理しますが、アプリケーションのルート以下に専用ディレクトリ app/session を作成して、そちらに保存する設定としました。

(広く利用されているSymfonyの標準コンポーネントだけあって、この辺りの設定は利用ケースに合わせて様々なクラスが用意されているようです)

この状態でビルトインWebサーバを起動してアプリケーションを動かすと、確かにセッションファイルが作成されました。

NativeSessionStorageクラスやNativeFileSessionHandlerクラスを覗いてみると、ini_get(), ini_set()やsession関数を利用してPHPのセッション機構を活かす実装がなされています。

NativeSessionStorageではコンストラクタの第1引数でsessionディレクティブの値を指定すればそれが使われて、指定しなければPHPの設定(php.iniとか.htaccess)がそのまま使われるようです。

また、セキュリティのためかsession.use_cookiesが強制的に有効(明示的に無効を指定しない限り)になるほか、クライアントキャッシュ制御用のHTTPヘッダ出力が強制的に無効(Responseクラスとの兼ね合いのため?)にされたり、シャットダウン関数としてsession_write_close()が登録されます。

NativeFileSessionHandlerではコンストラクタの第1引数が指定されていれば session.save_path にそれを設定してくれます。

なので、上記サンプルの場合はPHPの設定を指定したオプションで上書きして、セッションファイルの保存先はアプリケーション個別のディレクトリに変更しています。

(entropy_file=/dev/urandomとしていますがWindowsでは無視され、PHP 5.3.3以降でentropy_lengthに0以外の数値を設定した場合にWindows Random APIが利用されるとのこと。PHPマニュアル

アプリケーションオブジェクトにフラッシュメッセージの追加/取得用クラスを定義

Symfony Sessionクラスには、前述のリダイレクト先で表示するメッセージなど、一時的なデータの保存に最適な FlashBag という仕組みがありますので、これを利用します。

ただ、そのまま $app->session->getFlashBag()->add() とか $app->session->getFlashBag()->get() などと利用側スクリプトに書いてしまうと、せっかくこれまで Symfony HttpFoundation コンポーネントへの依存を隠していたのが台無しになってしまいます。

いつかSymfonyやめて他のライブラリ使いたくなった時にも修正箇所が増えないよう、アプリケーションオブジェクトにフラッシュメッセージの出し入れ専用の関数なりオブジェクトを登録しようと考えましたが、Rendererのようにいちいち専用のインタフェースやクラスを定義するのもちょっと面倒かも…。

というわけで、手抜き用の汎用クラス DataObject を作成しました。

(なんだか怒られそうなクラス名ですが、気にしたら負けです)

src/Acme/DataObject.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 DataObject 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 = array();
        foreach ($attributes as $name => $value) {
            $this->attributes[$name] = $value;
        }
    }

    /**
     * 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)) {
            return null;
        }
        return $this->attributes[$offset];
    }

    /**
     * ArrayAccess::offsetSet()
     *
     * @param mixed
     * @param mixed
     */
    public function offsetSet($offset, $value)
    {
        $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);
    }

}

ほとんど前回の Configuration クラスと同じなんですが、コンストラクタで自動的に配列を入れ子にしないのと、オブジェクト生成後にいくらでもプロパティ(というか要素というか)を追加できるのと、存在しないキーで取得しようとしてもしれっとNULLを返すところが違います。

これを使って、何かインタフェースに則ったクラスのフリをしつつアプリケーションオブジェクト経由でフラッシュメッセージを利用できるようにしてみます。

アプリケーション共通初期処理のコードから抜粋 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\DataObject;

// …中略…

// フラッシュメッセージ
$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->flash->add($name, $message) や $app->flash->has($name) や $app->flash->get($name, $default) といったインタフェースでフラッシュメッセージを利用できます。

また、フラッシュメッセージは取得時にセッション変数から破棄されてしまうため、テンプレートファイルから直接参照した方が良いとの判断から、 $app->flash->addError(), $app->flash->hasError(), $app->flash->getError() といった形のメソッドも実装しています。

(Bootstrapの仕様に合わせるために Error, Alert, Success, Info と関数を定義するのもなんだか微妙な感じはしますが…)

こういう手抜き実装を許してくれるPimpleさんって本当に包容力があって素敵です。

シリーズ記事の最初に書いた「実は自分が惚れたのはSilexではなくて、その母体たるPimpleだった」と考える理由は、まさにこの点に集約されています。

トークンオブジェクトによるCSRF対策

さて、せっかくセッション機構を導入しましたので、この際CSRF対策としてよく利用されるトランザクショントークンも実装してみます。

再びアプリケーション共通初期処理のコードから抜粋

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

// …中略…

// アプリケーション設定オブジェクトを生成
$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->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->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);
    });

    // セッション開始
    $app->session->start();

    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
        );
    }

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

    $response->send();
});

以前、生のセッション変数と独自のトークン処理クラスを使った output_add_rewrite_var()でCSRF対策してみる という記事を書きましたが、こちらはSymfony Sessionを使って似たようなことをやってます。

  • $app->token->name() secret_key設定値を元に生成したトークン名を返す
  • $app->token->value() トークン値(セッションID)を返す
  • $app->token->validate($value) 渡された値とトークン値の検証を行う
  • $app->csrfVerify($method) リクエスト変数から取得した値でトークン値の検証を行う

さらに $app->run() でレスポンス送信前に url_rewriter.tags と output_add_rewrite_var()関数を使ってフォームにCSRFトークン送信用タグを自動出力するように設定しておくことで、アプリケーションオブジェクト利用側ではフォームからのPOST時に $app->csrfVerify('P') とするだけでトークンの検証が実施できるという流れです。

超手抜き実装ですが、CSRF対策としてのトークンにセッションIDを使うのは徳丸本でも推奨されています。

いわゆるワンタイムトークンのような二重送信防止策としては使えませんが、どうせリダイレクトしてしまうので、そちらも問題ないはずです。

(output_add_rewrite_var()とurl_rewriter.tagsでCSRF対策する場合の注意点については、長くなるので記事の最後を見てください)

CSRFトークンの検証とフラッシュメッセージをスクリプトから利用する

アプリケーションオブジェクトへの実装を踏まえて、ページ側のスクリプトはこうなりました。

www/index.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * 投稿フォーム + エラー/例外テスト + CSRFトークン + フラッシュメッセージのテスト
 *
 * @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'),
        'change_secret_key' => $app->findVar('P', 'change_secret_key'),
        'validate_token'    => $app->findVar('P', 'validate_token'),
    );

    // 設定を動的に切り替える
    $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 (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('/');
        }

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

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

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

        // 投稿フォーム処理
        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('index.html', array(
        'title'  => '投稿フォーム',
        'form'   => $form,
        'errors' => $errors,
    ));
});

$app->run();

機能テストのため無駄に長いのが更に伸びてますが、CSRFトークン検証の有効/無効を切替えたり、フラッシュメッセージのテスト用の項目が増えています。

CSRFトークンの検証を確認するため、トークン名の生成に使われる秘密のキーをフォームから変更できるようにしています。

(CSRFトークン検証エラーの際はステータス403でエラー画面を返していますが、一般的にはどうなんでしょうね…)

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

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>

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

今回追加したセッション関連機能のテスト用フォームが追加されてます。

なお、CSRFトークンの出力は最後に行われるのと、フォームにエラーがない場合はリダイレクトしているため、検証の確認には「秘密のキーを変更する」と「検証を有効にする」を一緒にチェックして送信するか、「秘密のキーを変更する」のみチェックを入れて一度入力値エラーを起こしてから「検証を有効にする」をチェックして送信する必要があります。

前述の通り、フラッシュメッセージの取得と表示は共通テンプレートで行います。

変数のスコープ的にも、今後ページを増やすことを考えても、その方が良いでしょう。

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

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

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

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

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

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

$app->flash にくどくどと実装したおかげで、結構すっきり書けました。

$app->renderer でのレンダラ生成時にアプリケーションオブジェクトをアサインしているため、このような記述で参照可能になったのですが…。

アプリケーションオブジェクトはいわばアプリケーションスコープのグローバル変数みたいな大きい存在なので、テンプレート側に $app->escape() などと書く必要がなくなった現状、これに自由にアクセスできるのは良くない気もします。

とりあえず、利用するのは共通レイアウト側だけに留めておいた方が正解かも。

output_add_rewrite_var()とurl_rewriter.tagsでCSRF対策する場合の注意点

output_add_rewrite_var()で要注意なのは、output_add_rewrite_var()でCSRF対策してみる にも書きましたが、url_rewriter.tagsの設定に従って出力される結果は名前、値ともにHTMLエスケープされないことと、無条件で値がパーセントエンコードRFC 3986準拠のrawurlencode()ではなくurlencode()で…)されてしまうことです。

HTMLエスケープされない件は、名前の方は「" /><script>alert("Hello!");</script><input name="PHPSESSID」などといったものを設定しない限り大丈夫だと思いますが、厄介なのは値がパーセントエンコードされてしまうことです。

セッションIDは session.hash_bits_per_character の設定値によって 4の場合は「0-9,a-f」、5の場合は「0-9,a-v」と、パーセントエンコード/HTMLエスケープとも不要な文字で問題ないのですが、6に設定している場合は「0-9, a-z, A-Z, "-", ","」が利用される(PHPマニュアル)ため、","がパーセントエンコードされてしまうのです。

サンプルコードでNativeSessionStorageのコンストラクタに hash_bits_per_character = 5 を指定しているのは、動作確認しているのがWindows環境ということもありますが、この問題を回避するためでもあります。

また、url_rewriter.tags は session.use_trans_sid の動作にも影響しますので、もう機会は少ないと思いますがいわゆる「透過的なセッションID」の機能を利用する場合も要注意です。

(レスポンス出力後にoutput_reset_rewrite_vars()とかurl_rewriter.tagsの書き戻しをやっておいた方が行儀は良いんですが、output_add_rewrite_var()はPHPの出力バッファとして動作するため、めんどくさくなってやめました)

後は、トークンの値にセッションIDをそのまま利用しているので、セッションIDを変更する際(Symfony HttpFoundationのSessionクラスでは migrate()メソッドが利用できるようです)はタイミングに気をつける必要がありますね。

(昔のいわゆる「ケータイサイト」で trans_sid を使ってて、ブラウザキャッシュから戻るとログイン状態が切れてしまうというアホな失敗をやらかしたのを思い出しました…)

とまあ、このように結構罠が多い上に、Ajaxで送信する場合はやっぱり対応できないので、いにしえのスクリプトに手間をかけずCSRF対策しなきゃいけないようなケースを除き、この方法で手を抜くのは止めておいた方が良さそうです。というオチでした…。

さて、次はそろそろ、これまでずっと無視してきた「URLルーティング」の問題に取り組もうと思います。

マイクロフレームワークをつくろう - 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コードになるので、それに比べるとまあ…という感じですね。

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