LoginSignup
4
2

More than 5 years have passed since last update.

Lumenで例外ハンドラをいじって異常系レスポンスを自由に変更しよう

Last updated at Posted at 2018-12-12

前書き

Lumen、Exceptionスローした時に自前でcatch等していないとレスポンスの形式がhtml(Content-Typetext/html)で返却されます(オマケの項で後述)。
レスポンス形式=JSON(Content-Typeapplication/json)のAPIを作っている時に「正常系はControllerで普通にレスポンス返したらいいけど例外の時ってどこいじったらいいんだ」と公式ドキュメント見たりコアソース読んだりした結果を書きます。

結論

長々と記事を読まなくてもいいように先に結論から(公式ドキュメントにもココだ!っていうのが載ってます)。

app\exceptions\handler.php
/**
 * Render an exception into an HTTP response.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  \Exception  $e
 * @return \Illuminate\Http\Response
 */
public function render($request, Exception $e)
{
    return parent::render($request, $e);
}

↑コイツをいじる
parent::render() を返却していますがresponse()にjsonableな値をいれて返却するだけでレスポンス形式はJSONになります。

こんな感じ(あくまで一例です)

app\exceptions\handler.php
/**
 * Render an exception into an HTTP response.
 *
 * @param  \Illuminate\Http\Request $request
 * @param  \Exception $exception
 * @return \Illuminate\Http\Response
 */
public function render($request, Exception $exception)
{
    $validateResult = null;
    // swich (true)については思うところあるかもしれませんがサンプルなのでご容赦ください
    // instance ofで判定すればOKです
    switch (true) {
        case $exception instanceof NotFoundHttpException:
            $response = ['普通の配列でも', '連想配列でも'];
            break;
        case $exception instanceof MethodNotAllowedHttpException:
            $response = ['Jsonableな値をいれたら' => 'OK'];
            break;
        // (中略)
        default:
            // 想定外は今回親に任せました(上のコードのまんまの処理)
            return parent::render($request, $e);
    };
    return response($response);
}

超かんたんですね。ちゃんとresponseからアロー生やしてStatusCodeとかも適宜セットしましょう(指定無しだと200)。

もうここから下はオマケ

上述render()にたどり着くまでをちょっと追ってみる

コールしてるのはここ↓

vendor/laravel/lumen-framework/src/Concerns/RegistersExceptionHandlers.php
    protected function handleUncaughtException($e)
    {
        $handler = $this->resolveExceptionHandler();

        if ($e instanceof Error) {
            $e = new FatalThrowableError($e);
        }

        $handler->report($e);

        if ($this->runningInConsole()) {
            $handler->renderForConsole(new ConsoleOutput, $e);
        } else {
            // ココ
            $handler->render($this->make('request'), $e)->send();
        }
    }

$handlerは同ファイル内の↓で作っていて

vendor/laravel/lumen-framework/src/Concerns/RegistersExceptionHandlers.php
    /**
     * Get the exception handler from the container.
     *
     * @return mixed
     */
    protected function resolveExceptionHandler()
    {
        if ($this->bound('Illuminate\Contracts\Debug\ExceptionHandler')) {
            return $this->make('Illuminate\Contracts\Debug\ExceptionHandler');
        } else {
            return $this->make('Laravel\Lumen\Exceptions\Handler');
        }
    }

IlluminateのExceptionHandlerをmake()してるけどbootstrapの以下の行でbindしてる(singleton()内)からapp\exceptions\handler.phpのrender()につながってる

bootstrap/app.php
$app->singleton(
    Illuminate\Contracts\Debug\ExceptionHandler::class,
    App\Exceptions\Handler::class
);
vendor/illuminate/container/Container.php
    public function singleton($abstract, $concrete = null)
    {
        $this->bind($abstract, $concrete, true);
    }

handleUncaughtException()set_exception_handler() ( どこのtry-catchブロックにも引っかからなかった時に指定した関数を実行)で指定されています。なのでcatchされなかった場合上に述べてきたところを通ってきます。

vendor/laravel/lumen-framework/src/Concerns/RegistersExceptionHandlers.php
    protected function registerErrorHandling()
    {
        error_reporting(-1);

        set_error_handler(function ($level, $message, $file = '', $line = 0) {
            if (error_reporting() & $level) {
                throw new ErrorException($message, 0, $level, $file, $line);
            }
        });

        set_exception_handler(function ($e) {
            $this->handleUncaughtException($e);
        });

        register_shutdown_function(function () {
            $this->handleShutdown();
        });
    }

なんでレスポンス形式がhtmlになるのか追ってみる

デフォソースの親render()が↓で

vendor/laravel/lumen-framework/src/Exceptions/Handler.php
    /**
     * Render an exception into an HTTP response.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Exception  $e
     * @return \Illuminate\Http\Response
     */
    public function render($request, Exception $e)
    {
        if ($e instanceof HttpResponseException) {
            return $e->getResponse();
        } elseif ($e instanceof ModelNotFoundException) {
            $e = new NotFoundHttpException($e->getMessage(), $e);
        } elseif ($e instanceof AuthorizationException) {
            $e = new HttpException(403, $e->getMessage());
        } elseif ($e instanceof ValidationException && $e->getResponse()) {
            return $e->getResponse();
        }

        $fe = FlattenException::create($e);

        $handler = new SymfonyExceptionHandler(env('APP_DEBUG', config('app.debug', false)));

        // こいつ
        $decorated = $this->decorate($handler->getContent($fe), $handler->getStylesheet($fe));

        $response = new Response($decorated, $fe->getStatusCode(), $fe->getHeaders());

        $response->exception = $e;

        return $response;
    }

decorate()の中にタグ書いてあった。

    /**
     * Get the html response content.
     *
     * @param  string  $content
     * @param  string  $css
     * @return string
     */
    protected function decorate($content, $css)
    {
        return <<<EOF
<!DOCTYPE html>
<html>
    <head>
        <meta name="robots" content="noindex,nofollow" />
        <style>
            /* Copyright (c) 2010, Yahoo! Inc. All rights reserved. Code licensed under the BSD License: http://developer.yahoo.com/yui/license.html */
            html{color:#000;background:#FFF;}body,div,dl,dt,dd,ul,ol,li,h1,h2,h3,h4,h5,h6,pre,code,form,fieldset,legend,input,textarea,p,blockquote,th,td{margin:0;padding:0;}table{border-collapse:collapse;border-spacing:0;}fieldset,img{border:0;}address,caption,cite,code,dfn,em,strong,th,var{font-style:normal;font-weight:normal;}li{list-style:none;}caption,th{text-align:left;}h1,h2,h3,h4,h5,h6{font-size:100%;font-weight:normal;}q:before,q:after{content:'';}abbr,acronym{border:0;font-variant:normal;}sup{vertical-align:text-top;}sub{vertical-align:text-bottom;}input,textarea,select{font-family:inherit;font-size:inherit;font-weight:inherit;}input,textarea,select{*font-size:100%;}legend{color:#000;}
            html { background: #eee; padding: 10px }
            img { border: 0; }
            #sf-resetcontent { width:970px; margin:0 auto; }
            $css
        </style>
    </head>
    <body>
        $content
    </body>
</html>
EOF;
    }

ついでにresponse(Jsonableなやつ)はどこでJson化しているのか追ってみる

response()の中身

vendor/laravel/lumen-framework/src/helpers.php
/**
     * Return a new response from the application.
     *
     * @param  string  $content
     * @param  int     $status
     * @param  array   $headers
     * @return \Illuminate\Http\Response|\Laravel\Lumen\Http\ResponseFactory
     */
    function response($content = '', $status = 200, array $headers = [])
    {
        $factory = new Laravel\Lumen\Http\ResponseFactory;

        if (func_num_args() === 0) {
            return $factory;
        }

        return $factory->make($content, $status, $headers);
    }

貰った$contentmake()に横流し。make()は以下。

vendor/laravel/lumen-framework/src/Http/ResponseFactory.php
    /**
     * Return a new response from the application.
     *
     * @param  string  $content
     * @param  int     $status
     * @param  array   $headers
     * @return \Illuminate\Http\Response
     */
    public function make($content = '', $status = 200, array $headers = [])
    {
        return new Response($content, $status, $headers);
    }

\Illuminate\Http\Responseインスタンスを生成してるだけ。こいつのコンストラクタを見ると
(vendor/illuminate/http/Response.phpにコンストラクタはなく継承してる親クラスのコンストラクタソース)

vendor/symfony/http-foundation/Response.php
    /**
     * @throws \InvalidArgumentException When the HTTP status code is not valid
     */
    public function __construct($content = '', int $status = 200, array $headers = array())
    {
        $this->headers = new ResponseHeaderBag($headers);
        $this->setContent($content);
        $this->setStatusCode($status);
        $this->setProtocolVersion('1.0');
    }

setContent()に渡していますね。これはvendor/illuminate/http/Response.php側でオーバーライドしていてそのソースが以下。

    /**
     * Set the content on the response.
     *
     * @param  mixed  $content
     * @return $this
     */
    public function setContent($content)
    {
        $this->original = $content;

        // If the content is "JSONable" we will set the appropriate header and convert
        // the content to JSON. This is useful when returning something like models
        // from routes that will be automatically transformed to their JSON form.
        if ($this->shouldBeJson($content)) {
            // ↑でJsonableか見て
            // レスポンス形式をセット
            $this->header('Content-Type', 'application/json');

            // ↓morphToJson内でjson_encode()してます
            $content = $this->morphToJson($content);
        }

        // If this content implements the "Renderable" interface then we will call the
        // render method on the object so we will avoid any "__toString" exceptions
        // that might be thrown and have their errors obscured by PHP's handling.
        elseif ($content instanceof Renderable) {
            $content = $content->render();
        }

        parent::setContent($content);

        return $this;
    }

なるほどー。

あとがき

以前他社で制約があるプロジェクト(案件性質上フレームワーク無し)の中で開発していたことがありましたが似たようなコードを自前で書いてライブラリ作ってたのを思い出しました。
バグを生む原因にしかならないので可能であるならフレームワーク然り使えるものは使い尽くして例外処理に限らず自分で書くのはビジネスロジックだけにしましょう・・・

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2