TP2 - Un registre comme service - Contrôle optimiste de la concurrence
Table of Contents
- 1. Introduction : registre comme service, service comme registre
- 2. Registre : les règles chimiques
- 3. Exercice pratique : implémentation du registre
1 Introduction : registre comme service, service comme registre
Un registre offre deux services, l'un de lecture, l'autre d'écriture. Le serveur associé possède donc un état modifiable. C'est fréquemment le cas qu'un serveur présente un état modifiable par les clients : il est alors nécessaire de gérer convenablement la concurrence des accès en écriture. Nous suivons dans ce TP l'approche optimiste, mieux adaptée au Web.
On traite ci-dessous l'exemple d'un registre ayant pour contenu un entier. C'est l'exemple le plus simple d'un serveur possédant un état modifiable. Le traitement de la concurrence par un contrôle optimiste a cependant une valeur paradigmatique : il peut être appliqué à des serveurs dont l'état est bien plus complexe.
2 Registre : les règles chimiques
Voici la spécification des registres que nous allons implémenter, du plus simple au plus complexe.
2.1 Serveur sans contrôle de concurrence
Serveur
- deux canaux : get et set
- état : Ressource(x)
- get(rep) & Ressource(x) -> rep(x) & Ressource(x) - set(rep, y) & Ressource(x) -> rep(y) & Ressource(y)
Un client incrémentant la ressource
- canaux :
- lecture pour la réception des get
- ecriture pour la réception des set
- état : Affichage(x)
- get(lecture) // Etat initial - lecture(x) -> set(ecriture, x + 1) - ecriture(x) -> Affichage(x)
(incrémentation avec affichage des valeurs écrites)
L'exécution de deux tels clients peut ne pas produire les incrémentations attendues : des pertes en écriture sont possibles. La propriété de sérialisabilité n'est pas vérifiée : toute exécution n'est pas équivalente à une exécution séquentielle des clients.
2.2 Serveur avec contrôle optimiste de la concurrence
Pour éviter les pertes en écriture, on munit la ressource d'une version. L'écriture n'est possible que si le client connaît la version courante de la ressource.
Serveur
- deux canaux : get et set
- état : Ressource(x), Version(n)
- get(rep) & Ressource(x) & Version(n) -> rep(x, n) & Ressource(x) & Version(n) // Ecriture réussie - set(rep, y, n) & Ressource(x) & Version(n) -> rep(OK, y, n + 1) & Ressource(y) & Version(n + 1) // Ecriture annulée - set(rep, y, m) & Ressource(x) & Version(n) & (n != m) -> rep(KO, x, n) & Ressource(x) & Version(n)
Une requête set est accompagnée de la dernière version connue par le client. Si elle correspond à la version courante du serveur, le serveur réalise l'écriture, sinon il envoie la valeur courante de la ressource accompagnée de sa version. Les pertes en écriture sont ainsi évitées.
2.3 Serveur avec ajout d'un cache côté client
On peut améliorer l'efficacité de l'interaction en introduisant un cache côté client. Lorsque le client demande à lire la valeur de la ressource (via une requête get), le serveur peut lui répondre de regarder dans son cache s'il connaît la version courante.
Serveur
// Lecture à réaliser en cache - get(rep, n) & Version(n) -> rep(CACHE, n) & Version(n) // Lecture de la ressource - get(rep, m) & Ressource(x) & Version(n) & (n != m) -> rep(x, n) & Ressource(x) & Version(n) // Ecriture réussie - set(rep, y, n) & Ressource(x) & Version(n) -> rep(OK, y, n + 1) & Ressource(y) & Version(n + 1) // Ecriture annulée - set(rep, y, m) & Ressource(x) & Version(n) & (n != m) -> rep(KO, x, n) & Ressource(x) & Version(n)
Une requête get est accompagnée de la dernière version connue par le client. Si elle correspond à la version courante du serveur, le serveur répond que la valeur n'a pas changé et doit donc être trouvée dans le cache du client, sinon il envoie la valeur courante de la ressource accompagnée de sa version. La charge de travail du serveur diminue ainsi.
2.4 Client avec cache
Version répartie (simple)
Approche modulaire avec l'ajout d'un intercepteur : le client communique avec l'intercepteur qui communique avec le serveur. L'intercepteur gère les versions et le cache.
2.4.1 Client
Le client communique avec l'intercepteur. Lorsqu'il réalise une écriture, sa requête peut échouer : il reçoit alors une réponse KO au lieu de OK.
- canaux fournis :
- lecture pour la réception des get
- ecriture pour la réception des set
- canaux requis :
- getI et setI (fournis par l'intercepteur)
- état : Affichage(x)
// Etat initial - getI(lecture) // Incrémentation - lecture(x) -> setI(ecriture, x + 1) // Echec avec reprise - ecriture(KO, x) -> setI(ecriture, x + 1) // Succès - ecriture(OK, x) -> Affichage(x)
Le client gère la reprise de la transaction.
2.4.2 Intercepteur
L'intercepteur intercepte les requêtes du client qu'il transmet au serveur. Il gère le cache et annote les messages avec la version du cache.
- canaux fournis :
- lectureI pour la réception des get
- ecritureI pour la réception des set
- getI et setI (interception)
- canaux requis :
- get et set (serveur)
- lecture et ecriture (client)
- état : Cache(x, n)
// Etat initial - Cache(NON_DEFINI, NON_DEFINI) // Interception d'une requête en lecture - getI(k) & Cache(x, n) -> get(lectureI, n) & Cache(x, n) // Réponse demandant la lecture en cache - lectureI(CACHE, n) & Cache(x, n) -> lecture(x) & Cache(x, n) // Réponse transmettant le résultat et mise en cache - lectureI(y, m) & Cache(x, n) -> lecture(y) & Cache(y, m) // Interception d'une requête en écriture - setI(k, y) & Cache(x, n) -> set(ecritureI, y, n) & Cache(x, n) // Ecriture réussie avec mise à jour du cache - ecritureI(OK, y, m) & Cache(x, n) -> ecriture(OK, y) & Cache(y, m) // Ecriture annulée avec mise à jour du cache - ecritureI(KO, y, m) & Cache(x, n) -> ecriture(KO, y) & Cache(y, m)
3 Exercice pratique : implémentation du registre
On cherche à implémenter le registre précédent par un service Web Restful.
3.1 Modèle objet
Le modèle objet est fourni.
- Interface ServiceRegistre déclarant deux méthodes, get et set
- Classe Registre implémentant l'interface ServiceRegistre
- Classe Ressource utilisée par ServiceRegistre et Registre
3.2 Serveur : registre sans contrôle de la concurrence ni cache
Créer un nouveau projet, de type Dynamic Web Project.
Ajouter les dépendances suivantes dans le fichier pom.xml. La première permet d'importer Jersey, la seconde permet d'utiliser le format de données JSON. Par défaut, seul le format XML est géré. Enfin, la dernière permet d'utiliser le serveur HTTP Grizzly, à la fois léger et passant à l'échelle.
<dependency> <groupId>org.glassfish.jersey.containers</groupId> <artifactId>jersey-container-servlet</artifactId> <version>2.23.2</version> </dependency> <dependency> <groupId>org.glassfish.jersey.media</groupId> <artifactId>jersey-media-json-jackson</artifactId> <version>2.23.2</version> </dependency> <dependency> <groupId>org.glassfish.jersey.containers</groupId> <artifactId>jersey-container-grizzly2-http</artifactId> <version>2.23.2</version> </dependency>
Dans le fichier WebContent/WEB-INF/web.xml, ajouter après l'élément welcome-file-list la référence de la servlet à utiliser.
<servlet> <servlet-name>Jersey</servlet-name> <servlet-class>org.glassfish.jersey.servlet.ServletContainer</servlet-class> </servlet> <servlet-mapping> <servlet-name>Jersey</servlet-name> <url-pattern>/*</url-pattern> </servlet-mapping>
Importer dans le projet l'archive codeFourni_ServeurRegistre.zip.
Trois paquets sont importés :
- modele : le modèle objet
- infrastructure.jaxrs : filtres utilisés pour le contrôle de la concurrence et le cache
- serveur : une classe principale permettant de lancer un serveur déployant le service
3.2.1 Annotations JAX-RS
Annoter les méthodes de l'interface ServiceRegistre de manière à vérifier les propriétés suivantes.
- Chaque méthode est qualifiée par le type idoine de méthode HTTP.
- Les chemins relatifs d'accès à la ressource correspondant aux deux méthodes sont vides : il est inutile de le préciser.
- Les données sont produites ou consommées au format application/xml. (Pour indiquer qu'un résultat est sérialisé suivant un certain format javax.ws.rs.core.MediaType.F, on utilise l'annotation @Produces(MediaType.F) ou @Consumes(MediaType.F) après importation de MediaType.)
Annoter la classe d'implémentation Registre de manière à ce que la ressource soit accessible par le chemin relatif optimiste. Pour indiquer un chemin C, on utilise l'annotation @Path("C"). Annoter cette ressource de manière à en faire un singleton (en utilisant l'annotation javax.inject.Singleton).
(Toutes ces annotations appartiennent au paquet javax.ws.rs.)
3.2.2 Annotations JAX-B
Annoter la classe Ressource de manière à vérifier les propriétés suivantes.
- Une instance de la classe Ressource peut être traduite en un document XML racine.
- L'attribut i se traduit par un élément XML de nom x. (On annotera le getter correspondant.)
Cf. les annotations XmlRootElement et XmlElement.
3.2.3 Déploiement
Dans le fichier web.xml, référencer sous la balise servlet la définition du service dans la classe infrastructure.Service.
<init-param> <param-name>javax.ws.rs.Application</param-name> <param-value>infrastructure.Service</param-value> </init-param> <load-on-startup>1 </load-on-startup>
Etudier le code de cette classe. Noter qu'en plus de la fonctionnalité de log, une autre fonctionnalité est ajoutée :
- la capacité à traiter JSON en utilisant l'outil Jackson.
Comment le registre est-il enregistré dans la configuration ? Est-ce nécessairement un singleton ?
La configuration utilisée dan la classe Service est propre à Jersey : consulter la documentation de Jersey (notamment la section 4.7) et de ResourceConfig pour de plus amples informations.
Déployer le service sur le serveur Tomcat. Tester avec Poster.
Il est aussi possible de déployer le service par une application lançant un serveur HTTP, comme Grizzly. Lancer l'application serveur.Lancement après avoir modifié l'adresse utilisée pour le déploiement, en veillant à ce que le port utilisé (8087) ne soit pas celui du serveur Tomcat.
Etudier son code.
3.2.4 Premier client
Créer un nouveau projet Java.
- Le transformer en projet Maven.
Ajouter les dépendances suivantes dans le fichier pom.xml. Les deux premières donnent les bibliothèques côté client, tandis que les suivantes permettent à Jersey d'utiliser les formats XML et JSON pour échanger des données.
<dependency> <groupId>org.glassfish.jersey.core</groupId> <artifactId>jersey-client</artifactId> <version>2.23.2</version> </dependency> <dependency> <groupId>org.glassfish.jersey.ext</groupId> <artifactId>jersey-proxy-client</artifactId> <version>2.23.2</version> </dependency> <dependency> <groupId>org.glassfish.jersey.media</groupId> <artifactId>jersey-media-jaxb</artifactId> <version>2.23.2</version> </dependency> <dependency> <groupId>org.glassfish.jersey.media</groupId> <artifactId>jersey-media-json-jackson</artifactId> <version>2.23.2</version> </dependency>
Y importer à la racine l'archive codeFourni_ClientRegistre.zip.
Trois paquets sont importés :
- modele : le modèle objet sans l'implémentation du service
- infrastructure.jaxrs : filtres et intercepteurs utilisés pour le contrôle de la concurrence et le cache
- client : deux classes principales permettant de consommer le service
Annoter l'interface ServiceRegistre comme sur le serveur. Préciser également le chemin relatif optimiste.
Annoter la classe Ressource comme sur le serveur.
Etudier la fonction principale de la classe client.TestRegistre. Configurer correctement le proxy.
Tester votre application cliente.
Que constatez-vous lorsque vous lancez plusieurs clients simultanément ?
3.3 Serveur : registre avec contrôle de la concurrence et cache
Bibliographie : cf. Restful Web Services Cookbook, chap. 10.
On contrôle la concurrence à l'aide d'une gestion des versions : un client ne peut modifier la ressource que s'il a lu précédemment la dernière valeur de la ressource. On utilise aussi les versions pour gérer un cache situé chez le client : lorsqu'un client demande la valeur de la ressource alors qu'il connaît la version courante, le serveur peut répondre en envoyant une réponse vide.
3.3.1 Messages HTTP échangés
3.3.1.1 Requête get
Address: http://localhost:8080/Projet/optimiste Http-Method: GET Headers: {... if-none-match=[versionClient] ...}
Le client utilise la champ if-none-match de l'en-tête pour indiquer la version la plus récente qu'il connaît.
Si la version courante du serveur ne correspond pas à versionClient, alors répondre normalement à la requête en envoyant aussi la version courante (via le champ ETag ("Entity Tag")).
Response-Code: 200 Content-Type: application/xml Headers: {... ETag=[versionCourante]} Payload: <?xml version="1.0" encoding="UTF-8" standalone="yes"?><ressource><x>valeurCourante</x></ressource>
Sinon, répondre que la ressource n'a pas été modifiée.
Response-Code: 304 Headers: {... ETag=[versionClient], Content-Length=[0]}
3.3.1.2 Requête set
Address: http://localhost:8080/Projet/optimiste Http-Method: PUT Content-Type: application/xml Headers: {... if-match=[versionClient] ...} Payload: <?xml version="1.0" encoding="UTF-8" standalone="yes"?><ressource><x>nouvelleValeur</x></ressource>
Le client utilise la champ if-match de l'en-tête pour indiquer la version la plus récente qu'il connaît. S'il ne l'utilise pas, c'est une erreur ("428", précondition requise).
Response-Code: 428 Headers: {... ETag=[versionCourante] ...} Payload: erreur 428 - DOIT contenir l'en-tête if-match, NE DOIT PAS contenir l'en-tête if-none-match.
Si la version du serveur correspond à versionClient, alors exécuter normalement la requête et répondre en renvoyant la valeur écrite et la nouvelle version courante.
Response-Code: 200 Headers: {... ETag=[nouvelleVersion] ...} Payload: <?xml version="1.0" encoding="UTF-8" standalone="yes"?><ressource><x>nouvelleValeur</x></ressource>
Sinon répondre par une erreur ("412", précondition sur les versions invalide) en renvoyant la valeur courante et la version courante.
Response-Code: 412 Content-Type: application/xml Headers: {... ETag=[versionCourante] ...} Payload: <?xml version="1.0" encoding="UTF-8" standalone="yes"?><ressource><x>valeurCourante</x></ressource>
3.3.1.3 Format des données
Avec la description des requêtes et des réponses, la communication est ainsi entièrement spécifiée, si ce n'est qu'il manque le type des données échangées : une solution est de fournir le schéma des documents XML échangés.
Quel est le schéma vérifié par les documents Xml représentant les ressources ? Pour engendrer le schéma à partir de la classe : Ressource, New > Other > JAXB > Schema from Java Classes.
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <xs:schema version="1.0" xmlns:xs="http://www.w3.org/2001/XMLSchema"> <xs:element name="ressource" type="ressource"/> <xs:complexType name="ressource"> <xs:sequence> <xs:element name="x" type="xs:int"/> </xs:sequence> </xs:complexType> </xs:schema>
3.3.2 Filtres
Avec JAX-RS, il est possible de réaliser ces échanges de messages en utilisant des filtres.
Etudier les filtres du paquet infrastructure.jaxrs, côté serveur.
- Que font les filtres CompterRequetes et CompterReponses ?
- Que fait le filtre InteragirAtomiquement ?
- Que fait le filtre Cacher ?
- Que fait le filtre RealiserEcritureOptimiste ?
- Que fait le filtre AjouterVersionAuxReponses ?
A quoi sert la classe Versionneur ? Quels filtres partagent le même versionneur ?
Dans quel ordre les filtres s'exécutent-ils ?
Commenter tous ces filtres, avec un niveau de détail analogue à celui de la classe infrastructure.jaxrs.Cacher du projet client.
Complément : voir le cours.
3.3.3 Annotation de l'interface ServiceRegistre
Annoter l'interface ServiceRegistre de manière à appliquer les six filtres pour chaque appel de méthode. On utilisera les annotations définies dans le paquet infrastructure.jaxrs.annotations. Cf. la section 6.5.2 de la spécification de JAX-RS 2.0 pour des précisions complémentaires.
3.3.4 Déploiement des services
Mettre à jour la classe infrastructure.Service de manière
- à injecter un versionneur et
- à enregistrer les filtres.
Indication :
// Initialisation du décorateur avec version Versionneur rV = new Versionneur(r); // Enregistrement du lieur pour l'injection de dépendances relativement aux filtres this.register(new AbstractBinder() { @Override protected void configure() { bind(rV) .to(Versionneur.class); } }); // Enregistrement des filtres (alternative possible via providers) this.register(CompterRequetes.class); this.register(CompterReponses.class); this.register(new InteragirAtomiquement()); this.register(Cacher.class); this.register(RealiserEcritureOptimiste.class); this.register(AjouterVersionAuxReponses.class);
Noter que pour chaque classe enregistrée, Jersey instancie un filtre par interface implémentée ContainerRequestFilter ou ContainerResponseFilter : si l'on souhaite une seule instanciation, il est nécessaire d'enregistrer cette instance, comme cela a été fait pour la classe InteragirAtomiquement.
3.4 Client avec cache et gestion des versions
Dans le projet client, étudier les filtres AjouterPrecondition et Cacher du paquet infrastructure.jaxrs en suivant les commentaires. Compléter le filtre Cacher en suivant les commentaires.
Etudier la classe principale client.TestRegistre2, en particulier l'enregistrement des filtres et l'initialisation du gestionnaire d'erreurs 412. Pour les filtres côté client, la norme JAX-RS ne propose pas de mécanismes utilisant une annotation des méthodes. On doit configurer les cibles de type WebTarget par les filtres utilisés. Lorsqu'on crée un proxy, on lui associe une cible qui appliquera les filtres à toutes les méthodes. Si on veut discriminer entre les méthodes, soit on le code au sein des filtres, soit on réalise son propre proxy, utilisant plusieurs cibles.
Tester votre application, dans un contexte concurrent. Rajouter la reprise d'écriture en cas d'erreur "412".
do { s.setI(s.getI() + 1); s = proxyRegistre.set(s); } while (gestionnaire.erreur412());
Afficher finalement le nombre total de reprises.
System.out.println("Reprises de transaction : " + gestionnaire.getReprises());
Que se passe-t-il si l'on retire le filtre InteragirAtomiquement ?