Hello !
J'voulais vous présenter un petit projet en marge de mes contributions à Vert.x.
(NB : cette présentation aurait pu faire l'objet d'une "tribune libre" )
Si vous ne savez pas ce qu'est Vert.x, grosso modo c'est un peu comme Node.js. Si vous savez pas ce qu'est Node.js, c'est un peu comme Flask, si vous savez pas ce qu'est Flask, c'est un peu comme… Bref, c'est un framework de développement d'appli (web, notamment) asynchrones.
Deux trucs que j'aime et que je déteste dans Vert.x, c'est :
- l'API idiomatique
- l'API programmatique
Une API moins idiomatique, mais plus jolie
Une API idiomatique c'est quoi ? C'est que dans tous les langages supportés (Java, Javascript, Ruby, Groovy, Ceylon, Typescript), l'API a les mêmes primitives :
Java :
1 2 3 | router.get("/hello").handler({ context -> context.response().end("Hello: " + context.request().getParam("name")); }); |
Groovy :
1 2 3 | router.get('/hello').handler { context -> context.response().end("Hello: ${context.request().getParam('name')}"); }) |
Pourquoi c'est bien ?
Parce que peu importe le langage utilisé, on s'y retrouve, du coup, si on a une équipe composée de plein de monde, diverses parties d'une application peuvent être écrites en plusieurs langage, sans qu'on soit perdu. N'importe qui peut intervenir sans être totalement perdu. context.response()
on sait ce que ça veut dire, peu importe le langage.
Pourquoi parfois c'est dommage ?
Parce que certains langages offrent des possibilités qui du coup ne sont pas exploitées (surcharge d'opérateur par exemple) ou les conventions ne sont pas les mêmes (exemple en Groovy : objet.machin
c'est, par convention objet.getMachin()
) du coup on perd parfois en expressivité, malheureusement.
La solution
Enrichir l'API pour proposer ce qui manque. Certes, mais le problème c'est que ces APIs multi-langages sont générées (astucieusement) automatiquement (histoire de ne pas maintenir 200 projets, on comprend les développeurs de Vert.x…).
Et c'est la que la magie des langages de script (et de Groovy dans ce cas) nous sauve. Grâce à ses "Extension modules". Il est possible d'ajouter à une API existante des méthodes manquantes, simplement en les décrivant dans un fichier. N'importe quel utilisateur qui importera votre jar (avec ce fichier + les implémentations) bénéficiera des méthodes manquantes. Et en bénéficiera à la compilation !
D'où mon projet : https://github.com/aesteve/vertx-groovy-sugar
On reprend l'exemple précédent :
Rappel en Java :
1 2 3 | router.get("/hello").handler(context -> { context.response().end("Hello: " + context.request().getParam("name")); }); |
Avec le sucre :
1 2 3 | router['/hello'] = { context -> context.response << "Hello: " + context.request.params['name'] } |
Concrètement, y'a quoi dans cet exemple ?
- L'ajout de deux méthodes :
getRequest()
etgetResponse()
qui permettent donc d'écrirecontext.request
context.response
directement, sans parenthèse. Et ça se "lit" mieux, la réponse HTTP, c'est un simple attribut du contexte. - De la surcharge d'opérateur : l'opérateur
[
de l'objetRouter
a été surchargé. De même pourparams
qui est uneMultiMap
. L'opérateur<<
a été ajouté sur l'objetHttpServerResponse
permettant d'appelerend
.
Et y'a un peu de ça partout. Pour "imbriquer" deux streams : par exemple envoyer le contenu d'un fichier via une requête HTTP, on écrirait :
1 | Pump.createPump file, request |
Qui devient :
1 | file | request |
Pour ajouter un élément à un buffer, on écrirait :
1 | buffer.appendBuffer other |
Qui devient :
1 | buffer += other |
Etc. etc.
Une API moins programmatique, plus déclarative
L'autre soucis qui m'embête parfois, c'est l'API programmatique. Déclarer des routes dans un environnement web c'est le B à BA de tout développeur web. Comment on fait avec Vert.x ?
1 2 3 | router.get('/hello').handler { context -> context.response().end 'Hello world' } |
Pourquoi c'est bien ?
Parce que c'est le moyen le plus flexible qui soit, tout simplement. On est dans du code, donc on a accès à tout, les conditions, les boucles, les variables d'environnement, des événements extérieurs (la base de données est down, je fais en sorte que toutes les routes renvoient des 503) etc.
Donc c'est un choix tout à fait logique, c'est l'assurance de pouvoir tout faire.
Pourquoi c'est pénible ?
Parce que c'est atrocement verbeux…
La première fois qu'on écrit router.route('/machin').handler(...)
ça va, la seconde fois ça commence à être pénible, à la 30ème route (sachant qu'on peut router avec des regex, ça reste rare) on a envie de balancer l'écran par la fenêtre.
Parce que c'est pas très simple à lire
La déclaration des routes au milieu du code, j'ai jamais été trop fan.
Le premier réflexe quand on a un soucis (on voit dans les logs que /machin/bidule
renvoie une erreur 500) c'est de chercher où est définit le code qui gère ça.
Dans ce cas là, il faut chercher l'association : path http <-> code (classe, méthode, …) qui gère ça.
La plupart des frameworks web (RoR, Django, Grails, …) fournissent un (ou plusieurs) fichier(s) de routage et c'est à mon sens une excellente idée. Ca sert de point d'entrée, ça permet une approche très descriptive ("Tu cherches l'API des membres, tiens, elle est là") et ça fait gagner du temps.
La solution
Un autre truc hyper cool de Groovy, c'est la facilité de création de DSL. Donc il n'a pas été bien difficile d'écrire une DSL pour le fameux router.
Voyons ce que ça donne (avec en cadeau le sucre précédemment présenté) :
Standard Vert.x :
1 2 3 4 5 6 7 8 | router.get('/hello').produces('text/plain').handler { ctx -> ctx.response().end('Hello world') } router.post('/hello').consumes('application/json').produces('text/plain').handler { ctx -> String json = ctx.request().bodyAsString Map userInfos = new JsonSlurper().parseText json ctx.response().end "Hello ${userInfos['name']}" } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | router { route('/hello') { consumes 'application/json' produces 'text/plain' get { reponse << 'Hello world' } post { String json = request.body as String Map userInfos = new JsonSlurper().parseText json response << "Hello ${userInfos['name']}" } } } |
Ça fait plus de lignes (mais moins longues), mais je peux vous garantir que dans la vraie vie, on y retrouve beaucoup mieux ses petits
C'est (je trouve) plus facile à lire, à comprendre. Surtout lorsque l'on commence à avoir des routes imbriquées (ce qui est systématiquement le cas dans une API REST).
On note aussi que la variable response
vient de nulle part, elle paraît, par magie.
En réalité, c'est bien context.response
simplement au moment d'évaluer le code, Groovy injecte naturellement le context dans la closure (Groovy appelle ça un delegate
) et au moment d'évaluer une variable, il va d'abord aller la chercher dans le delegate (closure.resolveStrategy Closure.DELEGATE_FIRST
)
Cadeaux bonus
Le but d'une DSL, c'est d'être facile à lire. Du coup, y'a quand même des trucs qui reviennent tout le temps dans une appli web et qui sont pénibles à lire (qui font "du bruit") dans du code.
Checker des paramètres
Exemple type : si jamais l'URL contient un paramètre qui n'est pas un Long
, l'utilisateur se mange une erreur 400 (bad request).
Standard Vert.x :
1 2 3 4 5 6 7 8 9 10 | router.post('/items/:itemId/purchase').handler { ctx -> Long itemId try { itemId = Long.valueOf ctx.request().getParam('itemId') } catch(all) { ctx.response().fail 400 return } // ... } |
Avec la DSL :
1 2 3 4 5 6 7 8 | router { route('/items/:itemId/purchase') { expect { Long.valueOf request.params['itemId'] } post { // ... } } } |
Marshalling
Ce qui provient de la requête HTTP et ce qu'on envoie dans une réponse HTTP, c'est souvent des bêtes chaînes de caractères. Le problème, c'est qu'elles représentent des vrais "trucs". Et ces trucs, on va les manipuler dans le code. Le plus souvent, ce sont des objets (ça peut être des dictionnaires, des maps, etc.).
Faire cette conversion, c'est ce qu'on appelle du marshalling.
Et le marshalling avec Vert.x, c'est pas franchement foufou.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | router.post('/transform').consumes('application/json').produces('application/json').handler { ctx JsonObject json = ctx.request().bodyAsJson String text String type try { text = json.getString 'text' type = json.getString 'type' } catch(all) { // l'utilisateur a énvoyé un 'int' dans le noeud 'text' par exemple ctx.fail 400 return } switch(type) { case 'markdown': String transformed = new MarkdownParser().transform(text) // ... } return new JsonObject().put('text', transformed).put('type', type) } |
Et j'ai omis le code du style : "si le texte envoyé est un int, renvoie une erreur 400", …
Voilà le genre de trucs qu'on peut écrire, en admettant qu'on ait une classe TextAndFormat
:
1 2 3 4 5 6 7 8 9 10 11 12 | class TextAndFormat { String text String type public Parser() { parsers = ['markdown', new MarkdownParser(), ... ] } void transform() { text = parsers[type]?.transform(text) } } |
et pouf :
1 2 3 4 5 6 7 8 9 10 11 12 | router { marshaller 'application/json', new JacksonMarshaller() // fournit de base, on peut implémenter son propre Marshaller route('/transform') { consumes 'application/json' produces 'application/json' post { TextAndFormat original = request.body as TextAndFormat // invoque le marshaller original.transform() yield original // balance tout dans la réponse } } } |
Ajouter ses propres extensions
Parce que ouais, une DSL c'est cool, mais y'a toujours des trucs qui manquent, des trucs pour lesquels on se dit "ah flûte, j'aurais bien écrit ça moi".
Et bah c'est possible :
1 2 3 4 5 6 7 8 9 10 11 | router { extension('throttled') { nbReq, timeUnit -> new ThrottlingHandler(nbReq, timeUnit) // un middleware/handler que vous avez créé } route('/limited') { throttled 10, HOUR // 10 req/hour get { response << 'everything is fine for now' } } } |
Et, magie dans la magie de Groovy, en jouant quelques minutes avec les metaclass, vous pouvez parvenir à écrire un truc de ce genre :
1 2 3 4 5 6 7 8 9 10 11 | router { extension('throttled') { nbReq, timeUnit -> new ThrottlingHandler(nbReq, timeUnit) // un middleware/handler que vous avez créé } route('/limited') { throttled 10.req / hour get { response << "everything's fine for now" } } } |
Source : https://github.com/aesteve/vertx-groovy-sugar/blob/master/src/test/resources/throttled.groovy#L15
tbc