Billets plus anciens »

Fichiers statiques et le cache de Apache

Contexte

Depuis des années, je suis embêté par gestion du cache du navigateur sur les fichiers statiques servis par le serveur HTTP Apache.

Encore une fois, ce matin, ce problème de cache me pose problème : alors que j'avais publié une mise à jour sur la page d'actualité du site Coworking Metz, je constate qu'un certain nombre de personnes ne voient pas la mise à jour, car pour la voir il faut vider le cache du navigateur en faisant par exemple un CTRL-F5 sur la page.

J'ai donc décidé d'en savoir plus… pourquoi la configuration par défaut de Apache gère "mal" à mes yeux le cache.

Le comportement que je souhaite avoir est celui-ci :

« Je souhaite que dès que je modifie le contenu d'un fichier statique, celui-ci soit tout de suite visible par mes visiteurs »

Analyse

Lors de mes développements web, j'ai déjà "joué" avec les champs header Cache-Control, If-Modified-Since, ETag, Expires, Last-Modified

Je me suis dit « Apache doit envoyer un champ Expires avec une valeur par défaut ».

J'ai cherché dans la documentation de Apache et je n'ai rien trouvé au sujet de Expires en dehors du module Expires qui n'était pas activé sur mon serveur.

J'ai ensuite cherché à analyser les champs HTTP Response retournés par le serveur sur le site Coworking Metz.

Dans un premier temps, j'ai regardé du coté de Firebug mais je n'ai pas trouvé comment voir le résultat de la requête HTTP de la page courante (dans Network on voit uniquement les ressources de la page).

J'ai ensuite trouvé l'extension HttpFox (que l'on active via le menu Affichage => HttpFox).

Avec cette extension, j'ai pu voir que le serveur Apache retourne les champs Header suivants :

(Status-Line)        HTTP/1.1 304 Not Modified
Date                 Sat, 04 Jan 2014 11:19:29 GMT
Server               Apache/2.2.22 (Debian)
Connection           Keep-Alive
Keep-Alive           timeout=5, max=100
Etag                 "1920d20-442b-4ef22c61a1396"
Vary                 Accept-Encoding

Contrairement à ce que je pensais, par défaut Apache ne retourne pas le champ Expires.

C'est donc Firefox qui a une valeur par défaut et cette valeur par défaut est expliqué ici : How are expiration times calculated (since not every response includes an Expires header) ?.

Le texte suivant mérite un exemple pour être plus facilement compris :

Finally, if neither header is present, then we look for a "Last-Modified" header. If this header is present, then the cache's freshness lifetime is equal to the value of the "Date" header minus the value of the "Last-modified" header divided by 10. This is the simplified heuristic algorithm suggested in RFC 2616 section 13.2.4.

Exemple :

  • la dernière fois que le fichier index.html a été modifié : 2014-01-04 à 11h00 (last_modified_time)
  • le 2014-01-04 à 12h00 le navigateur fait une première requête (date_value)
age_expiration = (date_value - last_modified_time) * 0.1 => 3600 secondes * 0.1 => 360s
  • le 2014-01-04 à 12h01 le navagiteur fait une seconde requête, utilisation du cache car 60s < 360s
  • le 2014-01-04 à 12h10 le navigateur fait une troisième requête, le cache n'est pas utilisé car : 600s > 360s

Conclusion pour atteindre mon objectif, mes requêtes HTTP doivent retourner Expires: 0.

Solution : configuration de Apache

Activation du module Expires :

# a2enmod expires
# service apache2 restart

Placer un fichier .htaccess à la racine du site, avec le contenu suivant :

<IfModule mod_expires.c>
ExpiresActive on
ExpiresDefault "access plus 0 seconds"
</IfModule>

Code HTTP 304

Avec cette configuration, l'activité sur le serveur est certe plus élevée… mais un cache est toujours présent.

Lorsque le navigateur envoie la requête HTTP avec les paramètres suivants :

(Request-Line)      GET /news.html HTTP/1.1
Host                coworking.a-metz.info
Accept-Language     fr,en;q=0.8,fr-fr;q=0.5,en-us;q=0.3
Accept-Encoding     gzip, deflate
Connection          keep-alive
If-Modified-Since   Sat, 04 Jan 2014 11:33:53 GMT
If-None-Match       "1920d20-442d-4ef2366c0e11a"

en fonction de la date de modification du fichier news.html et de la valeur du champ If-Modified-Since, le serveur va renvoyer une réponse avec un Status 200 ou 304.

Dans le cas d'une réponse 304, le contenu de la page n'est pas envoyé au navigateur, c'est le cache du navigateur qui est utilisé.

Conclusion, avec des réponses 304, l'activité du serveur est certe supérieur mais consommation de la bande passante est tout de même limitée car le contenu des pages, des images… n'est pas transmis.

Qu'en est-t-il de Nginx ?

Par défaut, Nginx ne renvoie pas de champ HTTP ETag, ni de champ Expires.

J'ai observé que quand le serveur HTTP ne retourne pas de champ ETag alors le navigateur va interroger le serveur avec un champ HTTP If-Modified-Since pour savoir si il faut ou non utiliser le cache du navigateur.

Conclusion, la configuration par défaut de Nginx correspond au comportement que je souhaite, celui indiqué en début de billet.

Read and Post Comments

Python et les tests (nose1, nose2, python -m unittest)

Quand j'ai commencé à développer en Python, j'ai très rapidement utilisé Nose pour exécuter mes tests unitaires.

Je souhaite dans ce billet faire un petit point sur les différentes versions de Nose (Nose version 1, Nose version 2) ainsi que la commande discover de unittest2.

Nose1, Nose2 et python -m unittest discover ?

Ces trois outils ont pour fonction de trouver (discover) les tests d'un projet, de les exécuter et d'afficher le bilan de l'exécution des tests.

Les auteurs de ces outils

Nose1 et Nose2 sont principalement développés par Jason Pellerin.

Unittest est principalement développé par Michael Foord.

Utilisation de Nose

Je vais commencer par un très court exemple d'utilisation de Nose.

Ce que j'ai tout de suite aimé avec Nose, c'est que l'on peut commencer par écrire des tests très simples. Exemple, d'un fichier nommé test1.py :

def test_foo():
    a = 1
    assert a == 1

def test_bar():
    b = 'data'
    assert b == 'data'

Lancement de la commande nose :

$ bin/nosetests -v
test1.test_foo ... ok
test1.test_bar ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.002s

OK

Dans cet exemple, tout est minimaliste, pas de classes du type TestCase, de TestSuite

Pour débuter un projet, cette simplicité est bien pratique même si par la suite il est bon de migrer peu à peu vers une syntax Unittest (toujours exécutable par nose) afin de tirer profit par exemple des fonctionnalités suivantes :

Exemple :

import unittest

class MyTest(unittest.TestCase):
    def setUp(self):
        self.session = 'foobar'

    def tearDown(self):
        self.session = None

    def test_foo(self):
        a = 1
        self.assertEqual(a, 1)

    def test_bar(self):
        b = 'data' + self.session
        self.assertEqual(b, 'datafoobar')

L'exécution de ce test avec nose2 :

$ bin/nose2 -s tests/ -v
test_bar (test_basic.MyTest) ... ok
test_foo (test_basic.MyTest) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK

En 2013, est-ce que je dois utiliser Nose1 ou Nose2 ?

Tout d'abord, un peu d'histoire :

Pour simplifier, un des objectifs de Nose2 est de tirer parti des capacités d'extensions de unittest2 (je ne peux pas en dire plus, je ne connais pas le code interne de Nose).
D'après l'auteur, le code de Nose2 est plus petit et plus simple à maintenir. Illustration :
  • le coeur de Nose1 fait environ 3000 lignes de code
  • le coeur de Nose2 fait environ 1300 lignes de code

Une page de la documentation de nose2 qui liste les différences entre nose, nose2 et unittest.

Début 2013, j'ai demandé à l'auteur de Nose2 si je devais actuellement utiliser Nose1 ou Nose2 : « In 2013, I need to use nose1 or nose2 ? What is the future of nose ? ».
Pour résumer sa réponse :
  • Nose1 est en mode maintenance
  • L'avenir est du côté de Nose2
  • Jason Pellerin utilise de plus en plus Nose2 à la place de Nose1

Suite à cela, j'ai pris la décision d'utiliser quand c'est possible Nose2 à la place de Nose1.

Différences d'utilisation entre Nose2 et Nose1

En dehors du fait que l'exécutable de Nose1 se nomme nosetests et que celui de Nose2 se nomme simplement nose2, voici quelques différences d'utilisation que j'ai remarquées :

  • L'option -f permet d'arrêter les tests dès que nose rencontre une erreur (cette option était -x avec nose1)

  • L'affichage de la sortie standard est activé par défaut dans nose2. Avec nose1 il faut utiliser l'option -s pour activer l'affichage de la sortie standard.

  • Changement de syntaxe pour le lancement direct d'un ou plusieurs tests :

    Avec Nose1 :

    $ nosetests tests/test1.py:test_foo
    

    La même chose en Nose2 :

    $ nosetests tests.test1.test_foo
    
    À noter qu'avec nose1 le chemin du test à exécuter était relatif au dossier courant.
    Avec nose2 c'est relatif au dossier racine des tests.

Voici plusieurs méthodes pour indiquer la racine des tests :

  • par défaut c'est le dossier courant

  • il est possible d'indiquer le dossier racine via l'option -s, exemple :

    $ nose2 -s tests/
    
  • il est possible de configurer le dossier racine via le fichier de configuration unittest.cfg (fonctionnalité disponible dans le HEAD du projet ou dans la prochaine release), exemple :

    [unittest]
    start-dir=tests
    

En dehors de cela, j'utilise l'option verbose -v mais rien de particulier à ce niveau.

Utilisation de Discover

Depuis Python 2.7, il est possible de lancer les tests avec la fonction discover qui est directement intégré dans unittest (la version 2).

À tort ou à raison, je n'ai pratiquement jamais utilisé cette fonctionnalité discover.

Exemple :

$ python -m unittest discover -s tests
.............
----------------------------------------------------------------------
Ran 13 tests in 9.969s

D'après ce que j'ai pu observer, python -m unittest discover utilise les mêmes noms d'options que nose2.

Il est aussi possible de lancer directement un test, exemples :

$ python -m unittest test_basic
$ python -m unittest test_basic.TestClass
$ python -m unittest test_basic.TestClass.test_method

À noter que la syntaxe ci-dessous ne permet pas de spécifier le dossier racine des tests.

Pour le moment, l'avantage que je trouve à nose2 par rapport à python -m unittest est le support du fichier de configuration unittest.cfg.

Par contre, je n'ai pas testé s'il est possible d'activer le contôle de couverture de code avec python -m unittest.

Où placer ses tests

Une des premières questions que je me suis posées lorsque j'ai commencé à faire des tests unitaires en python, c'est où placer mes tests.

Deux choix :

  1. soit placer les tests à la racine du projet à côté des modules de l'application ou de la librairie, exemple :

    .
    ├── README.txt
    ├── myproject
    │   ├── __init__.py
    │   └── main.py
    ├── setup.py
    ├── tests
    │   └── test_basic.py
    └── unittest.cfg
    
  2. soit placer les tests dans le dossier qui contient les modules de l'application ou de la librairie, exemple :

    .
    ├── README.txt
    ├── myproject
    │   ├── __init__.py
    │   ├── main.py
    │   └── tests
    │       ├── __init__.py
    │       └── test_basic.py
    └── setup.py
    

J'ai personnellement une préférence pour le choix numéro 1. Pourquoi :

  • pour moi les tests ne font pas partie du coeur du code source de ma librairie ou de mon application
  • je trouve que cela n'a pas de sens de faire import myproject.tests.TestCase

Si l'on regarde de nombreux projets python, les deux solutions sont utilisées même si j'ai l'impression qu'il y a une petite domination pour la solution numéro 2.

Pour le moment, je n'ai pas trouvé d'argument absolu en faveur de l'une des deux solutions.

La suite...

Il y a encore deux sujets que je souhaitais aborder dans ce billet :

Ces deux sujets seront sans doute développés dans deux prochains billets.

Read and Post Comments

Mon cookbook : pip, virtualenv

Je présente dans cet article mon workflow concernant l'utilisation de virtualenv et pip

Je tiens à préciser qu'il y a d'autres workflows possibles, par exemple l'utilisation de buildout mais ce n'est pas le sujet de cet article.

Méthode « classique »

Voici la méthode que je nomme "classique" car je pense qu'elle est beaucoup utilisée. J'utilisais cette méthode il y a encore quelques mois.

Prérequis sur la machine (je ne précise pas leur installation, ce n'est pas mon sujet) :

Je prépare un dossier de travail et je clone mon projet :

$ mkdir ~/my_project/
$ cd ~/my_project/
$ git clone http://stephane@repos.stephane-klein.info/example1 .
$ ls -1
devel-requirements.txt
example1
requirements.txt
setup.py
tests
unittest.cfg

Je crée un environnement virtuel python dans le dossier courant :

$ virtualenv .

Comme mon projet est un package python, mon dossier contient un fichier setup.py.

Je lance son installation mais avec l'option -e pour qu'il soit éditable.

$ bin/pip install -e .

Je peux maintenant utiliser mon package :

$ bin/python
>>> import example1
>>> example1.hello_world()
Hello world
Voila, mon installation en mode développement est finie… La seule difficulté pour certaines personnes c'est l'installation des dépendances de bases (distribute/setuptools, pip, virtualenv).
La suite de l'article présente une solution plus simple, avec moins de dépendances.

Installation en mode « édition » ?

Voici deux méthodes pour installer un package python à partir du fichier setup.py :

$ python setup.py install

ou

$ pip install .

Ces deux commandes installent le package dans le dossier site-packages de votre environnement python.

Si vous modifiez le code source de votre package, vos modifications ne seront pas "visibles" lors de l'exécution du package dans votre environnement, car une copie des fichiers sources a été faite lors de l'installation.
Par conséquent, à chaque modification, vous allez devoir installer de nouveau votre package.

Ceci est très pénible si vous êtes en train de développer votre application.

C'est là que l'installation en mode editable entre en jeu.
Lors de l'installation en mode édition, le code source n'est pas copié vers le dossier site-packages mais un lien symbolique est utilisé.
Vos modifications seront tout le temps pris en compte, sans passer par l'étape d'installation.

Voici deux commandes pour effectuer une installation en mode « édition » :

$ python setup.py develop

ou

$ pip install -e .

Par le passé, python setup.py develop ne permettait pas la désinstalation du package. Maintenant ce n'est plus le cas, par conséquent ces deux commandes semblent être synonymes.

Utilisation des fichiers requirements.txt

L'option --requirement de pip est intéressante, elle permet d'installer des dépendances depuis un fichier texte.

Bon, je pense que je n'apprends rien à personne, cette option est très connue.

Par contre, sa syntaxe est moins bien connue, exemples :

  • -e package permet d'installer un package en mode édition
  • -e . est équivalent à pip install -e . ou python setup.py develop
  • -e https://github.com/pypa/pip.git#egg=pip fait un clone du dépôt dans src/pip/ et installe le package en mode édition
  • -r requirements.txt il est possible d'installer un autre fichier de requirements

Il est souvent utile d'avoir des dépendances de packages pour la version de production et des dépendances supplémentaires pour le mode développement.

Par conséquent on trouve souvent deux fichiers requirements :

  • requirements.txt
  • devel-requirements.txt

Exemple de fichier devel-requirements.txt que j'utilise :

-e .
git+https://github.com/nose-devs/nose2.git#egg=nose2
sphinx
Sphinx-PyPI-upload

Explications :

  • j'utilise -e . pour installer le package sur lequel je suis en train de travailler
  • j'installe une version qui n'est pas encore releasé de nose2
  • j'installe des outils de documentation dont j'ai besoin en mode développement

Donc pour installer la version de développement de mon projet, je fais :

$ pip install -r devel-requirements.txt

Installation de requirements.txt depuis setup.py

Il est possible d'avoir un setup.py qui utilise la liste des dépendances de requirements.txt.

Exemple :

from setuptools import setup, find_packages


def parse_requirements(file_name):
    requirements = []
    for line in open(file_name, 'r').read().split('\n'):
        if re.match(r'(\s*#)|(\s*$)', line):
            continue
        if re.match(r'\s*-e\s+', line):
            requirements.append(re.sub(r'\s*-e\s+.*#egg=(.*)$', r'\1', line))
        elif re.match(r'\s*-f\s+', line):
            pass
        else:
            requirements.append(line)

    return requirements

setup(
    name='my_project',
    version='0.1.0',
    packages=find_packages(),
    include_package_data=True,
    zip_safe=False,
    install_requires=parse_requirements("requirements.txt")
)

Ici, parse_requirements lit le fichier requirements.txt et utilise sont contenu avec l'argument install_requires de la fonction setup.

Mais attention ! La fonction setup ne sait pas traiter la syntax spécifique à pip. Par exemple, les lignes du style git+http... ne fonctionnneront pas.

Méthode avec bootstrap de virtualenv

Depuis quelques mois, j'utilise la fonctionnalité « Creating Your Own Bootstrap Scripts » de virtualenv.

Cette fonctionnalité permet de générer un fichier python, qui installera automatiquement un environnement virtuel comme la commande virtualenv . mais sans aucune dépendance à installer. pip sera bien présent dans bin même si vous ne l'avez pas installé avant.

Utilisation de bootstrap.py

Avant de voir comment générer un fichier bootstrap.py, nous allons voir comment l'utiliser :

$ mkdir ~/my_project/
$ cd ~/my_project/
$ git clone http://stephane@repos.stephane-klein.info/example1.1 .
$ python bootstrap.py
$ bin/pip install -r devel-requirements.txt

Voila, rien de plus… on va voir par la suite qu'il est même possible d'installer automatiquement les devel-requirements.txt.

Génération d'un bootstrap.py

La documentation de virtualenv, donne une exemple de génération d'un fichier bootstrap.py.

Personnellement, je place un fichier nommé create-bootstrap.py dans le dossier où je souhaite créer mon fichier bootstrap.py. Ce fichier create-bootstrap.py contient le code suivant :

import virtualenv, textwrap

output = virtualenv.create_bootstrap_script(textwrap.dedent("""
import os, subprocess

def adjust_options(options, args):
    if len(args) == 0:
        args.append('.')

def after_install(options, home_dir):
    subprocess.call([
        os.path.join('bin', 'pip'),
        'install', '-r', 'devel-requirements.txt'
    ])
"""))
f = open('bootstrap.py', 'w').write(output)

La ligne args.append('.') indique qu'au lancement de bootstrap.py l'environnement python sera installé dans le dossier courant.

Un peu plus bas, je lance l'installation de devel-requirements.txt.

J'ai juste à lancer (une fois) python create-bootstrap.py pour générer bootstrap.py :

$ ls -1
create-bootstrap.py
setup.py
devel-requirements.txt
requirements.txt
$ python create-bootstrap.py
$ ls -1
bootstrap.py
create-bootstrap.py
devel-requirements.txt
requirements.txt
setup.py

Maintenant, j'ajoute create-bootstrap.py et bootstrap.py dans mon dépôt.

$ git add create-bootstrap.py bootstrap.py

Par la suite, il n'y a plus besoin d'utiliser create-bootstrap.py à moins de vouloir modifier bootstrap.py pour ajouter/supprimer des actions automatiques.

Pour finir, mon workflow complet

Prérequis:

  • python

Simple niveau prérequis, non ? Un peu comme buildout pour ceux qui connaissent.

Je prépare un dossier avec mon projet :

$ mkdir ~/my_project/
$ cd ~/my_project/
$ git clone http://my-project.org/ .

En une seule commande, j'installe mon environnement python et mon projet en mode développement (éditable).

$ python bootstrap.py
Read and Post Comments

Ember.js - Question « Un adaptateur modèle <=> template ? »

Problématique

Depuis quelques mois, j'utilise Ember.js dans certain de mes projets.

Dernièrement, j'ai été confronté au problème présenté ci-dessous.

Par exemple, j'ai cette structure de données (couche modèle) :

* Lundi
    * Évenement 1
    * Évenement 2
    * Evenement 3

* Mardi
    * Évenement 4
    * Évenement 5

* Mercredi
    * Évenement 6
    * Évenement 7
    * Évenement 8

Avec deux boucles, je peux facilement réaliser le tableau suivant :

Lundi Évenement 1 Évenement 2 Évenement 3
Mardi Évenement 4 Évenement 5  
Mercredi Évenement 6 Évenement 7 Évenement 8

Pour faire ce tableau, je peux utiliser un template du style :

<table>
    {{#each day in App.day_list}}
    <tr>
        <td>
            {{ day.name }}
        </td>
        {{#each event in day.events}}
        <td>
            {{ event.name }}
        </td>
        {{/each}}
    </tr>
    {{/each}}
</table>

Par contre, il est bien plus difficile (sans changer la structure de données) de réaliser le tableau suivant :

Lundi Mardi Mercredi
Évenement 1 Évenement 4 Évenement 6
Évenement 2 Évenement 5 Évenement 7
Évenement 3   Évenement 8

Ici le tableau est inversé… je ne peux donc pas réaliser ce tableau avec les deux mêmes boucles utilisées dans l'exemple précédent.

La seul solution que j'ai trouvé c'est de fournir un objet de type "vue" qui adapte mon modèle au besoin de ma vue. Seulement ce n'est pas si simple… car :

  • il faut que si les données sont modifiées, que le template soit automatiquement mis à jour… il faut donc une gestion d'évenement entre les objets de la couche modèles et l'objet "vue"
  • en cas d'interactions au niveau de l'interface utilisateur il faut que les données soient automatiquement mis à jour.

Premier exemple, simple

Il est possible de tester le premier exemple simple via ce lien jsFiddle.

Ce qu'il est possible de voir et tester :

  • cliquez sur les liens « Step 1 », « Step 2 », « Step 3 », « Step 4 »
  • ces liens modifient le contenu de la couche modèle
  • dès la première étape, vous pouvez voir deux tableaux identiques… qui affichent le contenu de la couche modèle
  • à chaque étape, vous pouvez modifier la valeur des cellules des tableaux
  • lorsque la valeur d'une cellule est modifiée dans un des deux tableaux, la cellule dans le second tableau est aussi modifiée

Voici ci-dessous un petit screencast de ce premier exemple simple :

Recherche d'une solution pour le « Tableau pivoté »

Comme indiqué plus haut, je cherche une solution pour afficher le même tableau mais pivoté (voir exemple au dessus).

J'ai un début de solution dans ce jsFiddle.

Vous pouvez constater que les étapes 1, 2, 3 fonctionnent.
L'étape 4 ne fonctionne pas…

Au niveau du code source, vous pouvez voir cette ligne commentée à plusieurs endroits :

// App.planning.rows.sync();

C'est cette fonction sync qui synchronise mon objet planning avec les données de la couche modèle dont l'objet de racine est day_list. Cette méthode est automatiquement exécutée par un observateur quand il y a des modifications au niveau de l'objet App.day_list (les sync en commentaire sont simplement là pour voir le comportement que devrait avoir le programme si cette méthode était bien exécutée) :

Ember.addObserver(
    App.day_list,
    '@each.content.@each',
    App.planning.rows,
    'sync'
);

C'est cet observateur qui fonctionne bien pour l'étape 1, 2 et 3 mais pas pour l'étape 4.

Mes questions

  • Avez vous une solution pour que sync soit bien exécuté lors de l'étape 4 ?
  • Est-ce que mon approche est la bonne ?
  • Quel est le nom standard de l'objet que j'appelle "vue" ? est-ce que je suis dans le vrai en disant que c'est un objet de type "vue" ?
  • Est-ce qu'il existe un pattern classique pour ce type de besoin en Ember.js ?
Read and Post Comments

Ember.js - Épisode 2 - Déclarer un tableau dans une classe

Après un premier billet intitulé « Ember.js - Épisode 1 - Présentation de l'application feuille de matchs » voici un second billet à propos de Ember.js où je vais présenter comment résoudre le problème assez trivial que j'ai rencontré lors de la déclaration d'une propriété de type tableau dans une classe.

Note : je ne sais pas si j'ai bien réussi à doser le niveau de ce billet… je pense que par moment j'ai expliqué des concepts trop basiques de Ember.js. J'attends vos remarques à ce sujet dans les commentaires.

Les fonctions "create" et "extend" de Ember.js

Tout d'abord, voyons les différences entre Ember.Object.create et Ember.Object.extend.

Pour créer un objet avec Ember.js, vous devez utiliser la fonction suivante :

MyApp.contact1 = Ember.Object.create({
    firstname: 'Stéphane',
    lastname: 'Klein'
});

Pour créer une classe, vous devez utiliser la fonction suivante :

// Création de la classe
MyApp.Contact = Ember.Object.extend({
    sayHello: function() {
        alert('Hello');
    }
});

// Création de deux objets de type "Contact"
MyApp.contact1 = MyApp.Contact.create(
    firstname: 'Stéphane',
    lastname: 'Klein'
);
MyApp.contact2 = MyApp.Contact.create(
    firstname: 'Thomas',
    lastname: 'Petit'
);

MyApp.contact1.sayHello();

Vous l'aurez compris :

  • create permet de créer un objet
  • extend permet de créer une classe

Pour plus d'information à ce sujet, je vous invite à consulter la documentation de Ember.js.

Dans l'exemple de ce billet, j'ai besoin d'utilser la commande "Ember.Object.extend" pour déclarer une nouvelle classe.

Description de la classe que je souhaite créer

Je souhaite créer une classe nommée "Game" qui représente un match dans la feuille de matchs (vous pouvez retrouver la description de l'application dans le premier billet).

Cette classe "Game" doit remplir les objectifs suivants :

  • stocker les scores des sets du match
  • calculer le nombre total de points du match (ceci n'a en fait aucune utilité dans mon application mais c'est une fonction utile dans l'exemple de ce billet… car il illustre très bien la problématique)

Je veux pouvoir faire des choses comme :

MyApp.game_1 = MyApp.Game.create({});

MyApp.game_1.sets[0] = 5; // Attention ici "sets" correspond à des sets d'un match
MyApp.game_1.sets[1] = 2;

console.log(MyApp.game_1.total()); // retourne 7

Première implémentation qui ne fonctionne pas comme souhaité

Voici une première version de ma classe "Game" qui ne fonctionne pas comme souhaité.

Dans main.js :

MyApp = Ember.Application.create();

MyApp.Game = Ember.Object.extend({
    sets: [ // Attention, ici "sets" correpsond à des sets d'un match
        Ember.Object.create({ value: '' }),
        Ember.Object.create({ value: '' }),
        Ember.Object.create({ value: '' }),
        Ember.Object.create({ value: '' }),
        Ember.Object.create({ value: '' })
    ],
    total: function() {
        var total = 0;
        this.sets.forEach(function(item) {
            if (
                (item.value != '') &&
                (!isNaN(item.value))
            ) {
                total += parseInt(item.value);
            }
        });
        return total;
    }.property('sets.@each.value')
});

MyApp.games = [
    MyApp.Game.create(),
    MyApp.Game.create(),
    MyApp.Game.create()
]

Explication du code source :

  • ligne 1 : je déclare l'application
  • ligne 3 : je déclare la nouvelle classe nommée "Game"
  • lignes 4-10 : déclaration de la propriété sets avec des scores de sets vides (attention, ici un sets correspond à des sets de matchs)
  • lignes 11 à 22 : déclaration de la méthode total. Cette méthode parcourt tous les éléments de la propriété sets et fait la somme de ses valeurs en transformant la valeur des sets en entier…
  • ligne 22 : "l'annotation" property, indique que la valeur de la méthode total change dès qu'une proriété value de la propriété sets est modifiée
  • lignes 25-29 : création de 3 objet de type Game dans un tableau nommé games

Voici ci-dessous, le template qui utilise la couche modèle qui vient d'être défini (les instances dans "MyApp.games" et la classe "MyApp.Game").

Dans index.html :

<body>
<script type="text/x-handlebars">
  <h1>Game 1</h1>
    <ul>
      <li>
        {{view Ember.TextField valueBinding="MyApp.games.0.sets.0.value"}}
      </li>
      <li>
        {{view Ember.TextField valueBinding="MyApp.games.0.sets.1.value"}}
      </li>
      <li>
        {{view Ember.TextField valueBinding="MyApp.games.0.sets.2.value"}}
      </li>
      <li>
        {{view Ember.TextField valueBinding="MyApp.games.0.sets.3.value"}}
      </li>
      <li>
        {{view Ember.TextField valueBinding="MyApp.games.0.sets.4.value"}}
      </li>
    </ul>

  <p>Total : {{MyApp.games.0.total}}</p>

  <h1>Game 2</h1>
    <ul>
      <li>
        {{view Ember.TextField valueBinding="MyApp.games.1.sets.0.value"}}
      </li>
      <li>
        {{view Ember.TextField valueBinding="MyApp.games.1.sets.1.value"}}
      </li>
      <li>
        {{view Ember.TextField valueBinding="MyApp.games.1.sets.2.value"}}
      </li>
      <li>
        {{view Ember.TextField valueBinding="MyApp.games.1.sets.3.value"}}
      </li>
      <li>
        {{view Ember.TextField valueBinding="MyApp.games.1.sets.4.value"}}
      </li>
    </ul>

  <p>Total : {{MyApp.games.1.total}}</p>

  <h1>Game 3</h1>

    <ul>
      <li>
        {{view Ember.TextField valueBinding="MyApp.games.2.sets.0.value"}}
      </li>
      <li>
        {{view Ember.TextField valueBinding="MyApp.games.2.sets.1.value"}}
      </li>
      <li>
        {{view Ember.TextField valueBinding="MyApp.games.2.sets.2.value"}}
      </li>
      <li>
        {{view Ember.TextField valueBinding="MyApp.games.2.sets.3.value"}}
      </li>
      <li>
        {{view Ember.TextField valueBinding="MyApp.games.2.sets.4.value"}}
      </li>
    </ul>

  <p>Total : {{MyApp.games.2.total}}</p>
</script></body>

Explication :

  • ligne 2 : balise d'ouverture un bloc template de Ember.js
  • ligne 6 : cette "view" crée une balise du type <input type="text"…, ce champ input est directement connecté avec l'objet MyApp.games.0.sets.0.value de la couche modèle. Ce qui veut dire que lorsque cet objet est mise à jour du coté de la couche modèle alors le champ input est directement mise à jour. Inversement, si je champ input est mise à jour via l'Interface Utilisateur alors la couche modèle est mise à jour automatiquement.
  • ligne 22 : la valeur retournée par la méthode MyApp.games.0.total est affichée et elle est automatiquement mise à jour… grâce à l'annotation property('sets.@each.value')
  • lignes 22-42 : affichage de la seconde instance MyApp.games.1
  • lignes 45-65 : affichage de la troisième instance MyApp.games.2

Vous pouvez tester cette implémentation en live sur jsFiddle.

Qu'est ce qu'il ne fonctionne pas dans cette implémentation ?

Dans cette implémentation, lorsque l'on modifie la valeur des sets d'une instance, par exemple « Game 1 », les champs des autres matchs « Game 2 » et « Game 3 » sont eux aussi mise à jour avec les mêmes valeurs.

Toutes les instances de classe de type "Game" partagent les mêmes données !

Pourquoi ?

Parcequ'un objet de type tableau est directement affecté à la propriété sets de la classe Game lors de sa déclaration… ce tableau est créé une fois… et est donc identique et partagé par toutes les instances de type Game.

La bonne solution : déclarer le tableau dans le constructeur

Voici maintenant l'implémentation qui fonctionne comme voulu.

Dans main.js :

MyApp = Ember.Application.create();

MyApp.Game = Ember.Object.extend({
    init: function() {
        this._super();
        this.set('sets', [
            Ember.Object.create({ value: '' }),
            Ember.Object.create({ value: '' }),
            Ember.Object.create({ value: '' }),
            Ember.Object.create({ value: '' }),
            Ember.Object.create({ value: '' })
        ]);
    },
    total: function() {
        var total = 0;
        this.sets.forEach(function(item) {
            if (
                (item.value != '') &&
                (!isNaN(item.value))
            ) {
                total += parseInt(item.value);
            }
        });
        return total;
    }.property('sets.@each.value')
});

MyApp.games = [
    MyApp.Game.create(),
    MyApp.Game.create(),
    MyApp.Game.create()
]

Le fichier index.html est exactement identique à la première implémentation.

Tester cette version en live sur jsFiddle.

Explications :

  • lignes 4-13: cette fois, un constructeur (méthode init) est déclaré
  • ligne 5 : cette ligne importante pour le bon fonctionnement d'une classe Ember.js
  • ligne 6 : ici est la subtilité, il faut absolument passer par la méthode set pour ajouter une nouvelle propriété à la classe
  • ligne 7-11 : ajout de 5 objets directement dans le tableau

Cette fois, cette implémentation fonctionne comme voulu car chaque instance de type Game contient un tableau différent.

Attention à ne pas utiliser la méthode "push" mais bien "pushObject"

Voici une autre subtilité, l'exemple ci-dessous ne fonctionne pas comme souhaité car il utilise la méthode push de la classe Array.

Dans main.js :

MyApp = Ember.Application.create();

MyApp.Game = Ember.Object.extend({
    init: function() {
        this._super();
        this.set('sets', Ember.A([]));
        this.get('sets').push(Ember.Object.create({ value: '' }));
        this.get('sets').push(Ember.Object.create({ value: '' }));
        this.get('sets').push(Ember.Object.create({ value: '' }));
        this.get('sets').push(Ember.Object.create({ value: '' }));
        this.get('sets').push(Ember.Object.create({ value: '' }));
    },
    total: function() {
        var total = 0;
        this.sets.forEach(function(item) {
            if (
                (item.value != '') &&
                (!isNaN(item.value))
            ) {
                total += parseInt(item.value);
            }
        });
        return total;
    }.property('sets.@each.value')
});

MyApp.games = [
    MyApp.Game.create(),
    MyApp.Game.create(),
    MyApp.Game.create()
];

Tester l'exemple en live sur jsFiddle.

Exemple qui fonctionne avec l'utilisation de pushObject :

MyApp = Ember.Application.create();

MyApp.Game = Ember.Object.extend({
    init: function() {
        this._super();
        this.set('sets', Ember.A([]);
        this.get('sets').pushObject(Ember.Object.create({ value: '' }));
        this.get('sets').pushObject(Ember.Object.create({ value: '' }));
        this.get('sets').pushObject(Ember.Object.create({ value: '' }));
        this.get('sets').pushObject(Ember.Object.create({ value: '' }));
        this.get('sets').pushObject(Ember.Object.create({ value: '' }));
    },
    total: function() {
        var total = 0;
        this.sets.forEach(function(item) {
            if (
                (item.value != '') &&
                (!isNaN(item.value))
            ) {
                total += parseInt(item.value);
            }
        });
        return total;
    }.property('sets.@each.value')
});

MyApp.games = [
    MyApp.Game.create(),
    MyApp.Game.create(),
    MyApp.Game.create()
];

Tester l'exemple en live sur jsFiddle.

Explication :

  • Lorsque l'on utilise un tableau qui doit être observé par Ember.js, il est important d'utiliser la méthode pushObject et non pas push sinon property('sets.@each.value') ne fonctionne plus.

Dans le cadre de l'application « Feuille de matchs »

L'application « Feuille de matchs » met en oeuvre ce que l'on a vu ci-dessus.

J'ai perdu pas mal de temps avec la subtilité d'utiliser this.set dans le constructeur. J'ai aussi perdu du temps car j'ai utilisé par erreur la méthode push à la place de pushObject.

J'espère que ce billet vous sera utile et qui vous évitera de perdre du temps là où j'en ai perdu.

Read and Post Comments
Billets plus anciens »

Mes flux :