image/svg+xml $ $ ing$ ing$ ces$ ces$ Res Res ea ea Res->ea ou ou Res->ou r r ea->r ch ch ea->ch r->ces$ r->ch ch->$ ch->ing$ T T T->ea ou->r

Approches pour le développement d'applications web côté serveur

Tendance actuelle :

A propos de Vert.x

Un premier Verticle saluant le monde

Ecrivons un premier Verticle saluant le monde (comme la tradition l'exige). Pour cela on pourra utiliser l'exemple de projet hébergé à l'adresse suivante :
https://github.com/vert-x3/vertx-maven-starter

On suit les instructions de la page pour récupérer une copie du dépôt Git avec git clone puis en exécutant mvn exec:java pour lancer le serveur avec Maven. On peut aussi lancer le script redeploy.sh qui permet de recompiler et lancer automatiquement le serveur lorsque l'on modifie le code-source.

Pour des besoins personnalisés (dépendances spécifiques), on peut utiliser le générateur de projet disponible à cette adresse : https://start.vertx.io/

Voici le code-source du Verticle de salutation :

package io.vertx.starter;

import io.vertx.core.AbstractVerticle;

public class MainVerticle extends AbstractVerticle {

  @Override
  public void start() {
    vertx.createHttpServer()
        .requestHandler(req -> req.response().end("Hello Vert.x!"))
        .listen(8080);
  }

}

Le RequestHandler installé traite chaque requête et répond invariablement un message de bienvenue. Si nous souhaitons répondre uniquement pour un chemin déterminé (par exemple /hello), nous pouvons utiliser un Router comme RequestHandler qui lui-même peut accueillir des RequestHandler pour différents chemins :

HttpServer server = vertx.createHttpServer();
    Router router = Router.router(vertx);
    router.route("/hello").method(HttpMethod.GET).handler(req -> req.response().end("Hello with router"));
    server.requestHandler(router).listen(8080);

Le RequestHandler Router est fourni par la bibliothèque additionnelle vertx-web (page de référence), nous devons l'ajouter comme dépendance dans le fichier pom.xml utilisé par Maven pour la construction de projet :

<dependencies>
	...
	 <dependency>
      <groupId>io.vertx</groupId>
      <artifactId>vertx-web</artifactId>
    </dependency>
    ...
</dependencies>

En cas de souci avec le fonctionnement de notre application, nous pouvons activer un affichage des entrées loguées comme indiqué ici.

Il est possible d'utiliser des routes paramétrées pour personnaliser le contenu affiché :

router.route("/hello/:name").method(HttpMethod.GET)
	.handler(req -> req.response().end("Hello " + req.pathParam("name") + " with router"));

Il est possible d'ajouter plusieurs routes sur un Router ; lorsqu'une requête arrive, le serveur les teste par ordre de déclaration jusqu'à trouver une route validant la requête : la requête est envoyée au RequestHandler associé. Certains RequestHandler peuvent demander que la requête soit transmise à un éventuel RequestHandler suivant.

Nous pouvons par exemple ajouter en tant que première route validant toutes les requêtes un RequestHandler ajoutant un en-tête dans la réponse et demandant que la requête soit transmise au RequestHandler suivant (validant la requête) :

router.route().handler(req -> {
      req.response().putHeader("X-MyCustomizedHeader", "Here is the date " + new Date().toString());
      req.next();
      });

Un serveur web délivrant du contenu statique

Un serveur web peut délivrer :

vertx. fournit un RequestHandler destiné à la fourniture de contenu statique que nous pouvons installé sur le Router :

router.route("/static/*").handler(StaticHandler.create());

Pour cet exemple toutes les requêtes dont le chemin commence par /static/ sont redirigées vers le StaticHandler. Par défaut le StaticHandler recherche le contenu statique dans le répertoire src/main/resources/webroot. Ainsi si nous appelons l'URL http://localhost:8080/static/image.png, le StaticHandler retournera le fichier présent dans src/main/resources/webroot/image.png (s'il est présent, autrement une erreur 404 sera renvoyée).

Gestion des sessions

Le protocole HTTP est sans état ; on peut néanmoins gérer des sessions à l'aide de cookie. A la première requête d'un client web, un identifiant aléatoire est installé dans un cookie par le serveur web ; pour toutes les requêtes suivantes, le client web renvoit cet identifiant.

Une session est utile pour stocker transitoirement des données associées à un visiteur : nombre de pages visitées, panier d'achat... L'objet Session se manipule comme une Map.

Ecrivons une page affichant un compteur de requêtes réalisé par un visiteur (ainsi qu'une page permettant de réinitialiser ce compteur) :

SessionStore sessionStore = LocalSessionStore.create(vertx);
router.route().handler(SessionHandler.create(sessionStore));

router.route().handler(req -> {
  Integer requestNumber = req.session().get("requestNumber");
  if (requestNumber == null) requestNumber = 0;
  requestNumber++;
  req.session().put("requestNumber", requestNumber);
  req.next();
});

router.route("/getRequestNumber").method(HttpMethod.GET).handler(req -> {
  Integer requestNumber = req.session().get("requestNumber");
  req.response().end("RequestNumber=" + requestNumber);
});

router.route("/resetRequestNumber").method(HttpMethod.GET).handler(req -> {
  req.session().remove("requestNumber");
  req.response().end("resetted");
});

Utilisation d'un moteur de template

Il est possible de générer directement une chaîne de caractères représentant une page HTML dynamique à renvoyer par le serveur. La génération de la page depuis le code est fastidieuse (concaténation de chaînes) et s'oppose au principe de séparation entre la définition de la vue graphique (page HTML) et de la programmation des actions (récupération, stockage, traitement de données).

Pour générer une page HTML dynamique, nous pouvons utiliser un moteur de template. Nous écrivons alors la page HTML à retourner sous la forme d'un template, i.e. un fichier contenant des balises HTML avec des directives spécifiques permettant d'incorporer des variables, d'afficher certaines zones de la page selon certaines conditions, d'exécuter des boucles pour afficher des listes d'éléments... La syntaxe des directives de template est propre au moteur de template choisi.

Nous prenons l'exemple du moteur de templates Pebble (dont la syntaxe présente des similarités avec le moteur Jinja2 disponible pour Python) ; il existe également d'autres moteurs de template utilisant des syntaxes différentes compatibles avec Vert.x (par exemple Handlebars, Rocker, FreeMarker, ...). Nous pouvons créer un template simple base.peb pour l'affichage d'une page :

<html>
	<head>
		<title>{{title}}</title>
	</head>
	<body>
		<h1>{{title}}</title>
		{% block content %}
			to be replaced by template inheritance
		{% endblock %}
		{% block footer %}
			<hr>
			(C) Copyright SuperTemplater
		{% endblock %}
	</body>
</html>

Cette page intègre la variable title et définit des zones qui peuvent être modifiées par héritage : content et footer. Nous pouvons ainsi hériter de ce template pour une page dynamique de salutation affichant le nom de l'utilisateur ainsi que son adresse IP avec le template hello.peb :

{% extends "base.peb" %}

{% block content %}
<p>
	{% if name is defined %}  Hello {{ name }} 
	{% else %} Hello anonymous person {% endif %}
	, here is your IP address: {{ ip }}!
</p>
<p>
	Now we try to display a list of elements:
	<ul>
		{% for element in basket %}
			<li>{{element}}</li>
		{% endfor %}
	</ul>
</p>
{% endblock %}

N'oublions pas de déclarer dans le pom.xml la dépendance nécessaire afin de permettre l'utilisation du moteur de template Pebble :

<dependency>
	<groupId>io.vertx</groupId>
	<artifactId>vertx-web-templ-pebble</artifactId>
</dependency>

Ces templates peuvent être par exemple placés dans le répertoire src/main/resources/templates. Ecrivons maintenant le code du handler utilisant le template hello.peb :

final TemplateEngine engine = PebbleTemplateEngine.create(vertx);
router.get("/hello/:name").handler(req -> {
  Map<String, Object> vars = new HashMap<>();
  vars.put("title", "Hello world");
  vars.put("name", req.pathParam("name"));
  vars.put("ip", req.request().remoteAddress().host());
  vars.put("basket", Arrays.asList("apple", "pear", "grapefruit", "banana"));
  System.out.println(vars);
  engine.render(vars, "templates/hello", res -> {
	if (res.succeeded()) {
	  req.response().end(res.result());
	} else {
	  req.fail(res.cause());
	}
  });
});

Requêtes longues à traiter

Une requête longue à traiter exécutant des actions synchrones bloque la thread gérant la file d'événements. Prenons l'exemple d'une requête calculant le nombre d'entiers pairs de 0 à Integer.MAX_VALUE :

router.route("/getEvenNumber").method(HttpMethod.GET).handler(req -> {
  int even = 0;
  for (int i = 0; i < Integer.MAX_VALUE; i++)
	if (i % 2 == 0) even++;
  req.response().end("even: " + even);
});

Cette requête peut nécessiter plusieurs secondes de calcul durant lesquelles aucune autre requête arrivant au serveur ne peut être gérée. Pour éviter ce problème, on demande à vert.x d'exécuter le code bloquant sur une thread annexe pour ne pas bloquer la thread principale. On utilise pour cela vertx.runBlocking :

router.route("/getEvenNumber2").method(HttpMethod.GET).handler(req -> {
  vertx.executeBlocking(event -> {
    int even = 0;
    for (int i = 0; i < Integer.MAX_VALUE; i++)
      if (i % 2 == 0) even++;
    event.complete(even);
  },
  event -> {
    req.response().end("even: " + event.result());
  });
});

executeBlocking requiert deux paramètres passés sous la forme d'expressions lambda à exécuter :

  1. Le premier est le code bloquant à exécuter ; on appelle event.complete(result) pour signaler le résultat du calcul (on peut aussi appeler event.fail(ex) avec une exception survenue en cas de problème)
  2. Le deuxième est du code appelé sur la thread principale lorsque le résultat est calculé. Il est prudent avant d'appeler event.result() de vérifier si le calcul n'a pas échoué avec event.failed() (event.cause() permet de récupérer l'éventuelle exception)

Par défaut vert.x tolère un blocage de 2 secondes de la thread principale et d'une minute pour une thread secondaire. On peut modifier en cas de besoin ces valeurs :

VertxOptions options = new VertxOptions();
options.setBlockedThreadCheckInterval(120_000L);  // en millisecondes pour la thread annexe (ici 2 minutes)
options.setMaxEventLoopExecuteTime(1_000_000_000L); // en nanosecondes pour la thread principale d'événements (ici 1 seconde)
Vertx vertx = Vertx.vertx(options);

Un service REST JSON

Réalisons un service web de type REST acceptant une requête HTTP avec un contenu en JSON et retournant un réponse JSON. Ce service REST a pour objectif de compter le nombre d'occurrences de mot dans un texte fourni dans la requête.

Nous écrivons le RequestHandler que nous installons sur le Router (sans avoir oublié auparavant d'activer le BodyHandler nous permettant de récupérer le corps de la requête) :

router.route().handler(BodyHandler.create());

router.post("/countOccurrences").consumes("application/json").handler(req -> {
      JsonObject obj = req.getBodyAsJson();
      String s = obj.getString("text");
      vertx.executeBlocking(event -> {
          event.complete(countOccurrences(s));
        },
        event -> {
          JsonObject result = new JsonObject();
          Map<String, Integer> occurrMap = (Map<String, Integer>)event.result();
          for (Map.Entry<String, Integer> entry: occurrMap.entrySet())
            result.put(entry.getKey(), entry.getValue());
          req.response().putHeader("Content-Type", "application/json")
            .end(result.encodePrettily());
        }
      );
});

Nous écrivons également une méthode permettant de compter le nombre d'occurrences de mots dans un texte et retournant une Map :

private Map<String, Integer> countOccurrences(String s) {
    Map<String, Integer> occurrMap = new HashMap<>();
    for (String word : s.split("[\\p{Punct}\\s]+"))
      occurrMap.merge(word, 1, (x, y) -> x + y);
    return occurrMap;
}

Nous pouvons ensuite tester le service web avec un client HTTP tel que Curl (avec une interface en ligne de commande) :

curl -H "Content-Type: application/json" -d '{"text":"hello helo world test"}' http://localhost:8080/countOccurrences

Si le corps envoyé dans la requête n'est pas une chaîne JSON valide, la méthode req.getBodyAsJson() lève une exception et le serveur répond au client par une erreur 500. En capturant l'exception, nous pourrions renvoyer un message plus explicite :

JsonObject obj = null;
try {
	obj = req.getBodyAsJson();
} catch (DecodeException e) {
	req.response().setStatusCode(400).end("invalid input JSON");
	return;
}

Gestion de la configuration

Vert.x propose un plugin pour gérer la configuration d'une application. On peut l'activer en ajoutant dans le fichier pom.xml de Maven la dépendance suivante :

<dependency>
 <groupId>io.vertx</groupId>
 <artifactId>vertx-config</artifactId>
 <version>3.8.3</version>
</dependency>

Le plugin supporte des fichiers de configuration au format Java properties ou en JSON par exemple. Il est possible de récupérer la configuration à distance sur un serveur HTTP ou vi le bus de communication intégré à Vert.x.

Nous pouvons utiliser le ConfigRetriever afin de récupérer des données de configuration pour initialiser le serveur. On récupère la configuration sous la forme d'un objet JSON dont on peut explorer les clés pour récupérer les informations souhaitées. On peut par exemple créer un serveur web qui écoute sur un port personnalisé et retourne également une réponse différente selon le chemin interrogé.

public class SimpleVerticleWithCustomConfig extends AbstractVerticle {

  @Override
  public void start() throws Exception {
    super.start();
    ConfigRetriever retriever = ConfigRetriever.create(vertx);
    retriever.getConfig(json -> {
      JsonObject result = json.result();
      // messages is a JSON object (can be viewed as a map)
      JsonObject messages = result.getJsonObject("messages");

      Router router = Router.router(vertx);
      for (String key: messages.fieldNames())
        router.route(key).handler(req -> req.response().end(messages.getString(key)));

      vertx.createHttpServer()
        .requestHandler(router)
        .listen(result.getInteger("port"));
    });
  }
}

Il est nécessaire d'indiquer la configuration à utiliser. Par défaut vert.x interroge les sources suivantes avec cet ordre de priorité :

Ecrivons un fichier JSON pour la configuration :

{
	"messages": {
		"/hello": "Hello world!",
		"/bye": "Good bye world!"
	},
	"port": 80802
}

Déploiement de plusieurs verticles et communication entre-eux

Il est possible de déployer plusieurs verticles au sein d'une même application et de communiquer entre ces verticles en utilisant le bus intégré de communication. Ces verticles peuvent même être exécutés par des JVMs différentes que ce soit localement ou sur des machines différentes.

Le déploiement d'un verticle est réalisé en utilisant l'appel asynchrone deployVertice(Verticle v, Handler<AsyncResult<String>> handler), le handler étant appelé lorsque le verticle est déployé.

Un verticle peut envoyer un message sur le bus de communication que l'on peut obtenir avec vertx.eventBus(). Un message est toujours associé avec une adresse de destination. Un vertice peut écouter tous les messagesqui arrivent sur une adresse du bus en enregistrant un MessageConsumer :

MessageConsumer<String> consumer = vertx.eventBus().consumer("importantEvents");
consumer.handler(message -> {
	System.out.println("New important event received: " + message.body());
    message.reply("Receival of event " + message.body() + " acknowledged");
});

Un MessageConsumer peut répondre à un message avec la méthode reply(...). Il est possible d'échanger des messages de type String ou alors n'importe quel objet Java. Dans ce cas, il faut indiquer comment l'objet doit être sérialisé et désérialiser pour passer sur le bus de communication. On créé pour cela un MessageCodec capable de réaliser ces opérations de conversion et on indique au bus de l'utiliser pour la classe en question :

vertx.eventBus().registerDefaultCodec(Person.class, new PersonMessageCodec());

Pour envoyer un message vers une adresse sur le bus, trois possibilités sont proposées :

  1. L'envoi du message en broadcast à tous les MessageConsumer écoutant l'adresse avec eventBus.publish(message)
  2. L'envoi du message à un seul MessageConsumer (sélectionné par Vert.x s'il en existe plusieurs) avec eventBus.send(message)
  3. L'envoi d'un message à un MessageConsumer en attendant une réponse de celui-ci avec eventBus.request(message, response -> { if response.succeeded() { ... }})

Réalisons comme exemple une application retournant le mot qui suit un mot du dictionnaire. Tout d'abord, nous crééons un verticle chargé de gérer le dictionnaire avec un MessaceConsumer retournant le mot suivant pour une demande envoyée sur le bus :

public class DictionaryManagerVerticle extends AbstractVerticle {

  private final SortedSet<String> dictionary;

  public DictionaryManagerVerticle(SortedSet<String> dictionary) {
    this.dictionary = dictionary;
  }

  @Override
  public void start() throws Exception {
    super.start();
    MessageConsumer<String> consumer = vertx.eventBus().consumer("dictionary.nextWord");
    consumer.handler(message -> {
      String word = message.body();
      Set<String> tailSet = dictionary.tailSet(word);
      String nextWord = null;
      for (String candidate: tailSet) {
        if (candidate.equals(word)) continue; // next word
        nextWord = candidate;
        break;
      }
      System.err.println("Receiving " + message + " and replying " + nextWord);
      if (nextWord != null)
        message.reply(nextWord);
      else
        message.fail(0, "next word not found");
      message.reply(null);
    });
  }
}

Ensuite nous crééons le verticle principal qui déploiera le précédent verticle pour trouver les prochains mots pour chaque requête reçue :

public class DictionaryServerVerticle extends AbstractVerticle {

  @Override
  public void start(Future<Void> startFuture) throws Exception {
    super.start();
    ConfigRetriever retriever = ConfigRetriever.create(vertx);
    retriever.getConfig(json -> {
      // retrieve the configuration to get the path of the dictionary
      JsonObject config = json.result();
      String dictionaryPath = config.getString("dictionaryPath");
      SortedSet<String> dictionary = null;
      try {
        dictionary = DictionaryUtils.loadDictionary(dictionaryPath);
      } catch (IOException e) {
        startFuture.fail(e);
      }
      DictionaryManagerVerticle dmv = new DictionaryManagerVerticle(dictionary);
      vertx.deployVerticle(dmv, stringAsyncResult -> {
        // the verticle is deployed, we can start the server
        Router router = Router.router(vertx);
        router.route("/nextWord/:word").handler( req -> {
          String word = req.request().getParam("word");
          DeliveryOptions options = new DeliveryOptions();
          options.setSendTimeout(500);
          vertx.eventBus().request("dictionary.nextWord", word, options, answer -> {
            if (answer.succeeded()) {
              Object nextWord = answer.result().body();
              if (nextWord != null)
              req.response().end(nextWord.toString());
              return;
            }
            req.response().setStatusCode(404).end("not found");
          });
        });
        vertx.createHttpServer()
          .requestHandler(router)
          .listen(8081);
      });
    });
  }
}

Communication avec une WebSocket

Une communication WebSocket peut être initialisée à partir d'une connection HTTP classique. Il est possible d'échanger des messages binaires ou textuels bi-directionnellement entre le client et le serveur.

On peut ajouter un handler sur un serveur HTTP Vert.x afin de pouvoir traiter les communications WebSocket. A partir de la connexion WebSocket, nous pouvons enregister une fonction qui sera exécutée lorsqu'un message binaire ou textuel provient du client. Nous pouvons également envoyer des messages binaires et textuels vers le client.

Nous présentons ici comme exemple un Verticle gérant un salon de discussion recevant des messages et les relayant aux utilisateurs connectés.

public class WebSocketVerticle extends AbstractVerticle {
  @Override
  public void start() throws Exception {
    super.start();
    final Set<ServerWebSocket> sockets = new HashSet<>();
    vertx.createHttpServer()
      // install the websocket handler
      .websocketHandler(socket -> {
        sockets.add(socket);

        // inform all the websockets that a new person has arrived
        sockets.forEach(s -> s.writeTextMessage(socket.remoteAddress() + " entered the chat"));

        // action to do when a message arrives
        socket.textMessageHandler(msg -> {
          // relay the message to all the sockets (including the sending socket)
          sockets.forEach(s -> s.writeTextMessage(socket.remoteAddress() + ": " + msg));
        });

        // handle the closure of the socket
        socket.closeHandler(a -> {
          sockets.remove(socket);
          sockets.forEach(c -> c.writeTextMessage(socket.remoteAddress() + " leaved the chat"));
        });
      })

      // install an handler to send a HTML file with JavaScript code to contact the websocket
      .requestHandler(req -> {
        if (req.path().equals("/")) req.response().sendFile("websocket.html").end();
      })
      .listen(8081);
  }
}

L'appel à requestHandler nous permet d'envoyer une page HTML (websocket.html) contenant du code JavaScript nous permettant d'ouvrir une websocket vers le serveur et de recevoir et envoyer des messages avec celle-ci :

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>WebSocket chat</title>
</head>
<body>
<h1>WebSocket chat</h1>

<div>
  Received messages:
  <ol id="receivedMessages">

  </ol>
</div>

<p>
  <textarea id="messageToSend" cols="70" rows="2">test message</textarea> <br>
  <input type="submit" id="submitMessage">
</p>

<script>
  var openWebSocket = () => {
    console.log("Opening WebSocket");
    // take the current URL and replace the http protocol by ws at the head of the URL
    let socket = new WebSocket("ws" + window.location.href.substring(4)); // open the websocket
    let displayMessage = msg => {
      let a1 = document.createElement("li");
      a1.textContent = msg;
      document.getElementById("receivedMessages").appendChild(a1);
    };
    socket.onopen = event => { displayMessage("Connection opened"); }
    socket.onmessage = event => { displayMessage(event.data); }
    document.getElementById("submitMessage").addEventListener("click", e => {
      let msg = document.getElementById("messageToSend").value;
      socket.send(msg);
    });
  };
  window.onload = e => { openWebSocket(); };
</script>
</body>
</html>