ZF2 Cache #3: Full page caching

Full page cache banner

Introduction

This post will be covering the implementation of full-page caching in ZF2. It's also the last post in the ZF2 caching series in which we have already covered configuration caching and class/template maps.

Full-page caching is the saving of a dynamically generated web page to a static one that can later be retrieved and served to a user without rendering overhead. Take this page for example, when requested, the page makes around four calls to the database to create the navigation menu, retrieve the post itself, generate the "Recent Posts" and category lists on the sidebar. Consider how many resources we can save if this page was saved as a static file and stored on the file system ready to be called and served to the user.

Cache scenario for ZF2

To give you an understanding of how we will be achieving full page caching for ZF2, here is a breakdown of a typical request:

  1. Client requests a page e.g. http://localhost/post/6
  2. Using ZF2 route event (MvcEvent::EVENT_ROUTE), we first check if the page requested has already been cached.
  3. If it has been cached, the cached page is retrieved, passed to the response object and sent to the client.
  4. If it hasn't been cached, we let the rendering process finish (MvcEvent::EVENT_RENDER) which will leave us with the finished page.
  5. The rendered page is then saved to the file system and sent to the client.
  6. Subsequent calls to this page are then retrieved from the cache and served to the client without having to render again.

Implementing full-page caching

As you can see in this breakdown above, we will be making use of ZF2's event system with a particular focus on the ROUTE and RENDER events. Before we get to events, as ever, we will need to do some configuration. For this demonstration, I will be using the vanilla Application module as configured by the ZF2 Skeleton Application. These steps are not specific to the Skeleton app so feel free to try this out in any of your own modules.

Configuration

First, we need to define a factory service for Zend\Cache\StorageFactory and our custom class: CacheListener. Open the module/Application/config/module.config.php file and scroll down to the service_manager array key. Directly beneath the service_manager key add the highlighted code:

'service_manager' => array(
        'factories' => array(
            'Zend\Cache' => 'Zend\Cache\Service\StorageCacheFactory',
            'CacheListener' => 'Application\Service\Factory\CacheListenerFactory',
        ),
        'abstract_factories' => array(
            'Zend\Cache\Service\StorageCacheAbstractServiceFactory',
            'Zend\Log\LoggerAbstractServiceFactory',
        ),
        'aliases' => array(
            'translator' => 'MvcTranslator',
        ),
    ),

Our CacheListener factory will return an object that extends the Zend\EventManager\AbstractListenerAggregate class. This will allow us to define our event listeners and caching logic without cluttering the Application's Module.php file.

Without setting some rules about what gets cached, all valid requests that return a page will get cached. For example, if your website has a route that handles requests for your Blog and you only want to cache blog pages, you can add a flag to your 'blog' route. In this instance, we will add the cache flag to our 'home' route like so:

'home' => array(
                'type' => 'Zend\Mvc\Router\Http\Literal',
                'options' => array(
                    'route'    => '/',
                    'defaults' => array(
                        'controller' => 'Application\Controller\Index',
                        'action'     => 'index',
                        'cache'      => true
                    ),
                ),
            ),

When we come to creating our model file, you will see that we will use this flag to determine whether the response returned from the 'home' route will be cached or not.

Our final configuration addition will be in the same module.config.php file. Create a new array key called "cache" and ensure that it's not under another key. Add the following values:

'cache' => array(
        'adapter' => 'filesystem',
        'options' => array(
            'cache_dir' => 'data/cache/fullpage'
        )
    ),

This cache array is injected into the StorageFactory factory whenever we call our CacheListener service. The StorageFactory uses the 'adapter' key to set the adapter of the caching service and the 'cache_dir' key is the folder path to where we will be storing our full-page caches. Be sure to create this folder path now where 'data' should be in the same folder as your 'public' folder.

Creating the Model and Factory files

Create the factory file and name it 'CacheListenerFactory.php' and save it under module/Application/src/Application/Service/Factory. Add this code:

namespace Application\Service\Factory;

use Zend\ServiceManager\FactoryInterface;
use Zend\ServiceManager\ServiceLocatorInterface;
use Application\Model\CacheListener;

class CacheListenerFactory implements FactoryInterface {

    public function createService(ServiceLocatorInterface $serviceLocator) {
        return new CacheListener($serviceLocator->get('Zend\Cache'));
    }

}

The factory above basically instantiates our Application\Model\CacheListener model and injects the the object returned by StorageFactory defined in our service manager:

'Zend\Cache' => 'Zend\Cache\Service\StorageCacheFactory'

Now to create our Model. This class will do most of the heavy lifting when it comes to caching our pages.

<?php
namespace Application\Model;

use Zend\EventManager\AbstractListenerAggregate;
use Zend\EventManager\EventManagerInterface;
use Zend\Mvc\MvcEvent;

class CacheListener extends AbstractListenerAggregate {

    protected $listeners = array();
    protected $cacheService;

    public function __construct(\Zend\Cache\Storage\Adapter\Filesystem $cacheService) {
	// We store the cache service generated by Zend\Cache from the service manager
        $this->cacheService = $cacheService;
    }

    public function attach(EventManagerInterface $events) {
	// The AbstractListenerAggregate we are extending from allows us to attach our even listeners
        $this->listeners[] = $events->attach(MvcEvent::EVENT_ROUTE, array($this, 'getCache'), -1000);
        $this->listeners[] = $events->attach(MvcEvent::EVENT_RENDER, array($this, 'saveCache'), -10000);
    }

    public function getCache(MvcEvent $event) {
        $match = $event->getRouteMatch();
	
	// is valid route?
        if (!$match) {
            return;
        }
	
	// does our route have the cache flag set to true? 
        if ($match->getParam('cache')) {
            $cacheKey = $this->genCacheName($match);

	    // get the cache page for this route
            $data = $this->cacheService->getItem($cacheKey);
		
	    // ensure we have found something valid
            if ($data !== null) {
                $response = $event->getResponse();
                $response->setContent($data);

                return $response;
            }
        }
    }

    public function saveCache(MvcEvent $event) {
        $match = $event->getRouteMatch();

	// is valid route?
        if (!$match) {
            return;
        }
	
	// does our route have the cache flag set to true? 
        if ($match->getParam('cache')) {
                $response = $event->getResponse();
                $data = $response->getContent();

                $cacheKey = $this->genCacheName($match);
                $this->cacheService->setItem($cacheKey, $data);
        }
    }


    protected function genCacheName($match) {
        return 'cache_'
                . str_replace('/', '-', $match->getMatchedRouteName());
    }
}

The most important code has to be the 'attach' method which defines our event listeners and its callback methods which will execute when the event occurs. Notice that we use the ROUTE event to first check if the page has already been cached. If it hasn't, the request will end up executing the callback method of the RENDER event, which will in turn, cache a page if the requested route can be cached.

onBootstrap method

With our configuration and class files now created, we are ready to add the necessary code to our Application's Module.php file. We will be adding code to the onBootstrap method of the Application class. This method is executed after all enabled modules are loaded making it the perfect place to define some event listeners that will get executed later on in the request cycle.

To begin, open the file module/Application/Module.php. The onBootstrap method will be the first method. Add the following code:

public function onBootstrap(MvcEvent $e)
    {
        $eventManager        = $e->getApplication()->getEventManager();
        $moduleRouteListener = new ModuleRouteListener();
        $moduleRouteListener->attach($eventManager);
        
        // get the cache listener service
        $cacheListener = $e->getApplication()->getServiceManager()->get('CacheListener');
        
        // attach the listeners to the event manager
        $e->getApplication()->getEventManager()->attach($cacheListener);
    }

Line 8 instantiates our CacheListener model using the service manager and injects the Zend\Cache service into it. Also, the CacheListener model extends the AbstractListenerAggregate class allowing the listeners we defined within it to be passed to the Event Manager on line 11.

Ready to go...

If you have followed and implemented every step so far, your application should be ready for execution. With the cache flag set to true on the 'home' route, we can test if our caching worked by simply loading the home page of the SkeletonApp. If your code is correct, the standard ZendSkeletonApp welcome page would have loaded. To confirm that the page got cached, simply check the folder where the cache should have been saved which was: data/cache/fullpage.

Your folder name may be different from mine as Zend randomly generates it but your data directory should now look like this with your newly created cached file open:

ZF2 fully cached page

Clearing your cache

There may be a time when you have made changes to your cached pages and the cached version needs updating. Simply add this line of code to an action in one of your controllers and call it via a route:

$this->getServiceLocator()->get('CacheListener')->getCacheService()->flush();

The code above retrieves our CacheListener model using the service manager, gets the cache service stored within it and calls its "flush" method. The flush method simply deletes the cache generate files and folders under the path you defined (data/cache/fullpage). To regenerate your cached pages, simply call them via your browser.

Caching routes that have parameters

Caching a page that is accessed by a single route path may not be sufficient enough for websites that have database driven pages accessible by their ID. You can cache these database driven pages by updating the "genCacheName" method in our CacheListener model. We can append the parameters of the route to create the cache filename like so:

protected function genCacheName($match) {
        return 'cache_' . str_replace('/', '-', $match->getMatchedRouteName() . '-' . md5(serialize($match->getParams())));
    }
comments powered by Disqus