Sugar !

Sucre syntaxique pour écrire des apps webs en Groovy

L'auteur de ce sujet a trouvé une solution à son problème.
Auteur du sujet

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() et getResponse() qui permettent donc d'écrire context.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'objet Router a été surchargé. De même pour params qui est une MultiMap. L'opérateur << a été ajouté sur l'objet HttpServerResponse permettant d'appeler end.

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 :)

Édité par Javier

Happiness is a warm puppy

+7 -0
Auteur du sujet

En cours : Utiliser les AST transformations de Groovy pour modifier les API asynchrones existantes en API rx-friendly.

Exemple typique de méthode asynchrone Vert.x :

1
2
3
class MongoClient {
  MongoClient save(String collection, JsonObject document, Handler<AsyncResult<JsonObject>> handler) {...}
}

Qui serait transformé en :

1
2
3
4
class MongoClient {
  MongoClient save(String collection, JsonObject document, Handler<AsyncResult<JsonObject>> handler) {...}
  Observable<JsonObject> save(String collection, JsonObject document) {...}
}

On ajoute une méthode avec la même signature au dernier argument près. Cette méthode renverrait un observable.

C'est en cours, et c'est bien parti.

Édité par Javier

Happiness is a warm puppy

+0 -0
Auteur du sujet

J'ai renommé le projet en : Grooveex

Ajout d'une DSL pour instancier des verticles :

1
2
3
4
5
6
verticles {
  verticle('groovy:foo.bar.TheVerticle') {
    instances 2
    worker true
  }
}

Un verticle est un "microservice" : une unité de traitement qu'on demande à Vert.x de créer (et manager) et qui fait des choses (un serveur web, un service qui interroge un service externe périodiquement, …)

Rien de bien fou, mais c'est toujours un peu la même idée, je trouve plus propre d'avoir toutes ces configs stockées au même endroit.

Toujours en train d'essayer de trouver un moyen propre de m'interfacer avec rx-groovy. Pas si évident que je ne le pensais !

Édité par Javier

Happiness is a warm puppy

+0 -0
Auteur du sujet

Une nouvelle feature plutôt sympa : la possibilité de matcher plusieurs méthodes HTTP sans avoir besoin de se repéter, dans le cas où un handler (ou un middleware, selon vos influences grammaticales) s'applique à plusieurs méthodes.

Exemple : on veut pour le put et le patch lire le contenu de la requête, mais pas pour un get :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
router {
  route('/api/members/:memberId') {
    put | patch {
      def member = request.body as Member
      members.save member
    }
    get {
       yield members.find request.params['memberId']
    }
  }
}

J'ai choisi la syntaxe post | put, j'aurais pu choisir post & put ou post + put, vous en pensez quoi ? Les 3 (c'est possible aussi) ?

Happiness is a warm puppy

+0 -0
Staff

Cette réponse a aidé l'auteur du sujet

La sémantique du pipe est quand même bien ancrée je trouve. Perso j'éviterais.

put | patch je vois plus ça comme le put qui flanque sa sortie en entrée du patch, si je débarquais là comme ça.

La virgule peut-être ?

Je parle de JavaScript et d'autres trucs sur mon blog : https://draft.li/blog

+0 -0
Auteur du sujet

La virgule c'est pas un opérateur surchargeable, malheureusement (c'était la meilleure solution idd).

J'ai le choix là-dedans http://www.groovy-lang.org/operators.html#Operator-Overloading (faut scroller un tout petit peu y'a un tableau).

(en fait quand tu connais groovy ça biaise un peu, l'opérateur pipe s'appelle or, c'est le or bit à bit en fait)

Édité par Javier

Happiness is a warm puppy

+0 -0
Auteur du sujet

Nouveautés :

Un peu plus d'expressivité

1
2
3
4
5
6
7
8
route('/moreExpressive') {
  get {
    response.ok << 'A simple 200 OK' // response.setStatusCode(200).setStatusMessage('OK').end('A simple 200 OK')
  }
  post {
    response.created << 'A simple 201 created' // etc. pour tous les code info, succès, redirection (< 400)
  }
}

Bref, un moyen un peu plus expressif de modifier le statusCode et le statusMessage de la réponse HTTP. Complètement pompé sur Finatra. J'aime beaucoup l'idée.

Matcher toutes les méthodes déclarées

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
  route('/all') {
    all {
      response.chunked = true
      it++
    }
    get {
      response + 'get '
      it++
    }
    post {
      response + 'post '
      it++
    }
    all {
      response << 'Done'
    }
  }

all sert ici de raccourci pour get et post (mais pas options, delete, etc.)

Si vous avez des idées, des retours d'expérience sur express, sinatra, flask, et tous leurs copains. En gros des trucs soit que vous trouvez extrêmement pratiques soit extrêmement mal foutus, je prends. C'est hyper intéressant (et amusant) de chercher des solutions élégantes à des problèmes courants.

Happiness is a warm puppy

+0 -0
Auteur du sujet

Work in progress : Behaviour Driven Testing

1
2
3
4
5
6
7
8
9
    @Test
    @BehaviourDriven
    void test(TestContext context) {
        when { client.get('/api/test') }
        then {
            statusCode == 200
            body as String == 'OK!'
        }
    }

Pas encore tout à fait ça concernant le reporting des erreurs, mais quand les tests passent ça fonctionne bien.

Bon réveillon à tous !

Édité par Javier

Happiness is a warm puppy

+0 -0
Vous devez être connecté pour pouvoir poster un message.
Connexion

Pas encore inscrit ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte