LaravelはAssertionErrorを直接あつかうことができない。


ちょっとした小ネタです。

PHP7の地味だけど、意外と重要な新機能としてassert()が関数から言語構造に変更されました。

オンラインマニュアル(assert)

Expectation (PHP 7 のみ)

assert() は PHP 7 で言語構造となり、expectation の定義を満たすようになりました。 すなわち、開発環境やテスト環境では有効であるが、運用環境では除去されて、まったくコストのかからないアサーションということです。

今まではassert()を無効にしていても(assert_options(ASSERT_ACTIVE, 0))、assert()自体の呼び出しのオーバヘッドが発生してしまい、また、評価する式を文字列で記述しなければならないというイマイチ使いづらい関数でした。
zend.assertions-1にすることで、コンパイル時のassert()のopcode自体が生成されないため、オーバヘッドが0になりますし、文字列で評価式を記述する必要もなくなります。

またassert()が失敗した場合、今まではASSERT_CALLBACKで指定したコールバック関数が実行されて、そこで独自の例外に変換してthrowする処理を入れてたりしてたわけですが、assert.exception1に指定すると何もしなくてもAssertionErrorという例外オブジェクトをthrowしてくれます。

さて、ここからが本題。
PHP 7.1上のLumen(5.4)上で組んだシステムでこの挙動を確認しようと、こんなコードを書いてみました。

routes/web.php
$app->get('/assert', function(){
    assert(false);
});

実行した結果は以下の通り。

あれ? AssertionErrorではなくてFatalThrowableErrorになってる。
なぜだろうとソースを調べること小一時間。FatalThrowableErrorはPHPの組み込みの例外ではなくて、Symfonyで独自定義されている例外クラスであることがわかりました。

vendor/symfony/debug/Exception/FatalThrowableError.php
/*
 * This file is part of the Symfony package.
 *
 * (c) Fabien Potencier <[email protected]>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Symfony\Component\Debug\Exception;

/**
 * Fatal Throwable Error.
 *
 * @author Nicolas Grekas <[email protected]>
 */
class FatalThrowableError extends FatalErrorException
{
    public function __construct(\Throwable $e)
    {
        if ($e instanceof \ParseError) {
            $message = 'Parse error: '.$e->getMessage();
            $severity = E_PARSE;
        } elseif ($e instanceof \TypeError) {
            $message = 'Type error: '.$e->getMessage();
            $severity = E_RECOVERABLE_ERROR;
        } else {
            $message = $e->getMessage();
            $severity = E_ERROR;
        }

        \ErrorException::__construct(
            $message,
            $e->getCode(),
            $severity,
            $e->getFile(),
            $e->getLine()
        );

        $this->setTrace($e->getTrace());
    }
}

ということは、どこかでFatalThrowableErrorが生成されてthrowされたということです。さらに調べること小一時間。今度はLumen側のvendor/laravel/lumen-framework/src/Routing/Pipeline.phpThrowable (AssertionError)をcatchしてFatalThrowableErrorに変換していることがわかりました。

vendor/laravel/lumen-framework/src/Routing/Pipeline.php
/**
 * This extended pipeline catches any exceptions that occur during each slice.
 *
 * The exceptions are converted to HTTP responses for proper middleware handling.
 */
class Pipeline extends BasePipeline
{
(snip)
    /**
     * Get the initial slice to begin the stack call.
     *
     * @param  \Closure  $destination
     * @return \Closure
     */
    protected function prepareDestination(BaseClosure $destination)
    {
        return function ($passable) use ($destination) {
            try {
                return call_user_func($destination, $passable);
            } catch (Exception $e) {
                return $this->handleException($passable, $e);
            } catch (Throwable $e) {
                return $this->handleException($passable, new FatalThrowableError($e));
            }
        };
    }

LumenコンポーネントのソースをThrowableでgrepしてみると、その他の例外処理の部分で同じような処理をしていることがわかります。

vendor/illuminate/queue/Worker.php
    /**
     * Get the next job from the queue connection.
     *
     * @param  \Illuminate\Contracts\Queue\Queue  $connection
     * @param  string  $queue
     * @return \Illuminate\Contracts\Queue\Job|null
     */
    protected function getNextJob($connection, $queue)
    {
        try {
            foreach (explode(',', $queue) as $queue) {
                if (! is_null($job = $connection->pop($queue))) {
                    return $job;
                }
            }
        } catch (Exception $e) {
            $this->exceptions->report($e);
        } catch (Throwable $e) {
            $this->exceptions->report(new FatalThrowableError($e));
        }
    }

Lumen(Laravel) 5.4はPHP 7だけではなく、PHP 5.6をサポートしているので、(Throwableが扱えない)PHP 5.6のためにこのような変換をしているのでしょう。ちょっと残念な結果でした。

次のLumen(Laravel) 5.5は、PHP5系を切り捨てて、PHP7.0以上のサポートとなるのでThrowableを直接扱えるようになってると良いなと思います。