CakePHPのエラーハンドリングについて調べてみた①（PHP本体のエラーハンドリング編）


この記事はコネヒトアドベントカレンダー19日目の記事です

https://adventar.org/calendars/8994


CakePHP4.4以降ではエラーハンドリングの機構が大きく変わりました。

> ErrorHandler と ConsoleErrorHandler クラスは、非推奨となりました。 これらのクラスは、新しい ExceptionTrap と ErrorTrap クラスに置き換わりました

https://book.cakephp.org/4/ja/appendices/4-4-migration-guide.html#errorhandler-consoleerrorhandler

そのためCakePHPのエラーハンドリングを新しいクラスに置き換える必要が出てきたのですが、前提としてPHP本体のエラーについての理解が必要だったので自分なりに調べてみました。

```
# 実行環境
# php -v
PHP 8.1.25 (cli) (built: Nov  1 2023 12:51:36) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.1.25, Copyright (c) Zend Technologies
    with Xdebug v3.2.2, Copyright (c) 2002-2023, by Derick Rethans

# cat vendor/cakephp/cakephp/VERSION.txt
4.3.11
```

サンプルソース  
https://github.com/okdyy75/dev-cakephp/blob/cake43-errHandle/www/cakephp/sample.php

### まずはCakePHPのエラーハンドリングについて調べてみる

CakePHPのエラーハンドリングについては下記の通り`config/bootstrap.php`で行っているようでした

```php
$isCli = PHP_SAPI === 'cli';
if ($isCli) {
    (new ConsoleErrorHandler(Configure::read('Error')))->register();
} else {
    (new ErrorHandler(Configure::read('Error')))->register();
}
```

https://github.com/cakephp/app/blob/4.3.2/config/bootstrap.php#L125-L130

この二つのクラスですが、継承元は同じ `BaseErrorHandler` なので `register()`の中身を見ていきます

```php
    public function register(): void
    {
        $level = $this->_config['errorLevel'] ?? -1;
        error_reporting($level);
        set_error_handler([$this, 'handleError'], $level);
        set_exception_handler([$this, 'handleException']);
        register_shutdown_function(function (): void {
            if ((PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') && $this->_handled) {
                return;
            }
            $megabytes = $this->_config['extraFatalErrorMemory'] ?? 4;
            if ($megabytes > 0) {
                $this->increaseMemoryLimit($megabytes * 1024);
            }
            $error = error_get_last();
            if (!is_array($error)) {
                return;
            }
            $fatals = [
                E_USER_ERROR,
                E_ERROR,
                E_PARSE,
            ];
            if (!in_array($error['type'], $fatals, true)) {
                return;
            }
            $this->handleFatalError(
                $error['type'],
                $error['message'],
                $error['file'],
                $error['line']
            );
        });
    }
```

https://github.com/cakephp/cakephp/blob/4.3.11/src/Error/BaseErrorHandler.php#L89-L122

`register()` 時の処理は大きく分けて3つあり、PHP標準の関数を使用したエラーハンドリングでした。実際にこの3つの関数を動かして違いを調べてみました

- set_error_handler
    - ユーザー定義のエラーハンドラ関数を設定する
    - https://www.php.net/manual/ja/function.set-error-handler.php
- set_exception_handler
    - ユーザー定義の例外ハンドラ関数を設定する
    - https://www.php.net/manual/ja/function.set-error-handler.php
- register_shutdown_function
    - シャットダウン時に実行する関数を登録する
    - https://www.php.net/manual/ja/function.register-shutdown-function.php


## PHPのエラーハンドリング

### そもそもPHPのエラーについて

前提としてPHPには16種類ほどのエラーレベルが定数で定義されており、ざっくり分けると下記の通りになります

- PHP本体の内部エラー
  - fatalエラー系
    - E_ERROR
    - E_PARSE
    - E_CORE_ERROR
    - E_COMPILE_ERROR
    - E_RECOVERABLE_ERROR
  - 警告系
    - E_WARNING
    - E_NOTICE
    - E_CORE_WARNING
    - E_COMPILE_WARNING
    - E_DEPRECATED
- ユーザーによって発行されるエラー
  - fatalエラー系
    - E_USER_ERROR
  - 警告系
    - E_USER_WARNING
    - E_USER_NOTICE
    - E_USER_DEPRECATED
- その他
  - E_STRICT
    - 厳格なコード運用や互換性を維持するためのエラー。PHP7以降は廃止された
  - E_ALL
    - 全てのエラーと警告

※ドキュメントの説明を元に作成＆一部表記を変えています。
上記を参考にしつつエラーハンドリング関数を見ていきます

定義済み定数  
https://www.php.net/manual/ja/errorfunc.constants.php

## set_error_handler
WarningやNoticeといったそのまま処理が継続できる警告系のエラーをハンドリングできます

- エラーレベルとしてはE_WARNINGやE_NOTICE、E_DEPRECATEDなど
- `trigger_error()`でユーザーエラーを発生させて、それをハンドリングすることもできる（E_USER_XXX系）
- エラー発生後コールバック関数が呼ばれた後も、エラーが発生した次の行から処理は継続する

set_error_handler  
https://www.php.net/manual/ja/function.set-error-handler.php


### set_error_handler検証コード

```php
function handleError(int $code, string $description, ?string $file = null, ?int $line = null)
{
    var_dump(__METHOD__ . __LINE__);
    var_dump($code, $description, $file, $line);
}

function handleException(Throwable $exception)
{
    var_dump(__METHOD__ . __LINE__);
    var_dump($exception);
}

function shutdown()
{
    var_dump(__METHOD__ . __LINE__);
    $error = error_get_last();
    var_dump($error);
}

set_error_handler('handleError');
set_exception_handler('handleException');
register_shutdown_function('shutdown');

// set_error_handler()で捕捉できるエラー例
$hoge = $a;
// Warning: Undefined variable $a in /var/www/cakephp/sample.php on line 27
ob_clean();
// Notice: ob_clean(): Failed to delete buffer. No buffer to delete in /var/www/cakephp/sample.php on line 28
$arr = [PHP_INT_MAX + 1 => 'hoge'];
// Deprecated: Implicit conversion from float 9.223372036854776E+18 to int loses precision in /var/www/cakephp/sample.php on line 29


trigger_error('E_USER_DEPRECATEDです', E_USER_DEPRECATED);
// Deprecated: E_USER_DEPRECATEDです in /var/www/cakephp/sample.php on line 31
trigger_error('E_USER_NOTICEです', E_USER_NOTICE);
// Notice: E_USER_NOTICEです in /var/www/cakephp/sample.php on line 32
trigger_error('E_USER_WARNINGです', E_USER_WARNING);
// Warning: E_USER_WARNINGです in /var/www/cakephp/sample.php on line 33
trigger_error('E_USER_ERRORです', E_USER_ERROR);
// Fatal error: E_USER_ERRORです in /var/www/cakephp/sample.php on line 34

echo "---end---\n";
```

実際に検証してみたところ

- Deprecated、Notice、Warning等のエラーは `set_error_handler()` でハンドリングしなくても処理は継続される
- `trigger_error()`でE_USER_ERRORを発生させた場合は少し特殊で、Fatal errorと同様にその場で処理が終了する。ただし  でハンドリングすればそのまま処理を継続する事が可能


## set_exception_handler
ExceptionやErrorといったtry/catchすれば処理の継続が可能なfatalエラーをハンドリングできる

- より具体的にはThrowableを実装したErrorやExceptionといったキャッチされなかった例外をハンドリングできる
- エラーレベルとしては旧来のE_ERRORやE_PARSE、E_RECOVERABLE_ERRORなど
  - 「旧来」というのもPHP7以降ではfatal error や recoverable fatal error の多くが、Errorの例外に変換されるようになったため
    - https://www.php.net/manual/ja/migration70.incompatible.php
  - ただ全てのfatal errorをハンドリングできる訳ではないので後述の`register_shutdown_function()`で処理する必要がある
    - 例えば実行時間が長すぎる場合や、メモリ不足など
- エラー発生後コールバック関数が呼ばれた後は処理が終了する

定義済みの例外  
https://www.php.net/manual/ja/reserved.exceptions.php

set_exception_handler  
https://www.php.net/manual/ja/function.set-exception-handler.php


### set_exception_handler検証コード

```php
function handleError(int $code, string $description, ?string $file = null, ?int $line = null)
{
    var_dump(__METHOD__ . __LINE__);
    var_dump($code, $description, $file, $line);
}

function handleException(Throwable $exception)
{
    var_dump(__METHOD__ . __LINE__);
    var_dump($exception);
}

function shutdown()
{
    var_dump(__METHOD__ . __LINE__);
    $error = error_get_last();
    var_dump($error);
}

set_error_handler('handleError');
set_exception_handler('handleException');
register_shutdown_function('shutdown');

// set_exception_handler()で捕捉できるエラー例
throw new Exception('Exceptionです');
// Fatal error: Uncaught Exception: Exceptionです in /var/www/cakephp/sample.php:44
$hoge = 1 / 0;
// Fatal error: Uncaught DivisionByZeroError: Division by zero in /var/www/cakephp/sample.php:46
aaa();
// Fatal error: Uncaught Error: Call to undefined function aaa() in /var/www/cakephp/sample.php:48
eval("echo 'hoge' echo 'fuga'");
// Parse error: syntax error, unexpected token "echo", expecting "," or ";" in /var/www/cakephp/sample.php(50) : eval()'d code on line 1

echo "---end---\n";
```

## register_shutdown_functionについて
例外としてtry/catchできない処理の継続が困難なfatalエラーを最後にハンドリングできる

- エラーレベルとしてはE_ERRORやE_COMPILE_ERRORなど
- エラー発生後コールバック関数が呼ばれた後は処理が終了する

### register_shutdown_function検証コード

```php
function handleError(int $code, string $description, ?string $file = null, ?int $line = null)
{
    var_dump(__METHOD__ . __LINE__);
    var_dump($code, $description, $file, $line);
}

function handleException(Throwable $exception)
{
    var_dump(__METHOD__ . __LINE__);
    var_dump($exception);
}

function shutdown()
{
    var_dump(__METHOD__ . __LINE__);
    $error = error_get_last();
    var_dump($error);
}

set_error_handler('handleError');
set_exception_handler('handleException');
register_shutdown_function('shutdown');

// register_shutdown_function()で捕捉できるエラー例
ini_set("max_execution_time", "1");for(;;){};
// Fatal error: Maximum execution time of 1 second exceeded in /var/www/cakephp/sample.php on line 54
str_repeat("aaa", PHP_INT_MAX);
// Fatal error: Possible integer overflow in memory allocation (3 * 9223372036854775807 + 32) in /var/www/cakephp/sample.php on line 57
eval('function foo($a, $b, $unused, $unused) {}');
// Fatal error: Redefinition of parameter $unused in /var/www/cakephp/sample.php(60) : eval()'d code on line 1

echo "---end---\n";
```

### 3つの関数の違いについて

まとめると

- set_error_handler
  - WarningやNoticeといったそのまま処理が継続できる警告系のエラーをハンドリングできる
- set_exception_handler
  - ExceptionやErrorといったtry/catchすれば処理の継続が可能なfatalエラーをハンドリングできる
- register_shutdown_function
  - 例外としてtry/catchできない処理の継続が困難なfatalエラーを最後にハンドリングできる

いろいろ調べてみましたが、同じE_ERRORレベルであっても`set_exception_handler()`で処理できる場合もあれば、`register_shutdown_function()`でなければ処理できない場合もあるようで、エラーレベルごとにこのエラーハンドリング関数で処理されるとは一概に言えないようです。（`error_reporting(E_ERROR)`でエラーレベルを絞って確認してみました）