Nautilebleu

Dive into my universe

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 sf pour é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.