UP | HOME

TP1 - Un automate comme service - Cas "stateful" et cas "stateless"

Table of Contents

1 Introduction : automate comme service, service comme automate

On cherche à décrire un service proposant la reconnaissance de langages réguliers fermés par préfixe : ce sont les langages reconnus par des automates dont tous les états sont finals. Précisément, on s'intéressera au langage régulier \((ab)^\ast + (ab)^\ast a\) formé d'une succession, éventuellement vide, de mots \(ab\), suivi possiblement d'un \(a\). Par exemple, les mots \(ab\), \(aba\) et \(abab\) sont reconnus alors que le mot \(abaa\) n'est pas reconnu. Pour reconnaître ce langage, on utilise un automate à deux états, UN, un état initial et final, et DEUX, un état final.

../medias/soc_automata_ab-star.svg

Automate reconnaissant le langage ((ab) + (ab) a)

Cet exemple simple repose sur une abstraction très commune : la modélisation du comportement d'un serveur par un automate, appelé aussi machine à états. Il est donc paradigmatique.

2 Automates : les règles chimiques

On spécifie les serveurs et les clients à l'aide de règles chimiques. Ce modèle permet une spécification non seulement concise mais aussi précise des échanges de messages.

2.1 Serveur "stateful" (avec état de session)

Etat

  • Session(n) : compteur des sessions
  • Execution(n, e) : élément de la table Execution associant au numéro de session n (appelé identifiant de session) un état de l'automate e (UN ou DEUX)

Etat initial

Session(0)

Canaux

  • initier(ar) : initialise la session et renvoie sur le canal ar l'identifiant de session et un message OK.
  • accepter(c, n, ar) : suivant l'état associé à l'identifiant de session n par la table Execution, accepte le caractère c et renvoie sur le canal ar le couple (n, OK), ou refuse le caractère c et renvoie (n, KO).
  • clore(n) : termine la session d'identifiant n en retirant de la table l'entrée correspondant à n.

Règles

- initier(ar) & Session(n) -> ar(n, OK) & Session(n + 1) & Execution(n, UN)
- accepter('a', n, ar) & Execution(n, UN) -> ar(n, OK) & Execution(n, DEUX)
- accepter('b', n, ar) & Execution(n, DEUX) -> ar(n, OK) & Execution(n, UN)
- accepter(x, n, ar) & Execution(n, e) & ((x, e) != ('a', UN) & (x, e) != ('b', DEUX)) 
  -> ar(n, KO) & Execution(n, e) 
- accepter(x, n, ar) & (Execution(n, _) inactive)-> ar(n, KO)  
- clore(n) & Execution(n, e) ->

2.2 Client "stateful"

Etat

  • quatre états internes : Debut, EnCours, Succes, Echec
  • Mot(tab) : tableau de caractères tab formant le mot à transmettre à l'automate

Etat initial

Debut & Mot(['a', 'b', 'a', 'b'])

Canaux

  • rep(n, msg) : reçoit en réponse un identifiant de session n et un message msg, soit OK soit KO.

Règles

- Debut -> initier(rep) & EnCours
- EnCours & rep(n, OK) & Mot([tete, reste]) -> accepter(tete, n, rep) & Mot([reste]) & EnCours
- EnCours & rep(n, OK) & Mot([]) -> clore(n) & Succes
- EnCours & rep(n, KO) & Mot(m) -> Echec

2.3 Serveur "stateless" (sans état de session)

Etat

  • Session(n) : compteur des sessions

Etat initial

Session(0)

Canaux

  • initier(ar) : initialise la session et renvoie sur le canal ar l'identifiant de session et l'état initial de l'automate.
  • accepter(c, n, e, ar) : suivant l'état e, accepte le caractère c et renvoie sur le canal ar le couple (n, f), où f est le nouvel état, ou refuse le caractère c et renvoie (n, KO).

Règles

- initier(ar) & Session(n) -> ar(n, UN) & Session(n + 1) 
- accepter('a', n, UN, ar) > ar(n, DEUX) 
- accepter('b', n, DEUX, ar) -> ar(n, UN) 
- accepter(x, n, e, ar) & ((x, e) != ('a', UN) & (x, e) != ('b', DEUX)) -> ar(n, KO)

2.4 Client "stateless"

Etat

  • quatre états internes : Debut, EnCours, Succes, Echec
  • Mot(tab) : tableau de caractères tab formant le mot à transmettre à l'automate

Etat initial

Debut & Mot(['a', 'b', 'a', 'b'])

Canaux

  • rep(n, msg) : reçoit en réponse un identifiant de session n et un message msg, soit un état soit KO.

Règles

- Debut -> initier(rep) & EnCours
- EnCours & rep(n, e) & Mot([tete, reste]) -> accepter(tete, n, e, rep) & Mot(reste) & EnCours 
- EnCours & rep(n, e) & Mot([]) & (e != KO) -> Succes
- EnCours & rep(n, KO) & Mot(m) -> Echec

3 Exercice pratique : implémentation de l'automate

On cherche à implémenter l'automate précédent par des services Web Restful.

3.1 Modèle objet

Deux modèles objet sont fournis (cf. les archives associées). Ils diffèrent par la gestion des sessions, centralisée ou décentralisée, comme décrit par les modèles chimiques abstraits.

3.2 Service Restful avec état (stateful)

Créer un nouveau projet Dynamic web project pour le service Restful. Utiliser Tomcat 8.0 comme serveur. Noter que par défaut, le nom du projet sert de préfixe aux chemins des ressources ; pour changer le préfixe, voir : clic droit sur le projet > Properties > Web project settings. Transformer ensuite le projet en projet Maven.

  • 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é.

    <dependency>
      <groupId>org.glassfish.jersey.containers</groupId>
      <artifactId>jersey-container-servlet</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.media</groupId>
         <artifactId>jersey-media-json-jackson</artifactId>
         <version>2.26</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_ServeurAvecEtat.zip fournissant un paquet rest.

  • Interface Automate
  • Interfaces Session et Resultat
  • Implémentations de Session et Resultat
  • Implémentation de Automate (pour reconnaître le langage régulier \((ab)^\ast + (ab)^\ast a\))
  • Un paquet jaxb contenant deux adaptateurs JAXB nécessaires pour la traduction en documents XML des objets dont le type (statique) est une interface et le fournisseur associé détaillant les classes d'implémentation des interfaces.

3.2.1 Annotations

Annoter les méthodes de l'interface Automate de manière à vérifier les propriétés suivantes. Toutes ces annotations appartiennent au paquet javax.ws.rs.

  1. Les méthodes correspondent à des requêtes http de type POST ou PUT. Précisément, deux méthodes ne sont ni pures ni idempotentes, tandis qu'une méthode n'est pas pure mais est idempotente.
  2. Les chemins relatifs d'accès aux ressources correspondant aux méthodes sont les suivants :

    • initier : etat/initial,
    • accepter : etat/suivant.
    • clore : fin

    Ainsi, si l'URI de base est http://localhost:8080/Projet et si celle de la ressource implémentant l'automate est X, une URI sera par exemple : http://localhost:8080/Projet/X/etat/initial. Pour indiquer un chemin C, on utilise l'annotation @Path("C").

  3. Le chemin d'accès à la méthode accepter est complété par un suffixe, noté {lettre}, où lettre est un paramètre de chemin associé à l'argument x.

    @Path(".../{lettre}")
    Resultat accepter(@PathParam("lettre") char x, Session id);
    
  4. Les sessions et les résultats sont sérialisés au format application/xml ou application/json. Pour indiquer qu'un résultat est sérialisé suivant un certain format javax.ws.rs.core.MediaType.F, on utilise après importation de MediaType l'annotation @Produces(MediaType.F) pour les valeurs produites et @Consumes(MediaType.F) pour les valeurs consommées. On peut spécifier plusieurs valeurs dans un tableau de chaînes de caractères, le client choisissant le format à l'aide des en-têtes HTTP Accept et Content-Type respectivement.

Annoter la classe d'implémentation A_B_point_Etoile correspondant à la ressource par son chemin d'accès automate. Par défaut, une ressource nouvelle est créée à chaque requête. Pour l'éviter, annoter la classe par Singleton.

Pourquoi est-il nécessaire d'annoter l'interface Session par @XmlJavaTypeAdapter(TraductionSession.class) ?

Cette annotation est utilisée par le data binding JAXB. La fonction d'unmarshalling permettant de traduire un document en un objet est paramétrée par la classe de l'objet. Lorsqu'on utilise une interface pour type, JAXB ne peut déterminer la classe à utiliser pour réaliser la traduction de document à objet. On utilise alors un adaptateur, dont la classe est spécifiée par l'annotation. Cette classe implémente la classe abstraite javax.xml.bind.annotation.adapters.XmlAdapter<ImplemX, X> (X étant l'interface, ImplemX son implémentation) en définissant deux méthodes :

public ImplemX marshal(X x) throws Exception
public X unmarshal(ImplemX i) throws Exception

La première méthode permet de construire un nouvel objet de type ImplemX à partir d'un objet initial de type X. La seconde méthode s'implémente de manière triviale, par conversion implicite. JAXB procède alors ainsi.

  • document vers objet de type X : unmarshalling du document en un objet de type ImplemX, puis conversion implicite par application de la méthode unmarshall de l'adaptateur.
  • objet de type X vers document ; application de la fonction marshal produisant un objet de type ImplemX puis marshalling de cet objet en un document.

Pourquoi est-il nécessaire d'annoter la classe ImplemSession par @XmlRootElement(name="session") ? Cette annotation est nécessaire pour le data binding JAXB : elle signifie que cette classe se traduit par la définition d'un elément global dans le schéma associé par JAXB, et que les instances de cette classe peuvent être traduites en des documents XML racines, utilisant comme élément racine l'élément global du schéma produit.

Remarque. Le pilotage de JAXB, utilisé pour la traduction en XML ou en JSON, n'est pas uniforme suivant les implémentations de la norme JAX-RS. Avec Jersey, il est également nécessaire d'annoter l'interface Session par @XmlRootElement(name="session"), alors qu'elle l'est déjà par l'adaptateur TraductionSession. Avec CXF, une autre implémentation de la norme, ce n'est pas nécessaire. La documentation est souvent insuffisante.

3.2.2 Déploiement et premier test

Dans le fichier web.xml, référencer la définition du service dans la classe rest.Service. Etudier le code de cette classe. Noter qu'en plus de la fonctionnalité de log, deux autres fonctionnalités sont ajoutées :

  • la capacité à traiter JSON en utilisant l'outil Jackson,
  • un fournisseur de traductions XML pour les résultats et les sessions.

Cette configuration est propre à Jersey.

(A placer à l'intérieur de l'élément servlet)

<init-param>
   <param-name>javax.ws.rs.Application</param-name>
   <param-value>rest.Service</param-value>
</init-param>
<load-on-startup>1 </load-on-startup>

(L'élément "load-on-startup" (avec un contenu positif) indique que la servlet est chargée au démarrage. Pour la configuration du service, voir la section 14.2.1. ("Deployment of programmatic resources") de la documentation de Jersey.)

Déployer le service sur le serveur Tomcat. Tester avec RESTer sous Firefox (par exemple). Pour sélectionner le format du résultat, définir le champ accept. Pour spécifier le format du corps de la requête, initialiser le champ content-type.

3.2.3 Rappel sur le protocole HTTP et les URI

Cf. le cours.

3.2.4 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.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_ClientServeurAvecEtat.zip fournissant deux paquets rest et client.

Paquet rest

  • Le paquet diffère de celui côté serveur par le retrait de l'implémentation de l'automate. En effet, l'automate distant sera accédé par l'intermédiaire d'un proxy.

Paquet client

  • Classe AppliCliente contenant la fonction principale
  • Classe AutomateProxy définissant une implémentation de l'interface Automate par un proxy

Annoter l'interface Automate comme précédemment.

Dans la classe client.AppliCliente, implémenter le scénario suivant dans la fonction principale.

  • Créer une cible de type WebTarget en appelant la fonction clientJAXRS, à étudier au préalable et à comparer avec le constructeur de la classe Service côté serveur.
  • Créer un automate proxy en utilisant la fabrique WebResourceFactory, propre à Jersey.
  • Appeler une fonction de test en passant l'automate en argument.

Implémenter la fonction de test ainsi.

  • Initialiser un mot avec le tableau de caractères {'a', 'b', 'a', 'b', 'a', 'b'}.
  • Initier une session.
  • Appeler l'automate lettre par lettre pour vérifier si ce mot est accepté.
  • Si c'est le cas, afficher un message de succès, sinon afficher un message d'échec. Dans tous les cas, clore la session.

Tester votre application.

Décrire une requête correspondant à l'invocation de la méthode accepter aves les arguments suivants :

  • lettre : 'a',
  • session d'identifiant 3

ainsi que sa réponse.

méthode Http : ...
Adresse : ...
En-tête : ...
Message (payload) : ...
En-tête : ...
Réponse : ...

Comment faire pour utiliser JSON ? Quelle est la réponse alors ?

Réponse : ...

Implémenter un automate proxy dans la classe client.AutomateProxy, en utilisant les cibles de type WebTarget.

Tester de la même manière cet automate proxy, avec le format XML puis JSON.

3.3 Service Restful sans état (stateless)

Créer un nouveau projet Dynamic Web Project pour le service Restful. Le configuer comme le précédent.

Y importer l'archive codeFourni_ServeurSansEtat.zip fournissant un paquet rest.

  • Interface Automate
  • Interfaces Session et Resultat
  • Implémentations de Session et Resultat
  • Implémentation de Automate (pour reconnaître le langage régulier \((ab)^\ast + (ab)^\ast a\))
  • Un paquet jaxb contenant deux adaptateurs JAXB nécessaires pour la traduction en documents XML des objets dont le type (statique) est une interface et le fournisseur associé détaillant les classes d'implémentation des interfaces.

Relativement à la version précédente, deux différences apparaissent.

  • L'interface Automate se simplifie.
  • L'état de l'automate est agrégé dans chaque session.

3.3.1 Annotations

Annoter les méthodes de l'interface Automate de manière à vérifier les propriétés suivantes.

  1. Les deux méthodes correspondent à des requêtes http de type POST et GET. Précisément, comme la méthode initier n'est ni pure ni idempotente, elle doit être associée à une requête de type POST. Quant à la la méthode accepter, étant pure et idempotente, elle peut être associée à une requête de type GET.
  2. Les chemins relatifs d'accès aux ressources correspondant aux méthodes sont les suivants :

    • initier : etat/initial,
    • accepter : etat/suivant.

    Ainsi, si l'URI de base est http://localhost:8080/Projet et si celle de la ressource implémentant l'automate est X, une URI sera par exemple : http://localhost:8080/Projet/X/etat/initial. Pour indiquer un chemin C, on utilise l'annotation @Path("C").

  3. Le chemin d'accès à la méthode accepter est complété par un suffixe, noté {lettre}, où lettre est un paramètre de chemin associé à l'argument x.

    @Path(".../{lettre}")
    Resultat accepter(@PathParam("lettre") char x, Session id);
    
  4. Le chemin d'accès à la méthode accepter est aussi complété par une requête, notée ?id=E-n, où E-n est une représentation de l'argument id, E étant l'état de la session, n son numéro. Cettre traduction bi-univoque d'une session en String est calculée par la méthode toString et par la fonction fromString de l'interface Session. Déclarer la méthode accepter ainsi :

    Resultat accepter(@PathParam("lettre") char x, @QueryParam("id") Session id);
    
  5. Les sessions et les résultats sont sérialisés au format application/xml ou application/json. Pour indiquer qu'un résultat est sérialisé suivant un certain format javax.ws.rs.core.MediaType.F, on utilise après importation de MediaType l'annotation @Produces(MediaType.F) pour les valeurs produites et @Consumes(MediaType.F) pour les valeurs consommées. On peut spécifier plusieurs valeurs dans un tableau de chaînes de caractères, le client choisissant le format à l'aide des en-têtes HTTP Accept et Content-Type respectivement.

Annoter la classe d'implémentation A_B_point_Etoile correspondant à la ressource par son chemin d'accès automate. Comparer l'implémentation à celle du premier projet.

Par défaut, une ressource nouvelle est créée à chaque requête. Pour l'éviter, annoter la classe par Singleton.

3.3.2 Déploiement et premier test

Dans le fichier web.xml, référencer la définition du service dans la classe rest.Service. Etudier le code de cette classe. Noter que cette configuration est propre à Jersey.

<init-param>
   <param-name>javax.ws.rs.Application</param-name>
   <param-value>rest.Service</param-value>
</init-param>
<load-on-startup>1 </load-on-startup>

Déployer le service sur le serveur Tomcat. Tester avec RESTer sous Firefox (par exemple). Pour sélectionner le format du résultat, définir le champ accept.

3.3.3 Client

Créer un nouveau projet Java et le transformer en projet Maven. Le configurer comme le projet client précédent.

Y importer à la racine l'archive codeFourni_ClientServeurSansEtat.zip fournissant deux paquets rest et client.

Paquet rest

  • Le paquet diffère de celui côté serveur par le retrait de l'implémentation de l'automate. En effet, l'automate distant sera accédé par l'intermédiaire d'un proxy.

Paquet client

  • Classe AppliCliente contenant la fonction principale
  • Classe AutomateProxy définissant une implémentation de l'interface Automate par un proxy

Annoter l'interface Automate comme précédemment.

Dans la classe client.AppliCliente, reprendre le scénario précédent.

  • Créer une cible de type WebTarget en appelant la fonction clientJAXRS, à étudier au préalable et à comparer avec le constructeur de la classe Service côté serveur.
  • Créer un automate proxy en utilisant la fabrique WebResourceFactory, propre à Jersey.
  • Appeler une fonction de test en passant l'automate en argument.

Implémenter la fonction de test ainsi.

  • Initialiser un mot avec le tableau de caractères {'a', 'b', 'a', 'b', 'a', 'b'}.
  • Initier une session.
  • Appeler l'automate lettre par lettre pour vérifier si ce mot est accepté.
  • Si c'est le cas, afficher un message de succès, sinon afficher un message d'échec.

Tester votre application.

Décrire une requête correspondant à l'invocation de la méthode accepter aves les arguments suivants :

  • lettre : 'a',
  • session d'identifiant 3 et d'état UN

ainsi que sa réponse.

méthode Http : ...
Adresse : ...
En-tête : ...
Message (payload) : ...
En-tête : ...
Réponse : ...

Comment faire pour utiliser JSON ? Quelle est la réponse alors ?

Réponse : ...

Implémenter un automate proxy dans la classe client.AutomateProxy, en utilisant les cibles de type WebTarget.

Tester de la même manière cet automate proxy, avec le format XML puis JSON.

3.4 Contrôle de la concurrence

Les deux implémentations proposées suivent d'assez près la spécification chimique. Cependant, les règles chimiques ont une propriété fondamentale : elles s'exécutent (ou s'interprètent) de manière atomique (sans interruption). Ce n'est pas le cas des implémentations en Java. Il peut donc exister des accès concurrents, avec des risques de pertes en écriture.

3.4.1 Adapter à la concurrence le service avec état

Dans le premier projet client, créer une seconde classe de Test, TestConcurrence, contenant la fonction principale suivante.

public static void main(String[] args) {
  String adresse = "http://localhost:8080/AutomateAvecEtatSession/automate";
  System.out.println("*************");

  WebTarget cible = AppliCliente.clientJAXRS().target(adresse);
  Automate automateProxyJersey = WebResourceFactory.newResource(Automate.class, cible);

  int MAX = 100; // à augmenter possiblement

  Session[] sessions = new Session[MAX];
  for (int i = 0; i < MAX; i++) {
    sessions[i] = automateProxyJersey.initier();
    System.out.println(sessions[i].getNumero());
  }
  for (int i = 0; i < MAX; i++) {
    automateProxyJersey.clore(sessions[i]);
    System.out.println(sessions[i].getNumero());
  }
  System.out.println("*************");
}

Qu'observe-t-on lorsque plusieurs clients sont lancés simultanément (cinq à dix typiquement) ?

Alternative (suggérée par Damien Raymond, FIL A2, 2017) : utiliser une fonction principale lançant en parallèle un grand nombre de requêtes, par l'intermédaire d'un flot parallèle (Stream).

public static void main(String[] args) {
  String adresse = "http://localhost:8080/AutomateServeurAvecEtat/automate/concurrent";
  System.out.println("*************");

  WebTarget cible = AppliCliente.clientJAXRS().target(adresse);
  Automate automateProxyJersey = WebResourceFactory.newResource(Automate.class, cible);

  int REQUETES = 1000;
  final long incrementations = Stream
    .generate(() -> 0)
    .limit(REQUETES)
    .parallel()
    .map(j -> automateProxyJersey.initier().getNumero())
    .distinct()
    .count();

  System.out.println("Pertes en écriture : " + (REQUETES - incrementations) + "/ " + REQUETES + " requêtes.");
  System.out.println("*************");
}

On observe des pertes en écriture : le numéro de session n'est pas incrémenté à chaque requête initier.

Remarque : suivant la machine utilisée, son système d'exploitation, sa rapidité et son nombre de coeurs, des pertes en écriture peuvent apparaître avec plus ou moins d'itérations et plus ou moins de clients, ou ne pas apparaître du tout (cas sous Windows, semble-t-il, où les processus java s'exécutent sans entrelacement).

Pour garantir l'atomicité, il suffit de contrôler la concurrence dans le code Java en utilisant des verrous et des structures de données fonctionnant dans un contexte concurrent.

  • Créer une nouvelle classe A_B_point_EtoileConcurrent implémentant l'interface Automate, par copie de la classe A_B_point_Etoile.
  • Modifier le chemin d'accès en automate/concurrent.
  • Qualifier la méthode initier par le mot-clé synchronized : celui garantit l'exécution atomique de la méthode.
  • Remplacer les types Map et HashMap par les versions concurrentes ConcurrentMap et ConcurrentHashMap.
  • Enregistrer la nouvelle ressource dans le constructeur de la classe Service.
  • Tester la nouvelle ressource, après modification de l'adresse dans la fonction principale.

3.4.2 Adapter à la concurrence le service sans état

Procéder de même pour le second projet en adaptant le contrôle de la concurrence ainsi.

  • Qualifier la méthode initier par le mot-clé synchronized : celui garantit l'exécution atomique de la méthode.
  • Entourer la section critique réalisant l'incrémentation du compteur par l'opérateur synchronized.

    synchronized(this){
      compteur++;
      System.out.println("************** requête accepter numéro " + compteur + " *************");
    }
    

Cette seconde méthode permet de restreindre la section critique (celle où l'objet est verrouillé) à la seule incrémentation du compteur, et non pas à toute la méthode.

Author: Hervé Grall
Version history: v1: 2016-10-24; v2: 2017-04-07[update]; v3: 2017-10-31[update]; v4: 2018-04-05[*text, update].
Comments or questions: Send a mail.
The webpage content is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.