PHP, Zend Framework and Other Crazy Stuff
Archive for January, 2009
Zend Framework Page Caching: Part 3b: Tagging For Static File Caches
Jan 19th
Continued from Part 3…
Let’s make two more changes to complete the process. Here’s the new Controller from earlier where we set the tag “entries” on any Actions where blog entries are displayed or listed.
[geshi lang=php]
class EntryController extends Zend_Controller_Action
{
public function init()
{
$this->_helper->cache(array(‘index’), array(‘tag1′,’entries’,'tag3′));
$this->_helper->cache->useCleaner(‘entry’, array(‘process’,'delete’));
}
public function indexAction()
{
// show some entries, page should be cached
}
public function processAction()
{
// store the blog entry by whatever means necessary
}
public function deleteAction()
{
// delete one or more entries
}
}[/geshi]
Based on the tags being used, we can now simplify our previously nightmarish Cleaner to a single line!
[geshi lang=php]
class ZFExt_Cache_Cleaner_Abstract
{
protected $_cache = null;
public function __construct()
{
// Needing the Action Helper here may suggest the need to extract the
// functionality common to Helpers and Cleaners into it's own
// class for sharing
// Being an article, let's skip the obvious refactoring need before this
// becomes another book...
if (Zend_Controller_Action_HelperBroker::hasHelper('cache')) {
$this->_cache = Zend_Controller_Action_HelperBroker::getExistingHelper(‘cache’);
}
$this->_cache = Zend_Controller_Action_HelperBroker::getStaticHelper(‘cache’);
}
}
class EntryCleaner extends ZFExt_Cache_Cleaner_Abstract
{
public function afterProcess()
{
$this->_cache->removeTaggedPageCache(array(‘entries’));
}
public function afterDelete()
{
$this->_cache->removeTaggedPageCache(array(‘entries’));
}
}[/geshi]
I think we’ll stop here for now!
Conclusion
Over the last three (well, four if you count I ran out of space on Part 2 and needed a 2b ) parts of this weekend series I’ve covered page caching with a focus on caching output to static HTML files to offload work from Apache and PHP.
This caching strategy allows the webserver to avoid PHP completely, or alternatively for a frontend nginx/lighty reverse proxy to avoid Apache completely. Either way, it results in a very fast and efficient caching mechanism for entire pages which can be linked to Tags and Cleaners so they are updated only when the data they were originally generated from has changed.
It should be noted that while static HTML caching is one of the fastest possible caching mechanisms, it is not the most flexible. It is best suited to standalone, or small 2-3 unit scaled solutions, where filesystem operations can be unified with a frontend HTTP server. Trying to sync this style of caching across servers is to be avoided. It is also not suitable for highly dynamic pages which continually update or are specific to each visitor. In these cases the more expensive caching methods are often needed, though if the dynamic portions are small enough it might be worth delegating the generation of those dynamic portions to AJAX requests from the statically cached page, so that at least the bulk of any page is cached.
Additionally, once you do reach the point where static caching cannot be used, you should of course examine other caching options across the parts of the application impacted. Remember that you can cache database results, individual template output (even partials), CPU or memory intensive operations, etc.
In closing, I hope this series has offered a few good ideas .
Zend Framework Page Caching: Part 3: Tagging For Static File Caches
Jan 19th
Tracking What’s Being Cached
Expiring multiple caches linked to a specific change is easy to accomplish using Tagging, where we tag caches with keywords and clean caches based on those keywords. Unfortuntely, static files can’t be tagged in the normal way since their filenames must be constant. To deliver a similar system static file caching, we need to tag caches outside of the cache filename/contents itself which requires a Model or similar storage backend to keep track of tags and the caches to which they relate.
In a sense, we’re creating a cache within a cache. Although this is the simplest approach for small numbers of tags and URLs, a more robust system would be backed by a database to allow a greater degree of flexibility in minimising the size of the overall resultset needed to be loaded for any given page request.
For now, the form of the inner cache will be a simple array where for each Tag, we assign a list of tagged Request URIs. This array can then be cached either to a file or a memory slot in, for example, APC for retrieval in future requests. Its obvious flaw, without a database, is that it will be loaded in full for every request where the relevant Page Cache is utilised. We are applying some lazy loading however, but when it gets big enough the move to a database may be needed.
Let’s start by adapting ZFExt_Controller_Action_Helper_Cache to host an array of tags which is what we will cache and retrieve from the inner cache when any Tag operations are utilised. We’ll also make it possible to set tags when setting what Actions need to be cached. This will result in a system where tags are assigned for any cache we want, and those tags will be saved to the inner cache when the Action completes (using postDispatch()). We’ll also add a new removeTaggedPageCache() method which we can either call directly from an Action, or from a Cleaner class.
[geshi lang=php]< ?php
class ZFExt_Controller_Action_Helper_Cache extends Zend_Controller_Action_Helper_Abstract
{
protected $_caches = array();
protected $_cleaners = array();
protected $_caching = array();
protected $_obstarted = false;
protected $_tags = array();
protected $_tagged = array();
public function addCache($cacheId, $cache) {
if (!$cache instanceof Zend_Cache_Core && !$cache instanceof ZFExt_Cache_Backend_Static_Adapter) {
throw new Exception('Need to provide a valid cache!');
}
$this->_caches[$cacheId] = $cache;
}
public function createCache($cacheId, $frontend, $backend, $frontendOptions = array(), $backendOptions = array(), $customFrontendNaming = false, $customBackendNaming = false, $autoload = false) {
$cache = Zend_Cache::factory($frontend, $backend, $frontendOptions, $backendOptions, $customFrontendNaming, $customBackendNaming, $autoload);
$this->addCache($cacheId, $cache);
return $cache;
}
public function getCache($cacheId) {
if ($this->hasCache($cacheId)) {
return $this->_caches[$cacheId];
}
return false;
}
public function hasCache($cacheId) {
if (isset($this->_caches[$cacheId])) {
return true;
}
return false;
}
// Pass array of actions to cache for the current Controller
// optionally pass array of tags to assign.
public function direct(array $actions, array $tags = array()) {
$controller = $this->getRequest()->getControllerName();
foreach ($actions as $action) {
if (!isset($this->_caching[$controller])) {
$this->_caching[$controller] = array();
}
if (!isset($this->_caching[$controller][$action])) {
$this->_caching[$controller][] = $action;
}
if (!empty($tags)) {
if (!isset($this->_tags[$controller])) {
$this->_tags[$controller] = array();
}
if (!isset($this->_tags[$controller][$action])) {
$this->_tags[$controller][$action] = array();
}
foreach ($tags as $tag) {
if (!in_array($tag, $this->_tags[$controller][$action])) {
$this->_tags[$controller][$action][] = $tag;
}
}
}
}
}
// Remove page caches based on URL, with recursive matching directory
// removal for those where, for example, pagination is also being cached.
// Sec: remember what they say about “rm -R” - checks needed
public function removePageCache($relativeUrl, $recursive = false) {
if ($recursive) {
$this->getCache(‘page’)->removeRecursive($relativeUrl);
} else {
$this->getCache(‘page’)->remove($relativeUrl);
}
}
// delete all statically cached files which are associated with the
// given array of tags
public function removeTaggedPageCache(array $tags)
{
return $this->getCache(‘page’)->clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, $tags);
}
// create a nested array assigning cleaners to various
// controller+action combinations
public function useCleaner($cleanerName, array $actions)
{
foreach ($actions as $action) {
$controller = $this->getRequest()->getControllerName();
if (!isset($this->_cleaners[$controller])) {
$this->_cleaners[$controller] = array();
}
if (!isset($this->_cleaners[$controller][$action])) {
$this->_cleaners[$controller][$action] = array();
}
if (!isset($this->_caching[$controller][$action][$cleanerName])) {
$this->_cleaners[$controller][$action][] = $cleanerName;
}
}
}
// Commence caching for matching Actions
// Will exit if caching has already started
public function preDispatch()
{
$controller = $this->getRequest()->getControllerName();
$action = $this->getRequest()->getActionName();
if (!empty($this->_caching)) {
if (isset($this->_caching[$controller]) &&
in_array($action, $this->_caching[$controller])) {
// do not start caching if started earlier in cycle
// otherwise commence caching here
$stats = ob_get_status(true);
foreach ($stats as $status) {
if ($status['name'] == ‘Zend_Cache_Frontend_Page::_flush’) {
return;
}
}
$this->getCache(‘page’)->start();
$this->_obstarted = true;
}
}
}
// Run cache cleaning operations after actions are dispatched
// enforces Cleaner methods as being “after{ActionMethod}”
// Store the revised Tagged array into the inner cache.
public function postDispatch()
{
if (!empty($this->_cleaners)) {
$controller = $this->getRequest()->getControllerName();
$action = $this->getRequest()->getActionName();
if (isset($this->_cleaners[$controller][$action])) {
$cleanerNames = $this->_cleaners[$controller][$action];
foreach ($cleanerNames as $cleanerName) {
$cleaner = $this->createCleaner($cleanerName);
$method = ‘after’ . ucfirst($action);
$cleaner->{$method}();
}
}
}
if ($this->_obstarted) {
$this->getCache(‘page’)->end();
}
if (isset($this->_caching[$controller]) &&
in_array($action, $this->_caching[$controller])) {
$requestUri = $this->getRequest()->getRequestUri();
$this->_tagged = $this->_loadTagged();
if (isset($this->_tags[$controller][$action]) && !empty($this->_tags[$controller][$action])) {
foreach ($this->_tags[$controller][$action] as $tag) {
if (!isset($this->_tagged[$tag])) {
$this->_tagged[$tag] = array();
}
if (!in_array($requestUri, $this->_tagged[$tag])) {
$this->_tagged[$tag][] = $requestUri;
}
}
}
$this->_saveTagged();
}
}
// Cheat by stealing functionality from the Dispatcher! Haha!
// In a real class, should really implement this natively
// to keep down on dependencies, and allow cleaners to
// exist elsewhere. Also this is not Module friendly yet.
public function createCleaner($cleanerName)
{
$dispatcher = $this->getFrontController()->getDispatcher();
$className = $cleanerName . ‘Cleaner’;
$finalClassName = $dispatcher->loadClass($className);
$cleaner = new $finalClassName;
return $cleaner;
}
// load cached Tags from inner cache
protected function _loadTagged()
{
if (!$this->hasCache(‘tagged’)) {
throw new Zend_Exception(‘No “tagged” cache has been defined therefore Tagging cannot be utilised’);
}
if ($result = $this->getCache(‘tagged’)->load(‘zfextcache_tagged’)) {
$this->_tagged = $result;
}
}
// save existing Tags to inner cache
protected function _saveTagged()
{
if (!$this->hasCache(‘tagged’)) {
throw new Zend_Exception(‘No “tagged” cache has been defined therefore Tagging cannot be utilised’);
}
$this->getCache(‘tagged’)->save($this->_tagged, ‘zfextcache_tagged’);
}
}[/geshi]
You’ll notice that the Helper refers to a specific cache by name, and that the Static backend (presented below) refers to the same cache. This partial duplication may be another sign that the Action Helper is taking on too much responsibility, indicating the need for a discrete Cache Management class to centralise the inner cache with.
More >