Cache layer for slow PHP objects

When I was measuring execution time in a new PHP application using Doctrine and Zend_Date, I discovered lots of time is consumed by the code. It will slow down the app really if you show 50 objects in a list on a page when every object uses 100ms to list the results. It’s hard to get that library code fast, it’s easier to write a cache class around the object.

The problem with these objects is that it takes too much time to get the output from certain methods or properties.
See for example


$date = new Zend_Date(...);
....
print $date->toString();

The toString() method takes up lots of execution time. To ‘fix’ that, instead of this we use a cache object that has the original object and forwards all calls from the cache object to the original object, and caches the output, so next time, it will be served from cache.


$date = new Zend_Date(...);
....
$cachedDate = new Cache($date);
print $cachedDate->toString();

Preserve object type

Sometimes you want to use type hinting and other methods to force the right type of object to be used within your application. Therefore you can use interfaces so both the cache object and the date object roots from the same interface which describes the proper abilities:


interface Interface_Date { public function toString(); }

class Date extends Zend_Date implements Interface_Date {}

class Cache_Date extends Cache implements Interface_Date
{
	public function toString()
	{
		return parent::__call('toString', array());
	}
}

function printDate(Interface_Date $date) { print $date->toString(); }

$date = new Date(...);
....
$cachedDate = new Cache($date);
// both work now using type hinting:
printDate($date);
printDate($cachedDate);

Iterators

The cache object is most usefull when using and looping through long lists of objects. Therefore a Cache_Iterator becomes handy. For maximum execution efficiency, it will keep all calls to all containing objects in one cache item and writes it to cache on destruction of the Cache_Iterator container object.


/* for example */
$dates = $users->getBirthDates();
/* assume $dates is an iterator with slow Date objects in it */

$datesCached = Cache_Iterator($dates);

foreach($datesCached as $dateCached)
{
	// dateCached is now a Cache object proxying a Date object
	print $dateCached->toString();
}

Also here, type preserving can be done.


/* extend Cache_Iterator and make it
   producing Cache_Date objects instead
   of Date objects when looping */
class Cache_Iterator_Date extends Cache_Iterator
	{ protected $_class = "Cache_Date"; }

$dates = $users->getBirthDates();

$datesCached = Cache_Iterator_Date($dates);

foreach($datesCached as $dateCached)
{
	// dateCached is now a Cache_Date object
	printDate($dateCached);
}

Cache ID auto generated

The cache ID is generated by a md5 sum of a serialized object. The serialisation does not take significant execution time and it will make sure that an updated object or iterator with objects will generate new cache instead of showing the old contents. It however also means this won’t be usefull if the object or object iterator changes all the time. In such cases, find another unique ID like the combination of get_class($object) and $object->count() (Iterator), or $object->id (single object).

When does this cache method not help

When the time consuming or load is not caused by object code execution, but for example, (inefficient) database queries, database overload or a problem before or after or somewhere else but showing contents of the object. (No shit…) You should analize time consumption in your (too slow) application quite carefully. It’s however quite easy, by using print microtime(); between your statements.

The classes

The code can be downloaded at http://www.welmers.net/~bastiaan/php-cache.tar.gz


/**
 * @author Bastiaan Welmers
 *
 * cache layer to an object
 */
class Cache
{
	/**
	 * holds object that needs to be cached
	 * @var object
	 */
	private $object;

	/**
	 * the Cache ID of the cache data in Zend_Cache
	 * @var string
	 */
	private $cacheId;

	/**
	 * The instance of Zend_Cache
	 * @var Zend_Cache
	 */
	private $cacheInstance;

	/**
	 * The cache data
	 * @var array
	 */
	private $cache;

	/**
	 * Keeps track of changes in cache data. If true, cache data needs to be saved on destruction
	 * @var boolean
	 */
	private $needSave = false;

	/**
	 * Set to false by constructor if data doesnt need to be saved on destruction. default true.
	 * @var boolean
	 */
	private $saveAtDestruct = true;

	/**
	 * construct cache object.
	 * @param object Object that needs to be cached
	 * @param object optional object (passed by instance) where to save data in, instead of
	 *               creating our own. if this variable is set, cache data wil not be saved by us
	 *               @see Cache_Iterator for this kind of use
	 */
	public function __construct($object, &$cache = false)
	{
		$this->object = $object;
		if ($cache === false)
		{
			// cache variable passed, here we save the data in.

			// try to find an unique id of the object for saving
			if (isset($object->id))
				$this->cacheId = get_class($object) . $object->id;
			else
				$this->cacheId = get_class($object) . '_' . md5(serialize($object));

			// get cache instance from zend_registry. Here we use a Zend_Cache backend object.
			// Here you can set your own if not using Zend_Cache.
			// It must however implement save() and load().
			$this->cacheInstance = Zend_Registry::get('cache');

			// load data from cache if available
			$this->cache = $this->cacheInstance->load($this->cacheId);
			if (!is_array($this->cache))
				$this->cache = array();

		} else {
			// cache variable passed, here we save the data in.
			$this->cache =& $cache;
			if (!is_array($this->cache))
				$this->cache = array();
			// don't save the data on destruction
			$this->saveAtDestruct = false;
		}
	}

	/**
	 * catch all calls to this object and transparently pass them
	 * to the object with caching in between.
	 * @param string method name
	 * @param array arguments of functions
	 * @return mixed result of call
	 */
	public function __call($method, array $args)
	{
		if (is_callable(array($this->object, $method)))
		{
			// make up an string ID
			$id = "method_{$method}_" . md5(serialize($args));
			if (!isset($this->cache[$id]))
			{ // not found in cache, do the actual call to our object

				$return = call_user_func_array(array($this->object, $method), $args);

				// if having a Date object, return a dedicated date cache object
				if ($return instanceof Date)
					$return = new Cache_Date($return, $this->cache[$id . '_date']);

				// save our result in cache data array
				$this->cache[$id] = $return;
				// we need so save the cache at destruction
				$this->needSave = true;
			} else
				// found in cache, get data from cache
				$return = $this->cache[$id];

			return $return;

		} else
			throw new Cache_Exception(get_class($this->object) . "::$method() is not callable");
	}

	/**
	 * catch all properties of this object and transparently get them
	 * from the object with caching in between.
	 * @param string property
	 * @return mixed result of underlaying object
	 */
	public function __get($property)
	{
		if (isset($this->object->$property))
		{
			$id = "property_{$property}";
			if (!isset($this->cache[$id]))
			{ // not found in cache. request from our object
				$return = $this->object->$property;
				// save in cache data array
				$this->cache[$id] = $return;
				// we need so save the cache at destruction
				$this->needSave = true;
			} else
				// get our result from cache
				$return = $this->cache[$id];

			return $return;

		} else
			throw new Cache_Exception(get_class($this->object) . "::$name is not set");
	}

	/**
	 * Checks if an item is set. We pass it to the
	 * the object directly.
	 * @param string property
	 * @return boolean true if set false if not
	 */
	public function __isset($property)
	{
		return isset($this->object->$property);
	}

	/**
	 * destructor for saving cache data to cache if neccessary, on
	 * end of object lifetime.
	 */
	public function __destruct()
	{

		if ($this->saveAtDestruct && $this->needSave && isset($this->cacheInstance))
			$this->cacheInstance->save($this->cache, $this->cacheId);

	}

	/**
	 * __toString magic function if this object is used as string.
	 * pass call to __call()
	 */
	public function __toString()
	{
		return $this->__call('__toString', array());
	}

}

/**
 * @author Bastiaan Welmers
 *
 * cache layer to an iterator
 * @see Cache
 * @see Iterator
 */
class Cache_Iterator implements Iterator, Countable
{
	/**
	 * Cache object to be spawned by current()
	 * @var string
	 */
	protected $_class = 'Cache';

	/**
	 * holds Iterator object that needs to be cached
	 * @var Iterator
	 */
	private $object;

	/**
	 * The instance of Zend_Cache
	 * @var Zend_Cache
	 */
	private $cacheInstance;

	/**
	 * The cache data
	 * @var array
	 */
	private $cache;

	/**
	 * the Cache ID of the cache data in Zend_Cache
	 * @var string
	 */
	private $cacheId;

	/**
	 * construct cache object.
	 * @param Iterator Object that needs to be cached
	 */
	public function __construct(Iterator $object)
	{

		$this->object = $object;
		$this->cacheInstance = Zend_Registry::get('cache');
		$this->cacheId = get_class($object) . md5(serialize($object));
		$this->cache = $this->cacheInstance->load($this->cacheId);
		if (!is_array($this->cache))
			$this->cache = array();
	}

	/**
	 * Rewinds object.
	 * Passed directly.
	 */
	public function rewind()
	{
		$this->object->rewind();
	}

	/**
	 * Get current value, and cache it if it is an object
	 * @return mixed value
	 */
	public function current()
	{
		// get value
		$object = $this->object->current();
		if (is_object($object))
			// create cache layer object of object,
			// pass our cache data array to it so it can save
			// its data there
			$object = new $this->_class($object, $this->cache[$this->key()]);

		return $object;
	}

	/**
	 * Get key of current value
	 * Passed directly.
	 * @return int key
	 */
	public function key()
	{
		return $this->object->key();
	}

	/**
	 * Move to next value.
	 * Passed directly.
	 */
	public function next()
	{
		$this->object->next();
	}

	/**
	 * Is current object still valid?
	 * Passed directly.
	 * @return boolean valid or not
	 */
	public function valid()
	{
		return $this->object->valid();
	}

	/**
	 * For countable iterators, count the values
	 * Passed directly.
	 * @see Countable
	 * @return int amount of values
	 */
	public function count()
	{
		if ($this->object instanceof Countable)
			return $this->object->count();
		else
			throw new Cache_Exception('Object of class ' . get_class($object) . ' does not implement Countable');
	}

	/**
	 * Destructor which saves the cache data at the end of object life time
	 */
	public function __destruct()
	{
		$this->cacheInstance->save($this->cache, $this->cacheId);
	}

}

Tags: , ,

Leave a Reply