Pages

Date 7 décembre 2012

Jobeet ZF2 - Jour 5 - Le Routage

Après une longue absence, je reprend enfin les tutoriels pour ZF2.
Je m'étais engagé à finir ce tutoriel mercredi, mais je n'ai pas pu...Mais le voilà enfin.

A l'aube de ce jour 5, vous avez vu le modèle MVC  et vous avez codé vos modèles Job et Category, et créé votre premier contrôleur (et une vue associée à une action de ce contrôleur). Mais il reste encore du travail avant d'avoir un site complet et fonctionnel.
Vous avez aussi découvert (brièvement, je l'avoue) comment vous pouviez tester votre application grâce aux tests unitaires.

Nous verrons aujourd'hui le routage, et nous compléterons notre site Jobeet avec de nouveaux contrôleurs (et actions) et de nouvelles vues.


Les explications sur le routage que vous trouverez ci-dessous sont principalement une traduction de la documentation officielle de ZF2, disponible ici

Le routage

Le composant Router du Zend Framework 2 permet de trouver, à partir de l'URL de la requête cliente, le couple Contrôleur / Action qui sera utilisé comme action à effectuer. Pour ZF2, ce composant a été réécrit: l'exécution est très semblable au ZF1, mais les rouages ​​internes sont plus cohérents, plus performants et souvent plus simple.

L'unité de base du  routage est la route.
namespace Zend\Mvc\Router;

use zend\Stdlib\RequestInterface as Request;

interface RouteInterface
{
    public static function factory(array $options = array());
    public function match(Request $request);
    public function assemble(array $params = array(), array $options = array());
}

Une Route accepte une Requête, et détermine si elle correspond. Quand c'est le cas, la Route retourne un objet RouteMatch:
namespace Zend\Mvc\Router;

class RouteMatch
{
    public function __construct(array $params);
    public function setMatchedRouteName($name);
    public function getMatchedRouteName();
    public function setParam($name, $value);
    public function getParams();
    public function getParam($name, $default = null);
}

Généralement, vous aurez plusieurs routes. Pour cela, vous utiliserez un aggragat de route en implement l'interface RouteStack:
namespace Zend\Mvc\Router;

interface RouteStackInterface extends RouteInterface
{
    public function addRoute($name, $route, $priority = null);
    public function addRoutes(array $routes);
    public function removeRoute($name);
}

Les routes devraient être interrogé dans l'ordre LIFO (Last In, First Out), d'où la raison derrière du nom RouteStack. Zend Framework fournit deux implémentation de cette interface via deux Router, SimpleRouteStack et TreeRouteStack.
Dans chacun, vous enregistrez vos routes une par une en utilisant addRoute() ou en lot en utilisant addRoutes()
// One at a time:
$route = Literal::factory(array(
    'route' => '/foo',
    'defaults' => array(
        'controller' => 'foo-index',
        'action'     => 'index',
    ),
));
$router->addRoute('foo', $route);

// In bulk:
$router->addRoutes(array(
    // using already instantiated routes:
    'foo' => $route,

    // providing configuration to allow lazy-loading routes:
    'bar' => array(
        'type' => 'literal',
        'options' => array(
            'route' => '/bar',
            'defaults' => array(
                'controller' => 'bar-index',
                'action'     => 'index',
            ),
        ),
    ),
));

Bien que les deux routers (SimpleRouteTask et TreeRouteStack) implémentent la même interface, l'utilisation est différente (options et chemin d'execution différents)

SimpleRouteStack

Ce routeur prend simplement les routes individuelles qui fournissent leur logique complète de correspondance et boucle sur celles-ci dans l'ordre LIFO jusqu'à ce qu'une correspondance soit trouvée. Du coup, les routes qui correspondent souvent doivent être enregistrées en dernier, et celles qui correspondent moins souvent, en premier. De plus, vous devrez vous assurer que les routes qui pourraient se chevaucher sont enregistrées de sorte que la correspondance la plus spécifique soit trouvée en premier (=> ordre LIFO...)

TreeRouteStack

Il permet d'enregistrer un arbre de route, et il utilisera un algorithme B-Arbre pour comparer les routes. Pour cela, vous enregistrez une route unique, avec de nombreux enfants.
Pour configurer ce type de router, il vous faudra
  • Une Route  de base (servira de racine à l'arbre)
  • Une option "may_terminate" qui indique au routeur qu'il n'y a pas d'autre segments a suivre
  • Un tableau (optionel) child_route, qui va contenir les routes additionnelles, chaque route enfant pouvant être elle-même un TreeRouteStack (la route Part, que nous verrons plus loin, fonctionne de cette manière

Il existe plusieurs types de route tels que les routes statiques, les routes avec expressions régulières, etc, que nous allons voir maintenant, une par une.

Les différentes routes HTTP

Zend Framework 2 embarque plusieurs types de route HTTP (il existe aussi des routes Console, mais nous ne les verrons pas).

Hostname (Zend\Mvc\Router\Http\Hostname)

La Route Hostname tente de faire correspondre le nom d'hôte enregistré dans la requête par rapport à des critères spécifiques. Typiquement, ce sera dans l'une des formes suivantes:
  • subdomain.domain.tld
  • :subdomain.domain.tld
Il est possible d'indiquer une contrainte:
$route = Hostname::factory(array(
    'route' => ':subdomain.domain.tld',
    'constraints' => array(
        'subdomain' => 'adm\d{2}'
    ),
));
Cet example ne correspondra que si le sous-domaine commence par 'adm' et est suivi par 2 chiffres.

Literal (Zend\Mvc\Router\Http\Literal)

La route Literal permet de faire une comparaison exacte du chemin URI. La configuration est donc uniquement le chemin que vous voulez faire correspondre, et les "valeurs par défaut", ou les paramètres que vous souhaitez renvoyer.
$route = Literal::factory(array(
    'route' => '/news',
    'defaults' => array(
        'controller' => 'Application\Controller\IndexController',
        'action' => 'read'
    ),
));
L'exemple suivant correspondra au chemin /news, et les parametres retourné seront 'controller' et 'action'

Method (Zend\Mvc\Router\Http\Method)

La route Method va utiliser les méthodes HTTP (GET, POST, HEAD, PUT ou DELETE) spécifiée dans la requête HTTP. On peut eventuellement préciser plusieurs méthodes en les séparant par une virgule
$route = Method::factory(array(
    'verb' => 'post,put',
    'defaults' => array(
        'controller' => 'Application\Controller\IndexController',
        'action' => 'form-submit'
    ),
));
La route ci-dessus correspond à une requête http "POST" ou "PUT" et retourne un objet RouteMatch contenant une clé "action" avec une valeur "form-submit"

Part (Zend\Mvc\Router\Http\Part)

Une route Part (partielle) permet la fabrication d'un arbre de routes possibles en fonction des segments du chemin URI. Elle étend en fait le TreeRouteStack. Les routes Part sont difficiles à décrire, donc nous allons simplement donner un exemple ici.
$route = Part::factory(array(
    'route' => array(
        'type' => 'literal',
        'options' => array(
            'route' => '/',
            'defaults' => array(
                'controller' => 'Application\Controller\IndexController',
                'action' => 'index'
            )
        ),
    ),
    'route_plugins' => $routePlugins,
    'may_terminate' => true,
    'child_routes' => array(
        'blog' => array(
            'type' => 'literal',
            'options' => array(
                'route' => 'blog',
                'defaults' => array(
                    'controller' => 'Application\Controller\BlogController',
                    'action' => 'index'
                )
            ),
            'may_terminate' => true,
            'child_routes' => array(
                'rss' => array(
                    'type' => 'literal',
                    'options' => array(
                        'route' => '/rss',
                        'defaults' => array(
                            'action' => 'rss'
                        )
                    ),
                    'may_terminate' => true,
                    'child_routes' => array(
                        'subrss' => array(
                            'type' => 'literal',
                            'options' => array(
                                'route' => '/sub',
                                'defaults' => array(
                                    'action' => 'subrss'
                                )
                            )
                        )
                    )
                )
            )
        ),
        'forum' => array(
            'type' => 'literal',
            'options' => array(
                'route' => 'forum',
                'defaults' => array(
                    'controller' => 'Application\Controller\ForumController',
                    'action' => 'index'
                )
            )
        )
    )
));
  • / chargera le contrôleur Index, action index.
  • /blog chargera le contrôleur Blog, action index.
  • /blog/rss chargera le contrôleur Blog, action rss.
  • /blog/rss/sub  chargera le contrôleur Blog, action subrss.
  • /forum chargera le contrôleur Forum, action index.
Vous pouvez utiliser n'importe quel type de route comme route enfant d'une route partiel.

Regex (Zend\Mvc\Router\Http\Regex)

Une route Regex utilise une expression régulière pour correspondre au chemin de l'URI. Toute expression régulière valide est autorisée; la recommandation est d'utiliser des captures nommées pour toutes les valeurs que vous voulez retourner dans le RouteMatch.

Vu que les expressions régulières sont souvent complexes  vous devez spécifié une "spec" ou sépcification à utiliser lors de l'assemblage des URL à partir de routes Regex. La spécification est simplement une chaine; les remplacements sont identifiés en utilisatant "%keyname%" dans la chaine, avec les clés provenants soit des valeurs capturées soit des paramètres nommées passées à la méthode assemble().
Tout comme les autres routes, la route Regex peut accepter "defaults":
$route = Regex::factory(array(
    'regex' => '/blog/(?[a-zA-Z0-9_-]+)(\.(?(json|html|xml|rss)))?',
    'defaults' => array(
        'controller' => 'Application\Controller\BlogController',
        'action'     => 'view',
        'format'     => 'html',
    ),
    'spec' => '/blog/%id%.%format%',
));

L'exemple précédent fonctionnera avec "/blog/001-some-blog_slug-here.html", et retourne 4 itemsdans l'objet RouteMatch: un id, le  contrôleur, l'action, et le format. Lors de l'assemblage d'une URL de cette route, les valeurs id et format valeurs seront utilisées pour pour remplir les spécifications (spec).

Scheme (Zend\Mvc\Router\Http\Scheme)

La route Scheme correspond au schéma d'URI seulement, et doit être une correspondance exacte.
$route = Scheme::factory(array(
    'scheme' => 'https',
    'defaults' => array(
        'https' => true,
    ),
));

Segment (Zend\Mvc\Router\Http\Segment)

La route Segment permet de correspondre à n'importe quel segment d'un chemin URI. Les segments sont indiqués en utilisant ':', suivi de caractères alphanumériques; si un segment est facultatif, il doit être entouré par [ et ]. Par exemple, "/:foo[/:bar]" correspond à un "/" suivi d'un texte (assigné à la clé "foo"); si d'autres caractères "/" sont trouvés, le texte suivant le dernier sera attribué à la clé "bar".

Chaque segment peut avoir des contraintes qui lui sont associés. Chaque contrainte doit être simplement une expression régulière exprimant les conditions dans lesquelles ce segment doit correspondre.

Comme pour d'autres routes, vous pouvez fournir une valeur "defaults", ceci est particulièrement utile pour l'utilisation de segments optionnels.

Voici un exemple complexe:
$route = Segment::factory(array(
    'route' => '/:controller[/:action]',
    'constraints' => array(
        'controller' => '[a-zA-Z][a-zA-Z0-9_-]+',
        'action'     => '[a-zA-Z][a-zA-Z0-9_-]+',
    ),
    'defaults' => array(
        'controller' => 'Application\Controller\IndexController',
        'action'     => 'index',
    ),
));

Query (Zend\Mvc\Router\Http\Query)

La route Query vous permet de spécifier et de capturer les paramètres de chaîne de la requête pour une route donnée.
$route = Part::factory(array(
    'route' => array(
        'type'    => 'literal',
        'options' => array(
            'route'    => 'page',
            'defaults' => array(
            ),
        ),
    ),
    'may_terminate' => true,
    'route_plugins'  => $routePlugins,
    'child_routes'  => array(
        'query' => array(
            'type' => 'Query',
            'options' => array(
                'defaults' => array(
                    'foo' => 'bar'
                )
            )
        ),
    ),
));

Les routes pour notre projets

Nous avons vu les différentes routes fournis par Zend Framework 2; nous allons maintenant les mettre en oeuvre
Voyons d'abord les pages que nous allons ajouter.
Page Contrôleur Action
Accueil Index index
Offres d'une catégorie Category list
Détail d'un emploi Job get

Nous aurons (pour le moment) les url suivantes:
  • / pour la page d'accueil
  • /category/list/x pour la liste des emplois de la catégorie x
  • /job/get/y pour avoir la fiche de détail d'un emploi

Modifions le fichier module.config.php de notre module, pour y ajouter les routes suivantes
return array(
    'router' => array(
        'routes' => array(
            'home' => array(
                'type' => 'Zend\Mvc\Router\Http\Literal',
                'options' => array(
                    'route'    => '/',
                    'defaults' => array(
                        '__NAMESPACE__' => 'Front\Controller',
                        'controller' => 'Index',
                        'action'     => 'index',
                    ),
                ),
            ),
            'category' => array(
                'type'    => 'Zend\Mvc\Router\Http\Segment',
                'options' => array(
                    'route'    => '/category[/:action][/:id]',
                    'constraints' => array(
                        'action' => '[a-zA-Z][a-zA-Z0-9_-]*',
                        'id'     => '[0-9]+',
                    ),
                    'defaults' => array(
                        'controller' => 'Front\Controller\Category',
                        'action'     => 'index',
                    ),
                ),
            ),
            'job' => array(
                'type'    => 'Zend\Mvc\Router\Http\Segment',
                'options' => array(
                    'route'    => '/job[/:action][/:id]',
                    'constraints' => array(
                        'action' => '[a-zA-Z][a-zA-Z0-9_-]*',
                        'id'     => '[0-9]+',
                    ),
                    'defaults' => array(
                        'controller' => 'Front\Controller\Job',
                        'action'     => 'index',
                    ),
                ),
            ),
        ),
    ),
    [...]

Nous verrons plus tard pour avoir des URLs SEO-friendly (c-à-d destinés à améliorer le référencement).

Pour chaque module que vous mettrez en place, les routes doivent être définies dans le fichier  module.config.php de chacun de vos modules.
Une bonne pratique avec ZF2 (si des experts passent par là et pouvaient me confirmer ça) est de définir explicitement toutes les routes.


Lors de la définition de vos routes, n'oubliez surtout pas: l'analyse des routes est effectuée dans l'ordre LIFO (Last In, First Out). Comprenez: les urls les plus communes en dernier, les plus spécifique en premier.


Toujours dans le fichier module.config.php, supprimer le bloc suivant:
'controllers' => array(
        'invokables' => array(
            'Front\Controller\Index' => 'Front\Controller\IndexController',
            'Front\Controller\Category' => 'Front\Controller\CategoryController',
            'Front\Controller\Job' => 'Front\Controller\JobController',
        ),
    ),

Ce bloc était utile précédemment, mais avec l'ajout des tests unitaires sur les contrôleurs, j'ai du agir différemment: nous allons injecter les tables Category et Job dans nos contrôleurs. Ouvrez le fichier Module.php de notre module et ajoutez la méthode suivante:
public function getControllerConfig() {
        return array(
            'factories' => array(
                'Front\Controller\Category'    => function(ControllerManager $cm) {
                    $sm   = $cm->getServiceLocator();
                    $category = $sm->get('Front\Model\CategoryTable');
                    $job = $sm->get('Front\Model\JobTable');
                    $controller = new CategoryController($category, $job);
                    return $controller;
                },
                'Front\Controller\Job'    => function(ControllerManager $cm) {
                    $sm   = $cm->getServiceLocator();
                    $category = $sm->get('Front\Model\CategoryTable');
                    $job = $sm->get('Front\Model\JobTable');
                    $controller = new JobController($category, $job);
                    return $controller;
                },
                'Front\Controller\Index'    => function(ControllerManager $cm) {
                    $sm   = $cm->getServiceLocator();
                    $category = $sm->get('Front\Model\CategoryTable');
                    $job = $sm->get('Front\Model\JobTable');
                    $controller = new IndexController($category, $job);
                 return $controller;
                },
            ),
        );
    }

Cette méthode sera appelé automatiquement par ZF2 (via le ServiceManager, si je ne me trompe pas). En gros, nous récupérons les tables dont nous avons besoins (via le ServiceManager): pour rappel, nous avons défini une fabrique (clé "factories")  pour ces tables dans la méthode getServiceConfig() de ce meme fichier Module.php.

Si j'ai oublié précédemment, je remet la méthode ci-dessous:
public function getServiceConfig()
    {
        return array(
            'factories' => array(
                'Front\Model\CategoryTable' =>  function($sm) {
                    $tableGateway = $sm->get('CategoryTableGateway');
                    $table = new CategoryTable($tableGateway);
                    return $table;
                },
                'CategoryTableGateway' => function ($sm) {
                 $dbAdapter = $sm->get('Zend\Db\Adapter\Adapter');
                 $resultSetPrototype = new ResultSet();
                 $resultSetPrototype->setArrayObjectPrototype(new Category());
                 return new TableGateway('category', $dbAdapter, null, $resultSetPrototype);
                },
                'Front\Model\JobTable' =>  function($sm) {
                 $tableGateway = $sm->get('JobTableGateway');
                 $table = new JobTable($tableGateway);
                 return $table;
                },
                'JobTableGateway' => function ($sm) {
                 $dbAdapter = $sm->get('Zend\Db\Adapter\Adapter');
                 $resultSetPrototype = new ResultSet();
                 $resultSetPrototype->setArrayObjectPrototype(new Job());
                 return new TableGateway('job', $dbAdapter, null, $resultSetPrototype);
                },
            ),
        );
    }

Dans le jour 4, nous avions mis en place notre 1er contrôleur (Index) qui permettait de lister les emplois par catégorie. Nous allons maintenant rajouter la page permettant de voir les emplois d'une catégorie en particulier, puis nous mettrons en place la page permettant de visualiser un emploi.

Le contrôleur Category

Dans un premier temps, nous allons légèrement modifier le contrôleur Category. Nous avons injecter (cf plus haut ^^) les tables job et category dans notre le constructeur de notre contrôleur Category.
Ajoutons donc le code suivant:
[...]
class CategoryController extends AbstractActionController
{
    protected $jobTable;
    protected $categoryTable;

    public function __construct($category, $job)
    {
        $this->categoryTable = $category;
        $this->jobTable = $job;
    }
    [...]
}
Nous avons ajoutés 2 attributs ($jobTable et $categoryTable) et le constructeur où nous passons en parametre les tables Category et Job. (via la méthode getControllerConfig() de notre Module.php).

Ce que nous voulons, c'est afficher la page listant les emplois d'une categorie Y
Donc, nous allons créer un action List dans notre contrôleur Category et nous aurons besoin d'un paramètre (l'id de la catégorie):
Ajoutez l'action suivante dans le contrôleur Category:
public function listAction()
    {
        $id_category = $this->params()->fromRoute('id', null);

        if (!is_null($id_category)) {
            $category = $this->categoryTable->getCategory($id_category);
            $jobs = $this->jobTable->fetchAllByIdCategory($id_category);
    
            return new ViewModel(
                array(
                    'category' => $category,
                    'jobs'     => $jobs
                )
            );
        } else {
            $this->getResponse()->setStatusCode(404);
            return;
        }
    }

Nous récupérons le paramètre 'id' (provient de la route 'category' que nous avons défini précédemment). Puis nous récupérons les information de la catégorie via sa table (en lui fournissant l'id) et les emplois de cette meme categorie (via la table Job). Nous envoyons finalement ces informations à la vue associée (que nous allons créer dans quelques instant).

Si le paramètre 'id' est null (pas trouvée par la route), nous envoyons en réponse un code d'erreur 404, mais vous pouvez aussi rediriger sur l'action Index du contrôleur Index, c'est à vous de voir ce que vous préférez.

Il manque volontairement quelques contrôles (par exemple, vérifier que getCategory($id_category) nous retourne bien quelque chose).
Mais le but de ces tutoriaux n'est pas de vous apprendre à développer "proprement" (je considère que c'est un prérequis ^^), mais de vous aider à démarrer avec ZF2!

La vue list.phtml

Nous avons les données, il est maintenant temps de pouvoir les afficher.
Pour cela, créer un fichier list.pthml dans le répertoire view/front/category de notre module Front
<?php
    $this->headLink()->appendStylesheet('/css/jobs.css');
?>
<h1><?php echo $this->escapeHtml($this->category->name); ?></h1>
<table class="jobs">
    <tbody>
        <?php foreach($this->jobs as $job): ?>
        <tr class="<?php echo $this->cycle(array("even", "odd"))->next();?>">
            <td class="location"><?php echo $this->escapeHtml($job->location); ?></td>
            <td class="position">
                <a href="<?php echo $this->url('job', array('action'=>'get', 'id'=>$job->idJob));?>">
                    <?php echo $this->escapeHtml($job->position); ?>
                </a>
            </td>
            <td class="company">
                <?php echo $this->escapeHtml($job->company); ?>
            </td>
        </tr>
        <?php endforeach;?>
    </tbody>
</table>

Rien de bien compliqué dans cette vue: nous insérons une feuille de style (jobs.css), nous affichons le nom de la catégorie et nous bouclons sur la liste d'emplois de cette catégorie (en utilisant les 2 variables passées à la vue par le contrôleur).

Pour voir les emplois d'une categorie en particulier, il nous faut ajouter le lien vers cette page, dans la vue index.phtml de notre contrôleur Index  (view/front/index/index.phtml)
// Identifier la ligne affichant le nom de la catégorie (dans une balise h1)
// et remplacer par 
<h1><a href="<?php echo $this->url('category', array('action' => 'list', 'id' => $result['category']->idCategory))?>"><?php echo $result['category']->name; ?></a></h1>
L'url est formaté par $this->url(). Vous remarquerez que le premier paramètre correspond au nom de la route ('category') et que le paramètre suivant (un tableau) correspond aux paramètres attendu par notre route (action et id).
Voici un exemple de l'url générée : http://votre.domaine.fr/category/list/1.

Cliquez sur le nom de la catégorie dans la page d'accueil. Vous devriez tomber sur une page de ce style:


Affichons maintenant la fiche détail d'un emploi.
Pour cela, commençons par modifier notre contrôleur Job de la même manière que pour CategoryController (c-à-d en ajoutant les attributs categoryTable et jobTable, ainsi que le constructeur
[...]
class JobController extends AbstractActionController
{
    protected $jobTable;
    protected $categoryTable;
    
    public function __construct($category, $job)
    {
        $this->categoryTable = $category;
        $this->jobTable = $job;
    }
[...]
}

Ajoutons maintenant l'action 'get' qui nous permettra d'avoir le détail d'un emploi.
Toujours dans le contrôleur JobController:
[...]
    public function getAction()
    {
        $id_job = $this->params()->fromRoute('id', null);
        
        if (!is_null($id_job)) {
            $job = $this->jobTable->getJob($id_job);
            $category = $this->categoryTable->getCategory($job->idCategory);
        
            return new ViewModel(
                array(
                    'job'     => $job,
                    'category' => $category
                )
            );
        } else {
            $this->getResponse()->setStatusCode(404);
            return;
        }
    }
Comme pour l'action 'list' de CategoryController, nous récupérons l'id de l'emploi par $this->params()->fromRoute('id', null); (Allez voir nos définitions de route, pour la route 'job')

Créons la vue get.phml dans le répertoire view/front/job/ denotre module Front.
<?php
$this->headLink()->appendStylesheet('/css/job.css');
?>
<div id="job">
    <h1><?php echo $this->escapeHtml($this->job->company)?></h1>
    <h2><?php echo $this->escapeHtml($this->job->location)?></h2>
    <h3><?php echo $this->escapeHtml($this->category->name)?>
    <?php if (isset($this->job->type)):?>
        <small> - <?php echo $this->job->type; ?></small></h3>
    <?php endif;?>

  
    <div class="description">
        <p><?php echo $this->escapeHtml($this->job->description)?></p>
    </div>

    <h4>How to apply?</h4>
    <p class="how_to_apply"><?php echo $this->escapeHtml($this->job->howToPlay)?></p>

    <div class="meta">
        <?php 
            $date = date_create($this->job->createdAt);
        ?>
        <small>posted on <?php echo $date->format("d/m/Y");?></small>
    </div>
</div>
Encore une fois, rien de bien compliqué: nous affichons simplement les données que nous avons passé à notre ViewModel (dans le contrôleur JobController)

Le détails d'un emploi va être accessible à deux endroits: dans la page d'accueil, en cliquant sur le nom d'un emploi, et sur la page d'une categorie (aussi en cliquant sur le nom d'un job. Ajoutons ces 2 liens
// Dans la vue index.phtml du controller Index (view/front/index/index.phtml
<a href="<?php echo $this->url('job', array('action'=>'get', 'id'=>$job->idJob));?>">
                        <?php echo $this->escapeHtml($job->position); ?>
                    </a>

// Et dans la vue list.phtml du controller CategoryController (view/front/category/list.phtml
<a href="<?php echo $this->url('job', array('action'=>'get', 'id'=>$job->idJob));?>">
    <?php echo $this->escapeHtml($job->position); ?>
</a>
Désormais, un click sur le lien (sur le nom de l'emploi devrait vous amener sur le détail de l'emploi:

Les tests unitaires sur nos contrôleur

Ici, nous allons voir des tests unitaires un peu plus compliqués. J'ai eu quelques soucis sur cette partie, d'où la longue période sans tutoriel (sans compter le temps que je n'ai pas eu dernièrement...)

Nous voulons tester les accès à nos différentes actions de nos contrôleurs. J'étais bloquer, notamment à cause des actions nécessitant un id (pour l'id de la categorie dans l'action list du contrôleur CategoryController ou pour l'id du job pour l'action get du contrôleur JobController)

Pour régler ces problème, j'ai eu l'idée d'injecter directement ces modèles dans mes controllers. Je peux, ainsi, "simuler" des données dans mes tests. Je ne rentrerais pas plus dans le détail, car sur cette partie de tests unitaires, je ne suis pas forcément à l'aise et je ne suis pas certains que la méthode utilisée soit la bonne.
Ajoutez d'abord les 2 méthodes suivantes dans les 3 contrôleurs (IndexController, CategoryController et JobController), nous les utiliserons dans nos tests pour "injecter" nos tables CategoryTable et JobTable

[...]
public function setCategoryTable($category)
{
    $this->categoryTable = $category;
}
    
public function setJobTable($job)
{
    $this->jobTable = $job;
}
[...]
Voici le code (indigeste) d'un exemple de test pour le controller CategroyController que j'expliquerais (brièvement) ci-dessous
namespace Front\Controller;

use Front\Model\Job;
use Front\Model\Category;
use Front\Model\CategoryTable;
use Front\Model\JobTable;
use Zend\Db\ResultSet\ResultSet;
use Front\Controller\CategoryController;
use Zend\Http\Request;
use Zend\Http\Response;
use Zend\Mvc\MvcEvent;
use Zend\Mvc\Router\RouteMatch;
use PHPUnit_Framework_TestCase;

class CategoryControllerTest extends PHPUnit_Framework_TestCase
{
    protected $controller;
    protected $request;
    protected $response;
    protected $routeMatch;
    protected $event;
    
    protected function setUp()
    {
        $bootstrap        = \Zend\Mvc\Application::init(include 'config/application.config.php');
        
        // La partie intéressante: nous "créons" un modèle Category
        $categoryData = array('id_category' => 1, 'name' => 'Project Manager');
        $category     = new Category();
        $category->exchangeArray($categoryData);
        $resultSetCategory = new ResultSet();
        $resultSetCategory->setArrayObjectPrototype(new Category());
        $resultSetCategory->initialize(array($category));
        $mockCategoryTableGateway = $this->getMock('Zend\Db\TableGateway\TableGateway', array('select'), array(), '', false);
        $mockCategoryTableGateway->expects($this->any())
                         ->method('select')
                         ->with()
                         ->will($this->returnValue($resultSetCategory));
        $categoryTable = new CategoryTable($mockCategoryTableGateway);
        
        $jobData = array(
            'id_job' => 1,
            'id_category' => 1,
            'type' => 'typeTest',
            'company' => 'companyTest',
            'logo' => 'logoTest',
            'url' => 'urlTest',
            'position' => 'positionTest',
            'location' => 'locaitonTest',
            'description' => 'descriptionTest',
            'how_to_play' => 'hotToPlayTest',
            'is_public' => 1,
            'is_activated' => 1,
            'email' => 'emailTest',
            'created_at' => '2012-01-01 00:00:00',
            'updated_at' => '2012-01-01 00:00:00'
        );
        $job = new Job();
        $job->exchangeArray($jobData);
        $resultSetJob = new ResultSet();
        $resultSetJob->setArrayObjectPrototype(new Category());
        $resultSetJob->initialize(array($job));
        $mockJobTableGateway = $this->getMock('Zend\Db\TableGateway\TableGateway', array('select'), array(), '', false);
        $mockJobTableGateway->expects($this->any())
                            ->method('select')
                            ->with(array('id_category' => 1))
                            ->will($this->returnValue($resultSetJob));
        $jobTable = new JobTable($mockJobTableGateway);
        
        $this->controller = new CategoryController($categoryTable, $jobTable);
        $this->request    = new Request();
        $this->routeMatch = new RouteMatch(array('controller' => 'category'));
        $this->event      = $bootstrap->getMvcEvent();
        $this->event->setRouteMatch($this->routeMatch);
        $this->controller->setEvent($this->event);
        $this->controller->setEventManager($bootstrap->getEventManager());
        $this->controller->setServiceLocator($bootstrap->getServiceManager());
    }
    
    public function testListActionCanBeAccessed()
    {
        $this->routeMatch->setParam('action', 'list');
        $this->routeMatch->setParam('id', '1');
        
        $category = new Category();
        $category->exchangeArray(
            array(
                'id_category' => 1,
                'title'  => 'WEB DESIGNER'
            )
        );
        
        $resultSet = new ResultSet();
        $resultSet->setArrayObjectPrototype(new Category());
        $resultSet->initialize(array($category));
        
        $mockTableGateway = $this->getMock('Zend\Db\TableGateway\TableGateway', array('select'), array(), '', false);
        $mockTableGateway->expects($this->any())
                         ->method('select')
                         ->with(array('id_category' => 1))
                         ->will($this->returnValue($resultSet));
        $categoryTable = new CategoryTable($mockTableGateway);
        $this->controller->setCategoryTable($categoryTable);
        
        $result   = $this->controller->dispatch($this->request);
        $response = $this->controller->getResponse();
        $this->assertEquals(200, $response->getStatusCode());
        $this->assertInstanceOf('Zend\View\Model\ViewModel', $result);
    }
    
    public function testListActionCantBeAccessedIfCategoryIdIsMissing()
    {
        $this->routeMatch->setParam('action', 'list');
        $result   = $this->controller->dispatch($this->request);
        $response = $this->controller->getResponse();
        $this->assertEquals(404, $response->getStatusCode());
    }
    
    public function test404WhenActionDoesNotExist()
    {
        $this->routeMatch->setParam('action', 'autre-action');
        $result   = $this->controller->dispatch($this->request);
        $response = $this->controller->getResponse();
        $this->assertEquals(404, $response->getStatusCode());
    }
}
Le test intéressant est ici: testListActionCanBeAccessed().
Pour pouvoir accèder à cette action, nous avons besoin de l'id de la categorie dont nous voulons voir les emplois.
Pour cela, nous créons une fausse "Category" et nous simulons le resultat de la requetes (voir les objects mockTableGateway) Plus d'infos sur les object Mock ici

Je ne m'étendrais pas plus sur les tests unitaires (j'ai mis uniquement quelques exemples pour la culture personnel...). Les test unitaires mériteraient un blog à eux-seuls...(aller par exemple regarder les tests unitaires du ZF2, c'est très intéressant, mais pas facile à aborder sans connaissance sur le sujet...)

Comme d'habitude, les sources sont disponible sur mon compte Github. Je vous encourage à les télécharger: vu le temps que j'ai mis pour sortir cette article, j'ai certainement oublié de parler d'un point ou deux et votre code pourrait ne pas fonctionner du premier coup...

PS: J'ai mis à jour le module ZendDevelopperTools (via une amélioration trouvée sur le net): vous pourrez désormais voir, en plus du nombre de requêtes exécutées, les requêtes elles-mêmes.


PS2: Mettez à jour votre versions de ZF2 (et de Composer)
- php composer.phar self-update   (pour composer)
- php composer.phar update   (mettra à jour ZF2, entre autre. Actuellement en version 2.0.5)

14 commentaires:
  1. merci pour ce tuto !

    Tu as prévu de publier la suite prochainement ?


    RépondreSupprimer
  2. Oui, j'espère publié la suite pour ce weekend :-)

    RépondreSupprimer
  3. Bon, petit problème sur le tuto pendant le week-end.
    J'ai rajouté quelques règles énoncées au début du projet, concernant l'affichage des jobs
    - 10 jobs visibles par catégorie (page d'accueil)
    - Affichage de jobs valides (de moins de 30 jours)

    J'ai ajouté ces paramètres dans la config du module pour m'en servir lors de mes requêtes. Et c'est là que se produit le problème: l'utilisation de la clause LIMIT rend invalide la requête en quottant l'entier...(ex SELECT * FROM maTABLE LIMIT '10' => MySQL ne comprend pas le limit dans ce cas...). J'ai finalement trouver une solution de contournement, mais je n'en suis pas entièrement satisfait. Je vais chercher un peu aujourd'hui pour voir ce que j'aurais pu loupé ou mal faire...

    J'espère poster l'article en fin de soirée...(dur dur de tenir les délais prévus ^^)

    RépondreSupprimer
  4. Dans le fichier Module.php, après avoir ajouter la méthode 'Module::getControllerConfig()', il faut ajoute avant la déclaration de la classe Module:

    use Front\Controller\CategoryController;
    use Front\Controller\IndexController;
    use Front\Controller\JobController;
    use Zend\Mvc\Controller\ControllerManager;

    RépondreSupprimer
    Réponses
    1. Merci Seb, ça fonctionne maintenant !
      C'est vraiment une jungle ce ZF, je te dis pas la galère dès qu'un truc ne fonctionne pas, comment s'y retrouver dans toutes ces déclarations ?
      Au fait, question de noob, est-ce que le fait d'utiliser un espace de nom charge les classes associées à cet espace ou bien cela ne fait que privilégier l'appel aux classes issues de cet espace ? Et si on fait appel à 2 espaces de nom distincts comportant chacun une méthode de nom identique ?
      Soyez indulgents, je passe du procédural à l'objet en me mettant sur ZF, alors je nage souvent... Et souvent en brasse coulée !
      Marc

      Supprimer
    2. Faudra que je précise effectivement l'utilisation des "use" dans mes fichiers.
      Je n'y fais plus attention, mon IDE les ajoute automatiquement :-)

      Pour répondre à Anonyme:
      - les espaces de nom vont être utiles pour l'autoload par exemple (tu n'as plus de "include / require " dans ton code) )> l'autoload se base sur les espaces de nom.

      - une petite erreur dans ta question: Et si on fait appel à 2 espaces de nom distincts comportant chacun une méthode de nom identique ? La question ne serait-elle pas plutôt : Et si on fait appel à 2 classes ayant le même nom mais appartenant à des espaces de nom distincts ?

      Il faudrait précisé le namespace au moment de ton new class(), tu aurais:

      $monObjet1 = new Namespace1\MaClass();
      $monObjet2 = new Namespace2\MaClass();

      Autre solution, tu peux passer par des alias dans les espace de nom

      use Namespace1\TaClass as Toto;
      use Namespace2\TaClass as Tata;

      $monObjet1 = new Toto; // pas de parenthese il me semble pour utiliser l'alias
      $monObjet2 = new Tata;

      Et si tu as une class Toto qui existe dans un autre espace de nom et que tu veux l'itliser , sans être en conflit avec l'alias Toto, il te faudra préciser l'espace de nom de ta class Toto

      $monObjet3 = new \Namespace3\Toto();

      Supprimer
    3. Waw ! Merci Romain, j'y vois un peu plus clair maintenant (effectivement, tu as bien reformulé ma question ;-)

      Ton initiative est géniale car je suis un tétard en programmation objet et commencer par du Zend (je me forme pour le boulot), ben c'est l'enfer. Grâce à ton blog je progresse doucement (je dis doucement car il y a encore pas mal de zones d'ombres).

      Bon, quelques erreurs de copie de scripts traînent par ci, par là et ravagent régulièrement mon appli... mais je prends ça pour des TP. Entre les scripts que tu mets à disposition et les commentaires des internautes, j'arrive à peu près à tirer mon épingle du jeu.

      J'espère pouvoir mettre tout ça en application prochainement afin d'adapter un de nos programme sur Zend2 (oui oui, je suis plein d'espoir).
      En attendant, félicitation et n'arrête surtout pas d'alimenter ton blog car c'est une mine d'or.

      Au fait... moi c'est Marc !

      Supprimer
  5. Super boulot,

    J'attends la suite avec impatiente :D

    Merci, ZF2 devient enfin accessible grâce à toi !

    RépondreSupprimer
    Réponses
    1. Bonjour,

      Merci pour le commentaire :-)

      Par contre, comme préciser au début de la série de tutos, je découvre ZF2 au fur et à mesure que j'avance dans l'écriture des tutos. Du coup, il est possible que l'utilisation de certains composants ne soient pas optimisées (ou utiliser d'une mauvaise manière): mais l'objectif principal reste de rendre accessible l'accès au framework, car effectivement peu de ressources dispo pour le moment...

      Concernant la suite, je vais m'y remettre prochainement. J'ai un peu manquer de temps dernièrement (et un peu de motivation aussi), mais les commentaires qui demandent la suite, me reboostent.

      Supprimer
    2. Courage, on compte sur toi (motivation +1, pression +2 :D)

      Supprimer
  6. Bonjour

    merci pour votre tuto.

    l'appli ne reconu pas la fonction getControllerConfig()
    j'ai cette erreur
    : Missing argument 1 for Front\Controller\CategoryController::__construct(), called in
    donc j'ai configurer le controleur CategoryController comme IndexController pour faire fonctionner le code
    une solution ?
    merci

    RépondreSupprimer
  7. Bonjour,

    Comme pour Fadel, j'ai le même souci avec le controllerManager qui ne semble pas répondre correctement.

    Dans le CategoryController j'ai bien implémenté ce code :

    protected $jobTable;
    protected $categoryTable;

    public function __construct($category, $job)
    {
    $this->categoryTable = $category;
    $this->jobTable = $job;
    }

    J'ai bien implémenté getControllerConfig dans Module.php mais ca ne veut rien savoir ...

    Quelqu'un aurait une piste ?

    Encore merci pour ce tuto

    RépondreSupprimer