キャッチされなかった例外をHTML整形して表示する


# **前書き**

オレオレフレームワークを使った開発やノンフレームワークな開発してるときのこと。例外が発生してそれをキャッチしなかったときは

```
Fatal Error: Uncaught Exception 'Exception' with message ...
```

このようなエラーメッセージが出て強制終了されてしまいますよね。全ての例外をキャッチして正しくHTMLを表示させるのが原則ですが、毎回毎回こういった対応するのも面倒な話です。そこで、これを自動化させてみようと考えました。

# **基本**

## **コーディングポリシー**

多少冗長な書き方になってもいいので、以下の原則を遵守することにします。

- グローバル空間を変数や関数で汚してはならない
- 他のファイルに依存するコードを書いてはならない
- あらゆる可能性を想定してハンドラ内部でのエラーや例外を徹底的に防止する
- デバッグ時とリリース時で表示レベルを切り分けられるようにする

## **前提条件**

### **バッファリングを行う**

途中で出力した文字列を後から消去してきれいに表示させたいので、バッファリングを行っていることが条件となります。

```php
ob_start();
```

バッファリングが行われなかった場合の挙動は未定義です。

### **PHPのバージョンが5.3以降である**

以下のものはこのバージョン以降でしかサポートされていません。

- **[無名関数(クロージャ)](http://www.php.net/manual/ja/functions.anonymous.php)**
- **[Exception::getPrevious](http://www.php.net/manual/ja/exception.getprevious.php)** メソッド
- **[header_remove](http://www.php.net/manual/ja/function.header-remove.php)** 関数

## **HTTPステータスコード**

### **デバッグ時**

- 常に `500` **Internal Server Error**

### **リリース時**

- 表示可能なメッセージがあれば `400` **Bad Request**
- 表示可能なメッセージがなければ `500` **Internal Server Error**

## **表示レベル**

### **デバッグ時**

- 全ての例外に関して、発生した行やファイルといった情報も含めて詳細に表示する

### **リリース時**

#### 表示しないもの

- PHPコアによって自動生成された例外
- エクステンションによって自動生成された例外
- **[LogicException](http://php.net/manual/ja/class.logicexception.php)** 例外とその継承例外
- **[ErrorException](http://php.net/manual/ja/class.errorexception.php)** 例外とその継承例外

#### 表示するもの

- プログラマによって生成された、上記に該当しない全ての例外

## **動作のカスタム**

### **定数 `DISPLAY_PRIVATE_ERROR` の定義**

これが `True` として評価可能な値で定義されているとき、開発者向けの詳細な情報を出力します。それ以外の場合は、利用者向けの代替内容が表示されます。デバッグ時は `True` 、リリース時は `False` として定義しておいてください。

### **定数 `SITE_NAME` の定義**

表示したいサイト名を設定します。未定義の場合は `サイト名未設定` となります。

## **例外ハンドラの登録**

クロージャを使ってグローバル空間を汚さないように登録します。

```php
set_exception_handler(function (Exception $e) {
    // ヘッダー送信済みの場合は無視する
    if (headers_sent()) {
        return;
    }
    // バッファをすべてクリア
    while (ob_get_level()) {
        ob_end_clean();
    }
    // 送信予定のヘッダーをすべて削除
    header_remove();
    // 定数を取得
    $site = defined('SITE_NAME') ? SITE_NAME : 'サイト名未設定';
    $display = defined('DISPLAY_PRIVATE_ERROR') ? DISPLAY_PRIVATE_ERROR : false;
    // HTML特殊文字をエスケープして文字コードを統一するクロージャの用意
    $h = function ($str) {
        $str = mb_convert_encoding($str, 'UTF-8', 'ASCII,JIS,UTF-8,CP51932,SJIS-win');
        return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
    };
    // 表示内容を設定
    if (!$display) {
        // 本番稼動時にメッセージを表示すべきかどうかを判定するクロージャの用意
        $displayable = function (Exception $e) {
            // LogicExceptionとErrorExceptionは無視
            if ($e instanceof LogicException or $e instanceof ErrorException) {
                return false;
            }
            // グローバルスコープにて自前で生成した例外は扱う
            if (!$frame = current($e->getTrace())) {
                return true;
            }
            try {
                // PHPコアやエクステンションによって生成されたものは無視
                if (isset($frame['class'])) {
                    $reflection = new ReflectionClass($frame['class']);
                } else {
                    $reflection = new ReflectionFunction($frame['function']);
                }
                return !$reflection->isInternal();
            } catch (ReflectionException $e) {
                // クロージャは扱う
                return true;
            }
        };
        do {
            // 表示すべきでないものはスキップ
            if (!$displayable($e)) {
                continue;
            }
            $error[] = '    <li>' . $h($e->getMessage()) . '</li>';
        } while ($e = $e->getPrevious());
        if (empty($error)) {
            $error = implode("\n", array(
                '  <h1>内部サーバーエラー</h1>',
                '  <p>大変ご不便をおかけしております。申し訳ございません。</p>',
            ));
            // 500 Internal Server Error を返す
            header('Content-Type: text/html; charset=utf-8', true, 500);
        } else {
            $error = implode("\n", array(
                '  <h1>エラーが発生しました</h1>',
                '  <ul>',
                implode("\n", array_reverse($error)),
                '  </ul>',
            ));
            // 400 Bad Request を返す
            header('Content-Type: text/html; charset=utf-8', true, 400);
        }
    } else {
        do {
            $error[] = implode("\n", array(
                '  <dl>',
                '    <dt>メッセージ</dt>',
                '    <dd>' . $h($e->getMessage()) . '</dd>',
                '    <dt>ファイル<dt>',
                '    <dd>' . $h($e->getFile()) . '</dd>',
                '    <dt>行</dt>',
                '    <dd>' . $h($e->getLine()) . '</dd>',
                '  </dl>',
            ));
        } while ($e = $e->getPrevious());
        $error = implode("\n", array(
            '  <h1>例外発生</h1>',
            implode("\n", array_reverse($error)),
        ));
        // 500 Internal Server Error を返す
        header('Content-Type: text/html; charset=utf-8', true, 500);
    }
    // エラーページをレンダリング
    echo implode("\n", array(
        '<!DOCTYPE html>',
        '<html>',
        '<head>',
        '  <title>' . $h($site) . '</title>',
        '</head>',
        '<body>',
        $error,
        '</body>',
        '</html>',
    ));
});
```

# **応用**

## **エラーハンドラの登録**

エラーを例外に変換してスローさせることが目的です。クロージャを使ってグローバル空間を汚さないように登録します。

```php
set_error_handler(function ($no, $msg, $file, $line) {
    // エラー抑制演算子「@」が無い場合のみ実行
    if (error_reporting()) {
        // ErrorExceptionに変換
        throw new ErrorException($msg, 0, $no, $file, $line);
    }
});
```

第2引数を省略した場合、全てのエラーを表す **`E_ALL | E_STRICT`** が対象となります。

## **シャットダウン関数の登録**

通常のエラーハンドラだけでは **Parse Error** や Catchableでない **Fatal Error** に対応することが出来ません。そこでシャットダウン関数を活用して、これにも対応させてみることにします。クロージャを使ってグローバル空間を汚さないように登録します。

```php
register_shutdown_function(function () {
    // 致命的なエラーを表示する必要が無い、もしくはヘッダー送信済みの場合は無視する
    $flags = E_PARSE | E_ERROR | E_USER_ERROR | E_CORE_ERROR | E_COMPILE_ERROR;
    switch (true) {
        case !$e = error_get_last():
        case !($e['type'] & $flags):
        case !(error_reporting() & $flags):
        case headers_sent():
            return;
    }
    // バッファをすべてクリア
    while (ob_get_level()) {
        ob_end_clean();
    }
    // 送信予定のヘッダーをすべて削除
    header_remove();
    // 定数を取得
    $site = defined('SITE_NAME') ? SITE_NAME : 'サイト名未設定';
    $display = defined('DISPLAY_PRIVATE_ERROR') ? DISPLAY_PRIVATE_ERROR : false;
    // HTML特殊文字をエスケープして文字コードを統一するクロージャの用意
    $h = function ($str) {
        $str = mb_convert_encoding($str, 'UTF-8', 'ASCII,JIS,UTF-8,CP51932,SJIS-win');
        return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
    };
    // 表示内容を設定
    if (!$display) {
        $error = implode("\n", array(
            '  <h1>内部サーバーエラー</h1>',
            '  <p>大変ご不便をおかけしております。申し訳ございません。</p>',
        ));
    } else {
        $error = implode("\n", array(
            '  <h1>致命的なエラー</h1>',
            '  <dl>',
            '    <dt>メッセージ</dt>',
            '    <dd>' . $h($e['message']) . '</dd>',
            '    <dt>ファイル<dt>',
            '    <dd>' . $h($e['file']) . '</dd>',
            '    <dt>行</dt>',
            '    <dd>' . $h($e['line']) . '</dd>',
            '  </dl>',
        ));
    }
    // 500 Internal Server Error を返す
    header('Content-Type: text/html; charset=utf-8', true, 500);
    // エラーページをレンダリング
    echo implode("\n", array(
        '<!DOCTYPE html>',
        '<html>',
        '<head>',
        '  <title>' . $h($site) . '</title>',
        '</head>',
        '<body>',
        $error,
        '</body>',
        '</html>',
    ));
});
```

# **実運用を踏まえての工夫**

今回は依存性を一切排除するという強いポリシーのもと **HTMLをPHPコード中にべた書きする** という思い切った行動に出ましたが、実用性を考えていくとこのままでは支障があるので、以下のようにしてもいいでしょう。

- 「デバッグ時向け」「リリース時向け」のエラーテンプレートの絶対パスをそれぞれ定数として定義しておく。
- 定数が定義されているとき、<ins>Try-Catchブロックを設けて</ins>エラーテンプレートのレンダリングを試みる。
- 定数が定義されていない場合やエラーテンプレートのレンダリングで例外が発生した場合は、最初から発生していた例外に対してデフォルトのレンダリングを行う。必要に応じて、エラーテンプレートのレンダリング中に発生した例外も対象とする。

**何があっても** 表示できるようにするためには、依存性を一切排除した部分を持たせるのはやむを得ないというのが私の考えです。

# **活躍シーン**

内容が長くなるのでこれを一番最後に持ってきました。

```php:init.php
<?php
register_shutdown_function(function () {
    /* 省略 */
});
set_exception_handler(function (Exception $e) {
    /* 省略 */
});
set_error_handler(function ($no, $msg, $file, $line) {
    /* 省略 */
});
ob_start();
const DISPLAY_PRIVATE_ERROR = true or false; 
```

なんと、こんな手抜きコーディングが許されるようになります。ちょっとは生産性向上に期待できる（？）

```html+php:register.php
<?php
require 'init.php';
const SITE_NAME = 'My Site';
function h($str) {
    return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}
$e = null;
if ($_POST['name'] == '') {
    $e = new Exception('名前が入力されていません', 0, $e);
}
if (!ctype_digit($_POST['age'])) {
    $e = new Exception('年齢は数字で入力してください', 0, $e);
}
if ($e) {
    throw $e;
}
$pdo = new PDO('mysql:host=dbserver;dbname=test;charset=utf8', 'root', 'password');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$stmt = $pdo->prepare('INSERT INTO people(name, age) VALUES(:name, :age)');
$stmt->execute($_POST);
header('Content-Type: text/html; charset=utf-8');
?>
<!DOCTYPE html>
<html>
<head>
  <title><?=h(SITE_NAME)?></title>
</head>
<body>
  <p>以下の内容で登録しました。</p>
  <dl>
    <dt>名前</dt><dd><?=h($_POST['name'])?></dd>
    <dt>年齢</dt><dd><?=h($_POST['age'])?></dd>
  <ul>
</body>
</html>
```

```html:正しく登録できたとき
<!DOCTYPE html>
<html>
<head>
  <title>My Site</title>
</head>
<body>
  <p>以下の内容で登録しました。</p>
  <dl>
    <dt>名前</dt><dd>@mpyw</dd>
    <dt>年齢</dt><dd>20</dd>
  <ul>
</body>
</html>
```

## **リリース時の異常入力例**

```html:名前と年齢が両方ともチェックに引っかかったとき
<!DOCTYPE html>
<html>
<head>
  <title>My Site</title>
</head>
<body>
  <h1>エラーが発生しました</h1>
  <ul>
    <li>名前が入力されていません</li>
    <li>年齢は数字で入力してください</li>
  </ul>
</body>
</html>
```

```html:エラーや自動的に発生する例外全般
<!DOCTYPE html>
<html>
<head>
  <title>My Site</title>
</head>
<body>
  <h1>内部サーバーエラー</h1>
  <p>大変ご不便をおかけしております。申し訳ございません。</p>
</body>
</html>
```

## **デバッグ時の異常入力例**

```html:名前と年齢が両方ともチェックに引っかかったとき
<!DOCTYPE html>
<html>
<head>
  <title>My Site</title>
</head>
<body>
  <h1>例外発生</h1>
  <dl>
    <dt>メッセージ</dt>
    <dd>名前が入力されていません</dd>
    <dt>ファイル<dt>
    <dd>C:\xampp\htdocs\MySite\register.php</dd>
    <dt>行</dt>
    <dd>9</dd>
  </dl>
  <dl>
    <dt>メッセージ</dt>
    <dd>年齢は数字で入力してください</dd>
    <dt>ファイル<dt>
    <dd>C:\xampp\htdocs\MySite\register.php</dd>
    <dt>行</dt>
    <dd>12</dd>
  </dl>
</body>
</html>
```

```html:$_POST['name']が未定義のとき
<!DOCTYPE html>
<html>
<head>
  <title>My Site</title>
</head>
<body>
  <h1>例外発生</h1>
  <dl>
    <dt>メッセージ</dt>
    <dd>Undefined index: name</dd>
    <dt>ファイル<dt>
    <dd>C:\xampp\htdocs\MySite\register.php</dd>
    <dt>行</dt>
    <dd>8</dd>
  </dl>
</body>
</html>
```

```html:リモートのデータベースサーバーに接続できなかったとき
<!DOCTYPE html>
<html>
<head>
  <title>My Site</title>
</head>
<body>
  <h1>例外発生</h1>
  <dl>
    <dt>メッセージ</dt>
    <dd>PDO::__construct(): </dd>
    <dt>ファイル<dt>
    <dd>C:\xampp\htdocs\MySite\register.php</dd>
    <dt>行</dt>
    <dd>17</dd>
  </dl>
  <dl>
    <dt>メッセージ</dt>
    <dd></dd>
    <dt>ファイル<dt>
    <dd>C:\xampp\htdocs\MySite\register.php</dd>
    <dt>行</dt>
    <dd>17</dd>
  </dl>
</body>
</html>
```

```html:WindowsでMySQLを起動するのを忘れていたとき
<!DOCTYPE html>
<html>
<head>
  <title>My Site</title>
</head>
<body>
  <h1>例外発生</h1>
  <dl>
    <dt>メッセージ</dt>
    <dd>SQLSTATE[HY000] [2002] 対象のコンピューターによって拒否されたため、接続できませんでした。
</dd>
    <dt>ファイル<dt>
    <dd>C:\xampp\htdocs\MySite\register.php</dd>
    <dt>行</dt>
    <dd>17</dd>
  </dl>
</body>
</html>
```

```html:データベースでのアカウント認証に失敗したとき
<!DOCTYPE html>
<html>
<head>
  <title>My Site</title>
</head>
<body>
  <h1>例外発生</h1>
  <dl>
    <dt>メッセージ</dt>
    <dd>SQLSTATE[HY000] [1045] Access denied for user &#039;root&#039;@&#039;localhost&#039; (using password: YES)</dd>
    <dt>ファイル<dt>
    <dd>C:\xampp\htdocs\MySite\register.php</dd>
    <dt>行</dt>
    <dd>17</dd>
  </dl>
</body>
</html>
```

```html:$_POSTに余分な値が渡されたとき
<!DOCTYPE html>
<html>
<head>
  <title>My Site</title>
</head>
<body>
  <h1>例外発生</h1>
  <dl>
    <dt>メッセージ</dt>
    <dd>SQLSTATE[HY093]: Invalid parameter number: parameter was not defined</dd>
    <dt>ファイル<dt>
    <dd>C:\xampp\htdocs\MySite\register.php</dd>
    <dt>行</dt>
    <dd>21</dd>
  </dl>
</body>
</html>
```