Lightning Fast CakePHP
Brian Dailey is a LAMP-stack developer with a wide range of experience in the development world. Get in touch!
For more articles on the development trade, see the Blog.
Lightning Fast Caching for CakePHP
Frameworks: Benefits Come At A Cost
It is well known that CakePHP is not the fastest gun in the west. Paul Jones has published some great benchmark tests that compare the various frameworks against straight up PHP and HTML. I know that there are a lot of objections to this sort of test:
The benchmarks are somewhat dated: some of these frameworks have improved their performance.Edit: Paul tells me that the frameworks have not improved and in fact some of have gotten worse. He's working on a new round of tests but doesn't know when it'll be out yet.- Comparing the complicated tasks that frameworks routinely do against <?php echo 'Hello, world!'; ?> isn't really fair.
Despite numerous objections, the maxim still stands: frameworks come with a performance hit.
It boils down to this:
- Spend more time (and time equals money) on development.
- Spend more money on scaling hardware to meet demands
It's been pointed out by much smarter engineers than yours truly that development time is, generally speaking, much more expensive than scaling hardware. Therefore, many companies wisely choose to use a framework and enjoy the many advantages that come with it.
A Simultaneous Stroke Of Luck And Misfortune
You build a site on CakePHP, you launch it, and you hope the world beats a path to your door. Lets assume they do just that, but alas, you underestimated server capacity, the firehose of end-users, and the time it takes to migrate to a more robust environment. You need a quick fix.
If you have dynamic content, then you want to still give it to your users. Caching some pages for a few seconds or even minutes means that users may get information that is slightly dated, but in this case you don't care.
Here are the requirements for our caching mechanism:
- Do not cache pages for logged in users.
- Serve a cached page if available.
- Create a whitelist of pages that are not cached (search results, etc).
Have Your CakePHP And Eat It, Too
CakePHP works by routing all of your requests through webroot/index.php. The slowdown doesn't really occur until you launch the CakePHP Dispatcher. You can spend a lot of time tracking down performance hits in the CakePHP router, your own application code, or in some third-party code, but that takes time. The fastest way to make your application quicker is to skip all of that completely.
If you're familiar with CakePHP, you'll know that it already has a lot of caching built right in. You can cache views, queries, or other components, but maybe that's just not enough. We need lightning fast results with minimal server impact.
Therefore, our flow is as follows:
- Check to see if a user is logged in.
- If yes, then go ahead and dispatch. (This modification works best if most of your traffic isn't going to be logged in. )
- If no, then check and see if we have a non-expired cached version of the page.
- If a cached version is available, render it.
- If not, dispatch, capture the dispatch, and write it to a cache.
We'll actually be making modifications to webroot/index.php underneath the dreaded comment by CakePHP:
/** * Editing below this line should not be necessary. * Change at your own risk. * */
Decide Where To Cache
In this example I'm going to use a file system cache. It's easier to set up, but just remember that caching in memory is faster than caching on the disk. You can easily take this example and use Memcache instead.
To cache each page, we're going to need to create a key for each page. For this example, we're going to use the actual page URL, which is passed as $_GET['url'] (if you have .htaccess set up properly).
$cache_key = md5($_GET['url']);
You could also create a human-readable key by replacing slashes with underscores, but simply creating a hash of the URL is a pretty quick solution.
I'm going to save this to the tmp/cache directory, but as I mentioned, memcache is a far better solution.
$cached_file_name = CAKE_CORE_INCLUDE_PATH . 'app/tmp/cache/view_cache_' . $cache_key;
Detecting User Login
CakePHP uses it's own session name, so the first thing to do is grab session information. If your using a default install, it's probably named CAKEPHP.
session_name('CAKEPHP');
session_start();
You can leave the line checking for a favicon.ico request (I believe this is probably there because a lot of users do not have a favicon, so each request for that non-existant file would require a costly CakePHP dispatch call).
Just before $Dispatcher is called, we can see if this user is logged in.
if (isset($_GET['url']) && $_GET['url'] === 'favicon.ico') {
return;
} else {
if (
// Do not show cache if user is logged in
isset($_SESSION['User'])
) {
// Dispatch as normal.
if (!include(CORE_PATH . 'cake' . DS . 'bootstrap.php')) {
trigger_error("CakePHP core could not be found. Check the value of CAKE_CORE_INCLUDE_PATH in APP/webroot/index.php. It should point to the directory containing your " . DS . "cake core directory and your " . DS . "vendors root directory.", E_USER_ERROR);
}
$Dispatcher = new Dispatcher();
$Dispatcher->dispatch($url);
} else {
// Do nothing.
}
}
At this point, we're dispatching if the user has a session, otherwise, the user gets nothing. The next thing on our list is to check and see if a cached version of the page is available.
if (
// User logged in
) {
// Dispatch
} else if (file_exists($cached_file_name) && filemtime($cached_file_name) > (time()-300)) {
readfile($cached_file_name);
// echo '';
} else {
// Do nothing.
}
In the above, we're first checking to see if the cache exists, and since we're using a file cache, we also have to see how old the file is (in this case five minutes). If we were using Memcache, we could configure the cache to expire automatically.
readfile is a quick method to read the contents of the file and spit it straight out to the browser.
The next step is to handle a request from a user with no session when the cache is expired or unavailable.
if ( /* User logged in */ ) {
// Dispatch
} else if ( /* Cache available */ ) {
// Render cache.
} else {
// Dispatch, regenerate catch.
if (!include(CORE_PATH . 'cake' . DS . 'bootstrap.php')) {
trigger_error("CakePHP core could not be found. Check the value of CAKE_CORE_INCLUDE_PATH in APP/webroot/index.php. It should point to the directory containing your " . DS . "cake core directory and your " . DS . "vendors root directory.", E_USER_ERROR);
}
$Dispatcher = new Dispatcher();
$output = $Dispatcher->dispatch($url, array('return' => 1));
file_put_contents($cached_file_name, $output);
echo $output;
}
The important part here are the parameters we're passing to the $Dispatcher->dispatch() method. The second parameter tells the dispatcher that we don't want it to write output directly to the browser, instead, we want it returned so we can capture it in $output.
The next line simply writes the contents of $output to our cache file, and then echo it all out to the browser. Obviously, you'll want to add error-handling here in case the write to cache fails.
At this point, you should have a working cache, but this means *every* page is going to be fetched from cache. That includes dynamic pages like search results, a login page... everything. Obviously, that means no users can log in, and your application is effectively broken. Great job, padawan! Let's get to fixing it.
White Listing Pages
In the first logic test we checked to see if the user was logged in, and dispatched the page if they were. We're going to add these additional tests there.
if (
// Do not show cache if user is logged in
isset($_SESSION['User'])
// Do not cache the login or signup page.
|| (isset($_GET['url']) && strpos($_GET['url'], 'users/login') !== FALSE)
// Do not cache messages page
|| (isset($_GET['url']) && preg_match('/messages\/index$/', $_GET['url']) !== 0)
// Do not cache search results
|| (isset($_GET['url']) && preg_match('/search\/index$/', $_GET['url']) !== 0)
) {
You can use strpos (faster) or preg_match (a little slower) to match URLs that you do not want to cache.
Hooray! Uber-Fast CakePHP!
The final code placed after your CORE_PATH should look something like this:
$cached_file_name = CAKE_CORE_INCLUDE_PATH . 'app/tmp/cache/view_cache';
// url is not set for root path.
if (isset($_GET['url'])) {
$cached_file_name .= md5($_GET['url']);
}
session_name('CAKEPHP');
session_start();
if (isset($_GET['url']) && $_GET['url'] === 'favicon.ico') {
return;
} else {
if (
// Do not show cache if user is logged in
// This session name might vary by your application.
isset($_SESSION['User'])
// Do not cache the login or signup page.
|| (isset($_GET['url']) && strpos($_GET['url'], 'users/login') !== FALSE)
// Do not cache messages page
|| (isset($_GET['url']) && preg_match('/messages\/index$/', $_GET['url']) !== 0)
// Do not cache search results
|| (isset($_GET['url']) && preg_match('/search\/index$/', $_GET['url']) !== 0)
) {
if (!include(CORE_PATH . 'cake' . DS . 'bootstrap.php')) {
trigger_error("CakePHP core could not be found. Check the value of CAKE_CORE_INCLUDE_PATH in APP/webroot/index.php. It should point to the directory containing your " . DS . "cake core directory and your " . DS . "vendors root directory.", E_USER_ERROR);
}
$Dispatcher = new Dispatcher();
$Dispatcher->dispatch($url);
} else if (file_exists($cached_file_name) && filemtime($cached_file_name) > (time()-300)) {
// Check for non-expired cached version.
readfile($cached_file_name);
// For debugging speed, you can uncomment this.
// echo '';
} else {
// Dispatch, regenerate catch.
if (!include(CORE_PATH . 'cake' . DS . 'bootstrap.php')) {
trigger_error("CakePHP core could not be found. Check the value of CAKE_CORE_INCLUDE_PATH in APP/webroot/index.php. It should point to the directory containing your " . DS . "cake core directory and your " . DS . "vendors root directory.", E_USER_ERROR);
}
$Dispatcher = new Dispatcher();
$output = $Dispatcher->dispatch($url, array('return' => 1));
file_put_contents($cached_file_name, $output);
echo $output;
}
}
If this approach was helpful to you, or on the otherhand, it seems terribly wrong-headed, just let me know via the comments. You can also shoot me an email, my first name at this domain name.
Hey Brian,
I'm curious if you've benchmarked this approach vs the built in view caching.