UP | HOME

TP2 - Un registre comme service - Contrôle optimiste de la concurrence

Table of Contents

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. Bien remarquer que la gestion de le concurrence se réalise ici à l'échelle de l'interaction avec les services : elle est complémentaire d'une gestion de la concurrence interne à chaque service, qui peut être nécessaire aussi dès lors que le service réalise des écritures. Cette dernière suit généralement l'approche pessimiste : elle garantit que l'exécution d'un service est atomique en utilisant un verrou.

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[k] pour la réception des get (famille de canaux paramétrés par le canal k de chaque client)
    • ecritureI[k] pour la réception des set (famille de canaux paramétrés par le canal k de chaque client)
    • getI et setI (interception)
  • canaux requis :
    • get et set (serveur)
  • é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[k], n) & Cache(x, n)
// Réponse demandant la lecture en cache
- lectureI[k](CACHE, n) & Cache(x, n) -> k(x) & Cache(x, n)
// Réponse transmettant le résultat et mise en cache
- lectureI[k](y, m) & Cache(x, n) -> k(y) & Cache(y, m)

// Interception d'une requête en écriture 
- setI(k, y) & Cache(x, n) -> set(ecritureI[k], y, n) & Cache(x, n)
// Ecriture réussie avec mise à jour du cache 
- ecritureI[k](OK, y, m) & Cache(x, n) -> k(OK, y) & Cache(y, m)
// Ecriture annulée avec mise à jour du cache
- ecritureI[k](KO, y, m) & Cache(x, n) -> k(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é. La troisième fournit un injecteur de dépendances. Enfin, la dernière permet d'utiliser le serveur HTTP Grizzly, à la fois léger et passant à l'échelle.

    <dependencies>
          <dependency>
                  <groupId>org.glassfish.jersey.containers</groupId>
                  <artifactId>jersey-container-servlet</artifactId>
                  <version>2.26</version>
          </dependency>
          <dependency>
                  <groupId>org.glassfish.jersey.media</groupId>
                  <artifactId>jersey-media-json-jackson</artifactId>
                  <version>2.26</version>
          </dependency>
          <dependency>
                  <groupId>org.glassfish.jersey.inject</groupId>
                  <artifactId>jersey-hk2</artifactId>
                  <version>2.26</version>
          </dependency>
          <dependency>
                  <groupId>org.glassfish.jersey.containers</groupId>
                  <artifactId>jersey-container-grizzly2-http</artifactId>
                  <version>2.26</version>
          </dependency>
    </dependencies>
    
  • 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.

  1. Chaque méthode est qualifiée par le type idoine de méthode HTTP.
  2. Les chemins relatifs d'accès à la ressource correspondant aux deux méthodes sont vides : il est inutile de le préciser.
  3. 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.

  1. Une instance de la classe Ressource peut être traduite en un document XML racine.
  2. 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 dans 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 RESTer sous Firefox (par exemple).

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. La dernière fournit l'injecteur de dépendances.

      <dependency>
         <groupId>org.glassfish.jersey.core</groupId>
         <artifactId>jersey-client</artifactId>
         <version>2.26</version>
      </dependency>
      <dependency>
         <groupId>org.glassfish.jersey.ext</groupId>
         <artifactId>jersey-proxy-client</artifactId>
         <version>2.26</version>
      </dependency>
      <dependency>
         <groupId>org.glassfish.jersey.media</groupId>
         <artifactId>jersey-media-jaxb</artifactId>
         <version>2.26</version>
      </dependency>
      <dependency>
         <groupId>org.glassfish.jersey.media</groupId>
         <artifactId>jersey-media-json-jackson</artifactId>
         <version>2.26</version>
      </dependency>
    <dependency>
      <groupId>org.glassfish.jersey.inject</groupId>
      <artifactId>jersey-hk2</artifactId>
      <version>2.26</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. S'il ne l'utilise pas, la requête est traitée normalement. Cas particulier : l'utilisation du champ if-match est considérée comme une erreur ("428", précondition requise).

Response-Code: 428
Headers: {... ETag=[versionCourante] ...}
Payload: erreur 428 - DEVRAIT contenir l'en-tête if-none-match, NE DOIT PAS contenir l'en-tête if-match.

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.

Schéma récapitulatif

                                            ------------- Versionneur -------------- 
--- requête --> | Stat | --> |        | --> | Cache / GET *| --> | Ecriture / PUT *| --->  \/ 
                             | Verrou |                                               | Ressource |
<-- réponse --- | Stat | <-- |        |     |     Versionnage / réponses (200)     | <---  \/

(* : réponse anticipée possible)

Le verrou, de type java.util.concurrent.locks.ReadWriteLock, garantit l'exécution atomique du service lors d'une requête. C'est nécessaire du fait de la modification de la ressource lors d'une requête PUT. Contrairement au cas de l'automate (cf. le premier TP), l'exécution du service ne se limite pas à l'exécution d'une méthode, mais implique au contraire un ensemble de composants : les filtres et la méthode. C'est pourquoi il est nécessaire d'introduire un verrou particulier pour contrôler une section critique qui ne se réduit pas à une portée : le verrou associé à synchronized, utilisé dans le premier TP, serait ici insuffisant, la section critique associée étant la portée de la méthode ou d'un sous-bloc. Ce verrou particulier est optimal au sens où il permet plusieurs lectures simultanées, mais une seule écriture.

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 ; retenir notamment l'usage de l'annotation NameBinding permettant de relier le nom de l'annotation à un fournisseur (Provider) et aux utilisateurs.

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 : enregistrement automatique 
//      si les filtres sont déclarés comme 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);

(AbstractBinder vient du paquet org.glassfish.jersey.internal.inject.AbstractBinder.)

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 ?

Author: Hervé Grall
Version history: v1: 2015-10-28; v2: 2016-04-08[*text]; v3: 2016-11-07[update, *text]; v4: 2017-04-14[update, *text]; v5: 2017-11-21[update]; v6: 2017-11-27[update]; v7: 2018-04-06[*text, update].
Comments or questions: Send a mail.
The webpage content is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.