PHP, Zend Framework and Other Crazy Stuff
Archive for January 18, 2009
Zend Framework Page Caching: Part 2: Controller Based Cache Management
Jan 18th
Controller Based Cache Controls
Caches are really cool, but when you get right down to it dumping them into Zend_Registry is ugly. I really really obsessively like Dependency Injection, and when you throw in a DI problem with cache control within the application it won’t be long before you’re itching for a simple cache management method. One of the simplest solutions for controlling caches from Controllers is to create a new Action Helper specifically for that purpose. This new helper will interface with a named cache (so it holds and can tidy up control for numerous registered caches) stored either within itself (better injection) or Zend_Registry (no so pretty injection). Here’s one I prepared earlier.
[geshi lang=php]< ?php
class ZFExt_Controller_Action_Helper_Cache extends Zend_Controller_Action_Helper_Abstract
{
protected $_caches = 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;
}
// 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);
}
}
}[/geshi]
A little extra work, and I can tell Zend_Registry it’s no longer welcome in my Controllers. Now I can access caches from Controllers through the API above including my Bootstrap to save a little on space. This is a simple example (extremely so), since I’d have high hopes for such a helper. Imagine having access to fine-tuned cache controls, default caches you don’t have to preconfigure (much), controls extending into Views and even observing Models. I’m getting carried away dwelling on these ideas, so back to the article.
[geshi lang=php]class ZFExt_Bootstrap
{
// …
public function run()
{
$this->setupEnvironment();
// Implement Page Caching at Bootstrap level before any
// MVC operations so these operations can be completely
// avoided when a valid cache exists
$this->usePageCache();
// If a valid cache exists, execution exits!
$this->prepare();
$response = self::$frontController->dispatch();
$this->sendResponse($response);
}
public function usePageCache()
{
$frontendOptions = array(
‘default_options’ => array(
‘cache’ => false
),
// Only cache URLs for Index and News controllers
// matching the following patterns
‘regexps’ => array(
‘^/$’ => array(‘cache’ => true),
‘^/index/’ => array(‘cache’ => true),
‘^/news/’ => array(‘cache’ => true),
‘^/blog/tags’ => array(‘cache’ => true)
)
);
$backendOptions = array(
‘debug_header’ => true,
‘public_dir’ => self::$root . ‘/public’
);
$backend = new ZFExt_Cache_Backend_Static($backendOptions);
// use our Adapter to deal with the Core’s private validation
$cache = new ZFExt_Cache_Backend_Static_Adapter(
Zend_Cache::factory(‘Page’, $backend, $frontendOptions)
);
// Add the new cache to the Cache Control Action Helper
Zend_Controller_Action_HelperBroker::addPrefix(‘ZFExt_Controller_Action_Helper’);
Zend_Controller_Action_HelperBroker::getStaticHelper(‘Cache’)->addCache(‘page’, $cache);
// cache all output after this point to static HTML
// assuming caching is enabled for the current URL
$cache->start();
}
}[/geshi]
This is going well! The stage is now set, so let’s revisit our problem! As we mentioned earlier, setting up a static page cache would mean all requests for that page would never ever see PHP or our application. In order to get rid of these static caches, we need to manually invalidate and remove them whenever the underlying data (or whatever) has changed. One way of doing this is to use the above Action Helper whenever such changes are passing through a relevant Controller.
Let’s imagine, based on the cached output from our /blog/tags/zend-framework route earlier, there is a Controller for adding new blog entries. Obviously, once a new entry is added we might want to update any pages linked to the tags for that entry. You might do something along the lines of:
[geshi lang=php]< ?php
class EntryController extends Zend_Controller_Action
{
public function processAction()
{
// store the blog entry by whatever means necessary, then...
// Also clear index page to show new entry!
$this->_helper->cache->removePageCache(‘/’);
// Need to add support for variations on
// the URL matching the route in future
// “/” is equivelant to “/index” and “/index/index”!
// And….”/default/index/index”!
$this->_helper->cache->removePageCache(‘/index’);
$this->_helper->cache->removePageCache(‘/index/index’);
$this->_helper->cache->removePageCache(‘/default/index/index’);
// You could do this all day…
// based on tags for the new entry, selectively clear effected static
// HTML caches (we’ll assume pagination is used, and remove recursively)
foreach ($tags as $tag) {
$this->_helper->cache->removePageCache(‘/blog/tags/’ . $tag, true);
$this->_helper->cache->removePageCache(‘/default/blog/tags/’ . $tag, true);
}
}
}[/geshi]
Hmm, we appear to have hit a snag using our Cache Helper, but nothing we can’t fix. The problem is that any change might spark a massive net of cache expirations across all equivelant URLs. Tracking these manually is difficult within the Controller, so it’s time to extract the cache removals to a separate class, from which we can have a better view of the the situtation and perhaps arrive at a solution.
Extracting Cache Controls
One fairly common approach to use when Controller based cache control is becoming too complicated, is to allow the caches to do all the work and indirectly observe actions. The theory is a simple one to apply to the Zend Framework, we add an Action Helper (or change the one we just added!) which detects or is told which Actions have been performed and, based on a list of Controller Actions which effect caches, the Helper can then perform a set of cache removals associated with that Action. The actual cache removals are extracted from the Controller and maintained in a more easily configurable class.
Since these new classes are not going to be driven from a configuration file as such, but are all written by hand given how cache mapping works, they should be merged into the same category as Controllers, Views and Models. Unfortunately it’s not so easy or fast to design yet another class location without inflection or directory/classname maps. So I’ve elected to do what I usually do, and hijack the functionality of a similar mapping system. By assuming all “cleaner” classes are located within /application/{module}/controllers (same as Controllers), we can reuse the Controller loading logic that exists in the Dispatcher of the framework. When we have more time, we could create support for an actual /cleaners directory. Here’s the updated ZFExt_Controller_Action_Helper_Cache class with some additions.
[geshi lang=php]< ?php
class ZFExt_Controller_Action_Helper_Cache extends Zend_Controller_Action_Helper_Abstract
{
protected $_caches = array();
protected $_cleaners = 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;
}
// 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);
}
}
// 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;
}
}
}
// Run cache cleaning operations after actions are dispatched
// enforces Cleaner methods as being “after{ActionMethod}”
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}();
}
}
}
}
// 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;
}
}[/geshi]
The new ZFExt_Controller_Action_Helper_Cache::useCleaner() method accepts the name of a Cleaner to use for the array of actions passed to the second parameter. For example:
$this->_helper->cache->useCleaner(‘entry’, array(‘process’, ‘delete’));
If you call this from the relevant Controller, it tells the Cache Helper to locate and instantiate the EntryCleaner class located in /application/controllers/EntryCleaner.php. When the Controller’s processAction() method finishes, the Cleaner’s afterProcess() action method will be called (and the same for the delete action).
By now, some of you should be remembering a similar API from another framework
. To be fair, I can’t take credit for inventing something that already exists but I think the ZF would benefit from the same solutions.
Here’s what the Cleaner would look like (Abstract parent added for completeness):
[geshi lang=php]< ?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()
{
// We won’t cover it yet, but Cache control needs a way to pass
// contexts to the Cache Cleaner (the same as Views need Models to function)
foreach ($this->_cache->tags as $tag) {
$this->_cache->removePageCache(‘/blog/tags/’ . $tag, true);
$this->_cache->removePageCache(‘/default/blog/tags/’ . $tag, true);
}
// remove all possible cached routes to Index page
$this->_cache->removePageCache(‘/’);
$this->_cache->removePageCache(‘/index’);
$this->_cache->removePageCache(‘/index/index’);
$this->_cache->removePageCache(‘/default/index/index’);
}
public function afterDelete()
{
// similar cache expiration for deletes (or extract common
// expirations to shared protected methods).
}
}[/geshi]
With the extracted Cleaner in place, here’s what the original Controller moves to:
[geshi lang=php]< ?php
class EntryController extends Zend_Controller_Action
{
public function init()
{
$this->_helper->cache->useCleaner(‘entry’, array(‘process’,'delete’));
}
public function processAction()
{
// store the blog entry by whatever means necessary
}
public function deleteAction()
{
// delete one or more entries
}
}[/geshi]
Ticking our boxes, everything seems to be working with these changes. We still have two problems though.
URL variations that match the same route will create different caches depending on the URL used, so all cache expiries need to account for all alternative but equivelant URLs. Secondly, our Cleaner classes will work find for limited cache management, but the more complex things get the more difficult to maintain it will become.
In Part 3 we’ll attempt to deal with this problem, but for now let’s quickly add one more piece of functionality and present the final version of all concerned classes until next time.
Integrating Cache Initialisation Into Controllers
Since we can now expire static file caches from Controllers, it stands to reason we can also create them the same way!
This need a few more small changes to all classes (mainly for that Frontend private static method I referred to in Part 1), Here’s out Adapter:
More >
Zend Framework Page Caching: Part 2b: Controller Based Cache Management
Jan 18th
Continuing from Part 2!
Check out the direct() and preDispatch() methods – using these we can setup what to cache, start caching, and all without needing regular expressions since our Static backend is driven by the current REQUEST URI.
Given all the above changes, it’s no surprise the Static Backend has had a few updates!
[geshi lang=php]
class ZFExt_Cache_Backend_Static extends Zend_Cache_Backend implements Zend_Cache_Backend_Interface
{
const DEBUG_HEADER = 'DEBUG HEADER : This is a cached page !';
// Available options
protected $_options = array(
'public_dir' => null,
‘file_extension’ => ‘.html’,
‘index_filename’ => ‘index’,
‘file_locking’ => true,
‘cache_file_umask’ => 0600,
‘debug_header’ => false
);
// Test if a cache is available for the given id and (if yes) return it
// (false else)
// $id should be the REQUEST_URI whose static file is to be deleted
public function load($id, $doNotTestCacheValidity = false)
{
$id = $this->_decodeId($id);
if (empty($id) || $id == ‘_’) {
$id = $this->_detectId();
}
if (!$this->_verifyPath($id)) {
Zend_Cache::throwException(‘Invalid cache id: does not match expected public_dir path’);
}
if ($doNotTestCacheValidity) {
$this->_log(“ZFExt_Cache_Backend_Static::load() : \$doNotTestCacheValidity=true is unsupported by the Static backend”);
}
$fileName = basename($id);
if (empty($fileName)) {
$fileName = $this->_options['index_filename'];
}
$pathName = $this->_options['public_dir'] . dirname($id);
$file = $pathName . ‘/’ . $fileName . $this->_options['file_extension'];
if (file_exists($file)) {
$content = file_get_contents($file);
// move debug header to Frontend to prevent these gymnastics
return str_replace(self::DEBUG_HEADER, ”, $content);
}
return false;
}
// Test if a cache is available or not
// $id should be the REQUEST_URI whose static file is to be deleted
public function test($id)
{
$id = $this->_decodeId($id);
if (!$this->_verifyPath($id)) {
Zend_Cache::throwException(‘Invalid cache id: does not match expected public_dir path’);
}
$fileName = basename($id);
if (empty($fileName)) {
$fileName = $this->_options['index_filename'];
}
$pathName = $this->_options['public_dir'] . dirname($id);
$file = $pathName . ‘/’ . $fileName . $this->_options['file_extension'];
if (file_exists($file)) {
return true;
}
return false;
}
// Save content to a static content file in /public directory
// Note: We’re ignoring the ID as its not required.
public function save($data, $id, $tags = array(), $specificLifetime = false)
{
clearstatcache();
$requestUri = $this->_detectId();
$fileName = basename($requestUri);
if (empty($fileName)) {
$fileName = $this->_options['index_filename'];
}
$pathName = $this->_options['public_dir'] . dirname($requestUri);
if (!file_exists($pathName)) {
mkdir($pathName, $this->_options['cache_file_umask'], true);
}
if ($id !== ‘_’) { // empty ID since a Capture
$dataUnserialized = unserialize($data);
} else {
$dataUnserialized = array();
$dataUnserialized['data'] = $data;
}
if ($this->_options['debug_header']) {
$dataUnserialized['data'] =
self::DEBUG_HEADER . $dataUnserialized['data'];
}
$file = $pathName . ‘/’ . $fileName . $this->_options['file_extension'];
if ($this->_options['file_locking']) {
$result = file_put_contents($file, $dataUnserialized['data'], LOCK_EX);
} else {
$result = file_put_contents($file, $dataUnserialized['data']);
}
@chmod($file, $this->_options['cache_file_umask']);
if (count($tags) > 0) {
$this->_log(self::TAGS_UNSUPPORTED_BY_SAVE_OF_STATIC_BACKEND);
}
return (bool) $result;
}
// Remove a cache record
// $id should be the REQUEST_URI whose static file is to be deleted
public function remove($id)
{
$id = $this->_decodeId($id);
if (!$this->_verifyPath($id)) {
Zend_Cache::throwException(‘Invalid cache id: does not match expected public_dir path’);
}
$fileName = basename($id);
if (empty($fileName)) {
$fileName = $this->_options['index_filename'];
}
$pathName = $this->_options['public_dir'] . dirname($id);
$file = $pathName . ‘/’ . $fileName . $this->_options['file_extension'];
if (!file_exists($file)) {
return true;
}
return unlink($file);
}
// Remove a cache record recursively (i.e. the file AND matching directory)
// it ain’t perfect – there may be no file matching the directory name
// (but you get the point I’m sure!)
// $id should be the REQUEST_URI whose static file & dir tree is to be deleted
public function removeRecursively($id)
{
$id = $this->_decodeId($id);
if (!$this->_verifyPath($id)) {
Zend_Cache::throwException(‘Invalid cache id: does not match expected public_dir path’);
}
$fileName = basename($id);
if (empty($fileName)) {
$fileName = $this->_options['index_filename'];
}
$pathName = $this->_options['public_dir'] . dirname($id);
$file = $pathName . ‘/’ . $fileName . $this->_options['file_extension'];
$directory = $pathName . ‘/’ . $fileName;
if (file_exists($directory)) {
if (!is_writable($directory)) {
return false;
}
foreach (new DirectoryIterator($directory) as $file) {
if (true === $file->isFile()) {
if (false === unlink($file->getPathName())) {
return false;
}
}
}
rmdir(dirname($path));
}
if (file_exists($file)) {
if (!is_writable($file)) {
return false;
}
return unlink($file);
}
}
// Clean some cache records
// Not implemented here since we would need a backend tagging system given
// that static files themselves cannot be tagged in the filename.
public function clean($mode = Zend_Cache::CLEANING_MODE_ALL, $tags = array())
{
switch ($mode) {
case Zend_Cache::CLEANING_MODE_ALL:
case Zend_Cache::CLEANING_MODE_OLD:
case Zend_Cache::CLEANING_MODE_MATCHING_TAG:
case Zend_Cache::CLEANING_MODE_NOT_MATCHING_TAG:
case Zend_Cache::CLEANING_MODE_MATCHING_ANY_TAG:
$this->_log(“ZFExt_Cache_Backend_Static : Cleaning Modes Currently Unsupported By This Backend”);
break;
default:
Zend_Cache::throwException(‘Invalid mode for clean() method’);
break;
}
}
// Encoded by ZFExt_Cache_Backend_Static_Adapter
protected function _decodeId($id)
{
// another workaround since Zend_Cache_Core prevents
// empty or null IDs which we’ll have when Capturing
// and before the REQUEST_URI is checked
if ($id == ‘_’) {
return ”;
}
return pack(‘H*’, $id);
}
// “Danger, Will Robinson!”
// Sanity check to ascertain whether path is within the configured
// public_dir path
protected function _verifyPath($path)
{
$path = realpath($path);
$base = realpath($this->_options['public_dir']);
return strncmp($path, $base, strlen($base)) !== 0;
}
protected function _detectId()
{
// should strip query strings in future
// along with other fragments
return $_SERVER['REQUEST_URI'];
}
}[/geshi]
Two more source code listings remain – here’s the revised Bootstrap fragment:
[geshi lang=php]class ZFExt_Bootstrap
{
// …
public function run()
{
$this->setupEnvironment();
// Implement Page Caching at Bootstrap level before any
// MVC operations so these operations can be completely
// avoided when a valid cache exists
$this->usePageCache();
// If a valid cache exists, execution exits!
$this->prepare();
$response = self::$frontController->dispatch();
$this->sendResponse($response);
}
public function usePageCache()
{
$frontend = new ZFExt_Cache_Frontend_Capture();
$backendOptions = array(
‘debug_header’ => true,
‘public_dir’ => self::$root . ‘/public’
);
$backend = new ZFExt_Cache_Backend_Static($backendOptions);
// use our Adapter to deal with the Core’s private validation
$cache = new ZFExt_Cache_Backend_Static_Adapter(
Zend_Cache::factory($frontend, $backend)
);
// Add the new cache to the Cache Control Action Helper
Zend_Controller_Action_HelperBroker::addPrefix(‘ZFExt_Controller_Action_Helper’);
Zend_Controller_Action_HelperBroker::getStaticHelper(‘Cache’)->addCache(‘page’, $cache);
// Assume all caching initiated by Controllers (need to detect OB otherwise)
}
}[/geshi]
After all of that…our Controller has morphed into the following:
[geshi lang=php]
class EntryController extends Zend_Controller_Action
{
public function init()
{
$this->_helper->cache(array(‘index’));
$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]
Conclusion
Part 3 will start to address some of the weakness in the system so far (like track who has cached what and where
), but for now the system, in its prototype format, is operational. We can now statically cache HTML output on the fly from our controllers and implement Cleaners to clear that cache as actions dictate. We’ve even established the beginnings of a cache management system which favours Controller based instructions over regular expression url definitions. It’s still not fluid enough to be configurable, but we’re very close to that level.
Part 3 tomorrow!
