Help us understand the problem. What is going on with this article?

【CakePHP3】例外がthrowされた時の処理を追ってみた

追記

(今更ですが・・)

「Error例外が発生した場合はMiddlewareを貫通してしまう」という記述をしていますが、v3.5.7以降は修正がなされており、現行バージョンでは 「ThrowableならMiddleware内でcatchされる」という挙動になっています

https://github.com/cakephp/cakephp/pull/11462


業務でCakePHP3を用いたシステムを構築しています。
エラーを監視してSaaSに投げるようにしたかったのですが、適切なやり方が分からなかったので、調べてみました。
分かったことやムムムと感じた点がありましたので、まとめてみたいと思います。

※ PHP7環境での話になります。

TL;DR

エラー/例外について、「ErrorHandlerMiddlewareに捕捉されるかされないか」で処理される流れが異なる、というのがポイントだと思いました。

  1. \Error に関してはset_exception_handler()の設定を通じて、BaseErrorHandler::handleError() に渡される
  2. \Exceptionに関してはErrorHandlerMiddlewareによって補足される
    • ∴ (必ずしも))set_exception_handler()の設定したハンドラーまで処理が到達する訳ではない
  3. Shell処理内での例外については set_exception_handler()での設定内容に従う
  4. 本来 set_error_handlerregister_shutdown_function で扱われていた内容は、その通りに処理される
    • BaseErrorHandler::register() の内部で設定が行われる

概要については book を参照です。
また、ミドルウェアに関するイメージはコチラの記事 がとてもイメージが掴みやすく、参考にさせて頂きました。


エラーハンドリングに関する大まかな設定や処理の流れ

Handlerの登録 in bootstrap.php

アプリケーションの起動フェーズで、以下のようにエラーハンドラーを登録しています。

source:https://github.com/cakephp/app/blob/c8a523898c03dfdec8057d8bd681b9a732f968cd/config/bootstrap.php#L119

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

\Cake\Console\ConsoleErrorHandler\ConsoleErrorHandler\Cake\Error\ErrorHandlerとも、 \Cake\Error\BaseErrorHandler を継承しています。

両者の大きな違いは「エラー出力をどのように行うか」が関心事であり1、今回触れようとしている範囲においてハンドリングに関する決定的な差異は無いといって差し支えありません。
また、ここで現れたregister()はBaseErrorHandlerの実装となります。

register() 処理の中身を解剖

source: https://github.com/cakephp/cakephp/blob/003df77ed0c8582263ff7ab191136a8fa66b76fb/src/Error/BaseErrorHandler.php#L68
大まかに行われている仕事は4つです

  • error_reporting
  • set_error_handler
  • set_exception_handler
  • register_shutdown_function

要するにエラーの制御に関する設定と、エラー制御後のシャットダウン処理についての設定を行うことになります。

実際の流れ

エラーの場合2

例えば、 AppController::beforeFilter()内で trigger_error()を呼び出して見た場合の処理は、以下のようになります。

// App\Controller\AppController
public function beforeFilter(Event $event)
{
    trigger_error('errorが起きたよ!');
}
  1. AppController::beforeFilter()trigger_error()
  2. trigger_error()BaseErrorHandler::handleError()
    1. errorのレベルが Fatal なら BaseErrorHandler::handleFatalError()
      1. handleFatalError()BaseErrorHandler::handleException()
      2. handleException()BaseErrorHandler::stop()
      3. shutdown処理に入る
    2. errorのレベルが Fatalでなかったら ErrorHandler::_displayError()

つまり、 set_error_handler() で登録された通りのハンドラーに渡され、 (デフォルトの設定であれば)致命的なエラーでなければ復帰処理に入ります。

例外の場合2

例えば、 AppController::beforeFilter()内で \Exception()をthrowした場合は、以下のようになります。

  1. AppController::beforeFilter()throw new \Exception()
  2. throw → ErrorHandlerMiddleware::invoke() 内での catch (\Exception $e)
  3. catchブロック内で → ErrorHandlerMiddleware::handleException()
  4. handleException()ExceptionRenderer::render()
  5. ErrorHandler::logException()
  6. return Response (\Psr\Http\Message\ResponseInterface)

・・・・ 登録したexception handlerに処理が渡ってこないぞ・・?

web系の処理のときは、ErrorHandlerMiddlewareに処理が任される

エラー・例外ともにハンドラーをセットしているので、どこにも捕まらなかったものはセットされたハンドラーに引き渡されて処理される・・・というのを、個人的には期待していました。
しかし実態は、例外については、CakePHP全体の中でも最も外側に位置する部類の ErrorHandlerMiddleware内で捕捉されてしまっています。実質的には、アプリケーション内部から投げられた例外は(引き渡したかった相手である)ハンドラーにまでは渡ってきません。

set_exception_handler() の内容に処理が渡る場合

大まかにいえば、2つあると思います。

PHP7のError例外 が発生した場合

強調のために\Cake\Error\Middleware\ の内容を改めて見てみると、以下のようになっています。

   public function __invoke($request, $response, $next)
    {
        try {
            return $next($request, $response);
        } catch (Exception $e) {
            return $this->handleException($e, $request, $response);
        }
    }

これが何を意味するかというと、 「ExceptionでないThrowableなクラスはキャッチされていない」ということです。
つまり、 \Error は(ミドルウェアを通り抜けて)直接 set_exception_handler に渡されて処理が実行されることになります。

Middlewareを介さない場合

ミドルウェアがキューされているのは Application::middleware()の中です。
Shell処理を行う場合はこの限りではないので、直接的に set_excepton_handler での設定どおりにハンドリングされることになります。
例えばApp\Shell\ConsoleShell::main() 内で \Exceptionを投げた場合、以下のような流れになります。
※動作の確認用に、以下のように内容を書き換えています

// \App\Shell\ConsoleShell
    public function main()
    {
        throw new \Exception('例外!!');
    }
// \Cake\Error\BaseErrorHandler
    public function register()
    {
        set_exception_handler(function ($t) {
            dd($t);
        });
    }
$ bin/cake console
/src/Error/BaseErrorHandler.php (line 87)
########## DEBUG ##########
object(Exception) {
    [protected] message => '例外!!'
    [protected] code => (int) 0
    [protected] file => '/home/me/app/src/Shell/consoleShell.php'
    [protected] line => (int) 35
    [private] string => ''
    [private] trace => [
        (int) 0 => [
            'file' => '/home/me/app/vendor/composer/cakephp/cakephp/src/Console/Shell.php',
            'line' => (int) 507,
            'function' => 'main',
            'class' => 'App\Shell\ConsoleShell',
            'type' => '->',
            'args' => []
        ],
        (int) 1 => [
            'file' => '/home/me/app/vendor/composer/cakephp/cakephp/src/Console/ShellDispatcher.php',
            'line' => (int) 230,
            'function' => 'runCommand',
            'class' => 'Cake\Console\Shell',
            'type' => '->',
            'args' => [
                (int) 0 => [],
                (int) 1 => true,
                (int) 2 => []
            ]
        ],
        (int) 2 => [
            'file' => '/home/me/app/vendor/composer/cakephp/cakephp/src/Console/ShellDispatcher.php',
            'line' => (int) 182,
            'function' => '_dispatch',
            'class' => 'Cake\Console\ShellDispatcher',
            'type' => '->',
            'args' => [
                (int) 0 => []
            ]
        ],
        (int) 3 => [
            'file' => '/home/me/app/vendor/composer/cakephp/cakephp/src/Console/ShellDispatcher.php',
            'line' => (int) 128,
            'function' => 'dispatch',
            'class' => 'Cake\Console\ShellDispatcher',
            'type' => '->',
            'args' => [
                (int) 0 => []
            ]
        ],
        (int) 4 => [
            'file' => '/home/me/app/bin/cake.php',
            'line' => (int) 34,
            'function' => 'run',
            'class' => 'Cake\Console\ShellDispatcher',
            'type' => '::',
            'args' => [
                (int) 0 => [
                    (int) 0 => '/home/me/app/bin/cake.php',
                    (int) 1 => 'console'
                ]
            ]
        ]
    ]
    [private] previous => null
}

「例外の処理を共通化する」には、どうすれば良いんだろう・・

この状況に対し、自分なりにしっくりとくる解法を見つける事がでできていません。3
いくつか方針を練っては見たのですが・・・

  1. カスタムしたMiddlewareの実装を行って、その内部で例外をハンドリングするの?
    • でもエラーに関しては「ミドルウェアの外側」まで到達して、 set_error_handlerで登録された処理に渡されるので何となくいびつ・・
  2. エラーハンドラーのカスタムを行って、エラーもMiddlewareレイヤーで補足する?
    • これやるとエラーレベルを問わず元の処理に復帰できない・・・

現状で取れる手立てとしては、ErrorHandlerとMiddlewareのhadleError()/handleException()どちらからも呼び出される共通の機構を別途に持たせる、というところになるのでしょうか。
どこかに実装例や知見がないかなぁ〜という感じです><


  1. 出力に関しての処理を司る_displayError() _displayException()に関しては、 BaseErrorHandler で抽象メソッドとして宣言されているため、サブクラスで実装を行う必要があります。 

  2. set_error_handlerの対象にはErrorは含まれませんでした。set_exception_handlerで扱われました。(勘違いしてた・・・ 😖 => cf) お前は PHP 7 における Fatal Error / Catchable Fatal Error / Error / ErrorException / Exception の違いを言えるか? - Qiita 

  3. 当初の目論見としては、BaseErrrorHandlerを継承する形で独自のErrorHandlerを定義、 _logError()及び _logException() をオーバーライドして「エラー内容をSasSに投げる」という目的を実現するつもりでした 

o0h
CakePHPを触ったり、Slack Botを作ったり、暇に合わせて色々ごにょごにょしています。 cakeの話は最近こっちに書いてます。 -> https://cake.nichiyoubi.land
http://daisuki.nichiyoubi.land
binc
Eコマースプラットフォーム「BASE」、オンライン決済サービス「PAY.JP」、購入者向けID型決済サービス「PAY ID」の3つのサービスを運営しています。
https://binc.jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした