Archive for January, 2009

Zend Framework Page Caching: Part 2b: Controller Based Cache Management

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!