Accueil > symfony > Menez le web à la baguette avec symfony (2) : un squelette pour pas chair

Menez le web à la baguette avec symfony (2) : un squelette pour pas chair

Résumé de l’épisode 1

Dans l’article précédent,  nous avons rapidement vu que des solutions intéressantes apparaissent au sein du monde PHP. Parmi l’un des nombreux frameworks émergeants, nous avons choisi d’étudier symfony à travers la création d’une application web de gestion de bookmarks, Plum. Nous avons pour l’instant créé une application par défaut. Il reste maintenant à lui faire faire son “vrai” travail…

Nous manipulons pour l’instant peu de types de données : les bookmarks et les tags. Voici un diagramme UML les représentant :

7bda79a8

Diagramme de classes de notre modèle

(Ce magnifique diagramme a été réalisé en ligne sur le site de yuml.me. Publicité gratuite :)).

Pour facilement manipuler les données en base, symfony gère de base deux Object-Relational Mappers (ORM) : Doctrine et Propel. Cependant, comme Doctrine est devenu l’ORM par défaut depuis la version 1.3 du framework, c’est lui que nous utiliserons. Doctrine propose une solution élégante d’accès aux données via le langage DQL, inspiré du HQL d’Hibernate, ainsi qu’une API d’accès aux données.  Propel se base de son côté sur une API semblable aux Criteria. Que l’on utilise Doctrine ou Propel, symfony fonctionne de manière semblable. Dans la suite de cet article, je vais détailler pas à pas les étapes suivantes :

  • configuration de l’accès à la BDD (hôte, port etc.),
  • définition du modèle de données dans un fichier au format YAMLconfig/doctrine/schema.yml,
  • appel de la commande responsable de la génération des classes du modèle, du code SQL, de la création de la BDD et chargement des données de test en BDD,
  • éventuelle génération de modules dans une application pour faire du CRUD basique,
  • adaptation du code et utilisation de l’API Doctrine…

Définition du modèle de données

La configuration de la base de données se fait au moyen d’une commande :


1
./symfony configure:database --name=doctrine --class=sfDoctrineDatabase "mysql:host=localhost;dbname=plum" root password

Ce qui crée un fichier config/databases.yml utilisant le “driver” sfDoctrineDatabase sur une base MySQL locale nommé plum. Par la suite, il suffira de modifier directement ce fichier au lieu de rappeler cette commande :


1
2
3
4
5
6
7
8
# config/databases.yml
all
:
  doctrine
:
    class
: sfDoctrineDatabase
    param
:
      dsn
: 'mysql:host=localhost;dbname=plum'
      username
: root
      password
: password
Note : si vous commencez à vous perdre au milieu de toutes ces commandes, sachez qu’elles disposent d’une aide contextuelle. Vous pouvez y accéder en utilisant “symfony help” suivi de la commande qui vous cause du souci, par exemple :


1
2
3
4
$ ./symfony help doctrine:build
Usage:
 symfony doctrine:build [--application[="..."]] [--env="..."] [--no-confirmation] [--all] [--all-classes] [--model] [--forms] [--filters] [--sql] [--db] [--and-migrate] [--and-load[="..."]] [--and-append[="..."]]
...

La seconde étape est la définition du modèle, elle se fait de la manière suivante :


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# config/doctrine/schema.yml
Bookmark
:
  actAs
:
    Timestampable
:
      updated
:
        disabled
: true
  columns
:
    url
: { type: string(255), notnull: true }
    description
: { type: string(255) }

Tag
:
  columns
:
    name
: { type: string(255), notnull: true, unique: true }

BookmarkTag
:
  columns
:
    bookmark_id
: { type: integer, primary: true }
    tag_id
: { type: integer, primary: true }
  relations
:
    Bookmark
: { onDelete: CASCADE, local: bookmark_id, foreign: id }
    Tag
: { onDelete: CASCADE, local: tag_id, foreign: id }

On définit ici trois tables, ayant chacune une série de colonnes (champs). La clé actAs permet d’ajouter des comportements (behaviours) spécifiques à Doctrine. Par exemple, ici on décide que la table Bookmark est Timestampable, ce qui aura pour but d’ajouter automatiquement deux champs created_at et updated_at, qui seront mis à jour par magie. Mais nous ne sommes pas intéressés par le champ updated_at, donc nous le supprimons.

La table BookmarkTag est la table de liaison de la relation n-n entre bookmarks et tags. On y retrouve donc les deux champs correspondant aux clés étrangères. La relation proprement dite est définie sous la clé relations, avec le nom des tables référencées, ainsi que les champs locaux et distants. Nous allons maintenant pouvoir passer à la génération de la base de donn… comment ? “D’où viennent les champs id référencés dans la relation de la table BookmarkTag ?” Eh bien, ils sont générés automagiquement par Doctrine ! Ainsi, un champ “id” en clé primaire autoincrémentée sera ajouté aux tables Bookmark et Tag (mais pas à BookmarkTag qui possède déjà une clé primaire composée des deux clés étrangères).

Génération enchantée

Base et modèle de données

C’est à partir d’ici que nous allons pouvoir mesurer toute la puissance du framework (et de ses composants). Même si jusqu’ici on en a déjà pris plein les yeux, ça ne fait que commencer :D.  Tout va se produire dans cette ligne de commande aux allures mystiques :


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
$ symfony doctrine:build --all

 This command will remove all data in the following "dev" connection(s):  

 - doctrine                                                              

 Are you sure you want to proceed? (y/N)                                  

y
>> doctrine  Dropping "doctrine" database
>> doctrine  SQLSTATE[HY000]: General error:...ing Query: "DROP DATABASE plum"
>> doctrine  Creating "dev" environment "doctrine" database
>> doctrine  generating model classes
>> file+     C:\Users\Bastien\AppData\Local\Temp/doctrine_schema_38869.yml
>> tokens    C:/wamp/www/plum/trunk/lib/mode...ine/base/BaseBookmark.class.php
..
>> autoload  Resetting application autoloaders
>> file-     C:/wamp/www/plum/trunk/cache/fr.../config/config_autoload.yml.php
>> doctrine  generating form classes
>> tokens    C:/wamp/www/plum/trunk/lib/form/BaseForm.class.php
>> tokens    C:/wamp/www/plum/trunk/lib/form...base/BaseBookmarkForm.class.php
...
>> tokens    C:/wamp/www/plum/trunk/lib/form/doctrine/BookmarkForm.class.php
...
>> autoload  Resetting application autoloaders
>> file-     C:/wamp/www/plum/trunk/cache/fr.../config/config_autoload.yml.php
>> doctrine  generating filter form classes
>> tokens    C:/wamp/www/plum/trunk/lib/filt...aseBookmarkFormFilter.class.php
...
>> autoload  Resetting application autoloaders
>> file-     C:/wamp/www/plum/trunk/cache/fr.../config/config_autoload.yml.php
>> doctrine  generating sql for models
>> doctrine  Generated SQL successfully for models
>> doctrine  created tables successfully

Vu le nombre de lignes affichées en sortie, cette commande a dû faire pas mal de choses… En fait, c’est un énorme raccourci (d’où le –all) qui a fait les actions suivantes :

  • DROP de la base de données définie précédemment (avec une confirmation, qui peut être évitée en passant le paramètre supplémentaire  –no-confirmation),
  • création de la base de données,
  • création des model classes, qui sont une “traduction” sous formes d’entités PHP de notre modèle de données (typiquement ce qu’il y a dans le package domain dans les applis Java),
  • création des form classes, qui serviront dans les formulaires des vues (équivalent des Command Spring ou des ActionForm Struts). Ils seront entre autres chargés de valider les données entrées, et éventuellement de les transformer,
  • création de filter form classes, qui seront utilisées dans les formulaires pour chercher des enregistrements selon certains critères (des utilisateurs par sexe, couleur de cheveux, … enfin pas avec le modèle de données de Plum, bien sûr :)),
  • génération du code SQL nécessaire à la création des tables (correspondant à ce que nous avons décrit dans config/doctrine/schema.yml)
  • création des tables en BDD à partir du code SQL précédemment généré
Note : pour une fois, nous avons été feignants. Il est possible de faire toutes ces opérations étape par étape, en passant des paramètres à doctrine:build (comme –model, –filters etc. se référer à l’aide contextuelle !).

Ne vous inquiétez pas, nous nous attarderons plus tard sur chacun des points de la liste précédente (lorsque nous en aurons besoin, en fait). Notez tout de même que dans les noms de classes générées, on retrouve des héritages, comme par exemple BaseBookmarkForm et BookmarkForm. La première est gérée par symfony, et est susceptible d’être modifiée automatiquement suite à l’exécution d’une commande. La seconde hérite de la première, c’est celle-ci que nous pourrons modifier librement pour adapter le comportement à nos besoins (et bénéficier immédiatement des répercussions de modèle, par exemple, qui seront intégrées dans la classe de base regénérée).

Scaffolding

Scaffolding

Scaffolding en action...

Bien, maintenant que nous avons une version PHP et SQL de notre modèle, il faut l’utiliser. Pour cela, symfony, comme d’autres frameworks, propose de générer un squelette de code permettant de faire du CRUD (Create-Retrieve-Update-Delete). Cette génération est généralement appelée scaffolding dans le jargon RoR/Grails/<insérez ici votre framework agile préféré>. Allons-y :


1
2
3
4
5
6
7
8
9
10
$ symfony doctrine:generate-module frontend bookmark Bookmark
>> dir+      ..nd\modules/bookmark\actions
>> file+     ...kmark\actions/actions.class.php
>> dir+      ...nd\modules/bookmark\templates
>> file+     ...kmark\templates/editSuccess.php
>> file+     ...mark\templates/indexSuccess.php
>> file+     ...okmark\templates/newSuccess.php
>> file+     ...kmark\templates/showSuccess.php
>> file+     ...es/bookmark\templates/_form.php
...

Nous venons de générer un module. Dans le vocabulaire symfoniesque, un module est un sous-ensemble d’une application, qui va regrouper un certains nombre de fonctionnalités liées à une “entité”. Dans notre cas, les opérations de manipulation d’un marque-page seront groupées dans le module nommé bookmark. Ce module a été créé dans l’application frontend, et il est basé sur l’objet Bookmark défini dans notre modèle de données. Cette commande est donc liée à Doctrine (il suffit de regarder son préfixe “doctrine:”). Dans le cas où nous souhaiterions créer un module qui n’a rien à voir avec le modèle (par exemple un module contenant des pages statiques), il faudra utiliser la commande generate:module.

Aperçu

Il est temps de voir ce que cette génération rend visuellement. Pour cela, butinez l’adresse http://www.plum.local/frontend_dev.php/bookmark :

bookmarks-scaffolding-1

Etape 1 : la liste des bookmarks est vide

bookmarks-scaffolding-2

Etape 2 : on clique sur "Create", et on remplit les champs

bookmarks-scaffolding-3

Etape 3 : on valide. Tadaaa !!

Remarquez la barre de debug en haut à droite des pages rendues. En cliquant sur les différents textes affichés, il est possible d’avoir le détail de la configuration, des requêtes SQL exécutées, les logs générés, etc. Très utile donc, et en exclusivité chez symfony à ma connaissance (edit : ou alors ils étaient les premiers, les implémentations dans Django ou Grails ne semblent pas aussi complètes). Elle n’est disponible qu’en environnement “dev”, accessible via le contrôleur frontend_dev.php. Si vous souhaitez accéder à l’environnement prod, il faut utiliser le contrôleur index.php. Il existe également les environnement de test et de staging, pour plus d’info consultez la documentation symfony.

Remarquez également le motif des URL dans les captures précédentes. Après le nom du contrôleur frontend_dev.php identifiant l’application utilisée (qui peut être omis en production grâce à de l’URL rewriting Apache par exemple), nous retrouvons le nom du module (bookmark) et éventuellement une action (new, ou index par défaut). Plus d’infos sur les actions dans le chapitre qui suit.

Analyse du code généré

arborescence-module

Arborescence des modules

Cette magnifique image montre les deux endroits où le scaffolding a opéré.

Tout d’abord, un répertoire bookmark a été créé dans apps/frontend/modules. Il contient les actions et les vues. Les actions contiennent de la logique (mais pas métier), elles peuvent être apparentées à une couche service. C’est ici que nous ferons des appels aux classes du modèle générées par Doctrine.

Le fichier actions.class.php contient la classe bookmarkActions, dont voici un extrait :


1
2
3
4
5
6
7
8
9
10
11
12
13
14
class bookmarkActions extends sfActions
{
  public function executeIndex(sfWebRequest $request)
  {
    $this->bookmarks = Doctrine::getTable('Bookmark')
      ->createQuery('a')
      ->execute();
  }

  public function executeNew(sfWebRequest $request)
  {
    $this->form = new BookmarkForm();
  }
  //...

Chaque action correspond à une méthode dont le nom est de la forme execute<NomDeL’ActionEnCamlCase>. Dans l’extrait de code précédent, nous pouvons voir deux actions, “index” et “new”, responsable du listage et de la création d’un bookmark. Nous pouvons voir l’utilisation de la classe BookmarkForm générée par Doctrine, qui sera utilisée dans la vue.

Les vues sont définies dans le répertoire templates, en général on en retrouve une par action. Leur nom suit le motif  nomAction<Etat>.php. Par défaut, l’état vaut “success”, mais il est possible d’en spécifier un autre en retournant une chaine de caractères à la fin de la méthode d’action, par exemple “error”, ou mieux : sfView::ERROR. Dans ce cas, le template <nomAction>Error.php sera utilisé.

Symfony permet de renvoyer n’importe quel type de données dans la vue. Par défaut c’est du HTML, mais rien n’empêche de générer un PDF ou une image en réponse de la requête. Il faut alors veiller à ce que le serveur envoie le bon type MIME dans la réponse, pour que le navigateur gère correctement le résultat.

Le second endroit où le scaffolding a ajouté des fichier est le répertoire de tests. On remarque qu’un test fonctionnel a été ajouté pour la classe bookmarkActions :


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
include(dirname(__FILE__).'/../../bootstrap/functional.php');

$browser = new sfTestFunctional(new sfBrowser());

$browser->
  get('/bookmark/index')->

  with('request')->begin()->
    isParameter('module', 'bookmark')->
    isParameter('action', 'index')->
  end()->

  with('response')->begin()->
    isStatusCode(200)->
    checkElement('body', '!/This is a temporary page/')->
  end()
;

Nous n’allons trop nous attarder sur ce test. Sachez juste que le framework propose un “émulateur de navigateur”, sur lequel nous allons pouvoir appeler des pages, puis faire des vérifications dans la requête ainsi que dans la réponse. Ici, le test vérifie que si l’on appelle l’action index du module bookmark, la réponse a un statut HTTP 200 (aucun souci) et la page renvoyée ne contient pas la chaine “This is a temporary page” que l’on trouve dans la page par défaut d’un projet symfony.

Juste pour être surs, nous pouvons lancer les tests :


1
2
3
4
$ ./symfony test:functional frontend
bookmarkActionsTest..................................................ok
  All tests successful.
  Files=1, Tests=4

Routage

Récapitulons ce qui s’est passé. Nous venons de générer un module permettant de faire du CRUD sur la table des bookmarks. Les formulaires HTML générés sont accessibles à l’URL http://www.plum.local/frontend_dev.php/bookmark, mais pour l’instant l’URL “/” pointe toujours vers la page par défaut de symfony si on ne spécifie aucun module. Corrigeons cela en éditant le fichier de routage du frontend :


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# apps/frontend/config/routing.yml
# You can find more information about this file on the symfony website:
# http://www.symfony-project.org/reference/1_4/en/10-Routing

# default rules
homepage
:
  url
:  /
  param
: { module: default, action: index }

# generic rules
# please, remove them by adding more specific rules
default_index
:
  url
:  /:module
  param
: { action: index }

default
:
  url
:  /:module/:action/*

Le routage est un composant important du framework (même si en fait, tous les composants sont importants :)). Il va permettre d’appeler la bonne action sur le bon module en fonction de l’URL demandée. Le fichier de configuration précédent est une série de règles, qui vont être parcourues l’une après l’autre. La première matchant l’URL appelée gagne. Pour notre page par défaut, c’est la première règle qui est utilisée (rien après le slash). La clé param indique le module et l’action à utiliser : ici, c’est l’action index dans le module default situé au fin fond du framework.

Si la première règle ne convient pas, alors on essaye de voir si on peut trouver un nom de module, auquel cas on exécute par défaut l’action index. En dernier choix, le routage tente de trouver un module et une action (éventuellement suivie de paramètres dénotés par l’astérisque).

Nous souhaiterions afficher par défaut la liste des bookmarks enregistrés, il va donc falloir pointer vers bookmark/index :


1
2
3
4
# apps/frontend/config/routing.yml
homepage
:
  url
:  /
  param
: { module: bookmark, action: index }

Et voilà le résultat :

homepage-apres-routing-vers-bookmark

Nouvelle homepage

Note : si vous utilisez le contrôleur de prod, index.php, il se peut que les changements ne soient pas pris en compte, à cause du cache de l’application. Il faut alors exécuter la commande symfony cache:clear, ou pour les plus feignants symfony cc.

Grails propose un système de routes du même acabit, où le nom du contrôleur et de l’action sont également concaténés avec des slashs. Après recherches, Grails ne semble pas proposer de routes nommées comme le font symfony et Ruby on Rails (du moins, pas directement). Les routes nommées permettent de rediriger vers ou de créer une URL à partir d’un nom plus explicite. Dans le listing précédent, on voit un exemple avec la route nommée homepage qui pointe vers /bookmark/index. Elle peut ensuite être référencée ainsi :


1
link_to("Rentrer à la maison", '@homepage?param1=value1');

Bonux : comparaison des commandes grails et symfony + vocabulaire utilisé


1
2
3
4
5
6
7
# Création des classes du domaine
$ grails create-domain-class com.excilys.grails.Song # On fait générer des classes du modèle une par une. La base de donnée est modifiée au runtime.
$ symfony doctrine:build --all # On définit le modèle dans un fichier de config, et symfony génère les classes associées et met à jour la base de donnée.

# Génération des controleurs et des vues pour le CRUD
$ grails generate-all com.excilys.grails.Song
$ symfony doctrine:generate-module frontend bookmark Bookmark
Note: attention tout de même, ces commandes ne sont pas strictement équivalentes (notamment au niveau de la spécification d’une classe particulère à générer)

Certains relecteurs m’ont fait remarquer que les différences de vocabulaire entre symfony/grails/une application Java peuvent porter à confusion. Voici donc des équivalences de termes (à mon sens) :

  • contrôleur symfony <=> DispatcherServlet Spring
  • actions symfony/grails <=> Controller Spring
  • *Table extends Doctrine_Table <=> DAO

Notez également que symfony recommande de placer la logique métier dans les classes *Table, les actions vont uniquement faire des tâches “simples” comme vérifier les paramètres obligatoires dans une URL, appeler les DAO, définir les variables utilisées dans les vues, faire des redirect/forward et laisser la main à la vue.

Stay tuned for scenes from our next episode…

Nous venons de générer un module avec du code par défaut. Il va maintenant falloir l’adapter : supprimer les actions non utilisées, en ajouter si besoin, rendre l’apparence un peu plus attrayante… Bref, c’est maintenant que nous allons arrêter de faire du “par défaut” pour faire du “adapté à ce qu’on veut”. Et c’est aussi maintenant que l’on va pouvoir voir si le framework est suffisamment souple pour répondre à nos besoins (un indice : la réponse est “oui”).

A bientôt !

Accès au code source

Le code source de cet article est disponible sur le projet googlecode d’Excilys à l’adresse : http://excilys.googlecode.com/svn/projects/plum/tags/plum_article_2/

Share
  1. 16/01/2010 à 06:59 | #1

    Simple et concis.
    Un bon petit tutoriel pour voir comment fonctionner Symfony (peut-être plus simple pour une première approche que Jobeet).

    ;-) Merci encore

  2. Pierre-Yves RICAU
    16/01/2010 à 10:02 | #2

    Pas chair le squelette, pas chair ! :-)

    Jojo vient de citer Jobeet : c’est un tutoriel en 24 articles de 1 heure (soit 24h!), intégré à la documentation officielle : http://www.symfony-project.org/jobeet/1_4/Doctrine/en/ . Vous n’y retrouverez cependant pas des blagues aussi décapantes que celles de Bastien ;-) .

    Je ne connaissais pas http://yuml.me/ , j’aime beaucoup, merci !

    “symfony recommande de placer la logique métier dans les classes *Table”

    Pour compléter ton propos, je dirai qu’on ne retrouve pas dans symfony l’équivalent de la couche Service. Un DAO (classe *Table) est censé proposer des opérations diverses, pouvant parfois être très complexes, mais liées à un type d’entité. Le contrôleur a en charge la vérification des paramètres (mais la validation métier est réalisé en amont), la gestion de la session, le choix des vues et du modèle associé aux vues.

    Lorsque des opérations concernent plusieurs entités, on a tendance à les placer dans le DAO pour lequel ça a le plus de sens. Je pense cependant qu’il ne faut pas hésiter à recréer une couche service si le besoin se fait réellement sentir (autrement dit : éviter les services “passe plat” … et s’il s’agit juste d’appeler plusieurs DAO les uns à la suite des autres, le contrôleur peut très bien le faire).

    Pour continuer sur les équivalents, dans une action symfony :

    1
    2
    3
    public function executeMyPage(sfWebRequest $request) {
      $this->myObject = new MyObject();
    }

    est équivalent en Grails à :

    1
    2
    3
    def myPage = {
      [myObject: new MyObject()]
    }

    et en Spring MVC :

    1
    2
    3
    4
    @RequestMapping(value="/myPage",method=RequestMethod.GET)
    public MyObject myPage() {
      return new MyObject(); //attribut de modèle nommé "myObject"
    }

    Vous noterez qu’avec Symfony, les attributs de la vue ne sont pas envoyés en paramètres de retour, mais sont définis en tant que variables d’instance de l’action. Cela ne pose pas de problème d’accès concurrent car à chaque requête, toutes les classes sont réinstanciées. 1 requête = 1 nouvelle instance d’action.

  3. 13/04/2013 à 04:11 | #3

    An interesting discussion is value comment. I think that you need to write extra on this matter, it might not be a taboo topic but typically persons are not sufficient to talk on such topics. To the next. Cheers

  1. 15/01/2010 à 21:02 | #1
  2. 10/02/2010 à 17:45 | #2