PHP Developers Network

A community of PHP developers offering assistance, advice, discussion, and friendship.
 
Loading
It is currently Fri Dec 15, 2017 1:21 am

All times are UTC - 5 hours




Post new topic Reply to topic  [ 1 post ] 
Author Message
PostPosted: Fri Aug 28, 2015 7:44 am 
Offline
Moderator
User avatar

Joined: Tue Nov 09, 2010 3:39 pm
Posts: 6403
Location: Montreal, Canada
Lesson 0 - Set up
Lesson 1 - Database abstraction
Lesson 2 - Routing and templating
Lesson 2.5 - Cleanup

In the first lesson of this series, I stated that the use of or die was not an acceptable means of handling error conditions. Seeing as these were mostly found after mysql_query calls in the original code, they were removed when we refactored that code and created our DB class. While removing the die statements was a step in the right direction, not replacing them with anything means our app is still vomiting debug code to the user whenever anything goes wrong. Aside from the horrible user experience this provides, it's not doing us any favours in trying to track down what went wrong when bugs are reported. Our task here, then, is twofold; present the user with pretty (if somewhat generic) error messages that make it clear something has gone wrong without having it appear that the site is completely broken, while at the same time allow us to go back after the fact and determine what specifically went wrong and where the error occurred.

We'll start by covering error handling, as our logging efforts will tie in to the methods we're using when handling these errors. PHP, capricious, inconsistent beast that it is, makes use of both errors and exceptions. Errors, unless they're fatal, just get tossed out there and execution continues on as though everything were fine. Uncaught exceptions, like fatal errors, will bring execution to a halt. All of this is somewhat problematic. Continuing execution after an error could well leave us in a situation where we're dealing with incorrect and/or invalid data. Garbage in, garbage out. Might be better to just bail. Of course, bringing execution to a grinding halt also means that we can't display a properly formatted, somewhat friendly error message to the user. Also not ideal. The goal here, then, is to set up a handler that will catch all exceptions (so that they don't just make everything stop), record what happened and why, and decide what to do based on that. Fortunately, all of the tools needed to achieve this are built right into PHP and are fairly trivial to implement.

Before we even do that, let's get a sort of a baseline set. Change the password being passed to your PDO constructor, navigate to /browse, and see what happens. PDO can't connect, PHP barfs all over our screen. No good at all. Our first reaction might be to add a try/catch block around our PDO construction. That could work, but it would only work for this one particular exception. We would soon find ourselves adding try/catch blocks all over the place until they've all but taken over our code. Then you realize that you can have multiple catch blocks for one try, that execptions continue bubbling up until they are caught, and you find yourself wrapping your whole app in one giant try/catch block. That's not actually a million miles away from what we will be doing. The principal idea here is to collect all of our errors in the same place, record them for later debugging, and decide how to act on them based on the nature of the error. To start, we will create an error handler class in which to set our error and exception handlers. As I mentioned earlier, errors do not necessarily halt execution. In order to have better control over how we react when error situations arise, we will convert all of our errors to exceptions through the handy dandy ErrorException class. They won't be as rich as exceptions, but at least we will be able to keep most of our logic in one place.

Syntax: [ Download ] [ Hide ]
<?php

namespace Squeaker;

use Exception;
use ErrorException;

class ErrorHandler {

    public function __construct() {
        set_error_handler([$this, 'handleError']);
        set_exception_handler([$this, 'handleException']);
    }

    public function handleError($type, $message, $file = __FILE__, $line = __LINE__, $context = []) {
        if (error_reporting() & $type) {
            throw new ErrorException($message, 0, $type, $file, $line);
        }

        return;
    }

    public function handleException($e) {
        var_dump($e);
        exit;
    }
}


Let's start with something like that. It isn't terribly useful right now as it just dumps the caught errors right back to the screen, but it will make it easy to confirm that our errors are all being sent to a central location.

Syntax: [ Download ] [ Hide ]
$handler = new Squeaker\ErrorHandler();


With this in place, let's refresh our browse page. You can now see that the exception is being caught and var_dump()'d as we had instructed. Step in the right direction. You may also have noticed, however, that instead of getting the expected PDOExcetion, our router is throwing its own exception. What's going on here? Simply, the router's dispatcher is grabbing any unhandled errors, wrapping them in an UnhandledException, and throwing that again. We can, however, prevent that from happening simply by catching our exceptions within the context of Klein. Let's move our ErrorHandler's construction into Klein's first catch all callback, right before we lazy load our DB class. At the same time, we'll set some callbacks to handle exceptions thrown by the router.

Syntax: [ Download ] [ Hide ]
$router->respond(function($request, $response, $service, $app, $router) {
    $app->errorHandler = new Squeaker\ErrorHandler();

    $app->register('DB', function() {
        $pdo = new PDO('mysql:host=' . getenv('DBHOST') . ';dbname=' . getenv('DBNAME'), getenv('DBUSER'), getenv('DBPASS'));
        $db = new Squeaker\DB($pdo);
        return $db;
    });
});


Syntax: [ Download ] [ Hide ]
$router->onHttpError(function($status, $router, $routes, $params, $exception) {
    throw $exception;
});

$router->onError(function($router, $message, $type, $exception) {
    throw $exception;
});


Reload the page, we're now getting the expected exception. Good. The whole point of catching these exceptions was to keep the user from having to see them, instead presenting something a little nicer. With the exception caught, we can redirect the user to a custom error page. If you've looked at the manual page for set_error_handler, you'll notice that it doesn't account for fatal errors, but we will still want to handle those. Because fatal errors cause execution to halt, the secret sauce here is register_shutdown_handler. This function will be run when PHP stops, even if a fatal error was thrown. We can therefore grab the last error and, if it was fatal, toss that up to our exception handler for logging and to redirect the user. Let's comment out the return line in our lazy loaded DB callback to force a fatal error and work on getting these handled as well.

We'll add the following to our error handler's constructor,

Syntax: [ Download ] [ Hide ]
register_shutdown_function([$this, 'shutdown']);


define our shutdown handler

Syntax: [ Download ] [ Hide ]
public function shutdown() {
    $error = error_get_last();
    if ($this->isFatalError($error['type'])) {
        $fatal = new ErrorException($error['message'], 0, $error['type'], $error['file'], $error['line']);
        $this->handleException($fatal);
    }
}

protected function isFatalError($error) {
    return in_array($error, [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR]);
}


and try again. Reloading the page, we can see that our exception handler is indeed being called, but the fatal error itself is still being displayed beforehand. Turning off display errors will take care of that.

Now that we've got all of our error situations being sent to our error handler, we need to give some thought to what we actually want to do when an error situation occurs. Navigating to /bacon/pancakes or /leprechauns/are/great definitely needs to generate a 404. For now, maybe we can have everything else generate a 500 (Internal server error). Of course, it would be nice if these error pages matched the look and feel of the rest of our site, so let's pass our router instance as a parameter to our error handler's constructor and leverage that to send some nice responses to the browser.

We have already seen that navigating to a page that doesn't exist results in an HttpException being thrown by our router, so we can start by explicitly listening for those. We already have a layout defined, so let's put together a quick page to display these errors. It doesn't have to be beautiful -- that's beyond the scope of this tutorial series -- just something functional. Display the HTTP response code, the error message, and we're good.

Syntax: [ Download ] [ Hide ]
<h1><?= $this->code; ?></h1>
<div class="alert alert-danger"><?= $this->message; ?></div>


We'll adjust our exception handler to do something a little more useful than var_dump, at least for the HttpException instances

Syntax: [ Download ] [ Hide ]
public function handleException($e) {
    if ($e instanceof HttpException) {
        $response = $this->router->response();
        $service  = $this->router->service();
        $code     = $e->getCode();
        $message  = $e->getMessage();

        $service->sidebar = false;
        $response->code($code);
        $response->body($service->render($service->views_dir . 'error.php', ['code' => $code, 'message' => $message]));
        $response->send();
    } else {
        var_dump($e);
    }
    exit;
}


Head on over to /browse/for/something/hidden and, sure enough, we get our error page being displayed. The response code is there, but there's no message. Turns out Klein's HttpException::createFromCode method only populates the response code and not the message. We can set up some defaults based on the code being passed and fall back to those if no message is specified. If a message is provided, we'll prefer that.

Syntax: [ Download ] [ Hide ]
protected $messages = [
    '404' => "The page you requested could not be found.",
];


Syntax: [ Download ] [ Hide ]
public function handleException($e) {
    if ($e instanceof HttpException) {
        $response = $this->router->response();
        $service  = $this->router->service();
        $code     = $e->getCode();
        $message  = $e->getMessage() ? $e->getMessage() : $this->messages[$code];

        $service->sidebar = false;
        $response->code($code);
        $response->body($service->render($service->views_dir . 'error.php', ['code' => $code, 'message' => $message]));
        $response->send();
    } else {
        var_dump($e);
    }
    exit;
}


There. We now have a clean view being rendered with the appropriate HTTP code being returned when we catch HTTP exceptions. That just leaves... every other possible kind of exception. For now, at least, we can assume that if we're catching an exception that isn't HTTP related, something is pretty wrong with our application and a 500 is probably a suitable response.

Syntax: [ Download ] [ Hide ]
public function handleException($e) {
    $response = $this->router->response();
    $service  = $this->router->service();

    if ($e instanceof HttpException) {
        $code = $e->getCode();
        $message = $e->getMessage() ? $e->getMessage() : $this->messages[$code];
    } else {
        $code = 500;
        $message = 'Internal Server Error';
    }

    $service->sidebar = false;
    $response->code($code);
    $response->body($service->render($service->views_dir . 'error.php', ['code' => $code, 'message' => $message]));
    $response->send();

    exit;
}


We can fine tune that later if we want, but that should at least provisionally cover our error handling needs. Well, at least as far as the user is concerned. They now get a friendlier looking response. We still don't get anything useful. Enter Monolog. At the time of writing, the most recent version is 1.16.0, so let's toss that in our composer.json, run composer update, and start recoring those errors as they occur. This step is actually absurdly simple; create our logger, store it as a parameter in our error handler, log our exceptions. Easy peasy.

Of course, the first thing we're going to need is somewhere to actually write the log files. A directory called logs seems like an obvious choice. Just make sure your web server can write to that directory. You don't want your error logs being committed to your repo, so be sure to gitignore the contents of the directory by placing a .gitignore file inside it

Syntax: [ Download ] [ Hide ]
*
!.gitignore


This tells Git to ignore the entire contents of the directory, except the .gitignore file itself, so that cloning the project will create an empty logs directory for us. Now we just need to create the logger instance and pass it to our error handler. I opted for daily logs to keep the file size down and make it easier to know where to find the error you're looking for when the time comes.

Syntax: [ Download ] [ Hide ]
$app->register('logger', function() {
    $logfile = dirname(__DIR__) . '/logs/debug-' . date('Y-m-d') . '.log';
    $logger = new Monolog\Logger('squeaklog');
    $logger->pushHandler(new Monolog\Handler\StreamHandler($logfile, Monolog\Logger::WARNING));
    return $logger;
});

$app->errorHandler = new Squeaker\ErrorHandler($router, $app->logger());


We add it to our error handler, and write to the logs when an error comes in like so

Syntax: [ Download ] [ Hide ]
public function handleException($e) {
    $this->logger->error($e);

    $response = $this->router->response();
    $service  = $this->router->service();

    if ($e instanceof HttpException) {
        $code = $e->getCode();
        $message = $e->getMessage() ? $e->getMessage() : $this->messages[$code];
    } else {
        $code = 500;
        $message = 'Internal Server Error';
    }

    $service->sidebar = false;
    $response->code($code);
    $response->body($service->render($service->views_dir . 'error.php', ['code' => $code, 'message' => $message]));
    $response->send();

    exit;
}


That's really all there is to it. Head back to /top/secret/route, generate a 404, and check your log file. All there. Sadly, it's a little hard to read. Adding in a formatter should help with that. It's a touch more work to set up, but well worth it in the end.

Syntax: [ Download ] [ Hide ]
$app->register('logger', function() {
    $logfile = dirname(__DIR__) . '/logs/debug-' . date('Y-m-d') . '.log';
    $logger = new Monolog\Logger('squeaklog');
    $handler = new Monolog\Handler\StreamHandler($logfile, Monolog\Logger::WARNING);
    $formatter = new Monolog\Formatter\LineFormatter;
    $formatter->includeStacktraces();
    $handler->setFormatter($formatter);
    $logger->pushHandler($handler);
    return $logger;
});


Let's delete our old log file, generate a new 404 error by hitting a page that doesn't exist, and see a much cleaner and easier to read log file.

We've got error handling covered on the front end, we've got our exceptions being logged with stack traces, now maybe we want to think about generating some errors on our own. And when I say errors, I really mean exceptions. Why exceptions? They're more flexible, more customizable, can be thrown, caught, modified, and thrown again if needed. You can extend the base Exception class as much as you like, allowing you to create very specific exception classes you can listen for an act on. Let's look at an example.

Let's browse to /user/12. We get the expected page, see some stats about the user and a list of their squeaks. Now browse you /user/122. We get a broken app. Our routing logic is good -- /user/ followed by an integer should display that user's page -- but our router can't possibly know which user IDs are valid and which aren't. That's something we need to check for. We can create a method on our DB class to check for that easily enough

Syntax: [ Download ] [ Hide ]
public function userExists($id) {
    $query = "SELECT COUNT(id) FROM users WHERE id = :id";
    $stmt = $this->db->prepare($query);
    $exec = $stmt->execute(['id' => $id]);

    return $stmt->fetchColumn() == 1;
}


but what do we do when that returns false? We could redirect the user back to the home page and display a flash message that the user requested doesn't exist. That's probably OK for users of our site but sending a 302 to Google, telling it /user/122 has moved to /, isn't terribly helpful. What if we decide to build up an API so users can consume our service remotely? Getting nonsensical responses would be frustrating for them, to say the least.

We could trigger an error. We're currently converting errors to ErrorException but we don't have to. We could use specific error codes to mean specific things and try to act accordingly, but there are only so many error types available to us and the more third party packages we use (assuming they also rely on trigger_error) the more muddled things quickly become. Our best bet is to throw an exception and be as specific as we need to. We could create our own UserNotFoundException, which would be perfectly suited to this use case, or, since we're returning a 404 and are already listening for Klein's HttpException, we can throw one of those with code 404 and a custom message. Either option is fine, so let's use the latter for the sake of simplicity.

Syntax: [ Download ] [ Hide ]
$router->get('/user/[i:id]', function($request, $response, $service, $app) {
    $exists = $app->DB()->userExists($request->id);
    if (!$exists) {
        throw new Klein\Exceptions\HttpException('User not found', 404);
    }
    $squeaks = $app->DB()->getSqueaksForUser($request->param('id'));
    $service->sidebar_data = $app->DB()->getUserSidebar($request->param('id'));
    $service->render($service->views_dir . 'user.php', ['squeaks' => $squeaks]);
});


Hit /user/122 again and we do indeed get the expected 404 error. Open up the network tab in your browser's developer tools, reload the page, and you'll see we're also returning the correct HTTP response code. Open up today's log file and you see detailed output of exactly what went wrong and why. All good.

There is a lot more we could cover here, from having Monolog (or any PSR-3 logger) listen for different types of errors and responding differently, to triggering emails on critical errors, to more complex exception handling based on a number of more fine-grained user-defined exception classes, but I think this illustrates my point quite clearly and I'll leave the rest for the ready to explore on their own.

Download lesson

_________________
Supported PHP versions No longer supported versions


Top
 Profile  
 
Display posts from previous:  Sort by  
Post new topic Reply to topic  [ 1 post ] 

All times are UTC - 5 hours


Who is online

Users browsing this forum: No registered users and 1 guest


You cannot post new topics in this forum
You cannot reply to topics in this forum
You cannot edit your posts in this forum
You cannot delete your posts in this forum
You cannot post attachments in this forum

Jump to:  
Powered by phpBB® Forum Software © phpBB Group