Benjamin Tigano Developer at Large

Adding page caching capabilities

So, you've got some pages on your site that don't deserve the the whole dispatch process, and a dynamic rendering.  Real-world examples would be an about page, privacy policy, or anything else that wouldn't have dynamic content. For these cases, we're going to use page-caching.

First, I'd like to point out that page caching isn't for all web applications. If you don't get a lot of traffic, the juice isn't worth the squeeze (not that we'll be squeezing all that hard, but still.) Don't spend time over-optimizing for a problem you don't have. If you have a very complex, high-traffic web application, then you should probably use a caching system that's a little more granular. Caching nodes, or widgets is probably much more effective, as you can have dynamic portions of a page mixed with sections built from cache.

Now that that's out of the way, let's start.

First, the ini configuration file. I added the section below to register the plugin with the front controller, and store the cache settings. In the case below, I'm using a lifetime of 3600 seconds (1 hour) for my cache. You'll likely want to adjust this (and the other settings) based on your application's needs.

resources.frontController.plugins.pagecache = "My_Controller_Plugin_PageCache"
pageCache.frontEnd = core
pageCache.backEnd = file
pageCache.frontEndOptions.lifetime = 3600
pageCache.frontEndOptions.automatic_serialization = true
pageCache.backEndOptions.lifetime = 3600
pageCache.backEndOptions.cache_dir = APPLICATION_PATH "/../cache/pageCache"

The next item to discuss is the Bootstrap file (/application/Bootstrap.php). We have to register the namespace where the plugin will live (line highlighted below):

protected function _initAutoload()
{
	// add autoloader empty namespace
	$autoLoader = Zend_Loader_AutoLoader::getInstance();
	$autoLoader->registerNamespace('My_'); // added this for the early page cache plugin
	$resourceLoader = new Zend_Loader_Autoloader_Resource(array(
	'basePath' 		=> APPLICATION_PATH,
	'namespace' 	=> '',
));

Last but not least is the plugin itself. I won't go into too much depth on this because if you're not familiar with at least the basics of a Zend MVC application, you're probably not understanding the rest of this article. We start by creating methods for the dispatchLoopStartup and dispatchLoopShutdown. We use dispatchLoopStartup because after all this is an EARLY page cache. We use the dispatchLoopShutdown because at that point, we've gone through the full dispatch loop and have a complete response that we can cache for the next request.

In the dispatchLoopStartup, we grab the configuration from the Zend_Registry that we stored it to during bootstrapping (this was added in a previous lesson, but you knew that :).) We use those settings to create the Zend_Cache object. The first check we do is to make sure we're only caching GET requests. The next thing we do is create a cache pool key based on the page name. Next, we see if we can get a cache hit. If this is the first time we're visiting the page, it'll be a miss. If we do get a hit, we set the response based on what was in the cache, send the response, and then exit. If we didn't exit, we would go through the normal dispatch loop and avoid serving the cached page.

In the dispatchLoopShutdown, we do a few checks to make sure that the page is a good candidate for caching. If it is, we save it in the cache based on the same key we generated in the dispatchLoopStartup method.

Here is the code for the plugin class:

class My_Controller_Plugin_PageCache extends Zend_Controller_Plugin_Abstract
{
	public static $doNotCache = false;
	public $cache;
	public $key;

	public function dispatchLoopStartup(Zend_Controller_Request_Abstract $request)
	{
		$pageCacheConfig = Zend_Registry::get('config')->pageCache;

		$this->cache = Zend_Cache::factory(
		$pageCacheConfig->frontEnd,
		$pageCacheConfig->backEnd,
		$pageCacheConfig->frontEndOptions->toArray(),
		$pageCacheConfig->backEndOptions->toArray());

		if (!$request->isGet()) {
			self::$doNotCache = true;
			return;
		}

		$path = $request->getPathInfo();

		$this->key = md5($path);

		$data = $this->cache->load($this->key);
		if ($data)
		{
			$response = $data;
			$this->setResponse($response);
			$this->getResponse()->sendResponse();
			exit;
		}
	}

	public function dispatchLoopShutdown()
	{
		if (self::$doNotCache
		|| $this->getResponse()->isRedirect()
		|| (null === $this->key))
		{
			return;
		}
		$this->cache->save($this->getResponse(), $this->key);
	}
}

As always, there will be cases where you don't want a particular controller or action to be cached, in that case, we've built in a static property you can set in your controller that will stop the plugin from caching the response.

My_Controller_Plugin_PageCache::$doNotCache = true;

Currently, this will cache all GET requests except for the ones you override. You could reverse the $doNotCache property so that no pages are cached except for the ones you specify. You'd just change the declaration for that property in the My_Controller_Plugin_PageCache class to be initialized as true. And any pages you want cached would need to set it to false.

You could probably easily expand upon this for node/widget level caching, but I'll let you experiment with that. Feel free to post your work (or links to your work) in the comments.

If you've got questions, comments, or suggestions, you know what to do!

Download the project

Next up, we'll be connecting our application to a MySQL database.

Fork me on GitHub