Mini Controllers - modular page design
28 Feb 2007
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;
}
}
?>
<?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;}}?>
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;
}
}
?>
<?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;}}?>
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
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.
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 :)