ぼくのかんがえたさいきょうのPHPエラーハンドリング

## やりたいこと

* Fatal Errorはログを吐いてエラーページを表示したい
* 誰にもcatchされなかった例外も、ログを吐いてエラーページを表示したい
* 処理が続行可能なエラーはログだけ吐いて処理を続けたい
* ＠演算子でエラーを抑制している場合は、ログに残らないようにしたい
* エラーハンドラが宣言される前におきた処理続行可能なエラーも、後からログに残しておきたい

## 注意事項

PHP7未満ではエラーハンドリングの動作が大きく違うので、注意が必要です。

## Fatal Errorが発生したら

Fatal Errorが発生したら、PHPは処理を続けることができないので、処理をシャットダウンしにかかります。このとき、事前に`register_shutdown_function()`で終了処理を登録しておくことで最後のひと足掻きができます。終了処理内でエラーを拾って、ログ出力とエラーページの表示を行います。

こんなイメージ

~~~php
<?php
register_shutdown_function(function(){
    $lastError = error_get_last();
    putLog($lastError);
    if (isFatal($lastError['type'])) {
        showErrorPage();
    }
});
~~~

## 例外が誰にもキャッチされなかったら

throwした例外が誰にもcatchされなかった場合は、最終的に`set_exception_handler()`で登録しておいた例外ハンドラに拾われます。なので、ここに終了処理を書いておけばOKです。

こんなイメージ

~~~php
<?php
set_exception_handler(function(Throwable $throwable){
    putLog($throwable);
    showErrorPage();
});
~~~

## エラーが発生したがFatalではなかった場合

エラーが発生した場合、事前に`set_error_handler()`でエラーハンドラを登録しておけば、エラー処理を行うことができます。Fatalではないエラーの場合、エラー処理が完了すると、プログラムは元に戻って処理を続行します。エラーハンドラがfalse以外を返した場合は、PHP標準のエラー処理は完全にバイパスされます。

こんなイメージ

~~~php
<?php
set_error_handler(function($severity, $message, $file, $line){
    putLog($severity, $message, $file, $line);
    return true;
});
~~~

## エラーが発生したが＠演算子でエラーが抑制されていた場合

＠演算子でエラーを抑制していても、`set_error_handler()`でエラーハンドラが登録されていた場合は、エラー処理が実行されます。＠演算子に頼らないのが一番ですが、エラーログを吐かないようにあえて＠演算子をつけたい場合もないわけではありません。
そこで、エラーハンドラ内で＠演算子がついている場合はログを吐かないように制御します。これには`error_reporting()`関数を使います。＠演算子が効いている間はこの関数の戻り値が0になります。

こんなイメージ

~~~php
<?php
set_error_handler(function($severity, $message, $file, $line){
    if (error_reporting()) {
        putLog($severity, $message, $file, $line);
    }
    return true;
});
~~~

## エラーハンドラが宣言される前にエラーがおきた場合

`error_get_last()`関数を使うことで、最後におきたエラーを取得することができます。エラーハンドラを宣言するときについでにこの関数を呼ぶことで、確実にエラーログに残せるようになります。

## 全部を網羅したさいきょうのエラーハンドラ

~~~php
<?php

class ErrorHandler
{
    private $lastMessage = '';

    /**
     * ErrorHandler constructor.
     */
    public function __construct()
    {
        // エラーハンドラが宣言される前にエラーが起こっていたらログに残す
        $lastError = error_get_last();
        if ($lastError) {
            $logMessage = self::buildMessageForPHPError($lastError);
            $this->putLog($lastError['type'], $logMessage, $lastError['file'], $lastError['line']);
            $this->lastMessage = $lastError['message'];
        }

        set_exception_handler([$this, 'exceptionHandler']);
        set_error_handler([$this, 'errorHandler']);
        register_shutdown_function([$this, 'onShutdown']);
    }

    /**
     * ここに来る時は処理続行不可能なので、
     * エラーログを吐いてエラーページを表示する
     *
     * @param Throwable $throwable
     */
    public function exceptionHandler(Throwable $throwable)
    {
        $logMessage = self::buildMessageForThrowable($throwable);
        // ここの$severityはログの出力の判定に使うイメージ
        // 例外は処理続行できないので、ログ出力のレベルはE_ERRORと同等
        $this->putLog(E_ERROR, $logMessage, $throwable->getFile(), $throwable->getLine());

        $this->lastMessage = $throwable->getMessage();
        $this->showErrorPage();
    }

    /**
     * @param int $severity
     * @param string $message
     * @param string $file
     * @param int $line
     * @return bool
     * @throws ErrorException
     */
    public function errorHandler($severity, $message, $file, $line)
    {
        if (self::isFatal($severity)) {
            // FATAL ERRORは処理の続行が不可能なので、exceptionHandler()に処理を引き継ぐ
            // ただし、おそらくここを通ることはなく、
            // FATAL ERRORが発生する場合は直接onShutdownに行くと思われる
            throw new ErrorException($message, 0, $severity, $file, $line);
        }

        // @演算子でエラーを抑制している場合は error_reporting() の返り値が0になるので、エラーを出力しない
        if (error_reporting()) {
            $logMessage = self::buildMessageForPHPError(
                ['type' => $severity, 'message' => $message, 'file' => $file, 'line' => $line]
            );
            $this->putLog($severity, $logMessage, $file, $line);
        }

        // @演算子でエラーを抑制した場合でも
        // Shutdownハンドラ内で error_get_last() を呼び出すとエラーが取れてしまう。
        // その場合にエラーレポートしないよう、 @演算子の有無にかかわらず$messageは保存しておく
        $this->lastMessage = $message;

        // trueを返すとPHPのエラーハンドラはバイパスされる
        return true;
    }

    /**
     * @return void
     */
    public function onShutdown()
    {
        $lastError = error_get_last();

        if (empty($lastError)) {
            return;
        }
        if ($this->lastMessage == $lastError['message']) {
            // すでにこのエラーはログ出力済みなので何もしない
            return;
        }

        $logMessage = self::buildMessageForPHPError($lastError);
        $this->putLog($lastError['type'], $logMessage, $lastError['file'], $lastError['line']);

        // 処理続行不可能なE_ERRORが発生して、errorHandler()が拾えなかった場合
        // ここにくる
        if (self::isFatal($lastError['type'])) {
            $this->showErrorPage();
        }
    }

    /**
     * @return void
     */
    private function showErrorPage()
    {
        //バッファをOFFにする
        while (ob_get_level() > 0) {
            ob_end_flush();
        }
        /** @noinspection PhpIncludeInspection */
        require '../public/500.php';
    }

    /**
     * @param int $severity
     * @param string $message
     * @param string $file
     * @param int $line
     * @return void
     */
    private function putLog($severity, $message, $file, $line)
    {
        // TODO ログ出力
    }

    /**
     * プログラムの実行が中断される重大なエラーかどうか
     *
     * @param int $severity
     * @return bool
     */
    private static function isFatal($severity)
    {
        return boolval($severity & (E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR));
    }

    /**
     * @param array $errorDetail
     * @return string
     */
    public static function buildMessageForPHPError($errorDetail)
    {
        $message = ''; // TODO エラーメッセージを組み立てる
        return $message;
    }

    /**
     * @param Throwable $throwable
     * @return string
     */
    public static function buildMessageForThrowable($throwable)
    {
        $message = ''; // TODO エラーメッセージを組み立てる
        return $message;
    }
}

~~~