TD 11 - Mustache

Moteur de modélisation Mustache

Mustache est un moteur de modélisation performant qui permet de séparer un modèle des données qui y sont représentées. Une documentation plus complète se trouve ici

1. Un moteur de modélisation simple

Pour l’instant, on va considérer un modèle simplifié. Les variables seront contenues dans un fichier JSON, que l’on parsera avec ce qu’on a fait au TD 8.

Rappel du modèle

Pour rappel, on considère que le JSON parsé sera représenté par le modèle suivant :

data JSON = JsonNull                     -- Valeur null
          | JsonBool Bool                -- Booléens
          | JsonInt Int                  -- Entiers
          | JsonFloat Float              -- Flottants
          | JsonString String            -- Chaînes de caractères
          | JsonArray  [JSON]            -- Liste
          | JsonObject  [(String, JSON)] -- Objets
          deriving Show

On va aussi considérer qu’un modèle (un Template) sera une liste d’éléments, qui pourront être soit des variables (à remplacer dans le document final), soit du texte simple.

Une variable est constituée d’un nom (donc une chaîne de caractères), du texte sera juste une chaîne de caractères également.

On a donc le modèle suivant :

type Template = [Element]

data Element = Text String
             | Variable String
             deriving (Show, Eq)

Parser le modèle - version simple

Pour parser les variables, il faut qu’on utilise notre parseur. Les règles pour les variables sont qu’elles sont représentées par un nom (une chaîne de caractères) compris dans une paire de doubles accolades.

On aura alors par exemple :

Bonjour {{ nom }}, vous avez {{ age }} ans.

Ici, on va utiliser les variables nom et age.

Ce qui nous donne le parseur suivant :

parsePlaceholder :: Parser Element
parsePlaceholder = chaine "{{"
                 >> parseSpaces
                 >> many (toutSauf "/{} ")
                 >>= \s -> parseSpaces
                 >> chaine "}}"
                 >> return (Variable s)

Pour parser du texte, on peut juste estimer qu’on va parser n’importe quel caractère excepté des accolades.

On peut donc faire une version simple comme :

parseText :: Parser Element
parseText = some (toutSauf "{}") >>= \s -> return $ Text s 

Récupérer le modèle complet consiste donc à simplement répéter plusieurs fois la reconnaissance de variables ou de texte, soit :

parseTemplate :: Parser Template
parseTemplate = many (parsePlaceholder <|> parseText)

Instancier le modèle

Maintenant qu’on sait récupérer un modèle, on va s’attacher à remplacer les variables par leurs valeurs.

On va vouloir transformer les valeurs JSON en chaînes de caractères. Pour ça, on va écrire une petite fonction utile pour se simplifier le travail plus tard.

dataToString :: JSON -> String
dataToString (JsonString s) = s -- ici, on ne met pas show pour ne pas afficher les guillemets
dataToString (JsonBool b)   = show b
dataToString (JsonInt n)    = show n
dataToString (JsonFloat f)  = show f
dataToString _              = ""

Pour instancier un modèle, il faut pouvoir instancier un élément. Dans le cas des éléments de type texte, c’est facile. Pour les variables, il va falloir aller regarder dans notre environnement (un objet JSON) et récupérer la valeur de la variable.

On va encore une fois s’écrire une fonction utile pour faire le travail de recherche.

La fonction getValue va prendre en argument un objet JSON et une chaîne de caractères (la clé), et va renvoyer la valeur de la clé.

Ici, puisque les objets dans notre modèle JSON sont des listes associatives, on va utiliser la fonction lookup :: Eq a => a -> [(a, b)] -> Maybe b, qui fonctionne comme ceci :

ghci> lookup "b" [("a", 3), ("b", 5), ("c", 1)]
Just 5
ghci> lookup "d" [("a", 3), ("b", 5), ("c", 1)]
Nothing

On a donc simplement :

getValue :: JSON -> [String] -> Maybe JSON
getValue (JsonObject obj) x = lookup x obj -- on regarde dans l'objet
getValue _ _                = Nothing      -- dans tous les autres cas on échoue

On va donc pouvoir instancier un élément facilement maintenant. La fonction instElement va être une fonction qui va prendre un environnement JSON, un élément et qui va renvoyer une chaîne de caractères. Elle échouera cependant si la clé d’une variable n’est pas présente dans l’environnement.

Heureusement, on travaille avec Maybe, qui est un foncteur applicatif ! On peut donc tout simplement écrire :

instElement :: JSON -> Element -> Maybe String
instElement json (Variable x) = dataToString <$> getValue json x -- on convertit en string la valeur json récupérée
instElement _    (Text t)     = Just t                           -- c'est juste du texte
instElement _    _            = Nothing                          -- bon là, c'est pas couvert, donc on échoue

L’instanciation d’un modèle complet est un peu plus technique. On va vouloir instancier chaque élément, et concaténer les résultats. Si on map l’instanciation des éléments, on aura une liste de type [Maybe String]. Il faudra alors sortir le Maybe de la liste et concaténer les résultats.

Une autre solution est simplement de faire un pli de la liste. On commence avec une chaîne vide et on replie, en concaténant au fur et à mesure.

Pour chaque élément qu’on a, on va donc devoir l’instancier, et concaténer avec l’accumulateur.

Ce qui nous donne :

instTemplate :: JSON -> Template -> Maybe String
instTemplate json = foldr (liftA2 (++) . instElement json) (Just "")

On utilise ici liftA2 pour concaténer le contenu des deux Maybe (l’accumulateur et l’élément).

2. Ajouter des fonctionnalités

On va ajouter quelques petites fonctionnalités pour rendre notre moteur de modélisation encore plus utile et pratique.

Utiliser des variables sous la forme d’objets

Souvent dans les objets JSON, on a des objets imbriqués. Par exemple, on pourrait avoir une variable personne constituée comme ceci :

{
    "personne" : {
        "nom" : "Dupont",
        "prenom" : "Paul",
        "adresse" : {
            "rue" : "Avenue Paul Langevin",
            "cp"  : 59650,
            "ville" : "Villeneuve d'Ascq"
        }
    }
}

Dans ce cas, on va vouloir accéder au champ prenom en utilisant la notation personne.prenom et à son code postal avec personne.adresse.cp.

Pour ça, on a deux solutions :

  1. soit, comme c’est le cas jusqu’à présent, on est capable de parser des noms de variables qui contiennent des points, et dans ce cas, il faut séparer la chaîne de caractères en liste de clés successives ;
  2. soit on modifie le modèle pour que la clé d’une variable soit une liste de chaînes et on change le parseur pour reconnaître le motif de l’expression régulière suivante : "\s+(\.\s+)*" (c’est-à-dire une chaîne, suivie d’une répétition d’un point puis d’une chaîne).

On va se concentrer sur la seconde solution ici.

Il faut donc changer le modèle, qui devient alors :

data Element = Text String
             | Variable [String]
             deriving (Show, Eq)

Ensuite, il va falloir changer notre fonction getValue, qui va chercher la valeur d’une clé dans l’objet. Maintenant, il faut que l’on ne renvoie un objet que si la liste n’a plus qu’un seul élément. Si on en a plus, il faut qu’on aille récursivement chercher les clés dans les objets imbriqués.

Sur l’objet défini ci-dessus (qu’on va appeler obj), on aurait alors :

ghci> getValue obj ["personne", "nom"]
Just (JsonString "Dupont")
ghci> getValue obj ["personne"]
Just (JsonObject ...)

et ça échouerait toujours dans les cas où les clés ne sont pas trouvées etc.

Le code de la fonction getValue devient alors :

getValue :: JSON -> [String] -> Maybe JSON
getValue (JsonObject obj) [x]    = lookup x obj
getValue (JsonObject obj) (x:xs) = case lookup x obj of
    Nothing   -> Nothing
    Just json -> getValue json xs
getValue _ _                     = Nothing

Dans ce cas, comme le nom de variable est déjà une liste, le code de la fonction instElement ne change pas.

Faire des sections conditionnelles

Un des autres avantages de Mustache, c’est qu’on peut décider d’afficher ou non du contenu en fonction de la valeur d’une variable booléenne. On va ajouter cette fonctionnalité à notre moteur de templating.

Par exemple, si on considère le modèle suivant :

Bonjour {{prenom}},
Vous avez eu {{ note }} au DS.
{{ #valide }}
Félicitations, vous avez validé !
{{ /valide }}

Alors, si la clé valide vaut true, on aura :

Bonjour Bob,
Vous avez eu 19 au DS.

Félicitations, vous avez validé !

et sinon :

Bonjour Bob,
Vous avez eu 9 au DS.

Comme on peut le voir, les définitions de section ressemblent fortement aux définitions de variables. Cependant, le nom de la section est précédé par un # au début et par un / à la fin.

À l’intérieur d’une section, on peut trouver un modèle complet.

On peut donc modifier le modèle pour qu’il reconnaisse également les sections de la manière suivante :

data Element = Text String
             | Variable [String]
             | Section {
                name     :: [String],
                elements :: Template
             }
             deriving (Show, Eq)

On peut ensuite écrire un parseur qui reconnaît une section :

parseSection :: Parser Element
parseSection = chaine "{{"      -- ouverture de la balise
                >> parseSpaces  -- espaces potentielles
                >> chaine "#"   -- début de nom de section
                >> parseName    -- nom de section
                >>= \n -> parseSpaces
                >> chaine "}}"  -- fermeture de balise
                >> ((car '\n' >> return ()) <|> return ()) -- eventuellement un \n
                >> parseTemplate -- modèle intérieur
                >>= \template -> chaine "{{" -- ouverture de la balise
                >> parseSpaces  -- espaces potentielles
                >> chaine ("/" ++ n) -- nom de section précédé par le /
                >> parseSpaces  -- espaces potentielles
                >> chaine "}}"  -- fermeture de balise
                >> ((car '\n' >> return ()) <|> return ()) -- éventuellement un \n
                >> return (Section n template)

Ce parseur peut sembler compliqué, mais la seule difficulté réside dans le fait de vérifier à la fin que le nom de fermeture de section est le même qu’à l’ouverture.

On peut ensuite simplement modifier la fonction instElement pour qu’elle instancie correctement une section.

instElement :: JSON -> Element -> Maybe String
instElement json (Variable x)      = dataToString <$> getValue json (split '.' x)
instElement _    (Text t)          = Just t
instElement json (Section n templ) = case getValue json (split '.' n) of
    Just (JsonBool b)  -> if b then instTemplate json templ else Just ""
    _                  -> Nothing
instElement _    _                 = Nothing

Répéter du contenu

L’autre avantage des sections, c’est que si elles référencent une valeur sous la forme d’une liste, on peut les utiliser pour répéter du contenu.

Par exemple, si on a :

{
    "liste": [
        {"nom": "Pierre", "sujet": "Maths"},
        {"nom": "Paul", "sujet": "Français"},
        {"nom": "Jacques", "sujet": "Maths"}
    ]
}

Alors on peut écrire facilement une liste avec le modèle suivant (ici au format HTML) :

<ul>
    {{  #liste }}
    <li> {{ nom }} est fort en {{ sujet }} </li>
    {{ /liste }}
</ul>

Qui renverrait :

<ul>
    <li> Pierre est fort en Maths </li>
    <li> Paul est fort en Français </li>
    <li> Jacques est fort en Maths </li>
</ul>

Le format des sections n’a pas changé par rapport à tout à l’heure. On peut donc conserver tout ce qu’on a écrit.

Par contre, il va falloir se méfier au moment de modifier la fonction instElement.

On va commencer par instancier chaque élément de la liste. Si on applique juste un map à la liste des éléments instanciés, on se retrouve avec une liste de Maybe String. Il faut donc que l’on puisse sortir de la liste le Maybe, de manière à pouvoir concaténer les résultats et à obtenir une seule chaîne de caractères.

Pour cela, on peut utiliser l’opérateur sequence, qui peut s’implémenter comme ceci :

sequence :: [Maybe a] -> Maybe [a]
sequence []          = Just []
sequence (Just x:xs) = (x:) <$> sequence xs
sequence _           = Nothing

On peut ensuite intégrer ceci dans notre code :

instElement :: JSON -> Element -> Maybe String
instElement json (Variable x)      = dataToString <$> getValue json (split '.' x)
instElement _    (Text t)          = Just t
instElement json (Section n templ) = case getValue json (split '.' n) of
    Just (JsonArray l) -> concat <$> sequence (map (\x -> instTemplate x templ) l)
    Just (JsonBool b)  -> if b then instTemplate json templ else Just ""
    _                  -> Nothing
instElement _    _            = Nothing

On a maintenant un modèle de modélisation plus complet !

3. Implémenter l’application

Un exemple de structure d’application est disponible ici