0 notes &
3 jours avec Symfony2 (et 2,5H avec Flask)
Dernièrement, j’ai décidé de travailler un peu sur le projet de bibliothèque
SCORM réutilisable : l’idée est de concevoir en PHP une
implémentation de qualité qui puisse être réutilisée par les différents LMS, au
lieu que chaque équipe commence à travailler dans son coin avant d’abandonner
vue la complexité de la norme.
Au vu de ces prérequis, je souhaitais donc utiliser d’autres bibliothèques reconnues pour leur qualité (API, documentation, support…) afin de me concentrer sur une implémentation qui s’annonce assez difficile.
Puisque Symfony2 est bientôt prêt, je me suis dit que les nouveaux
composants Form et Twig, présentés comme pouvant être utilisés
de façon autonome, pourraient faire l’affaire. Comme la bibliothèque finale
devra pouvoir s’intégrer dans des environnements très différents, le
conteneur d’injection de dépendances (DiC) semble tout à fait approprié.
Mon but est de concevoir une petite démonstration d’une bibliothèque qui
permettrait l’import d’un package SCORM (upload et dézippage), sa
validation (présence du manifeste, vérification de sa syntaxe) et qui
retournerait au final une arborescence d’objets.
En pseudo code, ça devrait donner quelque chose comme ça :
<?php
class DefaultController extends Controller
{
public function importAction() {
$path = $this->generateUrl('IngeniumBundleDemoBundle_container');
$scormengine = new ScormEngine();
$form = $scormengine->getForm(); [1]
$request = $this->get('request');
if ($request->getMethod() == 'POST') {
$form->bindRequest($request);
if ($form->isValid()) { [2]
// do something with the PHP representation of the manifest
try {
$this->get('session')->setFlash('notice', 'Upload succeeded');
$package = $scormengine->getPackage(); [3]
foreach ($package->getItems() as $item) { [4]
// save the items in the DB, for example with a nestedset
}
foreach($package->getMetadata() as $metadata) {
// save the metadata
}
// copy files extracted from the package
copy($package->getFiles(), '/path/to/repository');
} catch {
//
}
return $this->redirect($path);
}
}
return $this->render('IngeniumBundleDemoBundle:Default:import.html.twig',
array('form' => $scormlib->renderForm($path)));
}
}
[1] La bibliothèque inclue un formulaire prêt à l’emploi.[2] La validation est fournie dans la bibliothèque.[3] Une fois le formulaire validé, on peut obtenir les informations à propos
du package.[4] L’arborescence du contenu de formation.[5] Les métadonnées du package.[6] Les fichiers proprement dit.
Cette étape ne me semblait pas d’une complexité énorme.
J’ai commencé l’installation vendredi 24 en fin d’après-midi. Je n’ai guère recontré de difficultés à ce stade.
Le lundi matin j’ai commencé à monter un petit projet de test : un formulaire permettant d’uploader un zip. L’idée étant ensuite d’arriver à déplacer cette fonctionnalité d’un bundle vers une bibliothèque agnostique vis-à-vis du framework.
Jour 1 :
Obtenir un formulaire fonctionnel selon la logique proposée par Symfony2, y
compris la validation n’a pas représenté un grand problème. Lundi soir, j’avais
un exemple fonctionnel.
class DefaultController extends Controller
{
public function importAction() {
$scorm_package = new ScormEnginePackage();
$form = $this->createForm(new ScormEnginePackageImporterForm(), $scorm_package);
$request = $this->get('request');
if ($request->getMethod() == 'POST') {
$form->bindRequest($request);
if ($form->isValid()) {
$this->get('session')->setFlash('notice', 'Upload succeeded');
return $this->redirect($this->generateUrl('IngeniumBundleDemoBundle_import'));
}
}
return $this->render('IngeniumBundleDemoBundle:Default:import.html.twig',
array(
'form' => $form->createView(),
));
}
}
Jour 2 :
Les choses se sont gâtées dès lors que j’ai voulue déplacer ce code (en fait la ligne 5 d’instanciation du formulaire) du bundle (et du contrôleur) vers ma bibliothèque.
Premier problème : à l’heure actuelle, il n’y a aucune documentation pour les
formulaires utilisés de façon autonome. La bibliothèque Form est présentée
comme un composant réutilisable, mais si vous voulez le faire, il vous faudra
trouver comment. Il n’y a même pas un petit README
dans le dépôt qui expliquerait comment on n’instancie un
formulaire. Certes le code n’est pas encore finalisé mais on se trouve en
actuellement en RC2. Je peux comprendre qu’une documentation extensive ne soit
pas encore disponible mais l’absence d’un README me semble difficilement
compréhensible.
Deuxième problème : comme il a beaucoup été question d’injection de dépendances, j’ai creusé la question. Malheureusement là encore la documentation n’est pas très satisfaisante et c’est bien dommage dans la mesure où ce code est central dans le fonctionnement du framework.
Le composant a bien une documentation mais :
- Le site ne redirige plus vers ce code, uniquement vers les dépôts github sans documentation ;
- La documentation est de toutes façons obsolète (les classes sont encore
préfixées par
sfpour éviter les collisions plutôt que d’utiliser les espaces de nom) ; - La documentation est la reprise d’une série d’articles publiés sur le blog personnel de Fabien Potencier. Bizarrement, ces articles sont bien mieux référencés que la documentation de la bibliothèque ;
- La documentation du framework explique bien comment ajouter les services d’un bundle mais pas d’une bibliothèque.
- Le reste du web dispose encore peu d’autres ressources vu la jeunesse du framework et même lorsque c’est le cas, en général, le code est obsolète. J’ai toutefois été pas mal aidé dans la compréhension du problème par cet article de knplabs, mais toujours à propos d’un bundle et pas d’une bibliothèque !
C’est finalement par hasard que je suis tombé sur l’information nécessaire,
situé dans le cookbock. Toutefois, je me suis rendu compte
que malgré l’assertion comme quoi on devait privilégier le format XML et que
l’on pouvait mixer XML, YAML et même INI, en fait ce n’était pas simple. Mardi
soir, j’ai réussi à faire fonctionner l’exemple de knplabs dans une
bibliothèque, mais en remplaçant le fichier XML par un YAML.
Bref pour importer une bibliothèque dans le conteneur avec Symfony2, il faut
ajouter une ligne au fichier de configuration app/config/config.yml :
imports:
- { resource: parameters.ini }
- { resource: security.yml }
- { resource: ../../vendor/chamilo/lib/Chamilo/Resources/config/services.yml } [1]
[1]: Chemin vers le fichier de configuration du service situé dans une
bibliothèque.
Jour 3 :
Fort de ce succès, j’ai cherché le 3ème jour à déplacer l’instanciation du formulaire dans ma bibliothèque.
Puisque le formulaire est instancié par un service accédé depuis le contrôleur, il faut chercher de ce côté pour créer dans la bibliothèque un conteneur au rôle similaire.
Toutefois la documentation actuelle ne précise pas comment (ni où) cela se fait
dans le framework.
La méthode $this->createForm() nous redirige vers le fichier
vendor/symfony/src/Symfony/Bundle/FrameworkBundle/Controller/Controller.php à
partir duquel le contrôleur est étendu. De là, le service form.factory est
appelé.
La solution est de faire une recherche de form.factory sur le projet, qui vous
retournera entre autres les fichiers
app/cache/dev/appDevDebugProjectContainer.php et
app/cache/dev/appDevDebugProjectContainer.xml. Ce dernier comprend la liste de
tous les services définis dans le projet et la version *.php en est la version
exécutée en PHP.
Il suffit donc de copier une partie du contenu de ce fichier pour permettre l’initialisation du fichier.
Après quelques copier-coller, on obtient le résultat suivant :
sur les 586 lignes du fichier, je n’ai ajouté que 6 lignes : le reste correspond
à l’initialisation des services requis par le composant Form. Par exemple,
chaque widget est exposé comme service.
A ce stade, je peux configurer mon formulaire depuis ma bibliothèque mais pas
l’afficher. En effet, contrairement à symfony 1.X où les formulaires sont rendus
sur par l’appel à __toString(), dans Symfony2, il faut utiliser une fonction
du TwigBundle. Au départ, je pensais avoir comme dépendances Form, Twig
et DependencyInjection.
Désormais, j’ai Form, Twig, DependencyInjection, Validator,
HttpFondation (pour la session) et je dois donc ajouter TwigBundle.
Je tente alors d’appliquer le même principe que pour Form : je recopie les
services appelés par Twig et TwigBundle, ce qui me conduit tout d’abord à
ajouter en plus FrameworkExtraBundle, avant d’arriver à une exception lorsque
je dois ajouter le kernel. Je n’ai plus trop l’impression d’avoir affaire à des
composants découplés si je dois insérer le framework dans une bibliothèque pour
pouvoir en utiliser une partie.
A ce stade, à la fin du 3ème jour, je décide de voir ailleurs si je peux parvenir au résultat initial.
Jour 4 :
Je me tourne donc vers Python le lendemain matin. Je sais qu’il existe plusieurs
moteurs de templates disponible sous formes de bibliothèques autonomes
(Cheetah, Genshi, Jinja, Mako,
TAL…) Connaissant bien le moteur de template de django, je préfère
Jinja2.
De même il existe plusieurs bibliothèques de formulaire mais
WTForms me semble la plus complète.
Pour la démonstration, je décide de monter un petit projet avec le
microframework Flask. Je n’ai utilisé aucune de ces bibliothèques
avant, ce qui rend la comparaison relativement honnête avec Symfony2 et ses
composants. Bien sûr, je compare un framework “fullstack” avec un
microframework. Mais ici, ce qui m’intéresse ce sont les composants plus que le
framework en lui-même.
Première différence, les bibliothèques viennent avec une documentation.
Evidemment Jinja et WTForms sont disponibles depuis plus longtemps que les
composants de Symfony2 et ne sont plus en phase de développement initial.
Toutefois, puisque nous sommes actuellement en RC2, on pourrait s’attendre
à une maturité des composants, y compris sur leur documentation.
Deuxième différence, et non des moindres : les bibliothèques ont été conçues
pour être vraiment autonomes. Par exemple, WTForms ne sait pas traiter les
fichiers uploadés, car c’est plus du ressort du framework que de la
bibliothèque. WTForms propose toutefois un widget pour permettre afficher un
champ de type <input type="file" />.
L’installation se déroule de façon classique : un virtualenv, 3 appels à pip
et me voilà opérationnel.
Je suis rapidement le tutoriel de Flask, avant de m’attaquer à ma démonstration. Résultat, 2 heures et demi plus tard :
# flask.py
# -*- coding: utf-8 -*-
import os
from flask import (Flask, flash, redirect, render_template, request, session,
url_for)
from werkzeug import secure_filename
from scormengine.importer import Importer
app = Flask(__name__)
app.config.from_object('conf')
@app.route('/', methods=['GET', 'POST'])
def import_package():
package = None
importer = Importer()
form = importer.get_form(request.form)
if request.method == 'POST':
package = request.files.get(form.package.name)
if package:
form.package_data(package)
if form.validate():
filename = secure_filename(package.filename)
package.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
flash('Upload succeeded!')
return redirect(url_for('import_package'))
return render_template('import.html', form=form)
# scormengine/importer.py
# -*- coding: utf-8 -*-
from lxml import etree
from zipfile import ZipFile
from tempfile import NamedTemporaryFile
from wtforms import Form, FileField, ValidationError, validators
from jinja2 import Environment, PackageLoader
ALLOWED_MIMETYPES = set(['application/zip'])
SCORM2004 = set(['2004', '2004 2nd Edition', '2004 3rd Edition', '2004 4th Edition'])
class ImporterForm(Form):
package = FileField(u'SCORM Package')
def __init__(self, formdata=None, obj=None, prefix='', **kwargs):
super(ImporterForm, self).__init__(formdata, obj, prefix)
self._package_data = None # Store the zip archive content
self._importer = kwargs['importer']
def __unicode__(self):
env = Environment(loader=PackageLoader('scormengine', 'templates'))
template = env.get_template('importer_form.html')
return template.render(form=self)
def package_data(self, data):
self._package_data = data
def validate_package(form, field):
if not form._package_data:
raise ValidationError('The file is required.')
if form._package_data.content_type not in ALLOWED_MIMETYPES:
raise ValidationError('The file is not a Zip archive.')
tmp = NamedTemporaryFile()
form._package_data.save(tmp)
try:
zip = ZipFile(tmp)
except:
raise ValidationError('Unable to unzip the file.')
try:
manifest = zip.open('imsmanifest.xml')
except:
raise ValidationError('Unable to find the "imsmanifest.xml" file. The archive doesn\'t seem to be a SCORM package.')
try:
parser = etree.XMLParser(ns_clean=True)
tree = etree.fromstring(manifest.read(), parser)
ns = tree.nsmap
except:
raise ValidationError('Unable to read the "imsmanifest.xml" file. It doesn\'t seem to be a valid XML file.')
scorm_version = tree.xpath('.//schemaversion', namespaces=tree.nsmap)
if scorm_version[0].text in SCORM2004:
form._importer.scorm_version = 2004
form._importer.scorm_edition = scorm_version[0].text[4:]
class Importer(object):
def __init__(self):
self.scorm_version = None
self.scorm_edition = None
def get_form(self, post, **kwargs):
return ImporterForm(post, importer=self)
# scormengine/templates/importer_form.html
<div>
{{ form.package.label }} {% if form.package.flags.required %}*{% endif %}:
{{ form.package(class="css_class") }}
{% if form.package.errors %}
<ul class="errors">
{% for error in form.package.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
Notons qu’au final, puisque WTForms ne gère pas les uploads mais que je
tiens à en intégrer la gestion dans ma bibliothèque, j’ai dû gérer du code pour
passer le fichier au formulaire. Pour valider que le fichier uploadé est bien
une archive ZIP, j’ai dû ajouter une autre dépendance,
Werkzeug,
Pour la suite, je pensais, outre finir la validation du manifeste et retourner son contenu comme ensemble d’objets Python imbriqués, permettre de définir le formulaire et son template. On aurait alors :
# scormengine.importer
class Importer(object):
def __init__(self, form=ImporterForm,
template=('scormengine', 'templates', 'importer_form.html')):
self.scorm_version = None
self.scorm_edition = None
self.form = form
self.template = None
def __unicode__(self):
env = Environment(loader=PackageLoader(self.template[0], self.template[1]))
template = env.get_template(self.template[2]')
def get_form(self, post, **kwargs):
return self.form(post, importer=self)
...
# flask.py
from foo import CustomImporterForm
@app.route('/', methods=['GET', 'POST'])
def import_package():
package = None
importer = Importer(form=CustomImporterForm,
template=('app', 'templates', 'custom_importer_form.html'))
form = importer.get_form(request.form)
...
# foo.py
from scormengine.importer import ImporterForm
class CustomImporterForm(ImporterForm):
categories = SelectMultipleField(u'Select categories')
def __init__(self, formdata=None, obj=None, prefix='', **kwargs):
super(ImporterForm, self).__init__(formdata, obj, prefix)
Conclusions
Form, Twig et DependyInjection font sans doute leur boulot dans le cadre
du framework. Mais voilà, il me semble difficile de les utiliser en dehors. Ils
sont proposés comme composants autonomes mais en l’état je ne vois pas comment
les utiliser efficacement. D’ailleurs, ils sont appelés “composants” et non pas
“bibliothèques” !. Peut être est-ce en fait moi qui ait compris de travers
finalement, et que ces composants visent à être réutilisés dans d’autres
frameworks (Zend Framework, eZ Publish…) plutôt que dans|comme des
bibliothèques.
Par ailleurs, je suis un peu déçu par la documentation : ça a été la force de
symfony jusqu’à la version 1.0, mais là, je sens revenir le symptôme
sfForm à l’époque de la 1.1. D’autant que le code assez opaque rend
difficile la rédaction de la documentation par quelqu’un n’ayant pas écrit
le code.
Et maintenant ? Je dois tout de même écrire ma bibliothèque ! Si je m’étais fixé
sur les composants Symfony, c’est qu’ils me semblaient les meilleurs choix,
écrits pour le futur (utilisation des avancées de PHP5.3 au lieu d’être
pensés pour PHP4, tests unitaires, etc.)
Je pense que je vais donner sa chance à Phorms, qui se veut inspirée
par la bibliothèque de formulaires de django. Pour les templates, je resterais
avec du PHP, sauf si l’implémentation de Mustache en PHP n’est pas trop mauvaise.