Mini Controllers - modular page design

By Andy, filed under CakePHP, Tools, requestAction

The MVC pattern adds some clarity to what should go where when it comes to designing a page which requires some data from a data source. The model gets the data from the data source; the controller might do something with that data and then the view will display what the controller passes to it. If the page handles only a single function that's rather easy to both conceptualize and do. However many pages don't just do a single thing, and are made up of modules which may be user customizable and it is this I wish to discuss here. To give a none-cake example of what I am referring to, take a look at the google personalized home page and consider how in cake you would build a page which allows users to select and put various 'blobs' onto their page, and how it would be initially rendered. I choose the term 'blobs' in an attempt to avoid ambiguity with cake terminology.

So use requestAction

Probably the immediate thought would be to use requestAction which is mentioned in the manual and of course in the API. RequestAction is a very useful function that (amongst other things) allows you to insert the result of a different url into the current page, and is a logical choice for such a requirement, and it's what I did in the past (until the recent site update). It's fast and easy to and get's the job done.

When not to use requestAction

The trouble with requestAction is it's not free. This becomes apparent if, as I did whilst experimenting in the past, you think "That's great, I´ll use if whenever I want something from a different view". As soon as you include more than 1 or 2 reqeustAction calls you may notice that the time to render (page + x*reqeustAction calls) is comparable to the time to render ((1+x)*normal page requests). The render time is less than the equivalent number of normal pages requests but it means that, considering the google example I mentioned above, it would not make a viable solution - if you include 10 blobs on the page your page is soaking up ~10 times what it should from the server. I will not delve into the misuse of requestAction to call logic that is in a controller that should be in a model etc :).

Enter Mini Controllers

Previously I made use of vast quantities of requestAction with the code for this blog to retrieve and insert my code snippets into posts as they were requested, and also for the tag cloud etc. Whilst fiddling around I came up with the following thought: "What if certain wholly self-sufficient components which can be swapped in and out are used to get the data, and some bullet proof logic is applied in the view/layout/an element to process this data?". In the case of the google home page this would apply to the entire view (with the exception of the search form); in my case this view logic is the side menu (perhaps obvious).

So using what i know best (my own code): For each menu block there is an component which retrieves and passes it's menu data to the view, and the side menu element cycles through the menu data using some funky logic to check for a specific element to render or whether to use a standard menu element. In this post I will focus only on the component side of the logic.

Configuring what appears in the side menu basically means only including or not the component in the controller, as if the component is removed, it's data isn't passed to the view and the side menu element will not add this 'blob' to the final rendered page. A bonus when it comes to debugging, remove the component and it´s influence disappears.

How does that work?

Below is the base component class that is used for this purpose. It is never instanciated and is my pseudo-appComponent class. At startup if the method _continue returns true, the component will automatically go find and set it's data to the context menu variable (incidentally there's also a test included to see if the side menu is completely disabled, which is activated making use of a principle I've mentioned before). If the data isn't available at startup (such as with related articles, whereby you need to know the contents of the article before you can find what's related to it) the component can be disabled by default and set in motion by calling the method process explicitly from the controller once the info is available.

{
<?php


/* SVN FILE: $Id: template.php 105 2007-04-01 19:24:49Z Andy $ */

/**
 * Template (menu-data) component.
 *
 * Get and set data for use in menu items
 *
 * Copyright (c), Andy Dawson
 *
 * Licensed under The MIT License
 * Redistributions of files must retain the above copyright notice.
 *
 * @filesource
 * @copyright    Copyright (c) 2007, Andy Dawson
 * @package      noswad
 * @subpackage   noswad.app.controllers.components
 * @since        Noswad site version 3
 * @version      $Revision: 105 $
 * @created      26/01/2007
 * @modifiedby   $LastChangedBy$
 * @lastmodified $Date: 2007-04-01 21:24:49 +0200 (dom, 01 abr 2007) $
 * @license      http://www.opensource.org/licenses/mit-license.php The MIT License
 */

//include_once(COMPONENTS.'template.php');
/**
 * Template component
 *
 * Queries the database and generates the data used to generate Template menu data
 */
class TemplateComponent extends Object { // extends TemplateComponent {
    var $name= 'Template';

    var $components= array (
        'Session',
        'RequestHandler'
    );

    /**
     * Automatic processing.
     *
     * If set to true, the component startup method will be run.
     * Note that the the call sequnce is
     *  Controller beforeFilter
     *  Component(s) startup
     *  Controller action
     * This var is useful if the component needs some info from the controller action before it can retrieve
     * info.
     *
     * @var boolean
     * @access public
     */
    var $auto= true;

    /**
     * Where to store data so duplicate queries aren't necessary.
     *
     * Uses the cache by default, the session can also be used or disabled
     *
     * @var mixed cache, session or false
     * @access public
     */
    var $cacheMedium= 'cache';

    /**
     * Time to cache data (if saving to the cache of course)
     *
     * @var string
     * @access public
     */
    var $cacheDuration= '+1 week';

    /**
     * Placeholder for where to save the data
     *
     * @var string
     * @access public
     */
    var $cachePath= null;

    /**
     * Startup method
     *
     * Calls the method process if and only iff the method _continue returns true
     *
     * @access public
     * @param object $controller
     * @return void result not used
     */
    function startup(& $controller) {
        $this->controller= & $controller;
        foreach($this->controller->uses as $model) {
            $this->{$model} =& $this->controller->{$model};
        }
        if (!$this->_continue($controller)) {
            return true;
        };
        $this->process();
    }

    /**
     * Generic process method
     *
     * @access public
     * @param object $controller
     * @return void result not used
     */
    function process() {
        $data= $this->_checkCache();
        if (!$data) {
            $data= $this->_getData();
            $this->_saveToCache($data);
        }
        $this->_setData($data);
    }

    function getCachePath() {
        if (!$this->cacheMedium) {
            return false;
        }
        if ($this->cachePath) {
            return $this->cachePath;
        }
        if ($this->controller->plugin) {
            $path[]= $this->controller->plugin;
        }
        $path[]= 'element_data';
        $path[]= $this->name;
        if ($this->cacheMedium == 'cache') {
            $cachePath= implode($path, '_').'.cache';
            $this->cachePath= $cachePath;
            return $cachePath;
        }
        elseif ($this->cacheMedium == 'session') {
            $cachePath= implode($path, '.');
            $this->cachePath= $cachePath;
            return $cachePath;
        }
    }
    /**
     * Get the data from the cache
     *
     * If the data has already been cached, use it rather than retrieving again.
     * If more appropriate override and use the session instead of cache.
     *
     * @access private
     * @param object $controller
     * @return void
     */
    function _checkCache() {
        if (!$this->cacheMedium) {
            return false;
        }
        elseif ($this->cacheMedium == 'cache') {
            $cache= cache($this->getCachePath());
            if ($cache) {
                return unserialize($cache);
            } else {
                return false;
            }
        } else {
            return $this->Session->read($this->getCachePath());
        }
    }

    /**
     * Data retrieval method
     *
     * return the data for this component/element
     *
     * @access private
     * @return array
     */
    function _getData() {
        return array ();
    }

    /**
     * Pass the data to the view
     *
     * Passes the data to the view, to be used/ with the corresponding
     * element
     *
     * @access private
     * @param array $data
     * @return void
     */
    function _setData($data) {
        if ($data) {
            if (($this->cacheMedium == 'cache')&&($this->cacheDuration)) {
                $elementData['cache'] = $this->cacheDuration;
            }
            $elementData['data'] = $data;
            if (isset($data['sequence'])) {
                $elementData['sequence'] = $data['sequence'];
                unset($data['sequence']);
            }
            $this->controller->viewVars['contextMenus'][$this->name]= $elementData;
        }
    }

    /**
     * Save the data to cache (or the session)
     *
     * Saves retrieved data to be used again rather than retrieving again.
     *
     * @access private
     * @param array $data
     * @return void
     */
    function _saveToCache($data) {
        if (!$this->cacheMedium) {
            return false;
        }
        elseif ($this->cacheMedium == 'cache') {
            cache($this->getCachePath(), serialize($data), $this->cacheDuration);
        } else {
            return $this->Session->write($this->getCachePath(), $data);
        }
    }
    /**
     * Determins whether to skip processing as part of the startup method.
     *
     * If the menu links aren't to be displayed, the automatic processing is bypassed.
     * Returns false if any of the following are true:
     *  The current controller was called via requestAction
     *  The current request is an ajax call (there's no menu for an ajax call)
     *  The menu has been explictly disabled
     *
     * @access private
     * @param object $controller
     * @return boolean
     */
    function _continue(& $controller) {
        if (
            (!$this->auto) ||
            isset ($controller->params['requested']) ||
            $this->RequestHandler->isAjax() ||
            ($this->Session->read('Config.components') === '0')
        ) {
            return false;
        }
        return true;
    }
}
?>
  1. <?php
  2. /* SVN FILE: $Id: template.php 105 2007-04-01 19:24:49Z Andy $ */
  3. /**
  4. * Template (menu-data) component.
  5. *
  6. * Get and set data for use in menu items
  7. *
  8. * Copyright (c), Andy Dawson
  9. *
  10. * Licensed under The MIT License
  11. * Redistributions of files must retain the above copyright notice.
  12. *
  13. * @filesource
  14. * @copyright Copyright (c) 2007, Andy Dawson
  15. * @package noswad
  16. * @subpackage noswad.app.controllers.components
  17. * @since Noswad site version 3
  18. * @version $Revision: 105 $
  19. * @created 26/01/2007
  20. * @modifiedby $LastChangedBy$
  21. * @lastmodified $Date: 2007-04-01 21:24:49 +0200 (dom, 01 abr 2007) $
  22. * @license http://www.opensource.org/licenses/mit-license.php The MIT License
  23. */
  24. //include_once(COMPONENTS.'template.php');
  25. /**
  26. * Template component
  27. *
  28. * Queries the database and generates the data used to generate Template menu data
  29. */
  30. class TemplateComponent extends Object { // extends TemplateComponent {
  31. var $name= 'Template';
  32. var $components= array (
  33. 'Session',
  34. 'RequestHandler'
  35. );
  36. /**
  37. * Automatic processing.
  38. *
  39. * If set to true, the component startup method will be run.
  40. * Note that the the call sequnce is
  41. * Controller beforeFilter
  42. * Component(s) startup
  43. * Controller action
  44. * This var is useful if the component needs some info from the controller action before it can retrieve
  45. * info.
  46. *
  47. * @var boolean
  48. * @access public
  49. */
  50. var $auto= true;
  51. /**
  52. * Where to store data so duplicate queries aren't necessary.
  53. *
  54. * Uses the cache by default, the session can also be used or disabled
  55. *
  56. * @var mixed cache, session or false
  57. * @access public
  58. */
  59. var $cacheMedium= 'cache';
  60. /**
  61. * Time to cache data (if saving to the cache of course)
  62. *
  63. * @var string
  64. * @access public
  65. */
  66. var $cacheDuration= '+1 week';
  67. /**
  68. * Placeholder for where to save the data
  69. *
  70. * @var string
  71. * @access public
  72. */
  73. var $cachePath= null;
  74. /**
  75. * Startup method
  76. *
  77. * Calls the method process if and only iff the method _continue returns true
  78. *
  79. * @access public
  80. * @param object $controller
  81. * @return void result not used
  82. */
  83. function startup(& $controller) {
  84. $this->controller= & $controller;
  85. foreach($this->controller->uses as $model) {
  86. $this->{$model} =& $this->controller->{$model};
  87. }
  88. if (!$this->_continue($controller)) {
  89. return true;
  90. };
  91. $this->process();
  92. }
  93. /**
  94. * Generic process method
  95. *
  96. * @access public
  97. * @param object $controller
  98. * @return void result not used
  99. */
  100. function process() {
  101. $data= $this->_checkCache();
  102. if (!$data) {
  103. $data= $this->_getData();
  104. $this->_saveToCache($data);
  105. }
  106. $this->_setData($data);
  107. }
  108. function getCachePath() {
  109. if (!$this->cacheMedium) {
  110. return false;
  111. }
  112. if ($this->cachePath) {
  113. return $this->cachePath;
  114. }
  115. if ($this->controller->plugin) {
  116. $path[]= $this->controller->plugin;
  117. }
  118. $path[]= 'element_data';
  119. $path[]= $this->name;
  120. if ($this->cacheMedium == 'cache') {
  121. $cachePath= implode($path, '_').'.cache';
  122. $this->cachePath= $cachePath;
  123. return $cachePath;
  124. }
  125. elseif ($this->cacheMedium == 'session') {
  126. $cachePath= implode($path, '.');
  127. $this->cachePath= $cachePath;
  128. return $cachePath;
  129. }
  130. }
  131. /**
  132. * Get the data from the cache
  133. *
  134. * If the data has already been cached, use it rather than retrieving again.
  135. * If more appropriate override and use the session instead of cache.
  136. *
  137. * @access private
  138. * @param object $controller
  139. * @return void
  140. */
  141. function _checkCache() {
  142. if (!$this->cacheMedium) {
  143. return false;
  144. }
  145. elseif ($this->cacheMedium == 'cache') {
  146. $cache= cache($this->getCachePath());
  147. if ($cache) {
  148. return unserialize($cache);
  149. } else {
  150. return false;
  151. }
  152. } else {
  153. return $this->Session->read($this->getCachePath());
  154. }
  155. }
  156. /**
  157. * Data retrieval method
  158. *
  159. * return the data for this component/element
  160. *
  161. * @access private
  162. * @return array
  163. */
  164. function _getData() {
  165. return array ();
  166. }
  167. /**
  168. * Pass the data to the view
  169. *
  170. * Passes the data to the view, to be used/ with the corresponding
  171. * element
  172. *
  173. * @access private
  174. * @param array $data
  175. * @return void
  176. */
  177. function _setData($data) {
  178. if ($data) {
  179. if (($this->cacheMedium == 'cache')&&($this->cacheDuration)) {
  180. $elementData['cache'] = $this->cacheDuration;
  181. }
  182. $elementData['data'] = $data;
  183. if (isset($data['sequence'])) {
  184. $elementData['sequence'] = $data['sequence'];
  185. unset($data['sequence']);
  186. }
  187. $this->controller->viewVars['contextMenus'][$this->name]= $elementData;
  188. }
  189. }
  190. /**
  191. * Save the data to cache (or the session)
  192. *
  193. * Saves retrieved data to be used again rather than retrieving again.
  194. *
  195. * @access private
  196. * @param array $data
  197. * @return void
  198. */
  199. function _saveToCache($data) {
  200. if (!$this->cacheMedium) {
  201. return false;
  202. }
  203. elseif ($this->cacheMedium == 'cache') {
  204. cache($this->getCachePath(), serialize($data), $this->cacheDuration);
  205. } else {
  206. return $this->Session->write($this->getCachePath(), $data);
  207. }
  208. }
  209. /**
  210. * Determins whether to skip processing as part of the startup method.
  211. *
  212. * If the menu links aren't to be displayed, the automatic processing is bypassed.
  213. * Returns false if any of the following are true:
  214. * The current controller was called via requestAction
  215. * The current request is an ajax call (there's no menu for an ajax call)
  216. * The menu has been explictly disabled
  217. *
  218. * @access private
  219. * @param object $controller
  220. * @return boolean
  221. */
  222. function _continue(& $controller) {
  223. if (
  224. (!$this->auto) ||
  225. isset ($controller->params['requested']) ||
  226. $this->RequestHandler->isAjax() ||
  227. ($this->Session->read('Config.components') === '0')
  228. ) {
  229. return false;
  230. }
  231. return true;
  232. }
  233. }
  234. ?>
  235.  
}

Below is a simple example of a mini-controller. The important thing to note is that only the _getData method has been overridden. Handling setting the data, and even data caching to avoid needless queries to the data source is handled by the base component class.

Should a new menu item be required, a new component is created extending the template class and the _getData method overridden to retrieve the necessary data for display.

{
<?php


/* SVN FILE: $Id: archives.php 107 2007-04-14 17:07:09Z Andy $ */

/**
 * Archives component.
 *
 * Manages setting data for providing a blog archives menu item
 *
 * Copyright (c), Andy Dawson
 *
 * Licensed under The MIT License
 * Redistributions of files must retain the above copyright notice.
 *
 * @filesource
 * @copyright    Copyright (c) 2007, Andy Dawson
 * @package      noswad
 * @subpackage   noswad.app.plugins.mi_blog.controllers.components
 * @since        Noswad site version 3
 * @version      $Revision: 107 $
 * @created      26/01/2007
 * @modifiedby   $LastChangedBy$
 * @lastmodified $Date: 2007-04-14 19:07:09 +0200 (sáb, 14 abr 2007) $
 * @license      http://www.opensource.org/licenses/mit-license.php The MIT License
 */

include_once(COMPONENTS.'template.php');
/**
 * Archive component
 *
 * Queries the database and generates the data used to generate archive links by year, month, week, day
 */
class ArchivesComponent extends TemplateComponent {
    var $name= 'Archives';

    /**
     * Data retrieval method
     *
     * Set menu data for this component/menu item
     *
     * @access private
     * @return array
     */
    function _getData() {
        $data= array ();
        $Adata= $this->Blog->getArchives();
        foreach ($Adata as $array) {
            extract($array[0]);
            $monthStr= date('F', mktime(1, 1, 1, $month, 1, $year));
            $url= array (
                'plugin' => $this->controller->PluginName,
                'controller' => 'Blogs',
                'action' => 'archives'
            );
            unset ($array[0]['count']);
            $url= am($url, $array[0]);
            $data[]= array (
                'title' => "$monthStr $year ($count)",
                'url' => $url
            );
        }
        return $data;
    }
}
?>
  1. <?php
  2. /* SVN FILE: $Id: archives.php 107 2007-04-14 17:07:09Z Andy $ */
  3. /**
  4. * Archives component.
  5. *
  6. * Manages setting data for providing a blog archives menu item
  7. *
  8. * Copyright (c), Andy Dawson
  9. *
  10. * Licensed under The MIT License
  11. * Redistributions of files must retain the above copyright notice.
  12. *
  13. * @filesource
  14. * @copyright Copyright (c) 2007, Andy Dawson
  15. * @package noswad
  16. * @subpackage noswad.app.plugins.mi_blog.controllers.components
  17. * @since Noswad site version 3
  18. * @version $Revision: 107 $
  19. * @created 26/01/2007
  20. * @modifiedby $LastChangedBy$
  21. * @lastmodified $Date: 2007-04-14 19:07:09 +0200 (sáb, 14 abr 2007) $
  22. * @license http://www.opensource.org/licenses/mit-license.php The MIT License
  23. */
  24. include_once(COMPONENTS.'template.php');
  25. /**
  26. * Archive component
  27. *
  28. * Queries the database and generates the data used to generate archive links by year, month, week, day
  29. */
  30. class ArchivesComponent extends TemplateComponent {
  31. var $name= 'Archives';
  32. /**
  33. * Data retrieval method
  34. *
  35. * Set menu data for this component/menu item
  36. *
  37. * @access private
  38. * @return array
  39. */
  40. function _getData() {
  41. $data= array ();
  42. $Adata= $this->Blog->getArchives();
  43. foreach ($Adata as $array) {
  44. extract($array[0]);
  45. $monthStr= date('F', mktime(1, 1, 1, $month, 1, $year));
  46. $url= array (
  47. 'plugin' => $this->controller->PluginName,
  48. 'controller' => 'Blogs',
  49. 'action' => 'archives'
  50. );
  51. unset ($array[0]['count']);
  52. $url= am($url, $array[0]);
  53. $data[]= array (
  54. 'title' => "$monthStr $year ($count)",
  55. 'url' => $url
  56. );
  57. }
  58. return $data;
  59. }
  60. }
  61. ?>
  62.  
}

Wrapping Up

Presented here is an idea of how to collect and present modular page info in a scalable manner. It breaks some rules (namely: don't manipulate a parent from a child object; don't access models in components), however the results are flexibility with good render times compared to alternatives. I didn't address the presentation logic in this post, lodge your interest if you'd like to see that :).

Bake On!

«  »
  • Daniel Hofstetter, on 28/2/07

    Hm, it's rather difficult to read your code snippets, the zebra look is a bit irritating, and (light)grey on (dark)grey is not that easy to read imho.

  • Daniel Hofstetter, on 28/2/07

    Oh, a part of my last name is cut off ;-)

  • Andy, on 28/2/07

    Hi Daniel,
    I get the feeling I need to increate a field lenght.
    Comments being gray on gray was deliberate to put focus on the code, I still haven't settled on what (default) colours to use going forwards. I have something in the works to allow user-customization of colurs and page styles - I'll add removing zebra stripes to the list of options :)

  • Tarique Sani, on 1/3/07

    Neat trick, but requestAction is more abused when one wants to get data from an unrelated controller/model May be I am missing something here but doing $this->controller->Blog->getArchives(); is hardly any different than doing $this->Blog->getArchives(); from within the controller. Tell me what am I missing?
  • Andy, on 2/3/07

    Hi Tarique,

    Yeah requestAction is the easy way to break all the rules IMO :). Needless to say there are times it is the right answer.

    May be I am missing something here but doing $this->controller->Blog->getArchives(); is hardly any different than doing $this->Blog->getArchives(); from within the controller. Tell me what am I missing?

    Tarique Sani

    You are missing nothing with regards to how to retrieve the data. However, the difference is that each component is self-supporting and easily swapped in and out; apart from including the component (which could be logic driven, e.g. from the controller constructor set the component array to include all components in the component folder), the controller code does not need to change at all to add or remove one of these mini-controllers. In addition, the generic logic to set the data to the view and handle cached source if it already exists is written in a single place. Putting all of this in the controller would make for fat controllers, which become ever fatter. The overal idea was for plug and play style modifications to a base installation - the concept is similar to cheesecake's add on functionality, if I understand it correctly, although that appears to be session dependent (is that correct?)

  • Tarique Sani, on 2/3/07

    Well, honestly I am also searching for a similar solution to enhance Cheesecake - currently Cheesecake addons are more like mini-apps (they are plugins) but including a part of the functionality from a plugin without having to alter the controllers is what I want to achieve...

  • Othman ouahbi aka , on 3/3/07

    Hmm..Nice


    I was actually looking for some modularity in

    the view domain, where I have a menubar and each menu ( and it's sub menu items ) is related to an installed plugin. That is,

    if that plugin doesn't exist, its menu isn't included. since it's purely presentation logic, I didn't think of a component

    to manage that and serve the view a dump to show.


    I created a helper that loops in the plugins dir and looks to see if

    the plugin is activated etc and includes an element from the plugin's dir I call it nav.ctp and it has a set of instructions

    something like




    $nav->attach('pluginName');
    $nav->attach('Sub1','/u/r/l/1','pluginName');
    ...


    and

    in the main layout something like


    $nav->attach('Main App');
    $nav->attach('Main App Sub

    1','/foo','Main App');
    $nav->includeAll();
    $nav->render();

    this method works fairly well for my

    needs because there is business logic involved, however I still need to solve an issue related to the order ( a plugin should

    decide which position in the menu bar to be included in )



    By the way, I think there is now a Component::enabled

    (var) so you might use it instead of auto, latest 1.2 code tho.



    Best Regards

  • Tane Piper, on 10/3/07

    Very nice solution. I've been looking for something like this to build with a JavaScript interface that allows you to drag and drop components into a page using jQuery. I can see this being used to define each "block" which can be dragged and dropped onto DIV sections.

    I'll hopefully have something to show soon.

  • Amit Badkas, on 2/8/07

    If I need to refresh any mini controller using AJAX, how can I do it ?

  • Andy, on 3/8/07

    Hi Amit,

    It depends what you mean exactly; but you need a controller action to recieve your ajax request, and a view to generate the response you want to send.

    If you are not using caching of any sort, you don't need any code in your controller method as the data will be regeerated automatically; if you are storing the data you need to delete from the session/cache and then call process to regenerate the data.

    Your view in both cases would call (only) whatever element/view logic you used to generate the original mini-content.

    hth

  • pulponair, on 25/10/07

    I am interessted in the view integration :)

Comments are now closed, however feel free to send an email with your thoughts