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

k-holyのPHPとか諸々メモ

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

マイクロフレームワークをつくろう - Pimpleの上に(Monolog導入・エラーログとスタックトレース出力編)

PHP PHPTAL CMOFW

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

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

Monologの導入とPSR-3のログレベルについて

これまで set_error_handler() 関数で登録したカスタムエラーハンドラではNOTICE以下のエラーも含め全てのエラーをErrorExceptionに変換、そのままスローしていただけでした。

さすがにそれでは実用環境には使えないので、error_reporting設定値に含まれていなければ例外はスローせずログのみ記録して処理を続行するように変更します。

この際、ロガーのインタフェースを定めた規格 PSR-3 に準拠しているという Monolog を導入してみます。

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",
        "monolog/monolog": ">=1.5,<1.5x.x-dev",
        "symfony/http-foundation": ">=2.3,<2.4-dev",
        "phptal/phptal": "dev-master",
        "volcanus/routing": "dev-master"
    }
}

READMEによれば、Monologでは RFC 5424 The Syslog Protocol に定義された8つのログレベル (DEBUG, INFO, NOTICE, WARNING, ERROR, CRITICAL, ALERT, EMERGENCY) に対応しているそうです。

PSR-3にも同じものが定義されているのですが、なぜか「特にsyslog互換が必要なければ DEBUG, INFO, WARNING, ERROR, CRITICAL, ALERT だけを使えばいい」ともあります。

どちらにしても使い分けの基準が難しいのですが、参考のためPSR-3のインタフェースのコメントを見てみます。

システムは使用不可能です。

  • ALERT

    Action must be taken immediately. Example: Entire website down, database unavailable, etc. This should trigger the SMS alerts and wake you up.

処置は直ちに講じられなければなりません。

例:全ウェブサイトが落ちている、データベース利用不可能、など。これはSMSアラートを引き起こし、あなたを起こすべきです。

  • CRITICAL

    Critical conditions. Example: Application component unavailable, unexpected exception.

危篤。

例:利用不可能なアプリケーション構成要素、予期しない例外。

  • ERROR

    Runtime errors that do not require immediate action but should typically be logged and monitored.

緊急行動を必要としないが、典型的に記録されモニターされるべきランタイムエラー。

  • WARNING

    Exceptional occurrences that are not errors. Example: Use of deprecated APIs, poor use of an API, undesirable things that are not necessarily wrong.

エラーでない例外的な発生。

例:推奨されていないAPIの利用、APIのまずい利用、必ずしも間違っていない不適当なもの。

  • NOTICE

    Normal but significant events.

正常であるが意味ありげなイベント。

  • INFO

    Interesting events.

興味深いイベント。

  • DEBUG

    Detailed debug information.

詳細なデバッグ情報。

フレームワークのエラーハンドラ/例外ハンドラで扱うログレベルは、分かりやすく以下のように考えました。

(カスタムエラーハンドラで扱えない E_ERROR, E_PARSE, E_CORE_ERROR, E_CORE_WARNING, E_COMPILE_ERROR, E_COMPILE_WARNING は除外しています)

  • CRITICAL … RuntimeException, LogicException
  • ERROR … E_RECOVERABLE_ERROR, E_USER_ERROR
  • WARNING … E_WARNING, E_USER_WARNING
  • NOTICE … E_NOTICE, E_USER_NOTICE
  • INFO … E_STRICT, E_DEPRECATED, E_USER_DEPRECATED

ErrorException に変換されたPHPエラーについては、「深刻度」を取得するための getSeverity() メソッドが用意されているので、これでエラーレベルを見て決定します。

(これまでエラーレベルを例外コードに当ててたんですが、こちらに当てるべきでした…)

カスタムエラーハンドラとカスタム例外ハンドラの処理をクラス化

カスタムエラーハンドラでは、エラー情報(エラーレベル、エラーメッセージ、発生ファイル、行番号)のほか、スタックトレースを取得して自分で加工する必要があります。

また、エラーハンドラや例外ハンドラには必ずロギングやエラー表示の問題がつきまとうため、汎用化しようとするほど複雑化して見通しが悪くなってくるわけです。(そこを設計するのがフレームワーク開発なんでしょうけど…)

これまでの経験からも、エラーハンドラをクラス化するよりも単純にエラー情報やスタックトレースの加工を個別にクラス化した方が使い回しが効いて良いように感じました。

そういうわけで今回はエラーのロギングに関しては、エラーフォーマッタ、例外フォーマッタ、トレースフォーマッタ、の3本立てでクラスを作成して、ロギングを行うクラスへの依存は例のごとくアプリケーションオブジェクトの関数に閉じ込める方針としました。

エラー情報をメッセージに書式化する、エラーフォーマッタ。

src/Acme/Error/ErrorFormatter.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\Error;

/**
 * エラーフォーマッタ
 *
 * @author k.holy74@gmail.com
 */
class ErrorFormatter
{

    /* @var array PHPエラーレベル */
    private static $errorLevels = array(
        E_ERROR             => 'Fatal error',
        E_WARNING           => 'Warning',
        E_NOTICE            => 'Notice',
        E_STRICT            => 'Strict standards',
        E_RECOVERABLE_ERROR => 'Catchable fatal error',
        E_DEPRECATED        => 'Depricated',
        E_USER_ERROR        => 'User Fatal error',
        E_USER_WARNING      => 'User Warning',
        E_USER_NOTICE       => 'User Notice',
        E_USER_DEPRECATED   => 'User Depricated',
    );

    /**
     * エラー情報を文字列に整形して返します。
     *
     * @param int エラーレベル
     * @param string エラーメッセージ
     * @param string エラー発生元ファイル
     * @param string エラー発生元ファイルの行番号
     * @return string
     */
    public function format($errno, $errstr, $errfile, $errline)
    {
        return sprintf("%s: '%s' in %s on line %u",
            (isset(static::$errorLevels[$errno]))
                ? static::$errorLevels[$errno]
                : 'Unknown error',
            $errstr,
            $errfile,
            $errline
        );
    }

}

例外オブジェクトをメッセージに書式化する、例外フォーマッタ。

src/Acme/Error/ExceptionFormatter.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\Error;

/**
 * 例外フォーマッタ
 *
 * @author k.holy74@gmail.com
 */
class ExceptionFormatter
{

    /**
     * 例外オブジェクトを文字列に整形して返します。
     *
     * @param \Exception 例外オブジェクト
     * @return string
     */
    public function format(\Exception $e)
    {
        return sprintf("%s[%d]: '%s' in %s on line %u",
            get_class($e),
            $e->getCode(),
            $e->getMessage(),
            $e->getFile(),
            $e->getLine()
        );
    }

}

スタックトレースの情報を書式化する、トレースフォーマッタ。

src/Acme/Error/TraceFormatter.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\Error;

/**
 * トレースフォーマッタ
 *
 * @author k.holy74@gmail.com
 */
class TraceFormatter implements TraceFormatterInterface
{

    /**
     * スタックトレースを文字列に整形して返します。
     *
     * @param array スタックトレース
     * @return string
     */
    public function arrayToString(array $stackTrace)
    {
        $results = array();
        foreach ($stackTrace as $trace) {
            $results[] = $this->format($trace);
        }
        return (count($results) >= 1)
            ? sprintf("\nStack trace:\n%s", implode("\n", $results))
            : '';
    }

    /**
     * 1レコード分のトレースを文字列に整形して返します。
     *
     * @param array 1レコード分のトレース
     * @return string
     */
    public function format(array $trace)
    {
        return sprintf('%s: %s(%s)',
            $this->formatLocation(
                isset($trace['file']) ? $trace['file'] : null,
                isset($trace['line']) ? $trace['line'] : null
            ),
            $this->formatFunction(
                isset($trace['class']) ? $trace['class'] : null,
                isset($trace['type']) ? $trace['type'] : null,
                isset($trace['function']) ? $trace['function'] : null
            ),
            $this->formatArguments(
                isset($trace['args']) ? $trace['args'] : null
            )
        );
    }

    /**
     * トレースのファイル情報を文字列に整形して返します。
     *
     * @param string ファイルパス
     * @param string 行番号
     * @return string
     */
    public function formatLocation($file, $line)
    {
        return (isset($file) && isset($line))
            ? sprintf('%s(%d)', $file, $line)
            : '[internal function]';
    }

    /**
     * トレースの関数呼び出し情報を文字列に整形して返します。
     *
     * @param string クラス名
     * @param string 呼び出し種別
     * @param string 関数名/メソッド名
     * @return string
     */
    public function formatFunction($class, $type, $function)
    {
        return sprintf('%s%s%s', $class ?: '', $type ?: '', $function ?: '');
    }

    /**
     * トレースの関数呼び出しの引数を文字列に整形して返します。
     *
     * @param array  引数の配列
     * @return string
     */
    public function formatArguments($arguments)
    {
        if (!isset($arguments) || empty($arguments)) {
            return '';
        }
        $self = $this;
        return implode(', ', array_map(function($arg) use ($self) {
            if (is_array($arg)) {
                $vars = array();
                foreach ($arg as $key => $var) {
                    $vars[] = sprintf('%s=>%s',
                        $self->formatVar($key),
                        $self->formatVar($var)
                    );
                }
                return sprintf('[%s]', implode(', ', $vars));
            }
            return $self->formatVar($arg);
        }, $arguments));
    }

    /**
     * 変数の型の文字列表現を返します。
     *
     * @param mixed
     * @return string
     */
    private function formatVar($var)
    {
        if (is_null($var)) {
            return 'NULL';
        }

        if (is_int($var)) {
            return sprintf('Int(%d)', $var);
        }

        if (is_float($var)) {
            return sprintf('Float(%F)', $var);
        }

        if (is_string($var)) {
            return sprintf("'%s'", $var);
        }

        if (is_bool($var)) {
            return sprintf('Bool(%s)', $var ? 'true' : 'false');
        }

        if (is_array($var)) {
            return 'Array';
        }

        if (is_object($var)) {
            return sprintf('Object(%s)', get_class($var));
        }

        return sprintf('%s', gettype($var));
    }

}

インタフェース TraceFormatterInterface を実装しているのは、次のスタックトレースイテレータがこのクラスに依存するので念のため。

実装内容はエラーハンドラ兼例外ハンドラ用クラスとして以前に作成した Volcanus_Error のコードを TraceFormatterInterface に合う形に変えて移植したものです。

繰り返されるisset()など面倒な部分を押し込めた結果、PHPが返すスタックトレースの配列(file, line, class, type, function, args)に依存する形となっているのが難点ですが…。

また今回初めての試みとして、エラー画面のテンプレートで柔軟にデザイン可能なスタックトレース出力をどうやって実現するかを考えて、素直にイテレータにしてみました。

src/Acme/Error/StackTraceIterator.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\Error;

/**
 * スタックトレースイテレータ
 *
 * @author k.holy74@gmail.com
 */
class StackTraceIterator implements \Iterator, \Countable
{

    /**
     * @var array スタックトレース
     */
    private $stackTrace;

    /**
     * @var TraceFormatterInterface トレースフォーマッタ
     */
    private $formatter;

    /**
     * @var int 現在のイテレーション位置
     */
    private $position;

    /**
     * コンストラクタ
     *
     * @param TraceFormatterInterface トレースフォーマッタ
     */
    public function __construct(TraceFormatterInterface $formatter)
    {
        $this->formatter = $formatter;
    }

    /**
     * オブジェクトを初期化します。
     *
     * @param array スタックトレース
     * @return $this
     */
    public function initialize(array $stackTrace = array())
    {
        $this->position = 0;
        if (!empty($stackTrace)) {
            $this->stackTrace = $stackTrace;
        }
        return $this;
    }

    /**
     * Iterator::rewind()
     */
    public function rewind()
    {
        $this->position = 0;
    }

    /**
     * Iterator::current()
     *
     * @return array
     */
    public function current()
    {
        $trace = $this->stackTrace[$this->position];
        return array(
            'index' => $this->position,
            'location' => $this->formatter->formatLocation(
                isset($trace['file']) ? $trace['file'] : null,
                isset($trace['line']) ? $trace['line'] : null
            ),
            'function' => $this->formatter->formatFunction(
                isset($trace['class']) ? $trace['class'] : null,
                isset($trace['type']) ? $trace['type'] : null,
                isset($trace['function']) ? $trace['function'] : null
            ),
            'argument' => $this->formatter->formatArguments(
                isset($trace['args']) ? $trace['args'] : null
            ),
        );
    }

    /**
     * Iterator::key()
     */
    public function key()
    {
        return $this->position;
    }

    /**
     * Iterator::next()
     */
    public function next()
    {
        $this->position++;
    }

    /**
     * Iterator::valid()
     *
     * @return bool
     */
    public function valid()
    {
        return isset($this->stackTrace[$this->position]);
    }

    /**
     * Countable::count()
     *
     * @return int
     */
    public function count()
    {
        return count($this->stackTrace);
    }

    /**
     * スタックトレースを文字列に整形して返します。
     *
     * @return string
     */
    public function __toString()
    {
        $stackTrace = array();
        foreach ($this->stackTrace as $index => $trace) {
            $stackTrace[] = sprintf('#%d %s', $index, $this->formatter->format($trace));
        }
        return implode("\n", $stackTrace);
    }

}

こちらもPHPが返すスタックトレースの配列(file, line, class, type, function, args)に依存する形となってしまってますが、やりたい事をそのままクラスにしました。

スタックトレースを引数にこのオブジェクトを生成すれば、普通にイテレーションして index, location, function, argument の各要素に簡略化されたスタックトレースの情報が文字列で取得できるようになります。

イテレーションする必要がなければ、文字列にキャストすればpre要素などにそのまま出力できます。

(PHPTALテンプレートではテンプレート変数に割り当てたオブジェクトが __toString()を実装していれば、そのまま文字列として出力してくれました)

アプリケーション共通初期処理および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\Error\ErrorFormatter;
use Acme\Error\ExceptionFormatter;
use Acme\Error\TraceFormatter;
use Acme\Error\StackTraceIterator;

use Acme\Renderer\PhpTalRenderer;

use Monolog\Logger;
use Monolog\Handler\StreamHandler;

$app = new Application();

//-----------------------------------------------------------------------------
// アプリケーション設定オブジェクトを生成
//-----------------------------------------------------------------------------
$app->config = $app->share(function(Application $app) {
    $config = new Configuration(array(
        'debug'      => true,
        'app_id'     => 'acme',
        '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->logger = $app->share(function(Application $app) {
    $app->logHandler = function() use ($app) {
        return new StreamHandler(
            $app->config->log_dir . DIRECTORY_SEPARATOR . $app->config->log_file,
            ($app->config->debug) ? Logger::DEBUG : Logger::NOTICE
        );
    };
    $logger = new Logger($app->config->app_id);
    $logger->pushHandler($app->logHandler);
    return $logger;
});

//-----------------------------------------------------------------------------
// ログ
//-----------------------------------------------------------------------------
$app->log = $app->protect(function($message, $level) use ($app) {
    return $app->logger->addRecord($level ?: Logger::INFO, $message);
});

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

//-----------------------------------------------------------------------------
// エラーフォーマッタ
//-----------------------------------------------------------------------------
$app->errorFormatter = $app->share(function(Application $app) {
    return new ErrorFormatter();
});

//-----------------------------------------------------------------------------
// 例外フォーマッタ
//-----------------------------------------------------------------------------
$app->exceptionFormatter = $app->share(function(Application $app) {
    return new ExceptionFormatter();
});

//-----------------------------------------------------------------------------
// トレースフォーマッタ
//-----------------------------------------------------------------------------
$app->traceFormatter = $app->share(function(Application $app) {
    return new TraceFormatter();
});

//-----------------------------------------------------------------------------
// スタックトレースイテレータ
//-----------------------------------------------------------------------------
$app->stackTrace = $app->share(function(Application $app) {
    return new StackTraceIterator($app->traceFormatter);
});

//-----------------------------------------------------------------------------
// エラーログ
//-----------------------------------------------------------------------------
$app->logError = $app->protect(function($level, $message, $file, $line) use ($app) {
    $app->log(
        $app->errorFormatter->format($level, $message, $file, $line),
        $app->errorLevelToLogLevel($level)
    );
});

//-----------------------------------------------------------------------------
// 例外ログ
//-----------------------------------------------------------------------------
$app->logException = $app->protect(function(\Exception $e) use ($app) {
    $app->log(
        $app->exceptionFormatter->format($e),
        ($e instanceof \ErrorException)
            ? $app->errorLevelToLogLevel($e->getSeverity())
            : Logger::CRITICAL
    );
});

//-----------------------------------------------------------------------------
// エラーレベルをログレベルに変換
//-----------------------------------------------------------------------------
$app->errorLevelToLogLevel = $app->protect(function($level) {
    switch ($level) {
    case E_USER_ERROR:
    case E_RECOVERABLE_ERROR:
        return Logger::ERROR;
    case E_WARNING:
    case E_USER_WARNING:
        return Logger::WARNING;
    case E_NOTICE:
    case E_USER_NOTICE:
        return Logger::NOTICE;
    case E_STRICT:
    case E_DEPRECATED:
    case E_USER_DEPRECATED:
    default:
        break;
    }
    return Logger::INFO;
});

//-----------------------------------------------------------------------------
// アプリケーション初期処理
//-----------------------------------------------------------------------------
$app->registerEvent('init');
$app->addHandler('init', function(Application $app) {

    // エラーハンドラを登録
    set_error_handler(function($errno, $errstr, $errfile, $errline) use ($app) {
        if (error_reporting() & $errno) {
            throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
        }
        $app->logError($errno, $errstr, $errfile, $errline);
        return true;
    });

    // 例外ハンドラを登録
    set_exception_handler(function(\Exception $e) use ($app) {
        $app->logException($e);
        echo $app->errorView($e, null, $e->getMessage());
    });

});

return $app;

コメントの形式も変更したため、行数はかなり増えましたが、やってることは単純です。

Monologのインスタンスを返す $app->logger や それを使ってロギングを行う $app->log() 関数。

独自に作成したエラーフォーマッタ、例外フォーマッタ、トレースフォーマッタ、スタックトレースイテレータをそれぞれ返す関数。

エラーレベルをログレベルに変換する $app->errorLevelToLogLevel() 関数。

更に、それらを使ってエラー情報をロギングする $app->logError() や、例外オブジェクトをロギングする $app->logException() が追加されています。

ログにスタックトレースを含めるかどうかは悩みどころですが、今回は除外しました。

また、エラーページのテンプレート変数 stackTrace の中身が、文字列化した例外のスタックトレースから、スタックトレースイテレータに変わってます。

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 Monolog\Logger;

// …中略…

//-----------------------------------------------------------------------------
// エラーページ用レスポンスを生成
//-----------------------------------------------------------------------------
$app->error = $app->protect(function(\Exception $exception) use ($app) {
    $statusCode = 500;
    $headers = array();
    $title = null;
    $message = null;
    if ($exception instanceof HttpException) {
        $statusCode = $exception->getCode();
        $headers = $exception->getHeaders();
        $message = $exception->getMessage();
        $title = $exception->getReasonPhrase();
    }
    return new Response(
        $app->errorView($exception, $title, $message),
        $statusCode,
        $headers
    );
});

// …中略…

//-----------------------------------------------------------------------------
// アプリケーション実行
//-----------------------------------------------------------------------------
$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->logException($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;

今回の更新内容とは直接の関係はないですが、例外を元にエラーページのレスポンスを生成する $app->error() の不要な引数を削除して、HTTP例外の場合はエラーページのタイトルは「500 Internal Server Error」といったHTTPステータスの説明句(Status Code and Reason Phrase)に固定しました。

(多くのWebサイトでHTTPエラー画面がおおむねそうなっていることと、任意のHTTP例外を発生させる $app->abort() との兼ね合いを考えて変えました)

また、$app->run() で例外をキャッチしてエラー画面のレスポンスを返す前にエラーログを取るところが変わってます。

エラー画面はレイアウトテンプレートの利用をやめて、ナビゲーションはトップページへのリンクのみにしました。

(レイアウトテンプレートとの記述の重複が発生しますが、レイアウト側で考慮すべきことや制御構文が増える方が面倒だなと…)

www/error.html

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

<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" />
<link rel="stylesheet" href="/css/error.css" tal:condition="config/debug" />
<title tal:content="title|default">エラー</title>
</head>

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

<div class="container-fluid">

  <header class="header">
    <h1 tal:content="title|default">エラー</h1>
  </header>

  <div class="content">

    <div class="hero-unit">
      <h2><span class="icon-warning-sign"></span><span tal:replace="message|default">システムエラーが発生しました</span></h2>
    </div>

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

      <div class="row-fluid">
        <h3>
          <span tal:replace="exception_class">RuntimeException</span> [<span tal:replace="exception/getCode">0</span>]
          &quot;<strong tal:content="exception/getMessage">例外メッセージ...</strong>&quot;
        </h3>
        <p><strong tal:content="exception/getFile">例外発生ファイル...</strong>(<strong tal:content="exception/getLine">例外発生行番号...</strong>)</p>
      </div>

      <div class="row-fluid" tal:condition="stackTrace/count">
        <table class="table table-condensed table-bordered">
          <caption><h3>StackTrace</h3></caption>
          <thead>
            <tr>
              <th>#</th>
              <th>Location: Function</th>
            </tr>
          </thead>
          <tbody>
            <tr tal:repeat="trace stackTrace">
              <td tal:content="trace/index">0</td>
              <td>
                <pre><span tal:replace="trace/location">/path/to/file(999)</span>:</pre>
                <code><span tal:replace="trace/function">function</span>(<span tal:replace="trace/argument">'argument'</span>)</code>
              </td>
            </tr>
          </tbody>
        </table>
      </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>

大きく変わったのがスタックトレースの表示で、単に Exception::getTraceAsString() の結果をpre要素で表示していたのが、スタックトレースイテレータ化によってテーブルで表示可能となりました。

このページ専用のスタイルシートを読み込んでますが、定義されているのはここを含めたデバッグ表示部分だけです。

(元のBootstrapのデフォルトスタイルを前提として、不要な背景色を透過指定に変えたり、不要な線を消したり、間隔を狭めたりといった地味な修正ですが)

他にも、タイトルやメッセージ部分で値がない(HttpException以外の例外がスローされた場合など)場合に、テンプレートに記載している要素の内容をそのまま表示するように変えています。

テスト画面でエラーハンドラや例外ハンドラの動作確認

エラーハンドラや例外ハンドラが意図した通りに動作しているかどうか、テスト画面で確認できるようにします。

テスト画面のページスクリプト。

www/error.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'),
        'ignore_error'      => $app->findVar('P', 'ignore_error'),
        'change_secret_key' => $app->findVar('P', 'change_secret_key'),
        'validate_token'    => $app->findVar('P', 'validate_token'),
    );

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

        // …中略…

        // error_reporting設定を変更
        if (isset($form['ignore_error'])) {
            switch ($form['ignore_error']) {
            // Error以下を無視
            case 'error':
                $error_reporting = error_reporting();
                if ($error_reporting & E_ERROR) {
                    $error_reporting = error_reporting($error_reporting &~E_ERROR);
                }
                if ($error_reporting & E_USER_ERROR) {
                    $error_reporting = error_reporting($error_reporting &~E_USER_ERROR);
                }
            // Warning以下を無視
            case 'warning':
                $error_reporting = error_reporting();
                if ($error_reporting & E_WARNING) {
                    $error_reporting = error_reporting($error_reporting &~E_WARNING);
                }
                if ($error_reporting & E_USER_WARNING) {
                    $error_reporting = error_reporting($error_reporting &~E_USER_WARNING);
                }
            // Notice以下を無視
            case 'notice':
                $error_reporting = error_reporting();
                if ($error_reporting & E_NOTICE) {
                    $error_reporting = error_reporting($error_reporting &~E_NOTICE);
                }
                if ($error_reporting & E_USER_NOTICE) {
                    $error_reporting = error_reporting($error_reporting &~E_USER_NOTICE);
                }
            // Info以下を無視
            case 'info':
                $error_reporting = error_reporting();
                if ($error_reporting & E_STRICT) {
                    $error_reporting = error_reporting($error_reporting &~E_STRICT);
                }
                if ($error_reporting & E_DEPRECATED) {
                    $error_reporting = error_reporting($error_reporting &~E_DEPRECATED);
                }
                if ($error_reporting & E_USER_DEPRECATED) {
                    $error_reporting = error_reporting($error_reporting &~E_USER_DEPRECATED);
                }
                break;
            }
        }

        // …中略…

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

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

        // …中略…

    }

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

$app->run();

フォームから選択された 'error', 'warning', 'notice', 'info' のいずれかの ignore_error 値が来た場合に、現在の error_reporting 設定値を見て、それぞれ含まれていれば無効に設定します。

カスタムエラーハンドラは設定値に関わらず呼ばれますが、無効に設定されているレベルのエラーはログのみ記録され、そのまま処理を続行します。

そうでなければ、ErrorException に変換してスローされた結果、エラーログに例外のログが記録され、エラー画面が出力されるという流れです。

テスト画面のテンプレート。

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>
        <dl class="control-group">
          <dt class="control-label">エラー設定</dt>
          <dd class="controls">
            <label class="radio">
              <input type="radio" name="ignore_error" value="info" tal:attributes="checked php:form['ignore_error']=='info'" />
              Info以下を無視
            </label>
            <label class="radio">
              <input type="radio" name="ignore_error" value="notice" tal:attributes="checked php:form['ignore_error']=='notice'" />
              Notice以下を無視
            </label>
            <label class="radio">
              <input type="radio" name="ignore_error" value="warning" tal:attributes="checked php:form['ignore_error']=='warning'" />
              Warning以下を無視
            </label>
            <label class="radio">
              <input type="radio" name="ignore_error" value="error" tal:attributes="checked php:form['ignore_error']=='error'" />
              Error以下を無視
            </label>
          </dd>
        </dl>
        <div class="form-actions">
          <p>
            <input type="submit" value="PHPエラー(Info)" class="btn btn-primary btn-warning" name="trigger-info" />
            <input type="submit" value="PHPエラー(Notice)" class="btn btn-primary btn-warning" name="trigger-notice" />
            <input type="submit" value="PHPエラー(Warning)" class="btn btn-primary btn-warning" name="trigger-warning" />
            <input type="submit" value="PHPエラー(Error)" class="btn btn-primary btn-warning" name="trigger-error" />
          </p>
          <p>
            <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" />
          </p>
        </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>

今回、初めてラジオボタンが加わりました。

tal:attributes="checked php:form['ignore_error']=='info'" の部分ですが、こういう値の持ち方(いたって普通なはずですが)だとあまり綺麗にはなりませんね…。

tal:attributes="checked form/ignore_error/is/info"(単一選択)や tal:attributes="checked form/ignore_error/contains/info"(複数選択)のように記述できれば見た目綺麗ですが、フォーム変数をオブジェクトにしてトリッキーな仕組みにしないと難しそう。

ともあれ、きちんと意図した通りにエラーログが記録されたり、エラー画面が表示されたりするのを確認できました。

今回はMonologの導入やエラー関連クラスの作成で、アプリケーションオブジェクトにもかなり変更がありましたが、ログやエラーは表に出ない部分なので地味な作業でした。

そろそろ、何の機能もなかった「投稿フォーム」の中身に手を付けようと思います。