views:

143

answers:

2

Currently I am trying to learn the Zend Framework and therefore I bought the book "Zend Framework in Action".

In chapter 3, a basic model and controller is introduced along with unit tests for both of them. The basic controller looks like this:

class IndexController extends Zend_Controller_Action 
{
    public function indexAction()
    {
        $this->view->title = 'Welcome';
        $placesFinder = new Places();
        $this->view->places = $placesFinder->fetchLatest();
    }
}

Places is the model class that fetches the latest places from the database. What bugs me here: how should I test the IndexController in isolation? As the reference to the Places class is "hardcoded", I cant inject any stubs or mocks in IndexController.

What I would rather like to have is something like this:

class IndexController extends Zend_Controller_Action 
{
    private $placesFinder;

    // Here I can inject anything: mock, stub, the real instance
    public function setPlacesFinder($places)
    {
        $this->placesFinder = $places;
    }

    public function indexAction()
    {
        $this->view->title = 'Welcome';
        $this->view->places = $this->placesFinder->fetchLatest();
    }
}

The first code sample I posted is most definately NOT unit test friendly as IndexController cannot be tested in isolation. The second one is much better. Now I just need some way to inject the model instances into the controller objects.

I know that the Zend Framework per se has no component for dependency injection. But there are some good frameworks out there for PHP, can any be used together with Zend Framework? Or is there some other way to do this in Zend Framework?

+4  A: 

Logic to models

First of all, it's worth to mention, that controllers should need only functional tests, though all the logic belongs to models.

My implementation

Here is an excerpt from my Action Controller implementation, which solves the following problems:

  • allows inject any dependency to actions
  • validates the action parameters, e.g. you may not pass array in $_GET when integer is expected

My full code allows also to generate canonical URL (for SEO or unique page hash for stats) based or required or handled action params. For this, I use this abstract Action Controller and custom Request object, but this is not the case we discuss here.

Obviously, I use Reflections to automatically determine action parameters and dependency objects.

This is a huge advantage and simplifies the code, but also has an impact in performance (minimal and not important in case of my app and server), but you may implement some caching to speed it up. Calculate the benefits and the drawbacks, then decide.

DocBlock annotations are becoming a pretty well known industry standard, and parsing it for evaluation purposes becomes more popular (e.g. Doctrine 2). I used this technique for many apps and it worked nicely.

Writing this class I was inspired by Actions, now with params! and Jani Hartikainen's blog post.

So, here is the code:

<?php

/**
 * Enchanced action controller
 *
 * Map request parameters to action method
 *
 * Important:
 * When you declare optional arguments with default parameters, 
 * they may not be perceded by optional arguments,
 * e.g.
 * @example
 * indexAction($username = 'tom', $pageid); // wrong
 * indexAction($pageid, $username = 'tom'); // OK
 * 
 * Each argument must have @param DocBlock
 * Order of @param DocBlocks *is* important
 * 
 * Allows to inject object dependency on actions:
 * @example
 *   * @param int $pageid
 *   * @param Default_Form_Test $form
 *   public function indexAction($pageid, Default_Form_Test $form = null)
 *
 */
abstract class Your_Controller_Action extends Zend_Controller_Action
{  
    /**
     *
     * @var array
     */
    protected $_basicTypes = array(
        'int', 'integer', 'bool', 'boolean',
        'string', 'array', 'object',
        'double', 'float'
    );

    /**
     * Detect whether dispatched action exists
     * 
     * @param string $action
     * @return bool 
     */
    protected function _hasAction($action)
    {
        if ($this->getInvokeArg('useCaseSensitiveActions')) {
            trigger_error(
                    'Using case sensitive actions without word separators' .
                    'is deprecated; please do not rely on this "feature"'
            );

            return true;
        }

        if (method_exists($this, $action)) {

            return true;
        }

        return false;
    }

    /**
     *
     * @param string $action
     * @return array of Zend_Reflection_Parameter objects
     */
    protected function _actionReflectionParams($action)
    {
        $reflMethod = new Zend_Reflection_Method($this, $action);
        $parameters = $reflMethod->getParameters();

        return $parameters;
    }

    /**
     *
     * @param Zend_Reflection_Parameter $parameter
     * @return string
     * @throws Your_Controller_Action_Exception when required @param is missing
     */
    protected function _getParameterType(Zend_Reflection_Parameter $parameter)
    {
        // get parameter type
        $reflClass = $parameter->getClass();

        if ($reflClass instanceof Zend_Reflection_Class) {
            $type = $reflClass->getName();
        } else if ($parameter->isArray()) {
            $type = 'array';
        } else {
            $type = $parameter->getType();
        }

        if (null === $type) {
            throw new Your_Controller_Action_Exception(
                    sprintf(
                            "Required @param DocBlock not found for '%s'", $parameter->getName()
                    )
            );
        }

        return $type;
    }

    /**
     *
     * @param Zend_Reflection_Parameter $parameter 
     * @return mixed
     * @throws Your_Controller_Action_Exception when required argument is missing
     */
    protected function _getParameterValue(Zend_Reflection_Parameter $parameter)
    {
        $name = $parameter->getName();
        $requestValue = $this->getRequest()->getParam($name);

        if (null !== $requestValue) {
            $value = $requestValue;
        } else if ($parameter->isDefaultValueAvailable()) {
            $value = $parameter->getDefaultValue();
        } else {
            if (!$parameter->isOptional()) {
                throw new Your_Controller_Action_Exception(
                        sprintf("Missing required value for argument: '%s'", $name));
            }

            $value = null;
        }

        return $value;
    }

    /**
     *
     * @param mixed $value 
     */
    protected function _fixValueType($value, $type)
    {
        if (in_array($type, $this->_basicTypes)) {
            settype($value, $type);
        }

        return $value;
    }

    /**
     * Dispatch the requested action
     *
     * @param   string $action Method name of action
     * @return  void
     */
    public function dispatch($action)
    {
        $request = $this->getRequest();

        // Notify helpers of action preDispatch state
        $this->_helper->notifyPreDispatch();

        $this->preDispatch();
        if ($request->isDispatched()) {
            // preDispatch() didn't change the action, so we can continue
            if ($this->_hasAction($action)) {

                $requestArgs = array();
                $dependencyObjects = array();
                $requiredArgs = array();

                foreach ($this->_actionReflectionParams($action) as $parameter) {
                    $type = $this->_getParameterType($parameter);
                    $name = $parameter->getName();
                    $value = $this->_getParameterValue($parameter);

                    if (!in_array($type, $this->_basicTypes)) {
                        if (!is_object($value)) {
                            $value = new $type($value);
                        }
                        $dependencyObjects[$name] = $value;
                    } else {
                        $value = $this->_fixValueType($value, $type);
                        $requestArgs[$name] = $value;
                    }

                    if (!$parameter->isOptional()) {
                        $requiredArgs[$name] = $value;
                    }
                }

                // handle canonical URLs here

                $allArgs = array_merge($requestArgs, $dependencyObjects);
                // dispatch the action with arguments
                call_user_func_array(array($this, $action), $allArgs);
            } else {
                $this->__call($action, array());
            }
            $this->postDispatch();
        }

        $this->_helper->notifyPostDispatch();
    }

}

To use this, just:

Your_FineController extends Your_Controller_Action {}

and provide annotations to actions, as usual (at least you already should ;).

e.g.

/**
 * @param int $id Mandatory parameter
 * @param string $sorting Not required parameter
 * @param Your_Model_Name $model Optional dependency object 
 */
public function indexAction($id, $sorting = null, Your_Model_Name $model = null) 
{
    // model has been already automatically instantiated if null
    $entry = $model->getOneById($id, $sorting);
}

(DocBlock is required, however I use Netbeans IDE, so the DocBlock is automatically generated based on action arguments)

takeshin
Hm, interesting approach using reflection to extract the arguments of the action method... thanks!
Max
+1  A: 

Ok, this is how I did it:

As IoC Framework I used this component of the symfony framework (but I didnt download the latest version, I used an older one I used on projects before... keep that in mind!). I added its classes under /library/ioc/lib/.

I added these init function in my Bootstrap.php in order to register the autoloader of the IoC framework:

protected function _initIocFrameworkAutoloader()
{
    require_once(APPLICATION_PATH . '/../library/Ioc/lib/sfServiceContainerAutoloader.php');

    sfServiceContainerAutoloader::register();
}

Next, I made some settings in application.ini which set the path to the wiring xml and allow to disable automatic dependency injection e. g. in unit tests:

ioc.controllers.wiringXml = APPLICATION_PATH "/objectconfiguration/controllers.xml"
ioc.controllers.enableIoc = 1

Then, I created a custom builder class, which extends sfServiceContainerBuilder and put it under /library/MyStuff/Ioc/Builder.php. In this test project I keep all my classes under /library/MyStuff/.

class MyStuff_Ioc_Builder extends sfServiceContainerBuilder
{
  public function initializeServiceInstance($service)
  {
      $serviceClass = get_class($service);
      $definition = $this->getServiceDefinition($serviceClass);


    foreach ($definition->getMethodCalls() as $call)
    {
      call_user_func_array(array($service, $call[0]), $this->resolveServices($this->resolveValue($call[1])));
    }

    if ($callable = $definition->getConfigurator())
    {
      if (is_array($callable) && is_object($callable[0]) && $callable[0] instanceof sfServiceReference)
      {
        $callable[0] = $this->getService((string) $callable[0]);
      }
      elseif (is_array($callable))
      {
        $callable[0] = $this->resolveValue($callable[0]);
      }

      if (!is_callable($callable))
      {
        throw new InvalidArgumentException(sprintf('The configure callable for class "%s" is not a callable.', get_class($service)));
      }

      call_user_func($callable, $service);
    }

  }
}

Last, I created a custom controller class in /library/MyStuff/Controller.php which all my controllers inherit from:

class MyStuff_Controller extends Zend_Controller_Action {
    /**
     * @override
     */
    public function dispatch($action)
    {
        // NOTE: the application settings have to be saved 
        // in the registry with key "config"
        $config = Zend_Registry::get('config');

        if($config['ioc']['controllers']['enableIoc'])
        {
            $sc = new MyStuff_Ioc_Builder();

            $loader = new sfServiceContainerLoaderFileXml($sc);
            $loader->load($config['ioc']['controllers']['wiringXml']);

            $sc->initializeServiceInstance($this);
        }

        parent::dispatch($action);
    }
}

What this basically does is using the IoC Framework in order to initialize the already created controller instance ($this). Simple tests I did seemed to do what I want... let´s see how this performs in real life situations. ;)

It´s still monkey patching somehow, but the Zend Framework doesn´t seem to provide a hook where I can create the controller instance with a custom controller factory, so this is the best I came up with...

Max
Could you show your XML config too? Why don't you get the config options directly from bootstrap? Wouldn't be enough to initialize DI component in Bootstrap?
takeshin
@takeshin good call, it´ll be better to initialize the container in the bootstrap file. Would you suggest to put the instance in Zend_Registry? I am just learning ZF, so it´s very likely that the code I showed could be optimised. Looking forward to your suggestions and I´ll update the code later along with my XML wiring code (dont have time right now... work, work, work ;)).
Max
@Max I don't use Registry at all. In the bootstrap make `_initSth(){return $object}` and the `$object` will be an application resource which you may get anywhere by: `$boostrap->getResource('sth')`, eg. in the controller: `$this->getInvokeArg('bootstrap')->getResource('sth')`
takeshin