Laravel Cafè #4 - Whoops, Looks Like Something Went Wrong

Il nostro Filippo, oggi, entra nel mondo delle eccezioni e della loro gestione, per darci uno spaccato di come funzionano. Whoops!
francesco
Filippo Galante
23/11/2016 in Tutorial


Benvenuto al Laravel Cafè! Ogni settimana proporremo un nuovo argomento sul mondo Laravel, quindi trova un posto libero, prendi un caffè e condividi le tue opinioni con la comunità! L'idea è di creare un punto di discussione. Se hai qualche perplessità sull'argomento trattato, leggi fino alla fine e fai una domanda usando il forum! Cercheremo di risponderci a vicenda e di aiutarci, ed il confronto ci farà crescere tutti un po' di più.

Di mercoledì in mercoledì parleremo di qualcosa di diverso, quindi tornaci a trovare! Potresti dare una mano a qualcuno in difficoltà, o ricevere tu un aiuto in caso di problemi! Dai, siediti, il primo caffè lo offriamo noi.

L'Argomento

Quante volte abbiamo letto sul nostro monitor "Whoops, looks like something went wrong", magari lanciando qualche imprecazione, o magari sapendo già il motivo di quell'errore e correggendo al volo il codice? Le eccezioni fanno parte della struttura di un software da sempre e, come tutte le altre "parti" del nostro software, vanno gestite a dovere. Come avrete intuito oggi dedicheremo la nostra pausa caffè alle Exception di Laravel, a come gestirle e come customizzarle.

Il Codice

A differenza degli ultimi articoli, durante questo caffè ci soffermeremo più sulla teoria che sulla pratica, dato che la personalizzazione delle exception può variare a seconda delle esigenze, o può semplicemente rimanere invariata. Come prima cosa vediamo nel dettaglio il workflow della gestione delle exception di Laravel. La documentazione ufficiale come al solito risulta un po' scarna sotto questo aspetto dato che spiega nel dettaglio gli strumenti, ma il workflow dell'Exception Handler rimane tutto racchiuso nel suo codice sorgente (anche questo molto intuitivo, ma molto meno commentato rispetto ad altre classi). Mi focalizzerò esclusivamente sul render di una exception, ma in realtà il framework gestisce anche il richiamo del logger della stessa.

Come ho anticipato, i sorgenti sono abbastanza intuitivi quindi vi consiglio di sbirciare i link ai sorgenti di Github che ho incluso nei vari punti.

Viene lanciata una exception e attraverso la classe Illuminate\Foundation\Bootstrap\HandleExceptions viene richiamato il metodo handleException($e). Come si può vedere il framework gestisce in maniera differente se è stato lanciato un comando da console o si è verificato un errore in una qualche request, in questo articolo ci focalizzeremo comunque solo sul secondo caso:

/**
 * Handle an uncaught exception from the application.
 *
 * Note: Most exceptions can be handled via the try / catch block in
 * the HTTP and Console kernels. But, fatal error exceptions must
 * be handled differently since they are not normal exceptions.
 *
 * @param  \Throwable  $e
 * @return void
 */
public function handleException($e)
{
    if (! $e instanceof Exception) {
        $e = new FatalThrowableError($e);
    }
        
    // Il metodo report controlla se l'exception va loggata o meno
    $this->getExceptionHandler()->report($e);
    
    if ($this->app->runningInConsole()) {
        // Render dell'exception da linea di comando
        $this->renderForConsole($e);
    } else {
        // Render dell'exception come view
        $this->renderHttpResponse($e);
    }
}
    
// Secondo step
/**
 * Render an exception as an HTTP response and send it.
 *
 * @param  \Exception  $e
 * @return void
 */
protected function renderHttpResponse(Exception $e)
{
    $this->getExceptionHandler()->render($this->app['request'], $e)->send();
}

Il metodo renderHttpResponse richiama la classe App\Exceptions\Handler che eredita il metodo render dalla classe Illuminate\Foundation\Exceptions\Handler e, nel caso in cui l'utente non modifichi nulla, lo richiama. Ricordiamoci che da questo momento in poi la gestione dell'exception viene standardizzata dal framework, e questo comporta una certa "rigidità" del flusso dal momento in cui il metodo `parent::render($request, $exception)' viene richiamato, al momento in cui riceviamo il response finale. Al tempo stesso, viene lasciata al developer la più totale libertà di modificarlo, dato che al momento l'unica cosa che ha fatto il framework è intercettare una exception e richiamare la classe addetta alla sua gestione.

Vediamo quindi come si comporta il framework da questo momento in poi


/**
 * 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)
{
    /* 
     * Qui si possono aggiungere le istruzioni custom per la gestione delle exception
     */
     
    // Caso base
    return parent::render($request, $exception);
}

Richiamato il metodo parent::render($request, $exception), il framework inizia a preparare un normale response (come avviene per qualsiasi altro controller dell'applicazione) che contiene sia i dati della request che l'exception lanciata. Prima di arrivare al response finale però effettua alcuni controlli sul tipo di exception lanciata, convertendola dove necessario per facilitarne la gestione.

Per farvi un esempio, il metodo $this->validate($request, $rules) nel caso in cui riscontra la violazione di una regola, lancia una ValidationException che segue lo stesso iter delle normali exception, ma il response finale è sarà un redirect:

// Validation exception
redirect()->back()->withInput($request->input())->withErrors($errors)

Nel caso in cui l'exception non rientri in uno dei casi particolari, allora verrà si procederà con la creazione del response:

/**
 * Render an exception into a response.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  \Exception  $e
 * @return \Symfony\Component\HttpFoundation\Response
 */
public function render($request, Exception $e)
{
    $e = $this->prepareException($e);

    if ($e instanceof HttpResponseException) {
        return $e->getResponse();
    } elseif ($e instanceof AuthenticationException) {
        return $this->unauthenticated($request, $e);
    } elseif ($e instanceof ValidationException) {
        return $this->convertValidationExceptionToResponse($e, $request);
    }

    // Caso base
    return $this->prepareResponse($request, $e);
}

/**
 * Prepare response containing exception render.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  \Exception $e
 * @return \Symfony\Component\HttpFoundation\Response
 */
protected function prepareResponse($request, Exception $e)
{
    if ($this->isHttpException($e)) {
        return $this->toIlluminateResponse($this->renderHttpException($e), $e);
    } else {
        return $this->toIlluminateResponse($this->convertExceptionToResponse($e), $e);
    }
}

Si passa quindi al render vero e proprio, che rispecchia la "rigidità" precedentemente citata e che, a mio avviso, può essere il motivo principale di modifica e personalizzazione della classe App\Exception\Handler. Nel caso in cui l'exception sia un'istanza della classe Symfony\Component\HttpKernel\Exception\HttpException o una sua estensione, verrà controllato lo statusCode (4xx, 5xx) e ricercata la corrispondente view nella cartella resources/views/errors/, altrimenti l'ExceptionHandler ritornerà l'html con lo stacktrace dell'eccezione. Mi soffermo solo sui metodo getHtml($exception) e decorate($content, $css), richiamati nel caso in cui l'exception non sia un'istanza/estensione della classe Symfony\Component\HttpKernel\Exception\HTTPException, poiché è il responsabile della generazione della pagina che tutti noi conosciamo bene, e che potrebbe essere spunto per una propria pagina custom.

/**
 * Gets the full HTML content associated with the given exception.
 *
 * @param \Exception|FlattenException $exception An \Exception or FlattenException instance
 *
 * @return string The HTML content as a string
 */
public function getHtml($exception)
{
    if (!$exception instanceof FlattenException) {
        $exception = FlattenException::create($exception);
    }

    return $this->decorate($this->getContent($exception), $this->getStylesheet($exception));
}

private function decorate($content, $css)
{
        return <<<EOF
<!DOCTYPE html>
<html>
    <head>
        <meta charset="{$this->charset}" />
        <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;
}

Per quanto questo workflow sia completo in ogni sua forma, possono nascere esigenze diverse per eccezioni particolari per le quali preferiamo che l'applicazione abbia un comportamento diverso rispetto allo standard. Vorrei ricordare inoltre che i metodi del "dietro le quinte", ovvero della classe Illuminate\Foundation\Exceptions\Handler, possono essere a loro volta utilizzati, evitando quindi codice ridondante. Vi faccio un piccolo esempio:

/**
 * 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)
{
    if ($exception instanceof App\Exceptions\MyCustomException && $request->ajax()) {
        // Ritorno l'eccezione in formato JSON e log
        Log::error($exception->getMessage());
        return response()->json(['whoops' => $exception->getMessage()], $exception->getStatusCode());
    } else if($exception instanceof App\Exceptions\MyCustomException && env('APP_ENV', 'local') == 'production'){
        // Ritorno una view apposita
        return response()->view('errors.custom_error.blade.php', ['whoops' => $exception->getMessage()]);
    } else if($exception instanceof App\Exceptions\MyCustomException) {
        // Ritorno la view di debug
        return $this->convertExceptionToResponse($exception);
    }

    return parent::render($request, $exception);
}

Da qui in poi dipende tutto dalle necessità che ciascun developer ha per la propria applicazione. Il consiglio che do è comunque di sfruttare il più possibile l'handler soprattutto per evitare spiacevoli schermate bianche in produzione o per crearsi un piccolo "monitor" che notifichi in tempo reale uno sviluppatore di quanto è accaduto (quindi dati della request, eccezione lanciata ecc...) senza dover scartabellare nella cartella dei log in cerca di risposta.

... ed ora?

Come gestite gli errori e i log nelle vostre applicazioni?

Se volete avere maggiori informazioni riguardo errori ed gestione degli stessi, piuttosto che gestire viste customizzate o response in JSON per, ad esempio, le rotte API, vi invito a continuare la discussione in tutti i canali di Laravel Italia così da riuscire rendere più completo ed esaustivo l'articolo di oggi.

Ah, e ci trovate anche sul nostro Slack!