Content with Style

Web Technique

A caching pattern for models

by Pascal Opitz on April 6 2009, 11:50

This is a caching pattern for models using Zend_Cache and the __call magic method.

The Basic Idea

The initial thought behind this is that a model should be able to return calls either uncached or cached, without initializing some cache object every time. It should be easy to switch between the two calls, and the cache should be coming with the model object already.

The old way

Before introducing the cache pattern, I would create an object that then would use Zend_Cache_Frontend_Class, then initialize a new instance of my model in there, then redefine a method that acts as somewhat like a proxy, but uses the cache. And if that wasn't enough, I'd have to initialize them both, like in this example:


$model = MyModel();
$model_cached = MyModelCached();

$values_direct = $model->doStuff();
$values_cached = $model_cached->doStuff();

The new way

So Matthias asked me: Would it not be nicer if the cache was already available in the model, and you could do something like this:


$model = MyModel();
$values_direct = $model->doStuff();
$values_cached = $model->cache->doStuff();

A great idea. As you can see this proposed way saves a bit of code, it's intuitive and easy to change, if you ever need to switch between cached and non-cached calls.

The Code

First we need to create an object that initializes the cache. We'll initialize a cache with Zend_Cache and then utilize the __call function to check whether a call exists in the cached object or not. This enables us to only do valid calls.


class BaseModelCache {
  private $object;
  private $cache;

  public function __construct($object) {
    $backendName = 'File';
    $frontendName = 'Class';

    $frontendOptions = array(
      'lifetime' => 1800,
    );

    $backendOptions = array(
      'cache_dir' => '/my/cache/dir/',
    );

    $this->object = $object;
    $frontendOptions['cached_entity'] = $object;

    try {
      Zend_Loader::loadClass('Zend_Cache');
      $this->cache = Zend_Cache::factory($frontendName, $backendName, $frontendOptions, $backendOptions);
    } catch(Exception $e) {
      throw($e);
    }
  }

  public function __call($method, $args) {
    $class = get_class($this->object);
    $class_methods = get_class_methods($class);

    if(in_array($method , $class_methods)) {
        $caller = Array($this->cache, $method);
        return call_user_func_array($caller, $args);
    }

    throw new Exception( " Method " . $method . " does not exist in this class " . get_class($class ) . "." );
  }
}

The second step is to create a base model class, that we'll use for all our models. It will initialize the cache object using the BaseModelCache and make it available as public property:


abstract class BaseModel {
  public $cache;

  public function __construct() {
    $this->cache = new BaseModelCache($this);
  }
}

Et VoilĂ ! Now we can create models by extending the BaseModel. The cache will be available as described above.

Comments

  • Hi Ludwig. When we developed this our models where based on web services. Also, I never really used Zend_Db_Table_Row, but instead create an instance of Zend_DB in my model and use it from within the methods. Always disliked the active record approach to be honest.

    Is the ID problem similar to what Sascha-Oliver described? Maybe when I have spare time, I will have a ponder and see what I can come up with ...

    by Pascal Opitz on May 17 2009, 08:43 #

  • Hi,

    Just played around with this the last couple hours.

    Its nice but not perfekt. eg. you cant use findParentRow or getDependentRowset when the Zend_Db_Table_Row is cached.

    You can get around this by using a own Zend_Db_Table_Row class which overrides setTable. (you can not just set "setTable" couse you loose the _tableClass property, and this is checked in setTable.

    If you got around this you fall into your next problem. The ID which is generated by "Zend_Cache_Frontend_Class::_makeId" dont take the contents of the object in account. so if you loop through a rowset and use findDependentRowset in this loop, it will always return the same contents, because the ID generated for it is always the same.

    For replication here are the classes I used:

    
    class Cwd_Db_Cache {
     // see comment by Robert Kummer
     // http://contentwithstyle.pascalopitz.com/content/a-caching-pattern-for-models#comment-5876
    }
    

    by LudwigR on May 17 2009, 00:36 #

  • I think the 'extends' solution isn't very flexible. In the real world the model objects usually extend a base model object, which requires the BaseModelCache to be embedded in it or something similar.

    Adding public function 'cache' to an existing model wolud be much simpler than extending.

    
    class MyModel extend aBaseModelObject
    {
      public function doSomething()
      {
        return 'resultFromComplexDatabaseQuery';
      }
    
      public function cache()
      {
        return new BaseModelCache($this);
      }
    }

    by Stoyan on May 12 2009, 09:25 #

  • Great. I wonder whether the cache suffix could be dynamically created from the passed params. I'll have a look into it.

    by Pascal Opitz on April 23 2009, 11:30 #

  • Hi Pascal,

    you can create the suffix dynamically, but i would prefer not to do so. The Problem is that sometimes you give an object to the constructor and then you have to serialize the whole object to create a cache suffix, and that is too much, i guess. It seems to be better, when you create the suffix manually.

    By the way, I made a mistake in the code above. the BaseModel.php won't need the setCacheSuffix, clean, setTagsArray, setPriority & setLifetime methods, because the cache property is public, so you can call it like this:

    $a = new Test('foo');
    $a->cache->setCacheSuffix('foo');
    $a->cache->foo('teststring');

    by Sascha-Oliver Prolic on April 23 2009, 12:22 #

  • What about serialize, then md5 it?

    by Pascal Opitz on April 23 2009, 12:25 #

  • Hi Pascal,

    this will work, but serializing big and heavy objects like Zend_Date f.e. will take a lot of time.

    But, if you like, you can use this:

    class aTest extends BaseModel
    {
       public function __construct($hoho)
       {
           $this->_hoho = $hoho;
           $args = func_get_args();
           parent::__construct(array(), array(), $args);
       }
    }
    
    abstract class BaseModel
    {
        public $cache;
    
        public function __construct($frontendOptions = array(), $backendOptions = array(), $childParams = array())
        {
            $this->cache = new BaseModelCache($this, $frontendOptions, $backendOptions);
            $this->cache->setCacheSuffix(md5(serialize($childParams)));
        }
    
    }

    by Sascha-Oliver Prolic on April 23 2009, 12:32 #

  • Hi,

    With the version of Sascha-Oliver i was able to get around this. but its not a very beautiful solution.

    The nice thing about it, it doesnt matter whats in the cache and what is fetched from the db. all combinations are working.

    
    $m_event = new EventModel();
    $m_act = new ActModel();
    $events = $m_event->cache->fetchAll(array("status='1'","deleted='0'"),'createdate desc',10,0);
    echo "<ul>";
    foreach($events as $event){
    	echo "<li>".$event->event_id."</li>";
    	$event->setTable($m_event);
    	$event->cache->setCacheSuffix('eventid_'.$event->event_id);
    
    	$acts = $event->cache->findDependentRowset('ActModel');
    	echo "<ul>";
    	foreach($acts as $act){
    		$act->setTable($m_act);
    		$act->cache->setCacheSuffix('actid_'.$act->act_id);
    		$foo = $act->cache->findParentRow('EventModel');
    		echo "<li>".$act->act_id." (".$foo->event_id.")</li>";
    	}
    	echo "</ul>";
    }
    echo "</ul>";
    

    You can find the classes at http://svn.cwd.at/cwd/trunk/Cwd/

    by LudwigR on May 17 2009, 09:41 #

  • Hi again,

    forgot to add the (quick & dirty) test case: The setTable call is neccessary if the row object cames from cache. (trying to figure out a way to do this automaticly)

    
    $m_event = new EventModel();
    $events = $m_event->cache->fetchAll(array("status='1'","deleted='0'"),'createdate desc',10,0);
    echo "<ul>";
    foreach($events as $event){
    	echo "<li>".$event->event_id."</li>";
    	$event->setTable($m_event);
    	$acts = $event->cache->findDependentRowset('ActModel');
    	echo "<ul>";
    	foreach($acts as $act){
    		echo "<li>".$act->act_id."</li>";
    	}
    	echo "</ul>";
    }
    echo "</ul>";
    

    by LudwigR on May 17 2009, 00:43 #

  • Hi Pascal,

    here is my proposal, I got already some properties from my config object. this will work, but i didn't wrote any unit tests, yet. please feel free to contact me:

    abstract class BaseModel
    {
        public $cache;
    
        public function __construct($frontendOptions = array(), $backendOptions = array())
        {
            $this->cache = new BaseModelCache($this, $frontendOptions, $backendOptions);
        }
    
        public function setCacheSuffix($suffix)
        {
            $this->cache->setCacheSuffix($suffix);
        }
    
        public function clean($mode = 'all', $tags = array())
        {
            $this->cache->clean($mode, $tags);
        }
    
        public function setTagsArray($tags = array())
        {
            $this->cache->setTagsArray($tags);
        }
    
        public function setPriority($priority)
        {
            $this->cache->setPriority($priority);
        }
    
        public function setLifetime($lifetime = false)
        {
            $this->cache->setLifetime($lifetime);
        }
    
    }
    
    class BaseModelCache
    {
        protected $_object = null;
        protected $_cache = null;
    
        protected $_cacheByDefault = NULL;
        protected $_objectMethods = null;
        protected $_frontendOptions = array();
        protected $_backendOptions = array();
        protected $_backendName = NULL;
        protected $_defaultCacheIdPrefix = '';
        protected $_lifetime = NULL;
    
        /**
         * the constructor
         *
         * @param mixed $object
         */
        public function __construct ($object, $frontendOptions = array(), $backendOptios = array())
        {
            if (!Zend_Registry::isRegistered('Zend_Config'))
            {
                throw new Project_Exception('Zend_Config is not registered in Zend_Registry.');
            }
    
            $this->_object = $object;
    
            $this->_cacheByDefault = Zend_Registry::get('Zend_Config')->caching->cache_by_default;
            $this->_defaultCacheIdPrefix = Zend_Registry::get('Zend_Config')->caching->default_cache_id_prefix;
            $this->_lifetime = Zend_Registry::get('Zend_Config')->caching->lifetime;
            $this->_backendName = Zend_Registry::get('Zend_Config')->caching->backendName;
    
            $this->setFrontendOptions($frontendOptions)
                 ->setBackendOptions($backendOptios);
    
            try
            {
                $this->_cache = Zend_Cache::factory('Class', $this->_backendName, $this->_frontendOptions, $this->_backendOptions);
            }
            catch (Zend_Cache_Exception $e)
            {
                throw ($e);
            }
            return $this;
        }
    
        /**
         * the main method, calls the models from cache
         *
         * @param $method
         * @param $args
         * @return unknown
         */
        public function __call ($method, $args)
        {
            $class_methods = $this->_getObjectMethods();
            if (in_array($method, $class_methods))
            {
                $caller = array($this->_cache , $method);
                return call_user_func_array($caller, $args);
            }
            throw new Exception("Method " . $method . " does not exist in this class " . get_class($class) . ".");
        }
    
        /**
         * returns object methods
         * @return array
         */
        protected function _getObjectMethods ()
        {
            if ($this->_objectMethods === null && $this->_object !== null)
            {
                $class = get_class($this->_object);
                $this->_objectMethods = get_class_methods($class);
            }
            return $this->_objectMethods;
        }
    
    
        /**
         * sets the FrontendOptions for the Cache Frontend
         *
         * @param array $frontendOptions
         * @return BaseModelCache
         */
        protected function setFrontendOptions($frontendOptions)
        {
            if (is_array($frontendOptions))
            {
                if (!isset($frontendOptions['cache_id_prefix']))
                {
                    $frontendOptions['cache_id_prefix'] = $this->_defaultCacheIdPrefix;
                }
                if (!isset($frontendOptions['lifetime']))
                {
                    $frontendOptions['lifetime'] = $this->_lifetime;
                }
                if (!isset($frontendOptions['cached_entity']))
                {
                    $frontendOptions['cached_entity'] = $this->_object;
                }
                $this->_frontendOptions = $frontendOptions;
            }
            else
            {
                throw new Zend_Cache_Exception('frontendOptions must be an array.');
            }
            return $this;
    
        }
    
        /**
         * sets the BackendOptions for the Cache Backend
         *
         * @param array $backendOptions
         * @return BaseModelCache
         */
        protected function setBackendOptions($backendOptions)
        {
            if (is_array($backendOptions))
            {
                if (!in_array('cache_dir', $backendOptions) && $this->_backendName = 'File')
                {
                    $backendOptions['cache_dir'] = APP_PATH . Zend_Registry::get('Zend_Config')->caching->cache_dir;
                }
                $this->_backendOptions = $backendOptions;
            }
            else
            {
                throw new Zend_Cache_Exception('backendOptions must be an array.');
            }
            return $this;
        }
    
        public function setCacheSuffix($suffix)
        {
            if (!is_string($suffix))
            {
                throw new Exception('Cache Suffix must be a string');
            }
            $cachePrefix = Zend_Registry::get('Zend_Config')->caching->default_cache_id_prefix;
            $this->_cache->setOption('cache_id_prefix', $cachePrefix . $suffix . '_');
        }
    
        public function clean($mode = 'all', $tags = array())
        {
            $this->_cache->clean($mode, $tags);
        }
    
        public function setTagsArray($tags = array())
        {
            $this->_cache->setTagsArray($tags);
        }
    
        public function setPriority($priority)
        {
            $this->_cache->setPriority($priority);
        }
    
        public function setLifetime($lifetime = false)
        {
            $this->_cache->setLifetime($lifetime);
        }
    }
    
    class Test extends BaseModel
    {
    
        private $_hoho = NULL;
    
        public function __construct($hoho)
        {
            $this->_hoho = $hoho;
            parent::__construct();
    
        }
    
        public function foo($bar)
        {
            for ($i = 0; $i < 1000000; $i++)
            {
            }
            return $this->_hoho . '_' . $bar;
        }
    }
    
    $a = new Test('hey');
    $a->setCacheSuffix('hey');
    echo 'non-cached: '.$a->foo('hh') . '';
    echo 'cached: '.$a->cache->foo('hh') .'';
    
    $b = new aTest('wiki');
    $b->setCacheSuffix('wiki');
    echo 'non-cached: '.$b->foo('hh'). '';
    echo 'cached: '.$b->cache->foo('hh'). '';

    by Sascha-Oliver Prolic on April 23 2009, 11:20 #

  • I didn't try objects with different constructor parameters, but i *assumed* that passing the object itself as cached entity would take care of this. Will need to have a think, and read the code for Zend_Cache_Frontend_Class.

    by Pascal Opitz on April 23 2009, 10:19 #

  • I see the following problem:

    
    class Test extends BaseModel
    {
        private $_hoho = NULL;
    
        public function __construct($hoho)
        {
            $this->_hoho = $hoho;
            parent::__construct();
        }
    
        public function foo($bar)
        {
            return $this->_hoho . '_' . $bar;
        }
    }
    
    $test = new Test('test1');
    echo $test->cache->foo('teststring');
    
    $test = new Test('test2');
    echo $test->cache->foo('teststring');
    
    -----
    
    will return:
    _teststring
    _teststring
    
    and not:
    test1_teststring
    test2_teststring
    

    I can work around, when setting cachedEntityLabel by myself, but this property of the Zend_Cache_Frontend_Class is set to private. So how would you cache objects with different constructor params?

    by Sascha-Oliver Prolic on April 23 2009, 10:07 #

  • Hi Pascal,

    here is a little optimization of your code:

    
    class BaseModelCache {
      private $object = null;
      private $cache = null;
      private $__objectMethods = null;
    
      public function __construct($object) {
        $backendName = 'File';
        $frontendName = 'Class';
    
        $frontendOptions = array(
          'lifetime' => 1800,
        );
    
        $backendOptions = array(
          'cache_dir' => '/my/cache/dir/',
        );
    
        $this->object = $object;
        // for initialization you can call $this->_getObjectMethods() here
    
        $frontendOptions['cached_entity'] = $object;
    
        try {
          Zend_Loader::loadClass('Zend_Cache');
          $this->cache = Zend_Cache::factory($frontendName, $backendName, $frontendOptions, $backendOptions);
        } catch(Exception $e) {
          throw($e);
        }
      }
    
      public function __call($method, $args) {
        $class_methods = $this->_getObjectMethods();
    
        if(in_array($method , $class_methods)) {
            $caller = Array($this->cache, $method);
            return call_user_func_array($caller, $args);
        }
    
        throw new Exception( " Method " . $method . " does not exist in this class " . get_class($class ) . "." );
      }
    
      /**
       * returns object methods
       * @return array
       */
      protected function _getObjectMethods()
      {
        if ($this->__objectMethods === null && $this->object !== null)
        {
          $class = get_class($this->object);
          $this->__objectMethods = get_class_methods($class);
        }
        return $this->__objectMethods;
      }
    }
    

    So you do not have to call the same methods again and again, because the information about the existing class methods never change within the runtime. But if so (maybe you have a dynamic linking plugin object, you can return the get_class_methods() result instead of setting the internal $__objectMethods - maybe per param flag for the method.

    A really nice solution, but not applicable in my projects.

    by Robert Kummer on April 17 2009, 06:08 #

  • A very good idea, thanks for that Robert.

    by Pascal Opitz on April 17 2009, 07:53 #

  • Really nice and elegant solution. Just an idea (haven't thought this through): if you use lazy initialization for the BaseModelCache instance instead of creating it immediately on __construct(), you could theoretically build this in standard in any project without (much) overhead in you choose not to use it. That way this solution would always be available to deal with the kind of real world scenarios Matthias described, even when you prefer to implement a "better" way of caching inside the model for that particular project.

    by Rick on April 17 2009, 10:05 #

  • One solution for invalidating the cache is deleting it on model updates and writes. You could just incorporate a clear function, so that from within the model you could do $this->cache->clear().

    If you're looking to create a more granular caching/uncaching, that gives you control over what you delete record by record, you might want to have a look into the use of tags. Zend_Cache_Class has a function called setTagsArray(), and the clear() also takes tags as an optional parameter.

    by Pascal Opitz on April 19 2009, 06:49 #

  • This looks good but how do you deal with expiring cached records? If my database row is modified, then I'd like to refresh the data. The ZF cache components look great but in practice I've only really managed to implement simple data caching so far - and it looks like the real advantages come from function/class/page level caching.

    by David on April 18 2009, 21:47 #

  • The main idea behind the concept is that the site this is used in is split up into lots of different, independent applications, and against my MVC preferences the main logic takes place in the controllers.

    This meant 2 things for me:

    • The solution needed to be able to take on existing models with minimum effort, and a strong convention
    • I wanted to see in the controller if I was caching or not, and have an easy way of of toggling between the two in debugging.

    If you were to build an application from scratch, and you knew exactly which data sources to cache, you might want to integrate the caching into your model, but in my case I'm iterating over the code base over and over again, refactoring and improving in small steps on a constant basis, and this is where I feel the approach was perfect.

    by Matthias Willerich on April 8 2009, 10:27 #

  • That's a great idea. Have to try that myself.

    by Alex on April 6 2009, 16:18 #